0 accounts need setup
Accounts Needing Setup
PayoutLab
Loading...
PayoutLab
AI Insights
`; const popup = window.open('', '_blank', 'width=800,height=600,resizable=yes,scrollbars=no,menubar=no,toolbar=no,location=no,status=no'); if (popup) { popup.document.write(html); popup.document.close(); } } // Initialize calendar filters on page load setTimeout(initCalendarFilters, 100); // Economic Briefing Generator window.generateEconomicBriefing = function() { const dateFilter = document.getElementById('briefing-date').value; const countryFilter = document.getElementById('briefing-country').value; const impactFilter = document.getElementById('briefing-impact').value; const placeholderDiv = document.getElementById('briefing-placeholder'); const loadingDiv = document.getElementById('briefing-loading'); const resultDiv = document.getElementById('briefing-result'); const contentDiv = document.getElementById('briefing-result-content'); // Show loading placeholderDiv.style.display = 'none'; resultDiv.style.display = 'none'; loadingDiv.style.display = 'block'; // Build initial briefing const today = new Date(); const dayOfWeek = today.getDay(); const briefingHtml = buildEconomicBriefing(dateFilter, countryFilter, impactFilter); // Show briefing immediately with AI loading placeholder loadingDiv.style.display = 'none'; resultDiv.style.display = 'block'; contentDiv.innerHTML = briefingHtml; // Now generate built-in bias insights (no cloud function to avoid trade references) const aiContainer = document.getElementById('ai-bias-insights'); if (aiContainer) { const targetDay = getTargetDayOfWeek(dateFilter); const calendarData = getEconomicCalendarData(dateFilter, countryFilter, impactFilter, targetDay); if (calendarData.events.length > 0) { const biasAnalysis = generateMarketBiasAnalysis(calendarData.events, dateFilter); aiContainer.innerHTML = `
${biasAnalysis}
`; } else { aiContainer.innerHTML = `
No major events scheduled for this period. Expect technically-driven, lower-volatility price action. Good conditions for mean reversion or trend-following strategies without news interference.
`; } } }; // Generate market bias analysis based on scheduled events function generateMarketBiasAnalysis(events, dateFilter) { const eventNames = events.map(e => e.name.toLowerCase()); const highImpactEvents = events.filter(e => e.impact === 'high'); const hasNFP = eventNames.some(n => n.includes('nfp') || n.includes('payroll')); const hasCPI = eventNames.some(n => n.includes('cpi')); const hasFOMC = eventNames.some(n => n.includes('fomc') || n.includes('fed')); const hasISM = eventNames.some(n => n.includes('ism')); const hasJobless = eventNames.some(n => n.includes('jobless')); const hasPCE = eventNames.some(n => n.includes('pce')); let bias = ''; let confidence = 'medium'; let direction = 'neutral'; // NFP Analysis if (hasNFP) { direction = 'cautious'; confidence = 'low'; bias = `🎯 Pre-NFP Bias: Neutral to Cautious

`; bias += `Markets typically consolidate ahead of NFP with reduced liquidity. Current consensus expects ~175K jobs. `; bias += `Bullish case: Goldilocks print (150-200K) with contained wages supports soft-landing narrative and risk-on. `; bias += `Bearish case: Hot print (>250K) or wage surge reignites "higher for longer" Fed fears. Very weak print (<100K) triggers recession concerns. `; bias += `

💡 Key Watch: First 15 minutes are noise. Wait for dust to settle. Watch 10-year yield reaction for true direction.`; } // CPI Analysis else if (hasCPI) { direction = 'data-dependent'; confidence = 'medium'; bias = `🎯 Pre-CPI Bias: Data-Dependent

`; bias += `Inflation data remains the key driver of Fed policy expectations. Core CPI (ex food/energy) matters most. `; bias += `Bullish case: Core CPI below consensus confirms disinflation trend, supports rate cut expectations. `; bias += `Bearish case: Any upside surprise, especially in services/shelter, would pressure risk assets. `; bias += `

💡 Key Watch: Pre-market reaction typically continues into cash session. Core MoM change is the number to watch.`; } // FOMC Analysis else if (hasFOMC) { direction = 'neutral'; confidence = 'low'; bias = `🎯 Pre-FOMC Bias: Neutral (Event Risk)

`; bias += `FOMC days are characterized by low conviction and tight ranges before 2PM ET. Liquidity thins as traders await the decision. `; bias += `Bullish case: Dovish statement, rate cut, or lower dot plot projections. `; bias += `Bearish case: Hawkish surprise, "higher for longer" emphasis, inflation concerns. `; bias += `

💡 Key Watch: Initial reaction often reverses. Powell's press conference at 2:30 PM frequently moves markets more than the statement itself.`; } // PCE Analysis else if (hasPCE) { direction = 'cautious-bullish'; confidence = 'medium'; bias = `🎯 Pre-PCE Bias: Cautious

`; bias += `PCE is the Fed's preferred inflation gauge. Core PCE drives rate expectations more than CPI. `; bias += `Bullish case: Core PCE trending toward 2% target validates disinflation and supports risk assets. `; bias += `Bearish case: Sticky or rising Core PCE keeps Fed hawkish, pressures growth stocks. `; bias += `

💡 Key Watch: Month-end rebalancing flows can amplify moves. Watch for position squaring.`; } // ISM Analysis else if (hasISM) { direction = 'growth-sensitive'; confidence = 'medium'; bias = `🎯 Pre-ISM Bias: Growth-Sensitive

`; bias += `ISM PMI data gauges economic momentum. Services PMI carries more weight (70%+ of economy). `; bias += `Bullish case: Above 50 (expansion) with strong new orders confirms economic resilience. `; bias += `Bearish case: Below 50 (contraction) triggers growth concerns. Prices paid component can reignite inflation worries. `; bias += `

💡 Key Watch: New Orders sub-index is most forward-looking. Employment component hints at labor market.`; } // Jobless Claims Analysis else if (hasJobless) { direction = 'neutral'; confidence = 'medium'; bias = `🎯 Pre-Claims Bias: Neutral to Positive

`; bias += `Weekly jobless claims are the most timely labor market indicator. Current baseline ~210-220K is healthy. `; bias += `Bullish case: Claims steady or declining = labor market resilience, soft landing intact. `; bias += `Bearish case: Sustained rise above 250K would signal labor market deterioration and recession risk. `; bias += `

💡 Key Watch: 4-week moving average more reliable than single prints. Holiday weeks are distorted.`; } // Multiple high-impact events else if (highImpactEvents.length >= 2) { direction = 'volatile'; confidence = 'low'; bias = `🎯 Bias: Elevated Volatility Expected

`; bias += `Multiple high-impact events create layered event risk. Markets may see extended ranges and whipsaw action. `; bias += `Key consideration: Each release can shift sentiment, making directional conviction difficult. `; bias += `

💡 Recommendation: Reduce position sizes, widen stops, or consider sitting out until volatility normalizes.`; } // Default for medium-impact events else if (events.length > 0) { direction = 'neutral'; confidence = 'medium'; bias = `🎯 Bias: Neutral with Moderate Event Risk

`; bias += `Scheduled releases are medium-impact. Expect localized volatility around release times but normal market conditions otherwise. `; bias += `Strategy: Standard trading approaches should work. Be aware of release times and consider reducing exposure 15-30 minutes prior. `; bias += `

💡 Key Watch: Surprises in any direction can still move markets. Trade the reaction, not the prediction.`; } return bias; } // Helper to get target day of week based on filter function getTargetDayOfWeek(dateFilter) { const today = new Date(); const dayOfWeek = today.getDay(); switch(dateFilter) { case 'today': return dayOfWeek; case 'tomorrow': return (dayOfWeek + 1) % 7; case 'this-week': return -1; // Special flag for whole week case 'next-week': return -2; // Special flag for next week default: return dayOfWeek; } } function buildEconomicBriefing(dateFilter, countryFilter, impactFilter) { const today = new Date(); const todayDayOfWeek = today.getDay(); const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; // Calculate target date based on filter const targetDay = getTargetDayOfWeek(dateFilter); const targetDate = new Date(today); if (dateFilter === 'tomorrow') { targetDate.setDate(today.getDate() + 1); } // Calculate actual week date ranges function getWeekRange(weekOffset) { const d = new Date(today); const currentDay = d.getDay(); const mondayOffset = currentDay === 0 ? -6 : 1 - currentDay; d.setDate(d.getDate() + mondayOffset + (weekOffset * 7)); const mon = new Date(d); const fri = new Date(d); fri.setDate(d.getDate() + 4); const fmt = (dt) => `${monthNames[dt.getMonth()]} ${dt.getDate()}`; return `${fmt(mon)} - ${fmt(fri)}, ${fri.getFullYear()}`; } const dateLabels = { 'today': `Today (${dayNames[todayDayOfWeek]}, ${monthNames[today.getMonth()]} ${today.getDate()})`, 'tomorrow': `Tomorrow (${dayNames[(todayDayOfWeek + 1) % 7]}, ${monthNames[targetDate.getMonth()]} ${targetDate.getDate()})`, 'this-week': `This Week (${getWeekRange(0)})`, 'next-week': `Next Week (${getWeekRange(1)})` }; const regionLabels = { 'all': 'Global', 'us': 'United States', 'eu': 'Eurozone', 'uk': 'United Kingdom', 'asia': 'Asia Pacific', 'americas': 'Americas' }; const impactLabels = { 'high': 'High Impact Only', 'high-medium': 'High & Medium Impact', 'all': 'All Impact Levels' }; // Get calendar data based on filters - pass targetDay const calendarData = getEconomicCalendarData(dateFilter, countryFilter, impactFilter, targetDay); let html = `
📊
Economic Briefing
${dateLabels[dateFilter]} • ${regionLabels[countryFilter]} • ${impactLabels[impactFilter]}
Generated ${new Date().toLocaleTimeString()}
`; // Two-column grid wrapper for sections 2-7 html += `
`; html += `
`; // left column // Summary Section html += `
🎯 Trading Day Summary
${calendarData.summary}
`; // Key Events Section if (calendarData.events.length > 0) { html += `
📅 Key Events to Watch
`; calendarData.events.forEach(event => { const impactColor = event.impact === 'high' ? 'var(--red)' : event.impact === 'medium' ? 'var(--orange)' : 'var(--yellow)'; const impactDot = event.impact === 'high' ? '🔴' : event.impact === 'medium' ? '🟠' : '🟡'; html += `
${impactDot} ${event.name} ${event.dayLabel ? `${event.dayLabel}` : ''}
${event.time}
${event.insight}
${event.volatility ? `
${event.volatility}
` : ''} ${event.strategy ? `
💡 ${event.strategy}
` : ''}
`; }); html += `
`; } html += `
`; // close left column html += `
`; // open right column // AI Bias Insights Section html += `
🧠 AI Market Bias Analysis Powered by AI
Analyzing market conditions and event expectations...
`; // Bullish/Bearish Scenarios html += `
🟢 Bullish Scenarios
    ${calendarData.bullish.map(b => `
  • ${b}
  • `).join('')}
🔴 Bearish Scenarios
    ${calendarData.bearish.map(b => `
  • ${b}
  • `).join('')}
`; // Trading Recommendations html += `
💡 Trading Recommendations
${calendarData.recommendations.map(rec => `
${rec}
`).join('')}
`; // Volatility Warning if (calendarData.volatilityWarning) { html += `
⚠️
High Volatility Expected: ${calendarData.volatilityWarning}
`; } html += `
`; // close right column html += `
`; // close two-column grid return html; } function getEconomicCalendarData(dateFilter, countryFilter, impactFilter, dayOfWeek) { // Comprehensive event database with detailed insights const eventDetails = { 'Non-Farm Payrolls (NFP)': { time: '8:30 AM ET', impact: 'high', insight: 'The most market-moving economic release. Reports jobs added/lost, unemployment rate, and wage growth. Consensus expects ~175K jobs. Above 250K = hot (Fed hawkish). Below 100K = weak (recession fears). Wage growth above 4% YoY is inflationary. First Friday of each month.', volatility: '30-80 point NQ moves typical. First move often fades within 5-15 minutes.', strategy: 'Most traders wait 5-15 min for dust to settle. Watch unemployment rate and wages equally.' }, 'Consumer Price Index (CPI)': { time: '8:30 AM ET', impact: 'high', insight: 'Monthly inflation reading. Core CPI (ex food/energy) matters more than headline. Fed targets 2% annual inflation. Even 0.1% deviation from consensus moves markets significantly. Released mid-month.', volatility: '30-100 point NQ swings. Direction usually clear within 5 minutes.', strategy: 'Pre-market reaction typically continues into cash session. Core > headline importance.' }, 'FOMC Rate Decision': { time: '2:00 PM ET', impact: 'high', insight: 'Federal Reserve interest rate decision + statement + dot plot projections. Most volatile event. Powell press conference at 2:30 PM often moves markets more than the decision itself. 8 meetings per year.', volatility: '50-150 point NQ moves possible. Extreme two-way volatility during presser.', strategy: 'Many experienced traders stay flat. Initial moves reverse frequently. Wait for presser to end.' }, 'Initial Jobless Claims': { time: '8:30 AM ET', impact: 'high', insight: 'Weekly unemployment claims - most timely labor indicator. Below 220K = healthy. Above 250K = concerning. Rising trend over 4 weeks signals recession risk. Every Thursday.', volatility: 'Usually 10-20 point moves unless big surprise or trend change.', strategy: 'Watch 4-week moving average, not single prints. Holiday weeks distorted.' }, 'Michigan Consumer Sentiment': { time: '10:00 AM ET', impact: 'medium', insight: 'Consumer confidence survey with critical inflation expectations component. Fed watches 1-year and 5-year inflation expectations closely. Preliminary mid-month, final end of month.', volatility: 'Usually 10-25 point moves. Inflation expectations can move independently.', strategy: 'Focus on inflation expectations component - often more market-moving than headline.' }, 'ISM Manufacturing PMI': { time: '10:00 AM ET', impact: 'high', insight: 'Key manufacturing health indicator. Above 50 = expansion, below 50 = contraction. New Orders sub-index is most forward-looking. Prices Paid signals inflation. First business day of month.', volatility: '20-40 point NQ moves typical.', strategy: 'New orders tells future story. Employment hints at NFP. Prices paid = inflation signal.' }, 'ISM Services PMI': { time: '10:00 AM ET', impact: 'high', insight: 'Services sector = 70%+ of US economy. More important than manufacturing PMI. Below 50 is rare and very bearish. Third business day of month.', volatility: '20-50 point moves. Can exceed manufacturing impact.', strategy: 'Services weakness more alarming than manufacturing. Employment component matters.' }, 'PCE Price Index': { time: '8:30 AM ET', impact: 'high', insight: "The Fed's PREFERRED inflation measure. Core PCE is THE number for rate decisions. Released end of month. Fed targets 2% Core PCE.", volatility: '20-40 point moves. Can set tone for month-end rebalancing.', strategy: 'This drives rate expectations more than CPI. Watch Core, not headline.' }, 'Retail Sales': { time: '8:30 AM ET', impact: 'high', insight: 'Consumer spending strength indicator. "Control Group" (ex autos, gas, building materials) feeds directly into GDP calculation. Released mid-month.', volatility: '15-30 point moves typical.', strategy: 'Control group > headline. Watch for prior month revisions.' }, 'GDP Growth Rate': { time: '8:30 AM ET', impact: 'high', insight: 'Quarterly economic output. Goldilocks = 2-2.5% growth. Too hot (>3%) = inflation. Negative = recession. Three readings: Advance, Preliminary, Final.', volatility: 'Moderate volatility. Often priced in from GDP Now estimates.', strategy: 'Backward-looking. Market focuses more on forward indicators like PMI.' }, 'JOLTS Job Openings': { time: '10:00 AM ET', impact: 'medium', insight: 'Labor demand indicator. Fed watches openings-to-unemployed ratio closely. Quits rate shows worker confidence. Data is 2 months lagged.', volatility: 'Usually modest unless big surprise.', strategy: 'Gradual decline = normalizing. Sharp drop = employers cutting back hard.' }, 'Producer Price Index (PPI)': { time: '8:30 AM ET', impact: 'high', insight: 'Wholesale/producer inflation - leading indicator for CPI. Core PPI matters most. Released day before or after CPI typically.', volatility: '15-30 point moves.', strategy: 'Use as preview for CPI direction. Pipeline inflation signal.' }, 'Fed Chair Powell Speech': { time: 'Varies', impact: 'high', insight: 'Every word parsed by algorithms. Hints about rate path, inflation assessment, labor views. Q&A often most impactful.', volatility: '30-50+ point swings on key phrases.', strategy: 'Expect choppy action throughout. Wait for speech to end before trading.' }, 'ECB Rate Decision': { time: '8:15 AM ET', impact: 'high', insight: 'European Central Bank policy. Press conference 8:45 AM. Policy divergence from Fed creates forex opportunities. Lagarde commentary matters.', volatility: 'Moves EUR/USD first, flows to equities.', strategy: 'Watch for Fed divergence. Dovish ECB = EUR weakness = potential USD strength.' }, 'UK CPI Inflation': { time: '2:00 AM ET', impact: 'high', insight: 'UK inflation. BOE target is 2%. UK inflation has been stickier than US. Released 7 AM London time.', volatility: 'Affects GBP, then broader sentiment.', strategy: 'Surprises can shift global rate expectations.' }, 'BOE Rate Decision': { time: '7:00 AM ET', impact: 'high', insight: 'Bank of England Monetary Policy Committee. MPC vote split tells future direction (9 members). Unanimous = strong conviction.', volatility: 'Moves GBP significantly, global sentiment moderately.', strategy: 'Watch vote split - contested decisions signal uncertainty.' }, 'Eurozone CPI Flash': { time: '5:00 AM ET', impact: 'high', insight: 'EU-wide inflation preview. ECB watches closely. Germany CPI releases day earlier as preview.', volatility: 'Pre-US session. Sets tone for European trading.', strategy: 'Use German CPI as preview. Flash more market-moving than final.' }, 'China Manufacturing PMI': { time: 'Overnight', impact: 'high', insight: 'China factory health = global growth bellwether. Official + Caixin (private) surveys. Above 50 = expansion. Impacts commodities heavily.', volatility: 'Sets Asia session tone, can gap US futures.', strategy: 'Strong China = commodity strength, risk-on. Weak = global growth fears.' }, 'Canada Employment': { time: '8:30 AM ET', impact: 'medium', insight: 'Canadian jobs report. Released same day as US NFP. Full-time vs part-time split matters.', volatility: 'Often overshadowed by NFP. Moves USD/CAD.', strategy: 'If US and Canada diverge, look for USD/CAD opportunity.' }, 'UK Employment Data': { time: '2:00 AM ET', impact: 'high', insight: 'UK jobs, unemployment rate, wage growth. Key for BOE policy outlook.', volatility: 'Moves GBP, affects European morning sentiment.', strategy: 'Wage growth = inflation signal for BOE.' }, 'NY Fed Manufacturing': { time: '8:30 AM ET', impact: 'medium', insight: 'Empire State Manufacturing Index. First regional manufacturing data each month. Above 0 = expansion.', volatility: 'Usually modest. Sets expectations for Philly Fed.', strategy: 'Use as preview for broader manufacturing health.' }, 'Philly Fed Index': { time: '8:30 AM ET', impact: 'medium', insight: 'Philadelphia Fed Manufacturing Index. Confirms or diverges from Empire State. New orders component forward-looking.', volatility: 'Usually modest unless big divergence from Empire.', strategy: 'Watch for confirmation of Empire State direction.' }, 'Building Permits': { time: '8:30 AM ET', impact: 'medium', insight: 'Leading housing indicator. Higher permits = builder confidence about future demand. Very rate-sensitive.', volatility: 'Usually low-moderate unless big surprise.', strategy: 'Forward-looking for construction activity.' }, 'Housing Starts': { time: '8:30 AM ET', impact: 'medium', insight: 'New residential construction begun. Rate-sensitive indicator. Single-family vs multi-family breakdown tells different stories.', volatility: 'Usually low-moderate.', strategy: 'Rising rates = falling starts typically.' }, 'EIA Crude Inventories': { time: '10:30 AM ET', impact: 'medium', insight: 'Weekly oil supply data. Drawdowns = bullish for oil. Builds = bearish. Released every Wednesday.', volatility: 'Moves oil/energy more than broad indices.', strategy: 'Compare to API data (released Tuesday night) for preview.' }, 'German ZEW Sentiment': { time: '5:00 AM ET', impact: 'medium', insight: 'German analyst expectations survey. Forward-looking indicator for Eurozone growth.', volatility: 'Pre-US session. Affects EUR.', strategy: 'Leading indicator for German economy.' }, 'German Ifo Business Climate': { time: '4:00 AM ET', impact: 'medium', insight: 'German business sentiment survey. Current assessment + expectations. Leading indicator for Germany/EU.', volatility: 'Pre-US session.', strategy: 'Germany = engine of Europe. Weak Ifo = EU growth concerns.' }, 'UK GDP Monthly': { time: '2:00 AM ET', impact: 'medium', insight: 'UK monthly economic growth. Two negative months in a row = technical recession signals.', volatility: 'Moves GBP, sets European tone.', strategy: 'Watch for recession signals in UK economy.' }, 'ADP Employment Change': { time: '8:15 AM ET', impact: 'medium', insight: 'Private payroll estimate. Released Wednesday before NFP. Historically unreliable predictor of NFP.', volatility: 'Moderate. Can shift NFP expectations.', strategy: 'Use as sentiment gauge only. Often misses NFP direction.' }, 'Durable Goods Orders': { time: '8:30 AM ET', impact: 'medium', insight: 'Orders for long-lasting goods. Core (ex-transportation) matters more - aircraft orders volatile. Business investment signal.', volatility: 'Usually moderate.', strategy: 'Focus on non-defense capital goods ex-aircraft for true picture.' }, 'Consumer Confidence': { time: '10:00 AM ET', impact: 'medium', insight: 'Conference Board consumer sentiment. Present situation vs expectations. Last Tuesday of month.', volatility: 'Usually moderate.', strategy: 'Expectations component more useful than present situation.' } }; // Date-aware event scheduling // Returns events for a specific calendar date based on known monthly patterns function getEventsForDate(date) { const dow = date.getDay(); // 0=Sun const dom = date.getDate(); // 1-31 const month = date.getMonth(); const dayNames2 = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; // Which occurrence of this weekday in the month? (1st, 2nd, 3rd...) const weekOfMonth = Math.ceil(dom / 7); const sched = { us: [], eu: [], uk: [], asia: [], americas: [] }; if (dow === 0 || dow === 6) return sched; // Weekend // ---- RECURRING WEEKLY (every week) ---- if (dow === 4) sched.us.push('Initial Jobless Claims'); if (dow === 3) sched.us.push('EIA Crude Inventories'); // ---- MONTHLY US EVENTS (approximate standard schedule) ---- // 1st business day: ISM Manufacturing PMI if (dow === 1 && weekOfMonth === 1) sched.us.push('ISM Manufacturing PMI'); // 1st Friday: NFP + Canada Employment if (dow === 5 && weekOfMonth === 1) { sched.us.push('Non-Farm Payrolls (NFP)'); sched.americas.push('Canada Employment'); } // 3rd business day: ISM Services PMI if (dow === 3 && weekOfMonth === 1) sched.us.push('ISM Services PMI'); // Wed before NFP: ADP if (dow === 3 && weekOfMonth === 1) sched.us.push('ADP Employment Change'); // Tue of 2nd week: CPI typically if (dow === 2 && weekOfMonth === 2) sched.us.push('Consumer Price Index (CPI)'); // Wed of 2nd week: PPI typically if (dow === 3 && weekOfMonth === 2) sched.us.push('Producer Price Index (PPI)'); // Mid-month (~15th): Retail Sales, NY Fed if (dow === 1 && weekOfMonth === 3) sched.us.push('NY Fed Manufacturing'); if (dow === 2 && weekOfMonth === 3) sched.us.push('Retail Sales'); // 3rd Thu: Philly Fed if (dow === 4 && weekOfMonth === 3) sched.us.push('Philly Fed Index'); // 3rd week: Building Permits, Housing Starts if (dow === 3 && weekOfMonth === 3) { sched.us.push('Building Permits'); sched.us.push('Housing Starts'); } // Last Tue: Consumer Confidence, Durable Goods if (dow === 2 && weekOfMonth === 4) { sched.us.push('Consumer Confidence'); sched.us.push('Durable Goods Orders'); } // Last week: PCE, Michigan Sentiment (final), JOLTS, GDP if (dow === 5 && weekOfMonth === 4) sched.us.push('Michigan Consumer Sentiment'); if (dow === 5 && weekOfMonth === 4) sched.us.push('PCE Price Index'); if (dow === 2 && weekOfMonth === 5) sched.us.push('JOLTS Job Openings'); // FOMC: ~6 weeks apart, 8 per year (Jan, Mar, May, Jun, Jul, Sep, Nov, Dec) const fomcMonths = [0, 2, 4, 5, 6, 8, 10, 11]; if (dow === 3 && weekOfMonth === 3 && fomcMonths.includes(month)) sched.us.push('FOMC Rate Decision'); // ---- INTERNATIONAL ---- // 1st business day: China PMI (overnight, shows on Monday) if (dow === 1 && weekOfMonth === 1) sched.asia.push('China Manufacturing PMI'); // ECB: ~6 weeks apart (Jan, Mar, Apr, Jun, Jul, Sep, Oct, Dec) const ecbMonths = [0, 2, 3, 5, 6, 8, 9, 11]; if (dow === 4 && weekOfMonth === 2 && ecbMonths.includes(month)) sched.eu.push('ECB Rate Decision'); // BOE: 8 per year (Feb, Mar, May, Jun, Aug, Sep, Nov, Dec) const boeMonths = [1, 2, 4, 5, 7, 8, 10, 11]; if (dow === 4 && weekOfMonth === 2 && boeMonths.includes(month)) sched.uk.push('BOE Rate Decision'); // UK CPI: mid-month Wed if (dow === 3 && weekOfMonth === 3) sched.uk.push('UK CPI Inflation'); // UK Employment: mid-month Tue if (dow === 2 && weekOfMonth === 2) sched.uk.push('UK Employment Data'); // UK GDP: 2nd week if (dow === 5 && weekOfMonth === 2) sched.uk.push('UK GDP Monthly'); // German ZEW: 2nd/3rd Tue if (dow === 2 && weekOfMonth === 3) sched.eu.push('German ZEW Sentiment'); // German Ifo: 4th week Mon if (dow === 1 && weekOfMonth === 4) sched.eu.push('German Ifo Business Climate'); // Eurozone CPI Flash: end of month if (dow === 5 && weekOfMonth === 4) sched.eu.push('Eurozone CPI Flash'); return sched; } // Build events with full details let events = []; const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; const buildEvents = (eventNames, dayLabel = '') => { return eventNames.map(name => { const details = eventDetails[name] || { time: 'TBD', impact: 'medium', insight: 'Economic data release.' }; return { name: name, time: details.time, impact: details.impact, insight: details.insight, volatility: details.volatility || '', strategy: details.strategy || '', dayLabel: dayLabel }; }); }; const buildEventsForDate = (date, dayLabel = '') => { const sched = getEventsForDate(date); let dayEvents = []; if (countryFilter === 'all') { dayEvents = [ ...buildEvents(sched.us || [], dayLabel), ...buildEvents(sched.eu || [], dayLabel), ...buildEvents(sched.uk || [], dayLabel), ...buildEvents(sched.asia || [], dayLabel), ...buildEvents(sched.americas || [], dayLabel) ]; } else if (countryFilter === 'americas') { dayEvents = [...buildEvents(sched.us || [], dayLabel), ...buildEvents(sched.americas || [], dayLabel)]; } else { dayEvents = buildEvents(sched[countryFilter] || [], dayLabel); } return dayEvents; }; // Calculate the actual dates for each filter option const today2 = new Date(); const currentDow = today2.getDay(); if (dayOfWeek === -1 || dayOfWeek === -2) { // This week or next week — get the actual Monday of the target week const mondayOffset = currentDow === 0 ? -6 : 1 - currentDow; const weekShift = dayOfWeek === -2 ? 7 : 0; for (let d = 0; d < 5; d++) { const date = new Date(today2); date.setDate(today2.getDate() + mondayOffset + weekShift + d); const dayEvents = buildEventsForDate(date, dayNames[date.getDay()]); events = [...events, ...dayEvents]; } } else { // Specific day (today or tomorrow) const targetDate2 = new Date(today2); if (dayOfWeek !== currentDow) targetDate2.setDate(today2.getDate() + 1); events = buildEventsForDate(targetDate2); } // Filter by impact if (impactFilter === 'high') { events = events.filter(e => e.impact === 'high'); } else if (impactFilter === 'high-medium') { events = events.filter(e => e.impact === 'high' || e.impact === 'medium'); } const summary = generateDaySummary(events, dayOfWeek, countryFilter); const bullish = generateBullishScenarios(events); const bearish = generateBearishScenarios(events); const recommendations = generateRecommendations(events, dayOfWeek); let volatilityWarning = null; const highImpactCount = events.filter(e => e.impact === 'high').length; if (highImpactCount >= 2) { volatilityWarning = `${highImpactCount} high-impact events scheduled. Consider reducing position sizes.`; } return { events, summary, bullish, bearish, recommendations, volatilityWarning }; } function generateDaySummary(events, dayOfWeek, countryFilter) { const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; const highImpact = events.filter(e => e.impact === 'high'); // Weekend check if (dayOfWeek === 0 || dayOfWeek === 6) { return `Markets are closed for the weekend. Use this time to review your trades, update your journal, and prepare for the week ahead.`; } // Week views if (dayOfWeek === -1) { if (events.length === 0) { return `Light economic calendar this week. Expect technically-driven price action without major news catalysts.`; } return `This week has ${highImpact.length} high-impact and ${events.length - highImpact.length} medium-impact events across Mon-Fri. Plan your trading around these scheduled releases.`; } if (dayOfWeek === -2) { if (events.length === 0) { return `Light economic calendar next week. Expect technically-driven price action without major news catalysts.`; } return `Next week has ${highImpact.length} high-impact and ${events.length - highImpact.length} medium-impact events across Mon-Fri. Plan ahead for these scheduled releases.`; } // No events for this day if (events.length === 0) { return `Light economic calendar for ${dayNames[dayOfWeek]}. This typically means more technically-driven price action with lower news-driven volatility. Good conditions for trend-following strategies.`; } let summary = `${dayNames[dayOfWeek]} has ${highImpact.length} high-impact and ${events.length - highImpact.length} medium-impact events. `; const eventNamesList = highImpact.map(e => e.name.toLowerCase()); if (eventNamesList.some(n => n.includes('nfp') || n.includes('payroll'))) { summary += `NFP day is typically the most volatile trading session of the month. Expect 30-80 point swings in NQ.`; } else if (eventNamesList.some(n => n.includes('fomc') || n.includes('fed'))) { summary += `FOMC days feature low volume before announcement, then extreme volatility. Initial moves often reverse.`; } else if (eventNamesList.some(n => n.includes('cpi'))) { summary += `CPI releases often set the tone for the entire day. Core CPI tends to move markets more than headline.`; } else if (eventNamesList.some(n => n.includes('jobless'))) { summary += `Jobless claims provide timely labor market read. Rising claims = recession fears.`; } else { summary += `Watch for increased volatility around release times.`; } return summary; } function generateBullishScenarios(events) { const scenarios = []; const names = events.map(e => e.name.toLowerCase()); if (names.some(n => n.includes('nfp') || n.includes('payroll'))) { scenarios.push('Moderate job growth (150-200K) with stable unemployment'); scenarios.push('Wage growth at or below expectations'); } if (names.some(n => n.includes('cpi') || n.includes('inflation'))) { scenarios.push('Inflation below consensus = Fed can pause/cut'); } if (names.some(n => n.includes('jobless'))) { scenarios.push('Claims below 220K = healthy labor market'); } if (scenarios.length === 0) { scenarios.push('Data beats expectations modestly'); scenarios.push('No negative surprises'); } return scenarios.slice(0, 4); } function generateBearishScenarios(events) { const scenarios = []; const names = events.map(e => e.name.toLowerCase()); if (names.some(n => n.includes('nfp') || n.includes('payroll'))) { scenarios.push('Very hot print (>300K) = Fed stays hawkish'); scenarios.push('Very weak (<50K) = recession fears'); } if (names.some(n => n.includes('cpi') || n.includes('inflation'))) { scenarios.push('Inflation above expectations = Fed must tighten'); } if (names.some(n => n.includes('jobless'))) { scenarios.push('Claims spike above 250K'); } if (scenarios.length === 0) { scenarios.push('Data significantly misses expectations'); scenarios.push('Negative revisions to prior data'); } return scenarios.slice(0, 4); } function generateRecommendations(events, dayOfWeek) { const recs = []; const highImpact = events.filter(e => e.impact === 'high'); // Weekend if (dayOfWeek === 0 || dayOfWeek === 6) { return ['Review weekly performance', 'Update trading journal', 'Plan next week']; } // Week views if (dayOfWeek === -1 || dayOfWeek === -2) { if (highImpact.length >= 3) { recs.push('Multiple high-impact days - plan position sizing carefully'); recs.push('Consider reducing exposure on heavy event days'); } recs.push('Mark event times on your calendar'); recs.push('Plan entries/exits around scheduled releases'); recs.push('Review historical reactions to similar events'); recs.push('Prepare scenarios for surprise outcomes'); return recs.slice(0, 6); } // No events if (events.length === 0) { return ['Good day for technical setups', 'Normal position sizing appropriate', 'Standard stops should work', 'Focus on price action']; } if (highImpact.length >= 2) { recs.push('Consider staying flat or reducing size'); recs.push('Widen stops for volatility'); } else if (highImpact.length === 1) { recs.push('Avoid new positions 15-30 min before release'); recs.push('Trade the reaction, not the prediction'); } if (events.some(e => e.time && e.time.includes('8:30'))) { recs.push('Key data at 8:30 AM ET - early volatility expected'); } recs.push('Honor your daily loss limit'); recs.push('Stick to your trading plan'); return recs.slice(0, 6); } document.getElementById('email-auth-form').addEventListener('submit', async (e) => { e.preventDefault(); const email = document.getElementById('auth-email').value; const password = document.getElementById('auth-password').value; try { if (isSignUp) { await auth.createUserWithEmailAndPassword(email, password); } else { await auth.signInWithEmailAndPassword(email, password); } } catch (error) { alert(error.message); } }); document.getElementById('logout-btn').addEventListener('click', () => { auth.signOut(); }); // Keyboard shortcuts document.addEventListener('keydown', (e) => { // Escape to close modals if (e.key === 'Escape') { closeTradeDetail(); closeWeeklyReview(); closePlaybook(); closeSetupModal(); } }); // Weekly review discipline slider document.getElementById('wr-discipline-rating').addEventListener('input', (e) => { document.getElementById('wr-discipline-value').textContent = e.target.value; }); // Mobile menu toggle const mobileMenuBtn = document.getElementById('mobile-menu-btn'); const sidebar = document.querySelector('.sidebar'); const sidebarOverlay = document.getElementById('sidebar-overlay'); mobileMenuBtn.addEventListener('click', () => { sidebar.classList.toggle('open'); sidebarOverlay.classList.toggle('active'); }); sidebarOverlay.addEventListener('click', () => { sidebar.classList.remove('open'); sidebarOverlay.classList.remove('active'); }); // Close mobile menu when navigating document.querySelectorAll('.nav-item').forEach(item => { item.addEventListener('click', () => { closeProfilePopup(); if (window.innerWidth <= 768) { sidebar.classList.remove('open'); sidebarOverlay.classList.remove('active'); } }); }); // Load prop firm config from API with fallback to hardcoded async function loadPropFirmConfigFromAPI() { try { // Check sessionStorage cache first (30-min TTL) const cached = sessionStorage.getItem('propFirmConfig'); const cachedAt = sessionStorage.getItem('propFirmConfigAt'); let data; if (cached && cachedAt && (Date.now() - Number(cachedAt)) < 30 * 60 * 1000) { data = JSON.parse(cached); console.log('[PropFirm Config] Using cached config (age: ' + Math.round((Date.now() - Number(cachedAt)) / 1000) + 's)'); } else { console.log('[PropFirm Config] Fetching from API...'); const response = await fetch('https://us-central1-trade-journal-fc3ba.cloudfunctions.net/getPropFirmConfig'); if (!response.ok) throw new Error(`API returned ${response.status}`); data = await response.json(); // Cache the raw response try { sessionStorage.setItem('propFirmConfig', JSON.stringify(data)); sessionStorage.setItem('propFirmConfigAt', String(Date.now())); } catch (e) { /* quota */ } } // Extract metadata if (data._meta) { propFirmDataSource = { source: data._meta.source || 'firestore', lastUpdated: data._meta.cachedAt ? new Date(data._meta.cachedAt).toLocaleString() : 'Unknown', firmCount: data._meta.firmCount || 0, configId: data._meta.sheetId || '' }; delete data._meta; console.log('[PropFirm Config] Loaded from remote source:', propFirmDataSource); } // Merge API data with hardcoded (API takes precedence) // Keep hardcoded firms that aren't in API (like 'personal', 'other') const hardcodedFallbacks = ['personal', 'other']; const preservedConfigs = {}; hardcodedFallbacks.forEach(key => { if (propFirmConfigs[key]) preservedConfigs[key] = propFirmConfigs[key]; }); // Merge API data with hardcoded — Firestore (API) is the source of truth. // Firestore data wins for ALL fields including accounts, accountsByPlan, caps. // Hardcoded values are fallbacks only for firms not in Firestore (personal, other). Object.keys(data).forEach(firmKey => { const hardcoded = propFirmConfigs[firmKey]; if (hardcoded) { // Firestore wins: spread hardcoded first, then Firestore overwrites propFirmConfigs[firmKey] = { ...hardcoded, ...data[firmKey] }; } else { propFirmConfigs[firmKey] = data[firmKey]; } }); // Extract firm commissions and update firm display names from API response firmCommissions = {}; Object.keys(data).forEach(firmKey => { if (firmKey === '_meta') return; if (data[firmKey]?.commissions && data[firmKey].commissions.length > 0) { firmCommissions[firmKey] = data[firmKey].commissions; } if (data[firmKey]?.name && !propFirmNames[firmKey]) { propFirmNames[firmKey] = data[firmKey].name; } }); // Restore preserved hardcoded-only firms Object.assign(propFirmConfigs, preservedConfigs); // Ensure TopStep has XFA plans (API may not have them yet) if (propFirmConfigs.topstep) { const tsPlans = propFirmConfigs.topstep.plans || []; const hasXfa = tsPlans.some(p => p.id === 'xfa_standard'); if (!hasXfa) { tsPlans.push({ id: 'xfa_standard', name: 'XFA Standard' }); tsPlans.push({ id: 'xfa_consistency', name: 'XFA Consistency' }); propFirmConfigs.topstep.plans = tsPlans; } // Add planAccounts for XFA Consistency caps ($6K vs $5K) if (!propFirmConfigs.topstep.planAccounts) { propFirmConfigs.topstep.planAccounts = { xfa_consistency: { 50000: { buffer: 0, caps: [6000, 6000, 6000, 6000, 6000], drawdown: 2000, maxContracts: 5 }, 100000: { buffer: 0, caps: [6000, 6000, 6000, 6000, 6000], drawdown: 3000, maxContracts: 10 }, 150000: { buffer: 0, caps: [6000, 6000, 6000, 6000, 6000], drawdown: 4500, maxContracts: 15 } } }; } } console.log('[PropFirm Config] Successfully loaded', Object.keys(data).length, 'firms from API'); // Debug: Log Lucid config structure if (propFirmConfigs.lucid) { console.log('[PropFirm Config] LUCID full config:', JSON.parse(JSON.stringify(propFirmConfigs.lucid))); console.log('[PropFirm Config] LUCID plans:', propFirmConfigs.lucid.plans); console.log('[PropFirm Config] LUCID accounts:', propFirmConfigs.lucid.accounts); console.log('[PropFirm Config] LUCID accountsByPlan:', propFirmConfigs.lucid.accountsByPlan); console.log('[PropFirm Config] LUCID evalRules:', propFirmConfigs.lucid.evalRules); console.log('[PropFirm Config] LUCID evalConfig:', propFirmConfigs.lucid.evalConfig); console.log('[PropFirm Config] LUCID rulesByPlan:', propFirmConfigs.lucid.rulesByPlan); } // Update UI indicator updateDataSourceIndicator(); } catch (error) { console.warn('[PropFirm Config] API fetch failed, using hardcoded fallback:', error.message); propFirmDataSource = { source: 'hardcoded_fallback', lastUpdated: 'N/A (using fallback)', firmCount: Object.keys(propFirmConfigs).length, error: error.message }; updateDataSourceIndicator(); } } // Dynamically populate all prop firm dropdowns from propFirmConfigs function populateFirmDropdowns() { // Build sorted list of prop firms (exclude personal, other, daytraders, thetradingpit) const specialKeys = ['personal', 'other']; const firmEntries = Object.entries(propFirmConfigs) .filter(([key]) => !specialKeys.includes(key)) .map(([key, config]) => ({ key, name: config.name || key })) .sort((a, b) => a.name.localeCompare(b.name)); // Dropdowns that use optgroup structure (Prop Firms / Self-Funded) const optgroupSelects = [ 'account-prop-firm', // Add account modal 'edit-account-prop-firm', // Edit account modal 'setup-account-prop-firm', // Account setup modal (auto-created) 'payout-prop-firm' // Payout calculator ]; optgroupSelects.forEach(id => { const el = document.getElementById(id); if (!el) return; const currentVal = el.value; el.innerHTML = '' + '' + firmEntries.map(f => ``).join('') + '' + '' + '' + '' + ''; if (currentVal) el.value = currentVal; }); // Simple flat dropdowns (no optgroup) const flatSelects = [ { id: 'expense-prop-firm', placeholder: 'General (no firm)', includePersonal: true, includeOther: true }, { id: 'commission-prop-firm', placeholder: 'Select Prop Firm...', includePersonal: false, includeOther: true }, { id: 'eval-prop-firm', placeholder: '-- Select Prop Firm --', includePersonal: false, includeOther: true } ]; flatSelects.forEach(({ id, placeholder, includePersonal, includeOther }) => { const el = document.getElementById(id); if (!el) return; const currentVal = el.value; let html = ``; html += firmEntries.map(f => ``).join(''); if (includeOther) html += ''; if (includePersonal) html += ''; el.innerHTML = html; if (currentVal) el.value = currentVal; }); console.log('[PopulateFirmDropdowns] Updated all firm dropdowns with', firmEntries.length, 'firms'); } // Update the data source indicator in Settings function updateDataSourceIndicator() { const indicator = document.getElementById('data-source-indicator'); const text = document.getElementById('data-source-text'); const updated = document.getElementById('data-source-updated'); const count = document.getElementById('data-source-count'); if (!indicator) return; if (propFirmDataSource.source === 'firestore') { indicator.style.background = 'var(--green)'; text.textContent = 'Connected (Live)'; text.style.color = 'var(--green)'; } else if (propFirmDataSource.source === 'hardcoded_fallback') { indicator.style.background = 'var(--orange)'; text.textContent = 'Hardcoded (Fallback)'; text.style.color = 'var(--orange)'; } else { indicator.style.background = 'var(--yellow)'; text.textContent = propFirmDataSource.source; text.style.color = 'var(--yellow)'; } updated.textContent = propFirmDataSource.lastUpdated || '--'; count.textContent = propFirmDataSource.firmCount || Object.keys(propFirmConfigs).length; } // Manual refresh of prop firm data async function refreshPropFirmData() { const btn = event.target; btn.disabled = true; btn.textContent = '🔄 Refreshing...'; try { // Force refresh from API const response = await fetch('https://us-central1-trade-journal-fc3ba.cloudfunctions.net/getPropFirmConfig?refresh=true'); if (!response.ok) throw new Error(`API returned ${response.status}`); const data = await response.json(); if (data._meta) { propFirmDataSource = { source: data._meta.source || 'firestore', lastUpdated: new Date().toLocaleString(), firmCount: data._meta.firmCount || 0 }; delete data._meta; } // Update configs const hardcodedFallbacks = ['personal', 'other']; const preservedConfigs = {}; hardcodedFallbacks.forEach(key => { if (propFirmConfigs[key]) preservedConfigs[key] = propFirmConfigs[key]; }); Object.assign(propFirmConfigs, data, preservedConfigs); updateDataSourceIndicator(); showToast('Prop firm data refreshed!', 'success'); } catch (error) { console.error('Refresh failed:', error); showToast('Refresh failed: ' + error.message, 'error'); } finally { btn.disabled = false; btn.textContent = '🔄 Refresh'; } } // Sync prop firm data with change detection async function syncPropFirmData() { const btn = event.target; btn.disabled = true; btn.textContent = '🔄 Syncing...'; try { const response = await fetch('https://us-central1-trade-journal-fc3ba.cloudfunctions.net/syncPropFirmConfig', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ triggeredBy: currentUser?.email || 'manual' }) }); if (!response.ok) throw new Error(`API returned ${response.status}`); const result = await response.json(); // Refresh local data await loadPropFirmConfigFromAPI(); populateFirmDropdowns(); // Show results if (result.changesDetected > 0) { const changesList = result.changes.map(c => c.details).join('\n• '); showToast(`Sync complete! ${result.changesDetected} change(s) detected:\n• ${changesList}`, 'success'); } else { showToast('Sync complete. No changes detected.', 'success'); } // Refresh logs if panel is open if (document.getElementById('sync-logs-panel').style.display !== 'none') { viewSyncLogs(); } } catch (error) { console.error('Sync failed:', error); showToast('Sync failed: ' + error.message, 'error'); } finally { btn.disabled = false; btn.textContent = '🔄 Sync Config'; } } // Sticky header scroll shadow window.addEventListener('scroll', function() { const w = document.getElementById('sticky-filter-wrapper'); if (w) w.classList.toggle('scrolled', window.scrollY > 10); }); // Session-scoped flag — once we've granted access in this session, subsequent // auth fires that fail the hasAccess check don't re-hide the visible app. // Only a genuine sign-out tears down the UI. let _authHasAccessGranted = false; auth.onAuthStateChanged(async (user) => { currentUser = user; const loadingScreen = document.getElementById('loading-screen'); if (user) { // Safety reset — ensures labActive is never // stuck true from a prior session or // cached page labActive = false; document.getElementById('auth-screen').classList.add('hidden'); document.getElementById('user-display-name').textContent = user.displayName || 'User'; const popupName = document.getElementById('popup-display-name'); const popupEmail = document.getElementById('popup-display-email'); if (popupName) popupName.textContent = user.displayName || 'User'; if (popupEmail) popupEmail.textContent = user.email; updateAllAvatars(); // Read user doc FIRST (for subscription check + reuse in loadAllData) // Must read BEFORE the email save write to avoid Firestore cache race condition const userDocSnap = await db.collection('users').doc(user.uid).get(); // Check if user is admin (from Firestore role field) isAdmin = userDocSnap.exists && userDocSnap.data().role === 'admin'; // Show/hide admin-only elements // Show affiliate nav for users with affiliateCouponCode (self-view) const _isAffiliate = userDocSnap.exists && !!userDocSnap.data().affiliateCouponCode; const affiliateNavSection = document.getElementById('affiliate-nav-section'); if (affiliateNavSection) { affiliateNavSection.style.display = _isAffiliate ? '' : 'none'; } // Show admin nav for admins only const adminNavSection = document.getElementById('admin-nav-section'); if (adminNavSection) { adminNavSection.style.display = isAdmin ? '' : 'none'; } // Restore admin nav collapsed state if (isAdmin) { var _acollapsed = localStorage.getItem('pl_admin_nav_collapsed') === 'true'; var _anav = document.getElementById('admin-nav-items'); var _achev = document.getElementById('admin-nav-chevron'); if (_anav && _acollapsed) _anav.style.display = 'none'; if (_achev) _achev.textContent = _acollapsed ? '▼' : '▲'; } // Silently sync affiliate referrals in background on admin login if (isAdmin) { user.getIdToken().then(token => { fetch('https://us-central1-trade-journal-fc3ba.cloudfunctions.net/syncAffiliateReferrals', { headers: { 'Authorization': 'Bearer ' + token } }).then(r => r.json()).then(data => { console.log('[Affiliate] Background sync:', data.synced, 'synced,', data.skipped, 'skipped'); if (data.synced > 0 && document.getElementById('page-affiliate')?.classList.contains('active')) { renderAffiliatePage(); } }).catch(e => console.log('[Affiliate] Background sync skipped:', e.message)); }); } // Save user email to Firestore — getIdToken() forces auth token sync before write user.getIdToken().then(() => db.collection('users').doc(user.uid).set({ email: user.email.toLowerCase(), displayName: user.displayName || '', lastLogin: new Date().toISOString() }, { merge: true })).catch(e => { /* transient auth timing — ignore */ }); // Capture whether this load came from a Stripe checkout. Don't early-return — // let the full auth handler load data and render the dashboard. The inline // claim block below handles the subscription via the Stripe-email-lookup // fallback, so polling is no longer required. let _postCheckout = false; try { const usp = new URLSearchParams(window.location.search); if (usp.get('checkout') === 'success') { _postCheckout = true; usp.delete('checkout'); const newSearch = usp.toString(); const newUrl = window.location.pathname + (newSearch ? '?' + newSearch : '') + window.location.hash; window.history.replaceState({}, '', newUrl); } } catch(_) {} // Check subscription from the doc we already read let hasAccess = false; if (isAdmin) { hasAccess = true; } else if (userDocSnap.exists) { const sub = userDocSnap.data().subscription; if (sub && (['active', 'trialing'].includes(sub.status) || sub.isFree === true)) { hasAccess = true; } } // Resolve subscription via Cloud Function — single source of truth. // Tries pending_subscriptions first, then falls back to a direct Stripe // lookup by email. Replaces the legacy inline client-side claim, which // failed silently because Firestore rules block client deletes on // pending_subscriptions (every successful claim threw on the delete and // the catch left hasAccess unset, requiring an F5 to recover). if (!hasAccess && user.email) { try { const token = await user.getIdToken(); const r = await fetch('https://us-central1-trade-journal-fc3ba.cloudfunctions.net/claimOrLookupSubscription', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }, body: '{}' }); if (r.ok) { const result = await r.json(); if (result.found) { hasAccess = true; console.log('[Claim] subscription claimed via', result.source); try { const baseAmt = result.subscription?.amount || 0; trackSubscriptionStart('early_supporter', baseAmt, user.uid); } catch(_) {} } } } catch (e) { console.log('[Claim] lookup failed:', e); } } // Belt-and-suspenders for ?checkout=success — if the initial claim missed // (rare race where Stripe customer index hasn't propagated yet), retry once // before bouncing the user to the paywall. if (_postCheckout && !hasAccess) { console.log('[PostCheckout] Initial claim missed, retrying in 2s'); await new Promise(r => setTimeout(r, 2000)); try { const token = await user.getIdToken(); const r = await fetch('https://us-central1-trade-journal-fc3ba.cloudfunctions.net/claimOrLookupSubscription', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }, body: '{}' }); if (r.ok) { const result = await r.json(); if (result.found) { hasAccess = true; console.log('[PostCheckout] Retry succeeded via', result.source); } else { console.log('[PostCheckout] Retry returned no subscription'); } } } catch (e) { console.log('[PostCheckout] Retry error:', e); } } if (hasAccess) { document.getElementById('paywall-screen').classList.add('hidden'); // Set user properties for analytics setUserProperties({ user_id: user.uid, account_count: 0 // Will be updated after loadAllData }); // Run prop firm config + data loading in PARALLEL. // Defensive wrap: even though both functions catch internally, a future // regression that lets a rejection escape would block the reveal. Catch // here so the user always sees an app-screen. try { const [, ] = await Promise.all([ loadPropFirmConfigFromAPI(), loadAllData(userDocSnap) ]); } catch (e) { console.error('[Auth] Promise.all unexpectedly rejected:', e); } // Reveal app FIRST so a render error doesn't leave the user staring at // a blank page. Any throw in renderAll/populateFirmDropdowns/etc. below // will surface in the console with the dashboard already visible. document.getElementById('app-screen').classList.remove('hidden'); _authHasAccessGranted = true; document.getElementById('mobile-menu-btn').style.display = window.innerWidth <= 768 ? 'flex' : 'none'; if (loadingScreen) loadingScreen.remove(); try { // Sweep any orphaned Lab Training demo data left behind by tab close if (!labActive) cleanupOrphanedLabData(); populateFirmDropdowns(); // Exchange rate fetch is non-blocking — app renders in USD until rates arrive // Once rates load, re-render all pages so converted values replace raw USD fetchExchangeRates().then(() => { updateCurrencyDisplay(); if (_userCurrency !== 'USD') renderAll(); }); // Reset P&L calendar to current month on every fresh login / page load currentMonth = new Date(); renderAll(); updateSetupWidget(); updateThemeOptions(); loadRithmicConnections().then(() => loadDashboardSyncBar()).then(() => triggerStaleConnectionSync()); // Check for auto-created accounts that need setup (non-blocking) setTimeout(() => checkPendingAccountSetups(), 2000); checkAutoReset(); // Restore saved route BEFORE showing app to prevent dashboard flash restoreSavedRoute(); } catch (e) { console.error('[Auth] Post-load render error — dashboard visible but partial:', e); } // Lab Training welcome. For ?checkout=success arrivals, brief delay so the // dashboard render is fully painted before the modal overlay appears. For // F5/returning users with an incomplete tour, fires via the existing 2.5s // gate inside checkLabTraining. if (_postCheckout) { setTimeout(() => checkLabTraining(), 800); } else { checkLabTraining(); } // Check for Tradovate OAuth callback redirect checkTradovateOAuthCallback().catch(e => console.log('Tradovate callback check skipped:', e)); // Session check + silent auto-sync on login (single trigger via loadTradovateAccounts) // Store promise so triggerStaleConnectionSync can await it before running Tradovate syncs _tradovateSessionCheckPromise = checkTradovateSession().catch(e => console.log('[Tradovate] Session check skipped:', e)); } else { // Past-due check — show the payment wall instead of the standard paywall // when the subscription is locked due to a failed payment. let isPastDue = false; try { const userDoc = await db.collection('users').doc(currentUser.uid).get(); if (userDoc.exists && userDoc.data()?.subscription?.status === 'past_due') { isPastDue = true; } } catch (_) { /* fall through to default paywall on lookup failure */ } if (isPastDue) { document.getElementById('past-due-screen').classList.remove('hidden'); document.getElementById('paywall-screen').classList.add('hidden'); } else { document.getElementById('paywall-screen').classList.remove('hidden'); document.getElementById('past-due-screen').classList.add('hidden'); } // Don't tear down a previously-revealed app on a re-entrant fire whose // claim CF transiently returned no subscription. Real sign-outs flow // through the user-null else branch below and reset the flag. if (!_authHasAccessGranted) { document.getElementById('app-screen').classList.add('hidden'); } document.getElementById('mobile-menu-btn').style.display = 'none'; if (loadingScreen) loadingScreen.remove(); } } else { detachRealtimeListeners(); // Clear all user-specific data to prevent leaking to next session trades = []; accounts = []; payouts = []; expenses = []; disciplineWins = []; tradeNotes = {}; weeklyReviews = {}; playbook = []; // Real sign-out — clear the session-scoped access flag so a subsequent // user signing in on this same tab gets a clean state. _authHasAccessGranted = false; document.getElementById('auth-screen').classList.remove('hidden'); document.getElementById('paywall-screen').classList.add('hidden'); document.getElementById('past-due-screen').classList.add('hidden'); document.getElementById('signup-blocked-screen').classList.add('hidden'); document.getElementById('app-screen').classList.add('hidden'); document.getElementById('mobile-menu-btn').style.display = 'none'; // Signup gate hook: a Google new-user signup was just blocked and the // Auth user deleted. Override the default auth-screen reveal and show // the block screen instead. The flag is set in the Google handler. if (sessionStorage.getItem('_signupGateBlocked') === '1') { sessionStorage.removeItem('_signupGateBlocked'); document.getElementById('auth-screen').classList.add('hidden'); document.getElementById('signup-blocked-screen').classList.remove('hidden'); } if (loadingScreen) loadingScreen.remove(); } }); // Loading-screen escape hatch — Stripe redirect intermittently stalls Firebase // auth init on first navigation, leaving the loading screen up indefinitely. // After 5s, force-reveal the auth screen so the user can sign in / sign up. setTimeout(() => { const ls = document.getElementById('loading-screen'); if (ls) { // Bail if a user has signed in — the auth handler is still running its // post-login work (Firestore reads, claim CF, post-checkout retry). // Forcing the auth screen here would stack the sign-in form on top of // the dashboard the handler is about to reveal. if (auth.currentUser) { console.warn('[Auth] Loading screen timeout — user signed in, deferring to handler'); return; } console.warn('[Auth] Loading screen timeout — forcing auth screen'); ls.remove(); const authScreen = document.getElementById('auth-screen'); if (authScreen) authScreen.classList.remove('hidden'); } }, 5000); // Check if user has valid subscription async function checkSubscriptionAccess() { if (!currentUser) return false; // Admins always have access if (isAdmin) { return true; } try { const userDoc = await db.collection('users').doc(currentUser.uid).get(); if (userDoc.exists) { const userData = userDoc.data(); const subscription = userData.subscription; if (subscription) { // Check if subscription is active or trialing const validStatuses = ['active', 'trialing']; if (validStatuses.includes(subscription.status)) { return true; } // Check if isFree flag is set (lifetime supporters) if (subscription.isFree === true) { return true; } } } return false; } catch (error) { console.error('Error checking subscription:', error); return false; } } // Handle window resize for mobile menu button window.addEventListener('resize', () => { if (currentUser) { document.getElementById('mobile-menu-btn').style.display = window.innerWidth <= 768 ? 'flex' : 'none'; if (window.innerWidth > 768) { sidebar.classList.remove('open'); sidebarOverlay.classList.remove('active'); } } }); // ===================================================== // DATA LOADING // ===================================================== async function loadAllData(preloadedUserDoc, forceServer) { if (!currentUser) return; // Lab Training mask must not be wiped by an incidental data refresh. // loadAllData reassigns accounts/trades/payouts/expenses from Firestore, // which would replace the demo arrays with the user's real data and // break the tour. Suppress the load entirely while a tour is active. if (typeof labActive !== 'undefined' && labActive) return; const _t0 = performance.now(); // Reset all user-specific data before loading new user's data trades = []; accounts = []; payouts = []; expenses = []; disciplineWins = []; tradeNotes = {}; weeklyReviews = {}; playbook = []; try { const uid = currentUser.uid; const userRef = db.collection('users').doc(uid); const todayKey = getTodayKey(); // ── Phase 1: Fire ALL independent Firestore reads in parallel ── const getOpts = forceServer ? { source: 'server' } : {}; const [ tradesSnap, accountsSnap, commissionsSnap, journalSnap, aiInsightsSnap, scorecardsSnap, checklistDoc, templatesDoc, confluenceDoc, userDoc, tradeNotesSnap, reviewsSnap, remindersDoc, playbookSnap, completionsSnap, widgetDoc, preferencesDoc, tradovatePrefsDoc ] = await Promise.all([ userRef.collection('trades').get(getOpts), userRef.collection('accounts').get(getOpts), userRef.collection('commissions').get(), userRef.collection('journal').get(), userRef.collection('aiInsights').get(), userRef.collection('scorecards').get(), userRef.collection('settings').doc('checklist').get(), userRef.collection('settings').doc('checklistTemplates').get(), userRef.collection('settings').doc('confluence').get(), preloadedUserDoc || userRef.get(), userRef.collection('tradeNotes').get(), userRef.collection('weeklyReviews').get(), userRef.collection('settings').doc('reminders').get(), userRef.collection('playbook').get(), userRef.collection('checklistCompletions').get(), userRef.collection('settings').doc('reportWidgets').get(), userRef.collection('settings').doc('preferences').get(), userRef.collection('settings').doc('tradovatePreferences').get() ]); console.log(`[loadAllData] Phase 1 (parallel reads): ${Math.round(performance.now() - _t0)}ms`); // ── Phase 2: Process all results (synchronous) ── trades = tradesSnap.docs.map(d => ({ ...d.data(), id: d.id })).filter(t => !t.deleted); accounts = accountsSnap.docs.map(d => ({ id: d.id, ...d.data() })); // Dedup: remove duplicate accounts (same propFirm + name) // Keep the account with the deterministic ID format (propFirm_sanitizedName) if one exists const seenAccountKeys = {}; const dupAccountIds = []; for (const acct of accounts) { const key = `${acct.propFirm}_${acct.name}`; if (seenAccountKeys[key]) { // Prefer deterministic ID (contains propFirm prefix) over random Firebase ID const existingId = seenAccountKeys[key]; const existingIsDeterministic = existingId.startsWith(acct.propFirm + '_'); const currentIsDeterministic = acct.id.startsWith(acct.propFirm + '_'); if (currentIsDeterministic && !existingIsDeterministic) { // Current is deterministic, existing is random — keep current, remove existing dupAccountIds.push(existingId); seenAccountKeys[key] = acct.id; console.warn(`[Dedup] Replacing random ID ${existingId} with deterministic ${acct.id} for ${key}`); } else { // Keep existing, remove current dupAccountIds.push(acct.id); console.warn(`[Dedup] Removing duplicate ${acct.id} (keeping ${existingId}) for ${key}`); } } else { seenAccountKeys[key] = acct.id; } } if (dupAccountIds.length > 0) { console.warn(`[loadAllData] Found ${dupAccountIds.length} duplicate accounts, cleaning up...`); // Before deleting, migrate trades from duplicate accounts to the kept account for (const dupId of dupAccountIds) { const dupAcct = accounts.find(a => a.id === dupId); if (!dupAcct) continue; const keptKey = `${dupAcct.propFirm}_${dupAcct.name}`; const keptId = seenAccountKeys[keptKey]; // Reassign trades from the duplicate to the kept account const dupTrades = trades.filter(t => t.accountId === dupId); if (dupTrades.length > 0) { console.log(`[Dedup] Migrating ${dupTrades.length} trades from ${dupId} → ${keptId}`); const migrateBatch = db.batch(); let migrateCount = 0; for (const trade of dupTrades) { migrateBatch.update( db.collection('users').doc(currentUser.uid).collection('trades').doc(trade.id), { accountId: keptId } ); trade.accountId = keptId; migrateCount++; if (migrateCount >= 490) break; // Firestore batch limit safety } await migrateBatch.commit(); } } const batch = db.batch(); dupAccountIds.forEach(id => { batch.delete(db.collection('users').doc(currentUser.uid).collection('accounts').doc(id)); }); await batch.commit(); accounts = accounts.filter(a => !dupAccountIds.includes(a.id)); console.log(`[loadAllData] Removed ${dupAccountIds.length} duplicate accounts`); } // Diagnostic: log trade counts per account const tradesByAcct = {}; trades.forEach(t => { const key = t.accountId || 'NO_ACCOUNT'; if (!tradesByAcct[key]) tradesByAcct[key] = { count: 0, sources: new Set(), dates: new Set() }; tradesByAcct[key].count++; tradesByAcct[key].sources.add(t.source || 'unknown'); if (t.date) tradesByAcct[key].dates.add(t.date); }); const orphanCount = tradesByAcct['NO_ACCOUNT']?.count || 0; const unmatchedAcctIds = Object.keys(tradesByAcct).filter(id => id !== 'NO_ACCOUNT' && !accounts.find(a => a.id === id)); if (orphanCount > 0) console.warn(`[loadAllData] ${orphanCount} trades have no accountId`); if (unmatchedAcctIds.length > 0) { console.warn(`[loadAllData] Trades exist for ${unmatchedAcctIds.length} account IDs that don't match any account:`, unmatchedAcctIds); unmatchedAcctIds.forEach(id => { const info = tradesByAcct[id]; console.warn(` Account ID ${id}: ${info.count} trades, sources: ${[...info.sources].join(',')}, dates: ${[...info.dates].sort().join(',')}`); }); } console.log(`[loadAllData] Loaded ${trades.length} trades across ${Object.keys(tradesByAcct).length} accounts, ${accounts.length} accounts`); // Separate eval accounts loadEvalAccounts(); commissions = commissionsSnap.docs.map(d => ({ id: d.id, ...d.data() })); journalSnap.docs.forEach(d => journal[d.id] = d.data()); // AI insights (legacy fallback is rare — only fetch if needed) aiInsightsSnap.docs.forEach(d => dayInsightsCache[d.id] = d.data()); if (aiInsightsSnap.empty) { const legacyInsightsSnap = await userRef.collection('dayInsights').get(); legacyInsightsSnap.docs.forEach(d => dayInsightsCache[d.id] = d.data()); } scorecardsSnap.docs.forEach(d => scorecards[d.id] = d.data()); // Checklist settings if (checklistDoc.exists) { checklistSettings = { ...checklistSettings, ...checklistDoc.data() }; if (checklistSettings.riskItems) { checklistSettings.riskItems = checklistSettings.riskItems.map((item, idx) => ({ ...item, id: item.id || `risk-${Date.now()}-${idx}` })); } if (checklistSettings.structureItems) { checklistSettings.structureItems = checklistSettings.structureItems.map((item, idx) => ({ ...item, id: item.id || `struct-${Date.now()}-${idx}` })); } const needsSave = checklistDoc.data().riskItems?.some(i => !i.id) || checklistDoc.data().structureItems?.some(i => !i.id); if (needsSave) { console.log('[Checklist] Generated missing IDs, saving...'); userRef.collection('settings').doc('checklist').set(checklistSettings); // fire-and-forget } } // Checklist templates if (templatesDoc.exists && templatesDoc.data().templates) { checklistTemplates = templatesDoc.data().templates.map(tmpl => ({ ...tmpl, items: (tmpl.items || []).map((item, idx) => ({ ...item, id: item.id || `cli-${Date.now()}-${idx}` })) })); // Heal duplicate template IDs (the 'cl-premarket' collision bug). Reassign a fresh // unique id to every duplicate beyond the first occurrence. NO template and NO item // array is ever dropped — only the colliding id is changed. Surface, don't silence. const _seenTplIds = new Set(); let _dupTplReassigned = 0; checklistTemplates.forEach(tmpl => { if (_seenTplIds.has(tmpl.id)) { const _oldId = tmpl.id; const _newId = 'cl-premarket-' + Date.now() + '-' + Math.random().toString(36).slice(2, 7); tmpl.id = _newId; _dupTplReassigned++; console.warn('[Checklist] reassigned duplicate template id', _oldId, '->', _newId); } _seenTplIds.add(tmpl.id); }); const needsTemplateSave = templatesDoc.data().templates.some(tmpl => tmpl.items?.some(i => !i.id) ); if (needsTemplateSave || _dupTplReassigned > 0) { userRef.collection('settings').doc('checklistTemplates').set({ templates: checklistTemplates }); } if (_dupTplReassigned > 0) { try { showNotification('Fixed a duplicate checklist entry.', 'warning', 5000); } catch (e) { /* notification not ready */ } } } if (checklistTemplates.length === 0) { migrateOldChecklistToTemplates(); if (checklistTemplates.length > 0) { saveChecklistTemplates(); // fire-and-forget } } // Checklist completions — build the full by-date map (one small {itemId:true} doc per day). // Same source feeds the live Process gauge (today) and the per-day history curve. checklistCompletionsByDate = {}; completionsSnap.docs.forEach(d => { checklistCompletionsByDate[d.id] = d.data(); }); // Today's live checklist state. const todayCompletion = checklistCompletionsByDate[todayKey]; if (todayCompletion) { checklistChecked = todayCompletion; } else { checklistChecked = {}; checklistSettings.riskItems.forEach(item => { if (item.checked) checklistChecked[item.id] = true; }); checklistSettings.structureItems.forEach(item => { if (item.checked) checklistChecked[item.id] = true; }); } // Confluence items if (confluenceDoc.exists && confluenceDoc.data().items) { confluenceItems = confluenceDoc.data().items; } // User doc (payouts, settings, subscription) if (userDoc.exists) { const userData = userDoc.data(); if (userData.payouts) payouts = userData.payouts; if (userData.payoutSettings) payoutSettings = { ...payoutSettings, ...userData.payoutSettings }; if (userData.disciplineWins) disciplineWins = userData.disciplineWins; if (userData.expenses) expenses = userData.expenses; if (userData.subscription) { updateSubscriptionDisplay(userData.subscription); } } tradeNotesSnap.docs.forEach(d => tradeNotes[d.id] = d.data()); reviewsSnap.docs.forEach(d => weeklyReviews[d.id] = d.data()); // Saturday reminder try { if (remindersDoc.exists && remindersDoc.data().saturdayDismissed) { localStorage.setItem('saturdayReminderDismissed', remindersDoc.data().saturdayDismissed); } } catch (e) { /* ignore */ } // Playbook playbook = playbookSnap.docs.map(d => ({ id: d.id, ...d.data() })); // Migrate legacy global confluence items into per-setup confluenceItems if (confluenceDoc.exists && confluenceDoc.data().items?.length > 0 && confluenceDoc.data().migrated !== true) { const legacyItems = confluenceDoc.data().items; let migrated = false; for (const setup of playbook) { if (!setup.confluenceItems || setup.confluenceItems.length === 0) { setup.confluenceItems = [...legacyItems]; userRef.collection('playbook').doc(setup.id) .update({ confluenceItems: legacyItems }); // fire-and-forget migrated = true; } } if (migrated) { userRef.collection('settings').doc('confluence') .update({ migrated: true }); // fire-and-forget console.log('[Migration] Legacy confluence items migrated to setups'); } } // Load theme const savedTheme = localStorage.getItem('theme'); if (savedTheme === 'light') { isDarkTheme = false; currentTheme = 'light'; document.body.classList.add('light-theme'); } else if (savedTheme === 'colorblind') { currentTheme = 'colorblind'; document.body.classList.add('colorblind-theme'); } // Widget settings (already fetched in parallel) if (widgetDoc.exists) { const data = widgetDoc.data(); if (data.order && !localStorage.getItem('reportWidgetOrder')) { localStorage.setItem('reportWidgetOrder', JSON.stringify(data.order)); } if (data.columns && !localStorage.getItem('reportWidgetColumns')) { localStorage.setItem('reportWidgetColumns', data.columns); } if (data.visibility && !localStorage.getItem('reportWidgetVisibility')) { localStorage.setItem('reportWidgetVisibility', JSON.stringify(data.visibility)); } } // User preferences — timezone + currency (single read, already fetched in parallel) if (preferencesDoc.exists) { const prefs = preferencesDoc.data(); if (prefs.timezone) _userTimezone = prefs.timezone; if (prefs.currency) _userCurrency = prefs.currency; } // Tradovate disconnected firms — load early so Connections page renders correctly if (tradovatePrefsDoc.exists && tradovatePrefsDoc.data().disconnectedFirms) { _tradovateDisconnectedFirms = new Set(tradovatePrefsDoc.data().disconnectedFirms); } else { _tradovateDisconnectedFirms = new Set(); } // Update UI dropdowns to reflect loaded preferences const tzSel = document.getElementById('timezone-select'); if (tzSel) tzSel.value = _userTimezone || ''; updateTimezoneDisplay(); const currSel = document.getElementById('currency-select'); if (currSel) currSel.value = _userCurrency; updateCurrencyDisplay(); console.log(`[loadAllData] Total: ${Math.round(performance.now() - _t0)}ms`); // Attach real-time Firestore listeners so server-side syncs // (auto-sync, other tabs) update the UI without a page refresh attachRealtimeListeners(); } catch (error) { console.error('Load error:', error); } } function detachRealtimeListeners() { if (_unsubAccounts) { _unsubAccounts(); _unsubAccounts = null; } if (_unsubTrades) { _unsubTrades(); _unsubTrades = null; } _realtimeReady = false; } function attachRealtimeListeners() { if (!currentUser) return; // Detach any previous listeners (e.g. if loadAllData is called again) detachRealtimeListeners(); let accountsInitial = true; let tradesInitial = true; _unsubAccounts = db.collection('users').doc(currentUser.uid) .collection('accounts').onSnapshot(snap => { if (accountsInitial) { accountsInitial = false; return; } // skip initial (already loaded) if (labActive) return; // Suppress real-data refreshes during Lab Training mask console.log('[Realtime] Accounts changed — refreshing'); accounts = snap.docs.map(d => ({ id: d.id, ...d.data() })); loadEvalAccounts(); renderAll(); updateSetupWidget(); }, err => console.warn('[Realtime] accounts listener error:', err)); _unsubTrades = db.collection('users').doc(currentUser.uid) .collection('trades').onSnapshot(snap => { if (tradesInitial) { tradesInitial = false; return; } // skip initial (already loaded) if (labActive) return; // Suppress real-data refreshes during Lab Training mask console.log('[Realtime] Trades changed — refreshing'); trades = snap.docs.map(d => ({ ...d.data(), id: d.id })).filter(t => !t.deleted); renderAll(); }, err => console.warn('[Realtime] trades listener error:', err)); _realtimeReady = true; console.log('[Realtime] Firestore listeners attached for accounts + trades'); } async function saveChecklistSettings() { if (!currentUser) return; try { await db.collection('users').doc(currentUser.uid).collection('settings').doc('checklist').set(checklistSettings); } catch (error) { console.error('Save settings error:', error); } } // saveConfluenceItems removed — confluence items now saved per-setup in saveSetup() // ===================================================== // CHECKLIST TEMPLATE MANAGEMENT // ===================================================== async function saveChecklistTemplates() { if (!currentUser) return; try { await db.collection('users').doc(currentUser.uid).collection('settings').doc('checklistTemplates').set({ templates: checklistTemplates }); } catch (error) { console.error('Save checklist templates error:', error); } } async function saveChecklistCompletion(dateKey) { if (!currentUser) return; try { await db.collection('users').doc(currentUser.uid).collection('checklistCompletions').doc(dateKey).set(checklistChecked); } catch (error) { console.error('Save checklist completion error:', error); } } // Generate a unique checklist-template id. Never hardcode a fixed id — a fixed id // ('cl-premarket') re-minted duplicates on rename and broke the id-based edit/save. function genChecklistTemplateId() { return 'cl-premarket-' + Date.now() + '-' + Math.random().toString(36).slice(2, 7); } function renderChecklistTemplatesList() { const container = document.getElementById('checklist-templates-list'); if (!container) return; // Auto-create a default Pre-Market Checklist ONLY when the user has no templates at all. // (Previously this was gated on an exact name match, so renaming the checklist made the // next render find no match and mint ANOTHER 'cl-premarket' → duplicate IDs → data loss.) if (checklistTemplates.length === 0) { checklistTemplates.push({ id: genChecklistTemplateId(), name: 'Pre-Market Checklist', items: [] }); saveChecklistTemplates(); } // Render ALL templates (selected by stable id) so a renamed checklist is always // visible and editable — it never goes invisible or triggers a duplicate re-mint. container.innerHTML = checklistTemplates.map(tmpl => { const items = tmpl.items || []; const isExpanded = expandedChecklistId === tmpl.id; return `
📋 ${tmpl.name} ${items.length} item${items.length !== 1 ? 's' : ''}
${isExpanded ? `
${items.length > 0 ? items.map(item => `
${item.text}
`).join('') : '
No items yet. Click Edit to add your pre-market checklist items.
'}
` : ''}
`; }).join(''); } function toggleChecklistExpand(id) { expandedChecklistId = expandedChecklistId === id ? null : id; renderChecklistTemplatesList(); } function openNewChecklist() { editingChecklistId = null; editingChecklistItems = []; document.getElementById('checklist-modal-title').textContent = 'New Checklist'; document.getElementById('checklist-name-input').value = ''; renderChecklistItemsEditor(); document.getElementById('checklist-modal').style.display = 'flex'; } function editChecklist(id) { const tmpl = checklistTemplates.find(t => t.id === id); if (!tmpl) return; editingChecklistId = id; editingChecklistItems = tmpl.items.map((i, idx) => ({ ...i, id: i.id || `cli-${Date.now()}-${idx}` })); document.getElementById('checklist-modal-title').textContent = 'Edit Checklist'; document.getElementById('checklist-name-input').value = tmpl.name; renderChecklistItemsEditor(); document.getElementById('checklist-modal').style.display = 'flex'; } function closeChecklistModal() { document.getElementById('checklist-modal').style.display = 'none'; editingChecklistId = null; editingChecklistItems = []; } function renderChecklistItemsEditor() { const container = document.getElementById('checklist-items-editor'); if (editingChecklistItems.length === 0) { container.innerHTML = '
No items yet. Add items below.
'; return; } container.innerHTML = editingChecklistItems.map((item, idx) => `
${idx + 1}. ${item.text}
`).join(''); } function addChecklistEditorItem() { const input = document.getElementById('checklist-new-item-text'); const text = input.value.trim(); if (!text) return; editingChecklistItems.push({ id: 'cli-' + Date.now() + '-' + Math.random().toString(36).substr(2, 5), text }); input.value = ''; renderChecklistItemsEditor(); input.focus(); } function editChecklistEditorItem(id) { const item = editingChecklistItems.find(i => String(i.id) === String(id)); if (!item) return; const newText = prompt('Edit item text:', item.text); if (newText !== null && newText.trim()) { item.text = newText.trim(); renderChecklistItemsEditor(); } } function deleteChecklistEditorItem(id) { editingChecklistItems = editingChecklistItems.filter(i => String(i.id) !== String(id)); renderChecklistItemsEditor(); } async function saveChecklist() { const name = document.getElementById('checklist-name-input').value.trim(); if (!name) { alert('Please enter a checklist name.'); return; } if (editingChecklistId) { // Update existing const idx = checklistTemplates.findIndex(t => t.id === editingChecklistId); if (idx >= 0) { checklistTemplates[idx].name = name; checklistTemplates[idx].items = editingChecklistItems; } else { // Target template not found (stale or de-duped id). Do NOT silently drop the user's // items — re-add them as a new template so nothing the user typed is lost. console.warn('[Checklist] editingChecklistId not found on save, re-adding as new template:', editingChecklistId); checklistTemplates.push({ id: genChecklistTemplateId(), name, items: editingChecklistItems }); try { showNotification('Saved as a new checklist (the original entry was missing).', 'warning', 5000); } catch (e) { /* notification not ready */ } } } else { // New checklist checklistTemplates.push({ id: 'cl-' + Date.now(), name, items: editingChecklistItems }); } await saveChecklistTemplates(); closeChecklistModal(); renderChecklistTemplatesList(); renderPremarketChecklist(); } async function deleteChecklist(id) { const tmpl = checklistTemplates.find(t => t.id === id); if (!tmpl) return; if (!confirm(`Delete "${tmpl.name}"? This cannot be undone.`)) return; checklistTemplates = checklistTemplates.filter(t => t.id !== id); await saveChecklistTemplates(); renderChecklistTemplatesList(); renderPremarketChecklist(); } // Migration: convert old checklistSettings into new templates format function migrateOldChecklistToTemplates() { if (checklistTemplates.length > 0) return; // Already migrated or has data const hasRisk = checklistSettings.riskItems && checklistSettings.riskItems.length > 0; const hasStruct = checklistSettings.structureItems && checklistSettings.structureItems.length > 0; if (!hasRisk && !hasStruct) return; const items = []; checklistSettings.riskItems.forEach(item => { items.push({ id: item.id, text: item.text, section: 'Risk Management' }); }); checklistSettings.structureItems.forEach(item => { items.push({ id: item.id, text: item.text, section: 'Structure' }); }); checklistTemplates.push({ id: 'cl-migrated-premarket', name: 'Pre-Market Checklist', items: items }); } async function saveScorecard(dateKey, data) { if (!currentUser) return; try { await db.collection('users').doc(currentUser.uid).collection('scorecards').doc(dateKey).set(data); scorecards[dateKey] = data; } catch (error) { console.error('Save scorecard error:', error); } } // ===================================================== // AUTO RESET LOGIC // ===================================================== function checkAutoReset() { const today = getTodayKey(); const lastReset = checklistSettings.lastResetDate; // Always reset if it's a new day (regardless of time) if (lastReset !== today) { // Track skipped items only if autoReset is enabled and it's past reset time const now = new Date(); const [resetHour, resetMin] = (checklistSettings.resetTime || '08:30').split(':').map(Number); const resetTime = new Date(); resetTime.setHours(resetHour, resetMin, 0, 0); // Only track skips if we're past the reset time (user "missed" completing them) if (checklistSettings.autoReset && now >= resetTime && lastReset) { checklistSettings.riskItems.forEach(item => { if (!item.checked) { checklistSettings.skipHistory[item.id] = (checklistSettings.skipHistory[item.id] || 0) + 1; } }); checklistSettings.structureItems.forEach(item => { if (!item.checked) { checklistSettings.skipHistory[item.id] = (checklistSettings.skipHistory[item.id] || 0) + 1; } }); } // Reset all items for the new day checklistSettings.riskItems.forEach(item => item.checked = false); checklistSettings.structureItems.forEach(item => item.checked = false); checklistChecked = {}; // Clear dynamic checklist state checklistSettings.lastResetDate = today; saveChecklistSettings(); // Show notice if it was an auto-reset if (lastReset) { document.getElementById('premarket-auto-reset-notice').classList.remove('hidden'); setTimeout(() => { document.getElementById('premarket-auto-reset-notice').classList.add('hidden'); }, 5000); } renderPremarketChecklist(); } } // ===================================================== // SUBSCRIPTION DISPLAY // ===================================================== function updateSubscriptionDisplay(subscription) { const statusEl = document.getElementById('subscription-status'); const planEl = document.getElementById('subscription-plan'); const priceEl = document.getElementById('subscription-price'); const billingEl = document.getElementById('subscription-next-billing'); if (!statusEl) return; // Helper to parse Firestore timestamp or ISO string const toDate = (val) => { if (!val) return null; return val.toDate ? val.toDate() : new Date(val); }; const fmtDate = (d) => d ? d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '--'; const fmtDateShort = (d) => d ? d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '--'; // Extract subscription fields const status = subscription.status || 'active'; const planName = subscription.plan || 'Early Supporter'; const baseAmount = subscription.amount !== undefined ? subscription.amount : 10; const interval = subscription.interval || 'month'; const isFree = subscription.isFree || false; const couponName = subscription.couponName || null; const couponPercentOff = subscription.couponPercentOff || null; const couponAmountOff = subscription.couponAmountOff || null; const couponDuration = subscription.couponDuration || null; const couponDurationInMonths = subscription.couponDurationInMonths || null; const discountEnd = toDate(subscription.discountEnd); const effectiveAmount = subscription.effectiveAmount !== undefined ? subscription.effectiveAmount : baseAmount; const trialEnd = toDate(subscription.trialEnd); const cancelAtPeriodEnd = subscription.cancelAtPeriodEnd || false; const currentPeriodEnd = toDate(subscription.currentPeriodEnd); const isLifetimePlan = planName.toLowerCase().includes('lifetime'); const isEffectivelyFree = effectiveAmount === 0; const isForeverFree = isLifetimePlan || (isFree && (couponDuration === 'forever' || interval === 'lifetime')) || (baseAmount === 0 && !currentPeriodEnd && !trialEnd) || (isEffectivelyFree && couponDuration === 'forever'); const hasCoupon = !!(couponName && (couponPercentOff || couponAmountOff || isEffectivelyFree)); // --- Status --- const statusMap = { 'active': { text: 'Active', color: 'var(--green)' }, 'trialing': { text: 'Free Trial', color: 'var(--cyan)' }, 'past_due': { text: 'Past Due', color: 'var(--orange)' }, 'canceled': { text: 'Canceled', color: 'var(--red)' }, 'unpaid': { text: 'Unpaid', color: 'var(--red)' }, 'incomplete': { text: 'Incomplete', color: 'var(--orange)' } }; const statusInfo = statusMap[status] || { text: 'Active', color: 'var(--green)' }; if (subscription.status) { statusEl.textContent = statusInfo.text; statusEl.style.color = statusInfo.color; statusEl.dataset.loaded = '1'; } // --- Plan name --- if (planEl && subscription.plan) { planEl.textContent = isLifetimePlan ? 'Lifetime Access' : planName; planEl.dataset.loaded = '1'; } // --- Price display (skip if no amount data yet — leave skeleton) --- if (priceEl && subscription.amount !== undefined) { priceEl.dataset.loaded = '1'; if (isForeverFree) { priceEl.innerHTML = 'FREE / lifetime'; } else if (couponPercentOff === 100 && discountEnd) { priceEl.innerHTML = `FREE until ${fmtDateShort(discountEnd)} · Then $${baseAmount}/mo`; } else if (couponPercentOff === 100 && couponDuration === 'repeating' && couponDurationInMonths) { priceEl.innerHTML = `FREE for ${couponDurationInMonths} months · Then $${baseAmount}/mo`; } else if (effectiveAmount === 0 && trialEnd) { priceEl.innerHTML = `FREE trial until ${fmtDateShort(trialEnd)} · Then $${baseAmount}/mo`; } else if (couponPercentOff && couponPercentOff > 0 && couponPercentOff < 100) { priceEl.innerHTML = `$${baseAmount} $${effectiveAmount}/mo`; } else if (couponAmountOff && couponAmountOff > 0 && effectiveAmount > 0) { priceEl.innerHTML = `$${baseAmount} $${effectiveAmount}/mo`; } else if (isEffectivelyFree) { // Catch-all: any coupon/discount that brings price to $0 const suffix = couponDuration === 'repeating' && couponDurationInMonths ? ` for ${couponDurationInMonths} months · Then $${baseAmount}/mo` : discountEnd ? ` until ${fmtDateShort(discountEnd)} · Then $${baseAmount}/mo` : ''; priceEl.innerHTML = `Free${suffix}`; } else { priceEl.textContent = `$${baseAmount}/${interval === 'year' ? 'yr' : 'mo'}`; } // Coupon badge const existingBadge = priceEl.parentElement?.querySelector('.coupon-badge'); if (existingBadge) existingBadge.remove(); if (hasCoupon && !isForeverFree) { const badge = document.createElement('span'); badge.className = 'coupon-badge'; badge.style.cssText = 'display: inline-block; font-size: 11px; background: rgba(0,212,170,0.1); color: var(--cyan); padding: 2px 8px; border-radius: 4px; margin-left: 8px; font-weight: 600;'; badge.textContent = `${couponName} applied`; priceEl.parentElement?.appendChild(badge); } } // --- Next billing / payment --- const billingWrap = document.getElementById('billing-wrapper'); const billingDiv = document.getElementById('billing-divider'); const showBilling = (show) => { if (billingWrap) billingWrap.style.display = show ? '' : 'none'; if (billingDiv) billingDiv.style.display = show ? '' : 'none'; }; if (billingEl) { if (isForeverFree || (isEffectivelyFree && !discountEnd && !trialEnd && !(couponDuration === 'repeating' && couponDurationInMonths))) { // Forever free or $0 with no end date — hide Next Payment entirely showBilling(false); } else if (isEffectivelyFree && discountEnd) { showBilling(true); billingEl.innerHTML = `Free until ${fmtDateShort(discountEnd)}
Then $${baseAmount} on ${fmtDate(discountEnd)}`; } else if (isEffectivelyFree && couponDuration === 'repeating' && couponDurationInMonths) { showBilling(true); billingEl.innerHTML = `Free for ${couponDurationInMonths} months
Then $${baseAmount}/mo`; } else if (status === 'trialing' && trialEnd) { showBilling(true); billingEl.innerHTML = `Trial ends ${fmtDateShort(trialEnd)}`; } else if (cancelAtPeriodEnd && currentPeriodEnd) { showBilling(true); billingEl.innerHTML = `${fmtDate(currentPeriodEnd)}
Cancels after this period`; } else if (currentPeriodEnd) { showBilling(true); billingEl.textContent = fmtDate(currentPeriodEnd); } else { showBilling(true); billingEl.textContent = '--'; } } // --- Payment method section --- const paymentSection = document.getElementById('sub-payment-method'); if (paymentSection) { if (isForeverFree) { paymentSection.innerHTML = `
No payment method required
You're on a lifetime free plan
`; } } // --- Manage section --- const manageTitle = document.getElementById('sub-manage-title'); const manageDesc = document.getElementById('sub-manage-desc'); const cancelBtn = document.getElementById('sub-cancel-btn'); if (isForeverFree) { if (manageTitle) manageTitle.textContent = 'Your Plan'; if (manageDesc) manageDesc.textContent = 'You\'re on a free plan — no action needed.'; if (cancelBtn) cancelBtn.style.display = 'none'; } else if (status === 'canceled') { if (manageTitle) manageTitle.textContent = 'Subscription Canceled'; if (manageDesc) manageDesc.textContent = 'Your subscription has been canceled. Resubscribe via the billing portal to restore access.'; if (cancelBtn) cancelBtn.style.display = 'none'; } else { if (manageTitle) manageTitle.textContent = 'Manage Subscription'; if (manageDesc) manageDesc.textContent = 'Update payment method, view invoices, download receipts, or cancel.'; if (cancelBtn) cancelBtn.style.display = ''; } // --- Plan comparison cards --- const earlyCard = document.getElementById('plan-card-early'); const standardCard = document.getElementById('plan-card-standard'); const earlyBtn = document.getElementById('plan-btn-early'); const standardBtn = document.getElementById('plan-btn-standard'); const earlyPriceEl = document.getElementById('plan-price-early'); // Update Early Supporter card price to reflect effective amount const earlySubtitleEl = document.getElementById('plan-subtitle-early'); const earlyIntervalEl = document.getElementById('plan-interval-early'); if (earlyPriceEl) { const isOnEarly = planName.toLowerCase().includes('early') || planName.toLowerCase().includes('lifetime') || planName.toLowerCase().includes('affiliate'); if (isOnEarly && isEffectivelyFree) { earlyPriceEl.innerHTML = 'Free'; if (earlyIntervalEl) earlyIntervalEl.style.display = 'none'; if (earlySubtitleEl && couponName) earlySubtitleEl.innerHTML = `${couponName} applied`; } else if (isOnEarly && effectiveAmount < baseAmount) { earlyPriceEl.innerHTML = `$${baseAmount} $${effectiveAmount}`; if (earlyIntervalEl) earlyIntervalEl.style.display = ''; if (earlySubtitleEl && couponName) earlySubtitleEl.innerHTML = `${couponName} applied`; } else { earlyPriceEl.textContent = `$${baseAmount}`; if (earlyIntervalEl) earlyIntervalEl.style.display = ''; } } const isEarly = planName.toLowerCase().includes('early') || (baseAmount <= 10 && !isFree); const isFreeLife = isFree || baseAmount === 0; if (earlyCard && standardCard) { if (isEarly || isFreeLife) { earlyCard.style.border = '1.5px solid var(--cyan)'; earlyCard.style.boxShadow = '0 0 20px rgba(0, 212, 170, 0.08)'; standardCard.style.border = '1px solid var(--border-color)'; standardCard.style.boxShadow = 'none'; let earlyBtnText = 'Current Plan'; if (status === 'trialing') earlyBtnText = 'Current Plan (Trial)'; if (earlyBtn) { earlyBtn.textContent = earlyBtnText; earlyBtn.style.background = 'rgba(0, 212, 170, 0.08)'; earlyBtn.style.borderColor = 'rgba(0, 212, 170, 0.3)'; earlyBtn.style.color = 'var(--cyan)'; } if (standardBtn) { standardBtn.textContent = 'Standard Plan'; standardBtn.style.background = 'var(--bg-card)'; standardBtn.style.borderColor = 'var(--border-color)'; standardBtn.style.color = 'var(--text-muted)'; } } else { standardCard.style.border = '1.5px solid var(--cyan)'; standardCard.style.boxShadow = '0 0 20px rgba(0, 212, 170, 0.08)'; earlyCard.style.border = '1px solid var(--border-color)'; earlyCard.style.boxShadow = 'none'; earlyCard.style.opacity = '0.6'; if (standardBtn) { standardBtn.textContent = 'Current Plan'; standardBtn.style.background = 'rgba(0, 212, 170, 0.08)'; standardBtn.style.borderColor = 'rgba(0, 212, 170, 0.3)'; standardBtn.style.color = 'var(--cyan)'; } if (earlyBtn) { earlyBtn.textContent = 'No longer available'; earlyBtn.style.background = 'var(--bg-card)'; earlyBtn.style.borderColor = 'var(--border-color)'; earlyBtn.style.color = 'var(--text-muted)'; } } } } // ===================================================== // RENDER FUNCTIONS // ===================================================== function renderAll() { // Invalidate per-render metrics cache — calculateAccountMetrics results are cached // for the duration of this render cycle to avoid redundant computation per account. _metricsCache = {}; // Set scorecard date to today if not already set const scorecardDateInput = document.getElementById('scorecard-date'); if (scorecardDateInput && !scorecardDateInput.value) { scorecardDateInput.value = getTodayKey(); } // Always update filter dropdowns and lightweight state (needed regardless of active page) populateGlobalPropFirmFilter(); populateGlobalAccountFilter(); populateInstrumentFilter(); updateGradeLegend(); updateFilterDropdowns(); updateImportStats(); checkSaturdayReminder(); // Payout account dropdown is cheap (options only) and must be ready before user visits payouts populatePayoutAccountDropdown(); // Page-aware rendering — only render pages that are currently visible. // Navigation (nav-item click handler) triggers page-specific renders on every visit, // so skipped pages will be re-rendered correctly when the user navigates to them. const _pa = id => document.getElementById('page-' + id)?.classList.contains('active') || false; if (_pa('dashboard')) { renderDashboard(); initPropComplianceWidget(); } if (_pa('reports')) { renderReports(); setTimeout(initWidgetDragDrop, 100); if (currentReportsTab === 'keystats') renderKeyStats(); } if (_pa('trades')) renderTradesTable(); if (_pa('labbuilder')) lbInit(); if (_pa('payouts')) renderPayoutTracker(); if (_pa('share-card')) loadUpdatedLabCardUserLogo(); if (_pa('roi')) renderROITracker(); if (_pa('journal')) { renderJournalMiniCal(); renderCalendar(); renderDayView(); } if (_pa('scorecard')) { renderPremarketChecklist(); renderTodaysTrades(); renderRecentScorecards(); renderProcessPage(); } if (_pa('settings')) { renderAccountsList(); renderCommissionsList(); } // Re-render Connections page if visible (already checks DOM internally) if (document.getElementById('unified-accounts-table')) renderConnectionsPage(); // Refresh Tradovate import-modal sync button state if it's in the DOM if (document.getElementById('tradovate-sync-status')) renderTradovateImportSyncStatus(); // Check evaluation status after data loads/changes if (typeof checkEvalAutoStatus === 'function') { checkEvalAutoStatus(); } // Initialize streamer mode applyStreamerMode(); // Initialize include archived toggle const includeArchivedCheckbox = document.getElementById('include-archived-toggle'); if (includeArchivedCheckbox) { includeArchivedCheckbox.checked = includeArchivedInMetrics; } // Initialize profit only toggle const profitOnlyCheckbox = document.getElementById('profit-only-toggle'); if (profitOnlyCheckbox) { profitOnlyCheckbox.checked = profitOnlyMode; } // Expire the metrics cache — it is only valid during this renderAll() execution. // Direct render calls from payout handlers, nav handlers, etc. must always recompute // fresh metrics from current in-memory data, never from a prior render cycle's results. _metricsCache = null; } function populatePayoutAccountDropdown() { const select = document.getElementById('payout-account'); const hintEl = document.getElementById('no-accounts-hint'); if (!select) return; computePayoutEligibility(); const bestEligibleId = window._payoutBestEligibleId; const bestEligibleAccount = bestEligibleId ? accounts.find(a => a.id === bestEligibleId) : null; // On initial load, align prop-firm dropdown // to the best eligible account's firm if (!window._payoutUserSelectedAccount && bestEligibleAccount && bestEligibleAccount.propFirm) { const firmSelect = document.getElementById( 'payout-prop-firm'); if (firmSelect && firmSelect.value !== bestEligibleAccount.propFirm) { firmSelect.value = bestEligibleAccount.propFirm; } } // Get selected prop firm const selectedPropFirm = document.getElementById('payout-prop-firm')?.value || ''; console.log('[Payout Accounts] Selected prop firm:', selectedPropFirm); console.log('[Payout Accounts] All accounts:', accounts.map(a => ({id: a.id, name: a.name, propFirm: a.propFirm, archived: a.archived}))); // Filter accounts by prop firm (case-insensitive) and not archived let filteredAccounts = accounts.filter(a => !a.archived); if (selectedPropFirm) { filteredAccounts = filteredAccounts.filter(a => { const accFirm = (a.propFirm || '').toLowerCase(); const searchFirm = selectedPropFirm.toLowerCase(); return accFirm === searchFirm; }); } console.log('[Payout Accounts] Filtered (non-archived):', filteredAccounts.map(a => ({id: a.id, name: a.name, propFirm: a.propFirm}))); // Also get archived accounts for this prop firm let archivedAccounts = accounts.filter(a => a.archived); if (selectedPropFirm) { archivedAccounts = archivedAccounts.filter(a => { const accFirm = (a.propFirm || '').toLowerCase(); const searchFirm = selectedPropFirm.toLowerCase(); return accFirm === searchFirm; }); } // Show hint if no accounts for this firm if (hintEl) { hintEl.style.display = (selectedPropFirm && filteredAccounts.length === 0) ? 'block' : 'none'; } // Determine which account to select: best eligible > saved > first const savedAccountId = payoutSettings.accountId || ''; const defaultAccountId = bestEligibleId || savedAccountId || ''; let html = ''; if (filteredAccounts.length === 0 && archivedAccounts.length === 0) { html = ''; } else { html = filteredAccounts.map(a => { const selected = a.id === defaultAccountId ? 'selected' : ''; return ``; }).join(''); if (archivedAccounts.length > 0) { html += ''; html += archivedAccounts.map(a => { const selected = a.id === defaultAccountId ? 'selected' : ''; return ``; }).join(''); html += ''; } } select.innerHTML = html; // Ensure selection is set — best eligible always wins on initial load if (bestEligibleId) { const eligibleInList = filteredAccounts.find(a => a.id === bestEligibleId); if (eligibleInList) { select.value = bestEligibleId; payoutSettings.accountId = bestEligibleId; } } else if (!select.value) { const firstFunded = filteredAccounts.find( a => a.stage !== 'evaluation'); if (firstFunded) select.value = firstFunded.id; } // Sync account size from selected account syncAccountSizeFromAccount(); } function syncAccountSizeFromAccount() { const accountSelect = document.getElementById('payout-account'); const sizeSelect = document.getElementById('payout-account-size'); const planSelect = document.getElementById('payout-plan'); if (!accountSelect || !sizeSelect) return; const selectedAccount = accounts.find(a => a.id === accountSelect.value); if (!selectedAccount || selectedAccount.startingBalance === undefined || selectedAccount.startingBalance === null) return; // Sync plan from account if (selectedAccount.plan && planSelect) { const planOption = Array.from(planSelect.options).find(o => o.value === selectedAccount.plan); if (planOption && planSelect.value !== selectedAccount.plan) { planSelect.value = selectedAccount.plan; } } // Find the closest account size option to the account's starting balance const sizes = Array.from(sizeSelect.options).map(o => parseInt(o.value)).filter(s => !isNaN(s)); if (sizes.length === 0) return; // No valid sizes to match against const accountBalance = selectedAccount.startingBalance; // Find exact match first, then closest let matchedSize = sizes.find(s => s === accountBalance); if (!matchedSize) { matchedSize = sizes.reduce((prev, curr) => Math.abs(curr - accountBalance) < Math.abs(prev - accountBalance) ? curr : prev , sizes[0]); // Add initial value to prevent empty array error } if (matchedSize && sizeSelect.value !== String(matchedSize)) { sizeSelect.value = matchedSize; // Update settings based on new size updatePropFirmSettings(); } } function updateImportStats() { // Use metrics-eligible trades for stats (excludes eval accounts and YOLO archived) const eligibleTrades = getMetricsEligibleTrades(); const totalTrades = eligibleTrades.length; const totalPnl = eligibleTrades.reduce((sum, t) => sum + getNetPnl(t), 0); const tradesEl = document.getElementById('import-total-trades'); const pnlEl = document.getElementById('import-total-pnl'); if (tradesEl) tradesEl.textContent = totalTrades; if (pnlEl) { pnlEl.textContent = formatCurrency(totalPnl); pnlEl.className = `stat-value ${totalPnl >= 0 ? 'positive' : 'negative'}`; } } function updateImportPageStats() { // Update stats based on global filter const globalFilter = document.getElementById('global-account-filter'); const selectedAccountId = globalFilter ? globalFilter.value : null; let filteredTrades; if (selectedAccountId) { // Filter to selected account only filteredTrades = trades.filter(t => t.accountId === selectedAccountId); } else { // Use metrics-eligible trades (excludes eval accounts and YOLO archived) filteredTrades = getMetricsEligibleTrades(); } const totalTrades = filteredTrades.length; const totalPnl = filteredTrades.reduce((sum, t) => sum + getNetPnl(t), 0); const tradesEl = document.getElementById('import-total-trades'); const pnlEl = document.getElementById('import-total-pnl'); if (tradesEl) tradesEl.textContent = totalTrades; if (pnlEl) { pnlEl.textContent = formatCurrency(totalPnl); pnlEl.className = `stat-value ${totalPnl >= 0 ? 'positive' : 'negative'}`; } } function renderPremarketChecklist() { const container = document.getElementById('premarket-checklist-dynamic'); if (!container) return; // Get all items from all templates const allItems = getAllChecklistItems(); if (allItems.length === 0) { container.innerHTML = `
No checklists configured.
`; updateChecklistStatus(); return; } // Group items by template let html = ''; checklistTemplates.forEach(tmpl => { if (tmpl.items.length === 0) return; html += `
${tmpl.name}
`; tmpl.items.forEach(item => { const isChecked = checklistChecked[item.id] === true; html += `
`; }); html += '
'; }); container.innerHTML = html; updateChecklistStatus(); } function toggleDynamicChecklistItem(id) { checklistChecked[id] = !checklistChecked[id]; const dateKey = document.getElementById('scorecard-date')?.value || getTodayKey(); saveChecklistCompletion(dateKey); // Also save to old checklistSettings for backwards compat syncChecklistCheckedToSettings(); saveChecklistSettings(); renderPremarketChecklist(); } function syncChecklistCheckedToSettings() { // Keep old checklistSettings in sync for backwards compat checklistSettings.riskItems.forEach(item => { item.checked = checklistChecked[item.id] === true; }); checklistSettings.structureItems.forEach(item => { item.checked = checklistChecked[item.id] === true; }); } function getAllChecklistItems() { const items = []; checklistTemplates.forEach(tmpl => { tmpl.items.forEach(item => items.push(item)); }); return items; } function updateChecklistStatus() { const allItems = getAllChecklistItems(); const totalCount = allItems.length || 1; const checkedCount = allItems.filter(i => checklistChecked[i.id] === true).length; const isComplete = allItems.length > 0 && checkedCount === allItems.length; const countEl = document.getElementById('premarket-count'); if (countEl) countEl.textContent = `${checkedCount}/${allItems.length} complete`; const progressEl = document.getElementById('premarket-progress'); if (progressEl) progressEl.style.width = `${(checkedCount / totalCount) * 100}%`; const statusEl = document.getElementById('premarket-status'); if (statusEl) { statusEl.className = `checklist-status ${isComplete ? 'complete' : 'incomplete'}`; statusEl.textContent = isComplete ? '✓ Complete' : 'Incomplete'; } // Placeholder text update (Add Trade is always enabled) const placeholderEl = document.getElementById('trades-placeholder-text'); if (placeholderEl) { placeholderEl.textContent = 'Click "+ Add Trade" to log your first trade'; } } function openEditModal(id, type) { editingItemId = id; editingItemType = type; const items = type === 'risk' ? checklistSettings.riskItems : checklistSettings.structureItems; // Convert to string for comparison (dataset attributes are always strings) const item = items.find(i => String(i.id) === String(id)); if (item) { document.getElementById('edit-item-text').value = item.text; document.getElementById('edit-item-modal').classList.add('active'); } } function deleteChecklistItem(id, type) { console.log('[Checklist Delete] Attempting to delete:', { id, type }); console.log('[Checklist Delete] Current items before:', type === 'risk' ? checklistSettings.riskItems : checklistSettings.structureItems); if (!confirm('Delete this checklist item?')) return; // Convert to string for comparison (dataset attributes are always strings) if (type === 'risk') { checklistSettings.riskItems = checklistSettings.riskItems.filter(i => String(i.id) !== String(id)); } else { checklistSettings.structureItems = checklistSettings.structureItems.filter(i => String(i.id) !== String(id)); } console.log('[Checklist Delete] Items after:', type === 'risk' ? checklistSettings.riskItems : checklistSettings.structureItems); saveChecklistSettings(); renderPremarketChecklist(); } function renderTodaysTrades() { const dateKey = document.getElementById('scorecard-date').value || getTodayKey(); const scorecard = scorecards[dateKey] || { trades: [] }; const container = document.getElementById('trades-container'); if (!scorecard.trades || scorecard.trades.length === 0) { container.innerHTML = `
📋
Click "+ Add Trade" to log your first trade
`; return; } container.innerHTML = scorecard.trades.map((trade, idx) => { const totalPts = trade.totalPoints || getTotalConfluencePoints(); const tradeScore = trade.score !== undefined ? trade.score : 0; const gradeInfo = getTradeGrade(tradeScore, trade.grayCandles); const direction = trade.direction || 'long'; const screenshotBtn = trade.screenshot ? `📷` : ''; return `
${direction.toUpperCase()} Trade #${idx + 1} ${screenshotBtn}
${tradeScore}/${totalPts} ${gradeInfo.grade}
${trade.notes ? `
${trade.notes.substring(0, 100)}${trade.notes.length > 100 ? '...' : ''}
` : ''}
`; }).join(''); // Add click handlers for screenshot buttons container.querySelectorAll('.view-screenshot-btn').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const dateKey = btn.dataset.tradeDate; const idx = parseInt(btn.dataset.tradeIdx); const sc = scorecards[dateKey]; if (sc && sc.trades[idx] && sc.trades[idx].screenshot) { openScreenshotModal(sc.trades[idx].screenshot); } }); }); } function renderRecentScorecards() { const container = document.getElementById('recent-scorecards'); const dates = Object.keys(scorecards).sort().reverse().slice(0, 7); if (dates.length === 0) { container.innerHTML = '
No scorecards yet
'; return; } container.innerHTML = dates.map(date => { const sc = scorecards[date]; const tradeCount = sc.trades?.length || 0; const avgScore = tradeCount > 0 ? (sc.trades.reduce((sum, t) => sum + (t.score || 0), 0) / tradeCount).toFixed(1) : '-'; // Day-average denominator from stored per-trade totalPoints (grades the avg badge // against the day's real scale, not the live dropdown). const avgTotal = tradeCount > 0 ? sc.trades.reduce((sum, t) => sum + (t.totalPoints || getTotalConfluencePoints()), 0) / tradeCount : 0; const checklistComplete = isChecklistCompleteForDate(date); // Calculate grade distribution — per-trade thresholds vs each trade's stored totalPoints const gradeDistribution = { conservative: 0, moderate: 0, aggressive: 0, noTrade: 0 }; (sc.trades || []).forEach(t => { const thresholds = getGradeThresholds(t.totalPoints || getTotalConfluencePoints()); if (t.grayCandles || (t.score || 0) <= thresholds.noTrade) gradeDistribution.noTrade++; else if ((t.score || 0) <= thresholds.aggressive) gradeDistribution.aggressive++; else if ((t.score || 0) <= thresholds.moderate) gradeDistribution.moderate++; else gradeDistribution.conservative++; }); // Calculate quality metrics const qualitySetups = gradeDistribution.conservative + gradeDistribution.moderate; const hasNoTradeSetups = gradeDistribution.noTrade > 0 || gradeDistribution.aggressive > 0; const dayQuality = hasNoTradeSetups ? 'Poor (took F/C setups)' : (tradeCount > 0 ? 'Good (quality setups only)' : 'No trades'); const dayQualityColor = hasNoTradeSetups ? 'var(--red)' : 'var(--green)'; // Build trade grade badges const gradeBadges = (sc.trades || []).map((t, idx) => { const score = t.score || 0; const thresholds = getGradeThresholds(t.totalPoints || getTotalConfluencePoints()); let gradeClass = 'grade-no-trade'; if (!t.grayCandles) { if (score > thresholds.moderate) gradeClass = 'grade-conservative'; else if (score > thresholds.aggressive) gradeClass = 'grade-moderate'; else if (score > thresholds.noTrade) gradeClass = 'grade-aggressive'; } return `${idx + 1}`; }).join(''); return `
${formatDate(date)}
${tradeCount} trade${tradeCount !== 1 ? 's' : ''}
${gradeBadges}
Avg: ${avgScore}/12${tradeCount > 0 ? (() => { const gi = getTradeGrade(parseFloat(avgScore), false, avgTotal); return ` ${gi.grade}`; })() : ''} ${checklistComplete ? '' : ''}
`; }).join(''); } window.toggleScorecard = function(date) { const row = document.querySelector(`.scorecard-row[data-date="${date}"]`); if (!row) return; const details = row.querySelector('.scorecard-details'); const arrow = row.querySelector('.expand-arrow'); if (details.style.display === 'none') { details.style.display = 'block'; arrow.style.transform = 'rotate(180deg)'; } else { details.style.display = 'none'; arrow.style.transform = 'rotate(0deg)'; } }; window.loadScorecard = function(date) { document.getElementById('scorecard-date').value = date; renderTodaysTrades(); }; window.deleteScorecard = async function(date) { const sc = scorecards[date]; const tradeCount = sc?.trades?.length || 0; if (!confirm(`Delete scorecard for ${formatDate(date)}?\n\nThis will remove ${tradeCount} trade${tradeCount !== 1 ? 's' : ''} and all checklist data for this day.`)) { return; } try { delete scorecards[date]; if (currentUser) { await db.collection('users').doc(currentUser.uid) .collection('scorecards').doc(date).delete(); } renderTodaysTrades(); renderRecentScorecards(); renderAnalytics(); alert('Day deleted successfully'); } catch (error) { console.error('Error deleting scorecard:', error); alert('Error deleting day'); } }; // ============================================ // RULE-BASED SCORING // ============================================ function switchScorecardTab(tab) { document.getElementById('sc-tab-daily').classList.toggle('active', tab === 'daily'); document.getElementById('sc-tab-discipline').classList.toggle('active', tab === 'discipline'); document.getElementById('sc-panel-daily').style.display = tab === 'daily' ? '' : 'none'; document.getElementById('sc-panel-discipline').style.display = tab === 'discipline' ? '' : 'none'; if (tab === 'discipline') renderProcessPage(); } // Returns { grade, color, bg } if scorecard data exists for this date, null otherwise function getCalendarScoreForDate(dateKey) { const sc = scorecards[dateKey]; if (!sc || !sc.trades || sc.trades.length === 0) return null; // Average score across all scored trades for the day let totalScore = 0; let totalPointsSum = 0; let validTrades = 0; let hasGray = false; sc.trades.forEach(t => { if (t.grayCandles) { hasGray = true; return; } totalScore += (t.score || 0); totalPointsSum += (t.totalPoints || getTotalConfluencePoints()); validTrades++; }); // If all trades were gray candles, grade is F if (validTrades === 0 && hasGray) return { grade: 'F', color: 'var(--red)', bg: 'var(--red-bg)' }; if (validTrades === 0) return null; const avgScore = totalScore / validTrades; const avgTotal = totalPointsSum / validTrades; // day-average stored denominator, not live dropdown const gradeInfo = getTradeGrade(avgScore, false, avgTotal); const colorMap = { 'grade-conservative': { color: 'var(--green)', bg: 'var(--green-bg)' }, 'grade-moderate': { color: 'var(--blue)', bg: 'var(--blue-bg)' }, 'grade-aggressive': { color: 'var(--yellow)', bg: 'var(--yellow-bg)' }, 'grade-no-trade': { color: 'var(--red)', bg: 'var(--red-bg)' } }; const c = colorMap[gradeInfo.class] || colorMap['grade-no-trade']; return { grade: gradeInfo.grade, color: c.color, bg: c.bg }; } // ============================================ // PAYOUT STATUS OVERVIEW // ============================================ let payoutOverviewCollapsed = false; function toggleAdminNav() { var nav = document.getElementById('admin-nav-items'); var chevron = document.getElementById('admin-nav-chevron'); var collapsed = localStorage.getItem('pl_admin_nav_collapsed') === 'true'; collapsed = !collapsed; localStorage.setItem('pl_admin_nav_collapsed', String(collapsed)); if (nav) nav.style.display = collapsed ? 'none' : ''; if (chevron) chevron.textContent = collapsed ? '▼' : '▲'; } function togglePayoutOverview() { payoutOverviewCollapsed = !payoutOverviewCollapsed; const content = document.getElementById('payout-overview-content'); const toggleText = document.getElementById('payout-overview-toggle-text'); if (payoutOverviewCollapsed) { content.style.display = 'none'; toggleText.textContent = 'Expand ▼'; } else { content.style.display = 'block'; toggleText.textContent = 'Collapse ▲'; } } function renderPayoutStatusOverview() { const container = document.getElementById('payout-overview-grid'); if (!container) return; // Read global filter state const globalPropFirmFilter = document.getElementById('global-prop-firm-filter')?.value || ''; // Get funded accounts, respecting global filters const fundedAccounts = accounts.filter(a => { // Exclude blown/failed/passed accounts — never show in payout overview const as = a.accountStatus || ''; const st = a.status || ''; if (as === 'blown' || as === 'passed' || st === 'failed' || st === 'passed') return false; // Exclude evaluation accounts by stage — only funded accounts belong here const stage = (a.stage || '').toLowerCase(); if (stage === 'evaluation' || a.isEvaluation) return false; // Must be a recognized funded stage — stage is always set on account creation const isFunded = stage === 'funded' || stage === 'pa' || stage === 'live' || stage === 'sim' || stage === 'live_funded' || stage === 'exhibition'; if (!isFunded) return false; // Respect archived toggle if (!includeArchivedInMetrics && a.archived) return false; if (!a.propFirm || a.propFirm === 'personal' || a.propFirm === 'other') return false; // Respect stage filter - hide funded accounts when "Eval" selected if (globalStageFilter === 'evaluation') return false; // Respect account selection filter if (selectedAllFirmsAccounts.length > 0) { if (!selectedAllFirmsAccounts.includes(a.id)) return false; } // Respect prop firm filter else if (globalPropFirmFilter) { if (a.propFirm !== globalPropFirmFilter) return false; } return true; }); if (fundedAccounts.length === 0) { container.innerHTML = `
📊
No funded accounts yet
Add accounts in Settings to track payout eligibility
`; return; } // Group by prop firm and calculate metrics for each const firmData = {}; fundedAccounts.forEach(account => { const firmKey = account.propFirm; const firmConfig = propFirmConfigs[firmKey] || {}; const metrics = calculateAccountMetrics(account, firmConfig); if (!firmData[firmKey]) { firmData[firmKey] = { name: firmConfig.name || firmKey, ready: [], almost: [], building: [] }; } // Calculate eligibility percentage const eligibilityScore = calculateEligibilityScore(metrics, firmConfig); const accountInfo = { id: account.id, name: account.name, metrics: metrics, score: eligibilityScore.score, details: eligibilityScore.details, missingItems: eligibilityScore.missingItems }; // Account is ready if either: // 1. metrics.isEligible is true (all conditions met per calculateAccountMetrics) // 2. Score is >= 99% (essentially complete - handles floating point issues) // 3. Score is 100% AND no missing items const scoreIsComplete = eligibilityScore.score >= 99; if (metrics.isEligible) { firmData[firmKey].ready.push(accountInfo); } else if (eligibilityScore.score >= 70) { firmData[firmKey].almost.push(accountInfo); } else { firmData[firmKey].building.push(accountInfo); } }); // Render the grid const firmKeys = Object.keys(firmData).sort(); container.innerHTML = firmKeys.map(firmKey => { const firm = firmData[firmKey]; const totalAccounts = firm.ready.length + firm.almost.length + firm.building.length; return `
${firm.name} (${totalAccounts})
${firm.ready.length}
Ready
${firm.almost.length}
Almost
${firm.building.length}
Building
`; }).join(''); // Store firm data for popover use window.payoutStatusData = firmData; } function calculateEligibilityScore(metrics, firmConfig) { let score = 0; let maxScore = 0; const details = []; const missingItems = []; const actualProfit = metrics.profitResetsAfterPayout ? metrics.profitSinceLastPayout : metrics.profit; const payoutCount = metrics.payoutCount || 0; // Profit requirement (30 points) // This changes based on payout stage: // - Before buffer reached: need buffer amount // - After buffer: need minWithdrawal or payout cap maxScore += 30; const bufferRequired = metrics.bufferRequired ?? 0; const minWithdrawal = metrics.minWithdrawal ?? 0; const bufferReached = actualProfit >= bufferRequired; // Determine the actual profit target for THIS payout let profitTarget = 0; let profitLabel = 'Profit Target'; if (payoutCount === 0 && bufferRequired > 0) { // First payout - need buffer profitTarget = bufferRequired; profitLabel = 'Buffer (1st Payout)'; } else if (payoutCount < 3 && bufferRequired > 0 && !bufferReached) { // Payouts 1-3 need buffer maintained profitTarget = bufferRequired; profitLabel = `Buffer (Payout #${payoutCount + 1})`; } else { // After buffer or no buffer requirement - just need min withdrawal profitTarget = minWithdrawal; profitLabel = 'Min Withdrawal'; } if (profitTarget > 0) { const profitProgress = Math.min(100, (actualProfit / profitTarget) * 100); const profitScore = (profitProgress / 100) * 30; score += profitScore; if (actualProfit >= profitTarget) { details.push({ label: profitLabel, value: '✓ Complete', met: true }); } else { const needed = profitTarget - actualProfit; details.push({ label: profitLabel, value: `${profitProgress.toFixed(0)}% (need ${formatCurrency(needed)})`, met: false }); missingItems.push(`Need ${formatCurrency(needed)} more profit`); } } else { score += 30; details.push({ label: profitLabel, value: '✓ N/A', met: true }); } // Trading days requirement (15 points) — only counts when the firm requires it. // Awarding free points for absent rules inflated the score and pushed every // no-rule firm (e.g. Take Profit Trader Pro) into "Almost Ready" at 0 profit. const requiredDays = metrics.requiredDays || 0; const tradingDaysSince = metrics.totalTradingDaysSinceLastPayout || metrics.totalTradingDays || 0; if (requiredDays > 0) { maxScore += 15; const daysProgress = Math.min(100, (tradingDaysSince / requiredDays) * 100); const daysScore = (daysProgress / 100) * 15; score += daysScore; if (tradingDaysSince >= requiredDays) { details.push({ label: 'Trading Days', value: `✓ ${tradingDaysSince}/${requiredDays}`, met: true }); } else { const needed = requiredDays - tradingDaysSince; details.push({ label: 'Trading Days', value: `${tradingDaysSince}/${requiredDays}`, met: false }); missingItems.push(`Need ${needed} more trading day${needed !== 1 ? 's' : ''}`); } } // Profitable days requirement (15 points) — only counts when the firm requires it. const requiredQualifyingDays = metrics.requiredQualifyingDays || 0; const qualifyingDays = metrics.qualifyingDaysSinceLastPayout || 0; if (requiredQualifyingDays > 0) { maxScore += 15; const qualProgress = Math.min(100, (qualifyingDays / requiredQualifyingDays) * 100); const qualScore = (qualProgress / 100) * 15; score += qualScore; if (qualifyingDays >= requiredQualifyingDays) { details.push({ label: 'Profitable Days', value: `✓ ${qualifyingDays}/${requiredQualifyingDays}`, met: true }); } else { const needed = requiredQualifyingDays - qualifyingDays; details.push({ label: 'Profitable Days', value: `${qualifyingDays}/${requiredQualifyingDays}`, met: false }); missingItems.push(`Need ${needed} more $${metrics.minProfitPerDay ?? 0}+ day${needed !== 1 ? 's' : ''}`); } } // Consistency requirement (40 points - heavily weighted since it's often the blocker) // Only counts when the firm has a consistency rule; absent rule contributes nothing. if (metrics.hasConsistencyRule) { maxScore += 40; if (metrics.consistencyMet) { score += 40; details.push({ label: 'Consistency', value: `✓ Met (${metrics.effectiveConsistency})`, met: true }); } else { // Calculate how close they are to meeting consistency const consistencyNeeded = metrics.consistencyNeeded || 0; if (consistencyNeeded > 0 && actualProfit > 0) { const consistencyProgress = Math.min(100, (actualProfit / consistencyNeeded) * 100); score += (consistencyProgress / 100) * 40; } const stillNeeded = Math.max(0, (metrics.consistencyNeeded || 0) - actualProfit); details.push({ label: `Consistency (${metrics.effectiveConsistency})`, value: `Need ${formatCurrency(stillNeeded)} more`, met: false }); if (stillNeeded > 0) missingItems.push(`Need ${formatCurrency(stillNeeded)} more for ${metrics.effectiveConsistency} consistency`); } } return { score: maxScore > 0 ? (score / maxScore) * 100 : 100, details: details, missingItems: missingItems }; } function showPayoutPopover(firmKey, status, event) { event.stopPropagation(); const firmData = window.payoutStatusData?.[firmKey]; if (!firmData) return; const accounts = status === 'ready' ? firmData.ready : status === 'almost' ? firmData.almost : firmData.building; if (accounts.length === 0) return; const popover = document.getElementById('payout-status-popover'); const titleEl = document.getElementById('popover-title'); const contentEl = document.getElementById('popover-content'); // Set title const statusLabels = { ready: { text: '✅ Payout Ready', color: themeGreen() }, almost: { text: '⏳ Almost Ready', color: '#f59e0b' }, building: { text: '🔨 Building', color: '#f97316' } }; const statusInfo = statusLabels[status]; titleEl.innerHTML = `${statusInfo.text} (${accounts.length} account${accounts.length !== 1 ? 's' : ''})`; // Build content contentEl.innerHTML = accounts.map(acc => { const m = acc.metrics; const availableForPayout = m.maxWithdrawal || 0; let statusDetails = ''; if (status === 'ready') { statusDetails = `
✓ ${formatCurrency(availableForPayout)} available to withdraw
`; } else { statusDetails = acc.missingItems.map(item => `
• ${item}
` ).join(''); } return `
${acc.name}
${acc.score.toFixed(0)}%
${statusDetails}
`; }).join(''); // Position popover near the click const rect = event.target.closest('.payout-status-cell').getBoundingClientRect(); const popoverWidth = 380; let left = rect.left + (rect.width / 2) - (popoverWidth / 2); let top = rect.bottom + 10; // Keep within viewport if (left < 10) left = 10; if (left + popoverWidth > window.innerWidth - 10) left = window.innerWidth - popoverWidth - 10; if (top + 300 > window.innerHeight) { top = rect.top - 310; } popover.style.left = left + 'px'; popover.style.top = top + 'px'; popover.style.display = 'block'; // Close on outside click setTimeout(() => { document.addEventListener('click', closePayoutPopoverOnOutsideClick); }, 10); } function closePayoutPopover() { document.getElementById('payout-status-popover').style.display = 'none'; document.removeEventListener('click', closePayoutPopoverOnOutsideClick); } function closePayoutPopoverOnOutsideClick(e) { const popover = document.getElementById('payout-status-popover'); if (!popover.contains(e.target)) { closePayoutPopover(); } } function scrollToAccountAndExpand(accountId) { closePayoutPopover(); // Small delay to allow popover to close setTimeout(() => { // Find the account's firm and ensure that firm section is expanded const account = accounts.find(a => a.id === accountId); if (account && account.propFirm) { const firmKey = account.propFirm; const firmBody = document.getElementById('firm-body-' + firmKey); const firmChevron = document.getElementById('firm-chevron-' + firmKey); if (firmBody && firmBody.style.display === 'none') { firmBody.style.display = 'block'; if (firmChevron) firmChevron.style.transform = 'rotate(0deg)'; _expandedFirms.add(firmKey); } } // Find the account card const accountCard = document.querySelector(`[data-account-id="${accountId}"]`); if (accountCard) { // Expand the account detail panel if collapsed (single-firm view) const detailPanel = accountCard.querySelector('.account-card-collapsible'); if (detailPanel && detailPanel.classList.contains('collapsed')) { const toggleBtn = accountCard.querySelector('.account-toggle-btn'); const summary = document.getElementById('summary-' + accountId); detailPanel.classList.remove('collapsed'); detailPanel.classList.add('expanded'); detailPanel.style.maxHeight = detailPanel.scrollHeight + 'px'; if (toggleBtn) toggleBtn.classList.remove('collapsed'); if (summary) summary.classList.remove('show'); } // Expand the All Firms detail panel if hidden const allFirmsDetail = document.getElementById('all-firms-detail-' + accountId); if (allFirmsDetail && allFirmsDetail.style.display === 'none') { allFirmsDetail.style.display = 'block'; const accChevron = document.getElementById('acc-chevron-' + accountId); if (accChevron) accChevron.style.transform = 'rotate(0deg)'; } // Scroll to the card accountCard.scrollIntoView({ behavior: 'smooth', block: 'center' }); // Highlight with blue/white glow (distinct from green payout-eligible) accountCard.style.transition = 'box-shadow 0.3s ease'; accountCard.style.boxShadow = '0 0 24px rgba(96, 165, 250, 0.6), 0 0 48px rgba(96, 165, 250, 0.2)'; setTimeout(() => { accountCard.style.transition = 'box-shadow 1.5s ease-out'; accountCard.style.boxShadow = ''; }, 2500); } else { // Account not visible — scroll to the Prop Firm Status widget const widget = document.getElementById('prop-compliance-widget'); if (widget) { widget.scrollIntoView({ behavior: 'smooth', block: 'start' }); } } }, 150); } function renderDashboard() { // Render Payout Status Overview first renderPayoutStatusOverview(); const filteredTrades = getMetricsEligibleTrades(); // Basic stats const totalPnl = filteredTrades.reduce((sum, t) => sum + getNetPnl(t), 0); const winners = filteredTrades.filter(t => getNetPnl(t) > 0); const losers = filteredTrades.filter(t => getNetPnl(t) < 0); const breakeven = filteredTrades.filter(t => getNetPnl(t) === 0); const winRate = filteredTrades.length > 0 ? (winners.length / filteredTrades.length * 100) : 0; const grossProfit = winners.reduce((sum, t) => sum + getNetPnl(t), 0); const grossLoss = Math.abs(losers.reduce((sum, t) => sum + getNetPnl(t), 0)); const profitFactor = grossLoss > 0 ? grossProfit / grossLoss : grossProfit > 0 ? Infinity : 0; const avgWin = winners.length > 0 ? grossProfit / winners.length : 0; const avgLoss = losers.length > 0 ? grossLoss / losers.length : 0; const avgRatio = avgLoss > 0 ? avgWin / avgLoss : avgWin > 0 ? Infinity : 0; // Expectancy const lossRate = filteredTrades.length > 0 ? (losers.length / filteredTrades.length) : 0; const expectancy = (winRate/100 * avgWin) - (lossRate * avgLoss); // Largest win/loss const largestWin = winners.length > 0 ? Math.max(...winners.map(t => getNetPnl(t))) : 0; const largestLoss = losers.length > 0 ? Math.min(...losers.map(t => getNetPnl(t))) : 0; // Daily stats - use trading session date (trades after 5pm CT count as next day) const dailyPnl = {}; filteredTrades.forEach(t => { const date = t.date || getTradingSessionDate(t.exitTime) || getDateKey(t.exitTime); dailyPnl[date] = (dailyPnl[date] || 0) + getNetPnl(t); }); const dailyValues = Object.values(dailyPnl); const winningDays = dailyValues.filter(v => v > 0).length; const losingDays = dailyValues.filter(v => v < 0).length; const dayWinRate = dailyValues.length > 0 ? (winningDays / dailyValues.length * 100) : 0; const bestDay = dailyValues.length > 0 ? Math.max(...dailyValues) : 0; const worstDay = dailyValues.length > 0 ? Math.min(...dailyValues) : 0; // Current streak const sortedDates = Object.keys(dailyPnl).sort().reverse(); let streak = 0; let streakType = null; for (const date of sortedDates) { const pnl = dailyPnl[date]; const isWin = pnl > 0; if (streakType === null) { streakType = isWin; streak = 1; } else if (isWin === streakType) { streak++; } else { break; } } const streakDisplay = streakType ? streak : streak > 0 ? streak : '0'; const streakLabel = streakType ? 'Winning days' : streak > 0 ? 'Losing days' : 'days'; // Max drawdown let peak = 0; let maxDrawdown = 0; let cumulative = 0; const sortedTrades = [...filteredTrades].sort((a, b) => new Date(a.exitTime) - new Date(b.exitTime)); sortedTrades.forEach(t => { cumulative += getNetPnl(t); if (cumulative > peak) peak = cumulative; const drawdown = peak - cumulative; if (drawdown > maxDrawdown) maxDrawdown = drawdown; }); // Lab Score (0-100) let winPct = Math.min(100, winRate); let pfScore = Math.min(100, profitFactor * 20); let recoveryScore = maxDrawdown > 0 ? Math.min(100, (totalPnl / maxDrawdown) * 30) : 50; let avgWLScore = avgRatio === Infinity ? 100 : Math.min(100, avgRatio * 50); let consistencyScore = dayWinRate; let perfScore = (winPct * 0.25 + pfScore * 0.25 + recoveryScore * 0.15 + avgWLScore * 0.2 + consistencyScore * 0.15); perfScore = Math.min(100, Math.max(0, perfScore)).toFixed(1); // Update DOM - KPI Cards const pnlEl = document.getElementById('stat-pnl'); if (pnlEl) { pnlEl.textContent = formatCurrency(totalPnl); pnlEl.className = totalPnl >= 0 ? 'positive' : 'negative'; } const tradesEl = document.getElementById('stat-trades'); if (tradesEl) tradesEl.textContent = filteredTrades.length; const winrateEl = document.getElementById('stat-winrate'); if (winrateEl) winrateEl.textContent = winRate.toFixed(1) + '%'; const winsEl = document.getElementById('stat-wins'); const lossesEl = document.getElementById('stat-losses'); if (winsEl) winsEl.textContent = winners.length; if (lossesEl) lossesEl.textContent = losers.length; const pfEl = document.getElementById('stat-pf'); if (pfEl) pfEl.textContent = profitFactor === Infinity ? '∞' : profitFactor.toFixed(2); const grossEl = document.getElementById('stat-gross'); if (grossEl) grossEl.textContent = formatCurrency(grossProfit); const daywinEl = document.getElementById('stat-daywin'); if (daywinEl) daywinEl.textContent = dayWinRate.toFixed(1) + '%'; const windaysEl = document.getElementById('stat-windays'); const lossdaysEl = document.getElementById('stat-lossdays'); if (windaysEl) windaysEl.textContent = winningDays; if (lossdaysEl) lossdaysEl.textContent = losingDays; const streakEl = document.getElementById('stat-streak'); const streakLabelEl = document.getElementById('stat-streak-label'); if (streakEl) { streakEl.textContent = streak; streakEl.className = streakType ? 'positive' : streak > 0 ? 'negative' : ''; } if (streakLabelEl) streakLabelEl.textContent = streakLabel; const avgratioEl = document.getElementById('stat-avgratio'); if (avgratioEl) avgratioEl.textContent = avgRatio === Infinity ? '∞' : avgRatio.toFixed(2); const avgwinEl = document.getElementById('stat-avgwin'); const avglossEl = document.getElementById('stat-avgloss'); if (avgwinEl) avgwinEl.textContent = formatCurrency(avgWin); if (avglossEl) avglossEl.textContent = formatCurrency(avgLoss); // Calendar header stats const calTotalEl = document.getElementById('cal-total'); const calDaysEl = document.getElementById('cal-days'); if (calTotalEl) { calTotalEl.textContent = formatCurrency(totalPnl); calTotalEl.className = totalPnl >= 0 ? 'positive' : 'negative'; } if (calDaysEl) calDaysEl.textContent = `${Object.keys(dailyPnl).length} days`; // Lab Score const perfScoreEl = document.getElementById('perf-score'); const perfBarEl = document.getElementById('perf-score-bar'); if (perfScoreEl) perfScoreEl.textContent = perfScore; if (perfBarEl) { perfBarEl.style.width = perfScore + '%'; perfBarEl.style.background = parseFloat(perfScore) >= 70 ? 'var(--green)' : parseFloat(perfScore) >= 50 ? 'var(--yellow)' : 'var(--red)'; } // Account Balance const balanceEl = document.getElementById('account-balance'); if (balanceEl) { balanceEl.textContent = formatCurrency(totalPnl); balanceEl.className = totalPnl >= 0 ? 'positive' : 'negative'; } // Analytics tile header numbers const cumDailyEl = document.getElementById('dash-cum-daily-total'); if (cumDailyEl) { cumDailyEl.textContent = formatCurrency(totalPnl); cumDailyEl.style.color = totalPnl >= 0 ? 'var(--green)' : 'var(--red)'; } const cumTradeEl = document.getElementById('dash-cum-trade-total'); if (cumTradeEl) { cumTradeEl.textContent = formatCurrency(totalPnl); cumTradeEl.style.color = totalPnl >= 0 ? 'var(--green)' : 'var(--red)'; } const dailyTotalEl = document.getElementById('dash-daily-total'); if (dailyTotalEl) { dailyTotalEl.textContent = `${Object.keys(dailyPnl).length} days`; } // Max drawdown let ddPeak = 0, ddCum = 0, ddMax = 0; [...filteredTrades].sort((a, b) => new Date(a.exitTime) - new Date(b.exitTime)).forEach(t => { ddCum += getNetPnl(t); if (ddCum > ddPeak) ddPeak = ddCum; const dd = ddPeak - ddCum; if (dd > ddMax) ddMax = dd; }); const maxDdEl = document.getElementById('dash-max-dd'); if (maxDdEl) { maxDdEl.textContent = '-' + formatCurrency(ddMax); } // Perf score color if (perfScoreEl) { perfScoreEl.style.color = parseFloat(perfScore) >= 70 ? 'var(--green)' : parseFloat(perfScore) >= 50 ? 'var(--yellow)' : 'var(--red)'; } // Render all charts renderDonutCharts(winRate, profitFactor, dayWinRate, avgRatio); renderCalendar(); renderWeekSummary(); // Re-apply streamer mode blur to new elements if (streamerMode) applySensitiveBlur(); } window._selectedWeekStart = null; // null = current week function selectCalendarWeek(weekStartKey) { if (window._selectedWeekStart === weekStartKey) { // Deselect — return to current week window._selectedWeekStart = null; } else { window._selectedWeekStart = weekStartKey; } // Update highlight on week cells document.querySelectorAll('.calendar-week-total').forEach(function(el) { el.classList.toggle('week-selected', el.dataset.weekStart === window._selectedWeekStart); }); // Update the This Week card title var titleEl = document.querySelector('#page-dashboard .card-title'); // Find the correct title (the one that says "This Week") document.querySelectorAll('.card-title').forEach(function(el) { if (el.textContent.includes('This Week') || el.textContent.includes('Week of') || el.textContent.includes('📅')) { if (window._selectedWeekStart) { var ws = new Date(window._selectedWeekStart + 'T12:00:00'); var we = new Date(ws); we.setDate(ws.getDate() + 6); el.textContent = '📅 Week of ' + ws.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); } else { el.textContent = '📅 This Week'; } } }); renderWeekSummary(); } function renderWeekSummary() { // Use selected week or fall back to current week var startOfWeek; if (window._selectedWeekStart) { startOfWeek = new Date(window._selectedWeekStart + 'T12:00:00'); var dow = startOfWeek.getDay(); startOfWeek.setDate(startOfWeek.getDate() - dow); } else { const today = new Date(); const dayOfWeek = today.getDay(); startOfWeek = new Date(today); startOfWeek.setDate(today.getDate() - dayOfWeek); } startOfWeek.setHours(0, 0, 0, 0); const endOfWeek = new Date(startOfWeek); endOfWeek.setDate(startOfWeek.getDate() + 6); endOfWeek.setHours(23, 59, 59, 999); // Create date keys for week start/end for string comparison const startKey = `${startOfWeek.getFullYear()}-${String(startOfWeek.getMonth() + 1).padStart(2, '0')}-${String(startOfWeek.getDate()).padStart(2, '0')}`; const endKey = `${endOfWeek.getFullYear()}-${String(endOfWeek.getMonth() + 1).padStart(2, '0')}-${String(endOfWeek.getDate()).padStart(2, '0')}`; // Week range display const weekRangeEl = document.getElementById('week-range'); if (weekRangeEl) { const startStr = startOfWeek.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); const endStr = endOfWeek.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); weekRangeEl.textContent = `${startStr} - ${endStr}`; } // Filter trades for this week using trading session dates // Use metrics-eligible trades (excludes YOLO accounts) const filteredTrades = getMetricsEligibleTrades(); const weekTrades = filteredTrades.filter(t => { const tradeDateKey = t.date || getTradingSessionDate(t.exitTime) || getDateKey(t.exitTime); return tradeDateKey >= startKey && tradeDateKey <= endKey; }); // Calculate week stats const weekPnl = weekTrades.reduce((sum, t) => sum + getNetPnl(t), 0); const weekWinners = weekTrades.filter(t => getNetPnl(t) > 0); const weekLosers = weekTrades.filter(t => getNetPnl(t) < 0); const weekWinRate = weekTrades.length > 0 ? (weekWinners.length / weekTrades.length * 100) : 0; const weekGrossProfit = weekWinners.reduce((sum, t) => sum + getNetPnl(t), 0); const weekGrossLoss = Math.abs(weekLosers.reduce((sum, t) => sum + getNetPnl(t), 0)); const weekPF = weekGrossLoss > 0 ? weekGrossProfit / weekGrossLoss : weekGrossProfit > 0 ? Infinity : 0; const weekAvgPerTrade = weekTrades.length > 0 ? weekPnl / weekTrades.length : 0; // Daily breakdown - use trading session dates const dailyPnl = {}; weekTrades.forEach(t => { const date = t.date || getTradingSessionDate(t.exitTime) || getDateKey(t.exitTime); dailyPnl[date] = (dailyPnl[date] || 0) + getNetPnl(t); }); const tradingDays = Object.keys(dailyPnl).length; // Update DOM const weekPnlEl = document.getElementById('week-pnl'); if (weekPnlEl) { weekPnlEl.textContent = formatCurrency(weekPnl); weekPnlEl.className = weekPnl >= 0 ? 'positive' : 'negative'; } const weekTradeCountWrap = document.getElementById('week-trade-count-wrap'); if (weekTradeCountWrap) { if (weekTrades.length > 0) { weekTradeCountWrap.style.display = ''; weekTradeCountWrap.textContent = weekTrades.length + ' trade' + (weekTrades.length !== 1 ? 's' : ''); } else { weekTradeCountWrap.style.display = 'none'; } } const weekWinrateEl = document.getElementById('week-winrate'); if (weekWinrateEl) { weekWinrateEl.textContent = weekWinRate.toFixed(0) + '%'; weekWinrateEl.style.color = weekWinRate >= 50 ? 'var(--green)' : 'var(--red)'; } const weekPfEl = document.getElementById('week-pf'); if (weekPfEl) { weekPfEl.textContent = weekPF === Infinity ? '∞' : weekPF.toFixed(2); weekPfEl.style.color = weekPF >= 1 ? 'var(--green)' : 'var(--red)'; } const weekDaysEl = document.getElementById('week-days'); if (weekDaysEl) weekDaysEl.textContent = tradingDays; const weekAvgEl = document.getElementById('week-avg'); if (weekAvgEl) { weekAvgEl.textContent = formatCurrency(weekAvgPerTrade); weekAvgEl.className = weekAvgPerTrade >= 0 ? 'positive' : 'negative'; } // Daily breakdown const breakdownEl = document.getElementById('week-daily-breakdown'); if (breakdownEl) { const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; let breakdownHtml = ''; for (let i = 0; i < 7; i++) { const dayDate = new Date(startOfWeek); dayDate.setDate(startOfWeek.getDate() + i); const dateKey = `${dayDate.getFullYear()}-${String(dayDate.getMonth() + 1).padStart(2, '0')}-${String(dayDate.getDate()).padStart(2, '0')}`; const dayPnl = dailyPnl[dateKey]; const todayKey = getTodayKey(); const isToday = dateKey === todayKey; let pnlDisplay = '-'; let pnlClass = ''; if (dayPnl !== undefined) { pnlDisplay = formatCurrency(dayPnl); pnlClass = dayPnl >= 0 ? 'positive' : 'negative'; } breakdownHtml += `
${dayNames[i]} ${pnlDisplay}
`; } breakdownEl.innerHTML = breakdownHtml; } // Weekly Review button const reviewBtnContainer = document.getElementById('week-review-btn-container'); if (reviewBtnContainer) { // Calculate this week's Thursday (week ending date for reviews) const thu = new Date(startOfWeek); thu.setDate(startOfWeek.getDate() + 4); // Thursday const thuKey = `${thu.getFullYear()}-${String(thu.getMonth() + 1).padStart(2, '0')}-${String(thu.getDate()).padStart(2, '0')}`; const hasReview = weeklyReviews[thuKey]; if (hasReview) { reviewBtnContainer.innerHTML = ``; } else { reviewBtnContainer.innerHTML = ``; } } } // ===================================================== // PROP FIRM COMPLIANCE WIDGET // ===================================================== function initPropComplianceWidget() { const widget = document.getElementById('prop-compliance-widget'); if (!widget) return; // Always show the widget widget.style.display = 'block'; // Render the widget immediately (now uses global filters) renderPropComplianceWidget(); } // Widget account multi-select state let selectedWidgetAccountIds = []; // Empty means all accounts for the firm function updateWidgetAccounts() { const firmSelect = document.getElementById('widget-prop-firm'); const optionsContainer = document.getElementById('widget-account-options'); if (!firmSelect || !optionsContainer) return; const selectedFirm = firmSelect.value; optionsContainer.innerHTML = ''; selectedWidgetAccountIds = []; // Reset selection // Get accounts based on selection - all prop firms or specific firm let firmAccounts; if (!selectedFirm) { // All Prop Firms - get all non-archived prop accounts firmAccounts = accounts.filter(a => !a.archived && a.propFirm && a.propFirm !== 'personal' && a.propFirm !== 'other'); } else { firmAccounts = accounts.filter(a => a.propFirm === selectedFirm && !a.archived); } if (firmAccounts.length === 0) { updateWidgetAccountDisplay(); return; } // Sort alphabetically/numerically firmAccounts.sort((a, b) => { const nameA = a.name || ''; const nameB = b.name || ''; const numA = nameA.match(/\d+/g); const numB = nameB.match(/\d+/g); const prefixA = nameA.replace(/\d+/g, ''); const prefixB = nameB.replace(/\d+/g, ''); if (prefixA === prefixB && numA && numB) { const lastNumA = parseInt(numA[numA.length - 1]) || 0; const lastNumB = parseInt(numB[numB.length - 1]) || 0; return lastNumA - lastNumB; } return nameA.localeCompare(nameB); }); firmAccounts.forEach(acc => { const isFunded = acc.stage === 'funded' || acc.stage === 'Funded'; const isEval = acc.stage === 'evaluation' || acc.stage === 'Evaluation' || (!acc.stage && acc.propFirm !== 'personal'); const tag = isFunded ? 'Funded' : isEval ? 'Eval' : ''; // Show firm name when viewing all firms const firmLabel = !selectedFirm && acc.propFirm ? `(${propFirmConfigs[acc.propFirm]?.name || acc.propFirm})` : ''; optionsContainer.innerHTML += `
`; }); updateWidgetAccountDisplay(); // Reapply streamer mode blur if (streamerMode) applySensitiveBlur(); } window.toggleWidgetAccountDropdown = function() { const dropdown = document.getElementById('widget-account-dropdown'); if (!dropdown) return; dropdown.style.display = dropdown.style.display === 'none' ? 'block' : 'none'; }; window.updateWidgetAccountDisplay = function() { const checkboxes = document.querySelectorAll('#widget-account-options input[type="checkbox"]'); const checked = Array.from(checkboxes).filter(cb => cb.checked); const display = document.getElementById('widget-account-text'); if (!display) return; // Element doesn't exist anymore if (checkboxes.length === 0) { display.textContent = 'No Accounts'; selectedWidgetAccountIds = []; return; } if (checked.length === 0) { display.textContent = 'No Accounts'; selectedWidgetAccountIds = ['none']; } else if (checked.length === checkboxes.length) { display.textContent = 'All Accounts'; selectedWidgetAccountIds = []; } else if (checked.length <= 2) { const names = checked.map(cb => { const acc = accounts.find(a => a.id === cb.value); return acc ? acc.name.split('-').pop() : cb.value; }); display.textContent = names.join(', '); selectedWidgetAccountIds = checked.map(cb => cb.value); } else { display.textContent = `${checked.length} accounts`; selectedWidgetAccountIds = checked.map(cb => cb.value); } }; window.selectAllWidgetAccounts = function() { document.querySelectorAll('#widget-account-options input[type="checkbox"]').forEach(cb => cb.checked = true); updateWidgetAccountDisplay(); renderPropComplianceWidget(); }; window.selectNoWidgetAccounts = function() { document.querySelectorAll('#widget-account-options input[type="checkbox"]').forEach(cb => cb.checked = false); updateWidgetAccountDisplay(); renderPropComplianceWidget(); }; window.selectFundedWidgetOnly = function() { document.querySelectorAll('#widget-account-options input[type="checkbox"]').forEach(cb => { const acc = accounts.find(a => a.id === cb.value); cb.checked = acc && (acc.stage === 'funded' || acc.stage === 'Funded'); }); updateWidgetAccountDisplay(); renderPropComplianceWidget(); }; // Close widget dropdown when clicking outside document.addEventListener('click', function(e) { const container = document.getElementById('widget-account-wrapper'); const dropdown = document.getElementById('widget-account-dropdown'); if (container && dropdown && !container.contains(e.target)) { dropdown.style.display = 'none'; } }); // ===================================================== // GLOBAL FILTER SYSTEM // ===================================================== // Populate global prop firm filter dropdown function populateGlobalPropFirmFilter() { const select = document.getElementById('global-prop-firm-filter'); if (!select) return; // Preserve current selection across rebuild const currentValue = select.value; // Get unique prop firms from accounts const propFirms = [...new Set(accounts.filter(a => !a.archived && a.propFirm && a.propFirm !== 'personal' && a.propFirm !== 'other').map(a => a.propFirm))]; select.innerHTML = ''; propFirms.forEach(firm => { const config = propFirmConfigs[firm] || {}; const firmName = config.name || firm; select.innerHTML += ``; }); // Restore selection (if the firm still exists in the list) if (currentValue) select.value = currentValue; } // Populate global account filter dropdown based on selected prop firm // Filter accounts by stage (All/Funded/Evaluation) function filterByStage(stage) { globalStageFilter = stage; // Update button states document.querySelectorAll('.stage-filter-btn').forEach(btn => { btn.classList.toggle('active', btn.dataset.stage === stage); }); // Clear account selection when stage changes selectedAllFirmsAccounts = []; // Re-populate account dropdown with filtered accounts populateGlobalAccountFilter(); // Update filter indicator and pills renderActiveFilterPills(); updateGlobalFilterIndicator(); // Debounce the heavy renderAll call to prevent stacking with rapid filter changes if (_renderAllDebounceTimer) clearTimeout(_renderAllDebounceTimer); _renderAllDebounceTimer = setTimeout(() => { _renderAllDebounceTimer = null; renderAll(); }, 300); } function populateGlobalAccountFilter() { const propFirmSelect = document.getElementById('global-prop-firm-filter'); const listEl = document.getElementById('account-dropdown-list'); if (!listEl) return; const selectedFirm = propFirmSelect?.value || ''; // Filter accounts based on selected prop firm AND stage filter let filteredAccounts = accounts.filter(a => (includeArchivedInMetrics || !a.archived) && a.propFirm && a.propFirm !== 'personal' && a.propFirm !== 'other'); if (selectedFirm) { filteredAccounts = filteredAccounts.filter(a => a.propFirm === selectedFirm); } if (globalStageFilter === 'funded') { filteredAccounts = filteredAccounts.filter(a => a.stage !== 'evaluation' && !a.isEvaluation); } else if (globalStageFilter === 'evaluation') { filteredAccounts = filteredAccounts.filter(a => a.stage === 'evaluation' || a.isEvaluation); } // Remove stale selections selectedAllFirmsAccounts = selectedAllFirmsAccounts.filter(id => filteredAccounts.find(a => a.id === id)); // Group accounts by prop firm const accountsByFirm = {}; filteredAccounts.forEach(acc => { if (!accountsByFirm[acc.propFirm]) accountsByFirm[acc.propFirm] = []; accountsByFirm[acc.propFirm].push(acc); }); // Build checkbox list let html = ''; Object.entries(accountsByFirm).forEach(([firmKey, firmAccounts]) => { const config = propFirmConfigs[firmKey] || {}; const firmName = config.name || firmKey; // Sort: funded accounts first, then evals firmAccounts.sort((a, b) => { const aIsEval = a.stage === 'evaluation' || a.isEvaluation ? 1 : 0; const bIsEval = b.stage === 'evaluation' || b.isEvaluation ? 1 : 0; return aIsEval - bIsEval || (a.name || '').localeCompare(b.name || ''); }); html += `
${firmName}
`; firmAccounts.forEach(acc => { const isEval = acc.stage === 'evaluation' || acc.isEvaluation; const checked = selectedAllFirmsAccounts.includes(acc.id); html += ``; }); }); if (filteredAccounts.length === 0) { html = '
No accounts match filters
'; } listEl.innerHTML = html; updateAccountFilterLabel(); } function updateAccountFilterLabel() { const label = document.getElementById('account-filter-label'); if (!label) return; const count = selectedAllFirmsAccounts.length; if (count === 0) { label.textContent = 'All Accounts'; label.style.color = ''; } else if (count === 1) { const acc = accounts.find(a => a.id === selectedAllFirmsAccounts[0]); label.textContent = acc?.name || '1 account'; label.style.color = 'var(--cyan)'; } else { label.textContent = `${count} accounts selected`; label.style.color = 'var(--cyan)'; } } const FIRM_PILL_COLORS = { 'apex': '#f59e0b', 'topstep': '#3b82f6', 'tradeify': '#8b5cf6', 'takeprofittrader': '#ec4899', 'bulenox': '#14b8a6', 'myfundedfutures': '#06b6d4', 'earn2trade': '#84cc16', 'elitetraderfunding': '#ef4444', 'lucid': '#f97316', 'fundednext': '#6366f1', 'ffn': '#10b981', 'thetradingpit': '#a855f7', 'alphafutures': '#eab308', 'daytraders': '#22d3ee', }; function getFirmPillColor(firmKey) { return FIRM_PILL_COLORS[firmKey] || '#6b7280'; } function renderActiveFilterPills() { const container = document.getElementById('active-filter-pills'); if (!container) return; if (selectedAllFirmsAccounts.length === 0) { container.style.display = 'none'; return; } let html = ''; selectedAllFirmsAccounts.forEach(id => { const acc = accounts.find(a => a.id === id); if (!acc) return; const color = getFirmPillColor(acc.propFirm); const name = acc.name || 'Unnamed'; html += ` ${name} `; }); html += ``; container.innerHTML = html; container.style.display = 'flex'; if (streamerMode) applySensitiveBlur(); } function removeFilterPill(accountId) { selectedAllFirmsAccounts = selectedAllFirmsAccounts.filter(id => id !== accountId); // Update checkboxes in dropdown to stay in sync const cb = document.querySelector(`#account-dropdown-list input[value="${accountId}"]`); if (cb) cb.checked = false; updateAccountFilterLabel(); renderActiveFilterPills(); updateGlobalFilterIndicator(); if (_renderAllDebounceTimer) clearTimeout(_renderAllDebounceTimer); _renderAllDebounceTimer = setTimeout(() => { _renderAllDebounceTimer = null; renderAll(); }, 300); } function clearAllFilterPills() { selectedAllFirmsAccounts = []; const checkboxes = document.querySelectorAll('#account-dropdown-list input[type="checkbox"]'); checkboxes.forEach(cb => { cb.checked = false; }); updateAccountFilterLabel(); renderActiveFilterPills(); updateGlobalFilterIndicator(); renderAll(); } function toggleAccountDropdown(event) { event.stopPropagation(); const panel = document.getElementById('account-dropdown-panel'); if (!panel) return; const isVisible = panel.style.display !== 'none'; panel.style.display = isVisible ? 'none' : 'block'; if (!isVisible) { // Close on outside click setTimeout(() => { document.addEventListener('click', closeAccountDropdown); }, 0); } } function closeAccountDropdown(e) { const panel = document.getElementById('account-dropdown-panel'); const trigger = document.getElementById('global-account-filter'); if (panel && !panel.contains(e?.target) && !trigger?.contains(e?.target)) { panel.style.display = 'none'; document.removeEventListener('click', closeAccountDropdown); } } function onAccountCheckboxChange() { const checkboxes = document.querySelectorAll('#account-dropdown-list input[type="checkbox"]'); selectedAllFirmsAccounts = Array.from(checkboxes).filter(cb => cb.checked).map(cb => cb.value); updateAccountFilterLabel(); renderActiveFilterPills(); updateGlobalFilterIndicator(); if (_renderAllDebounceTimer) clearTimeout(_renderAllDebounceTimer); _renderAllDebounceTimer = setTimeout(() => { _renderAllDebounceTimer = null; renderAll(); }, 300); } function selectAllAccounts(selectAll) { const checkboxes = document.querySelectorAll('#account-dropdown-list input[type="checkbox"]'); checkboxes.forEach(cb => { cb.checked = selectAll; }); if (selectAll) { selectedAllFirmsAccounts = Array.from(checkboxes).map(cb => cb.value); } else { selectedAllFirmsAccounts = []; } updateAccountFilterLabel(); renderActiveFilterPills(); updateGlobalFilterIndicator(); if (_renderAllDebounceTimer) clearTimeout(_renderAllDebounceTimer); _renderAllDebounceTimer = setTimeout(() => { _renderAllDebounceTimer = null; renderAll(); }, 300); } // Handle prop firm filter change function onGlobalPropFirmChange() { // Clear account selection when firm changes selectedAllFirmsAccounts = []; // Repopulate account dropdown populateGlobalAccountFilter(); // Update filter indicator and pills renderActiveFilterPills(); updateGlobalFilterIndicator(); if (_renderAllDebounceTimer) clearTimeout(_renderAllDebounceTimer); _renderAllDebounceTimer = setTimeout(() => { _renderAllDebounceTimer = null; renderAll(); }, 300); } // onGlobalAccountChange no longer needed — multi-select uses onAccountCheckboxChange function onGlobalInstrumentChange() { updateGlobalFilterIndicator(); if (_renderAllDebounceTimer) clearTimeout(_renderAllDebounceTimer); _renderAllDebounceTimer = setTimeout(() => { _renderAllDebounceTimer = null; renderAll(); }, 300); } function populateInstrumentFilter() { // Instrument filter removed from dashboard — no-op } function applyWidgetFilter() { const firmSelect = document.getElementById('widget-prop-firm'); // Sync with global filter const globalFirmSelect = document.getElementById('global-prop-firm-filter'); if (globalFirmSelect && firmSelect) { globalFirmSelect.value = firmSelect.value; populateGlobalAccountFilter(); } // Sync with global multi-select filter based on widget selections const checkboxes = document.querySelectorAll('#widget-account-options input[type="checkbox"]:checked'); const selectedIds = Array.from(checkboxes).map(cb => cb.value); // Re-render dashboard with new filter renderDashboard(); } // Track which account type filter is active (funded, evaluation, or all) let accountTypeFilter = 'funded'; // Default to funded accounts // Quick filter for account types (Funded / Evaluation / All) function filterAccountType(type) { console.log('[FilterAccountType] Filtering by:', type); accountTypeFilter = type; // Clear individual selections when using type filter selectedAllFirmsAccounts = []; // Get all accounts const allPropAccounts = accounts.filter(a => !a.archived && a.propFirm && a.propFirm !== 'personal' && a.propFirm !== 'other'); const fundedAccounts = allPropAccounts.filter(a => a.stage !== 'evaluation' && !a.isEvaluation); const evalAccounts = allPropAccounts.filter(a => a.stage === 'evaluation' || a.isEvaluation); console.log('[FilterAccountType] Found accounts - Funded:', fundedAccounts.length, 'Eval:', evalAccounts.length); // Select accounts based on type if (type === 'funded') { selectedAllFirmsAccounts = fundedAccounts.map(a => a.id); } else if (type === 'evaluation') { selectedAllFirmsAccounts = evalAccounts.map(a => a.id); } else { // 'all' - select all accounts selectedAllFirmsAccounts = allPropAccounts.map(a => a.id); } console.log('[FilterAccountType] Selected account IDs:', selectedAllFirmsAccounts.length); // Update button styles const fundedBtn = document.getElementById('filter-funded-btn'); const evalBtn = document.getElementById('filter-eval-btn'); const allBtn = document.getElementById('filter-all-btn'); [fundedBtn, evalBtn, allBtn].forEach(btn => { if (btn) { btn.style.borderColor = 'var(--border-color)'; btn.style.color = 'var(--text-secondary)'; btn.style.fontWeight = '500'; btn.style.background = 'var(--bg-card-inner)'; } }); const activeBtn = type === 'funded' ? fundedBtn : type === 'evaluation' ? evalBtn : allBtn; if (activeBtn) { activeBtn.style.borderColor = 'var(--cyan)'; activeBtn.style.color = 'var(--cyan)'; activeBtn.style.fontWeight = '700'; activeBtn.style.background = 'rgba(0, 212, 170, 0.15)'; } // Re-render dashboard renderDashboard(); } // Handle account selection in All Prop Firms view // Track which firms user has explicitly expanded (default: all collapsed) const _expandedFirms = new Set(); function toggleFirmCollapse(firmKey) { const body = document.getElementById('firm-body-' + firmKey); const chevron = document.getElementById('firm-chevron-' + firmKey); if (!body) return; const isCollapsed = body.style.display === 'none'; body.style.display = isCollapsed ? 'block' : 'none'; if (chevron) chevron.style.transform = isCollapsed ? 'rotate(0deg)' : 'rotate(-90deg)'; if (isCollapsed) { _expandedFirms.add(firmKey); } else { _expandedFirms.delete(firmKey); } } function toggleAllFirmsDetail(accountId) { const detail = document.getElementById('all-firms-detail-' + accountId); const chevron = document.getElementById('acc-chevron-' + accountId); if (!detail) return; const isHidden = detail.style.display === 'none'; detail.style.display = isHidden ? 'block' : 'none'; if (chevron) chevron.style.transform = isHidden ? 'rotate(0deg)' : 'rotate(-90deg)'; } function updateGlobalFilterIndicator() { // Filter indicator is now handled by the active filter pill bar // This function is kept for backward compatibility with existing callers } function clearGlobalFilter() { const instrumentFilter = document.getElementById('global-instrument-filter'); if (instrumentFilter) instrumentFilter.value = ''; selectedAllFirmsAccounts = []; populateGlobalAccountFilter(); renderActiveFilterPills(); updateGlobalFilterIndicator(); if (_renderAllDebounceTimer) clearTimeout(_renderAllDebounceTimer); _renderAllDebounceTimer = setTimeout(() => { _renderAllDebounceTimer = null; renderAll(); }, 300); } function renderPropComplianceWidget() { const content = document.getElementById('prop-compliance-content'); if (!content) return; // Use global filters const globalFirmSelect = document.getElementById('global-prop-firm-filter'); const selectedFirm = globalFirmSelect?.value || ''; // Get all prop firm accounts, applying global filters let allPropAccounts = accounts.filter(a => (includeArchivedInMetrics || !a.archived) && a.propFirm && a.propFirm !== 'personal' && a.propFirm !== 'other'); // Apply global prop firm filter if (selectedFirm) { allPropAccounts = allPropAccounts.filter(a => a.propFirm === selectedFirm); } // Apply global stage filter if (globalStageFilter === 'funded') { allPropAccounts = allPropAccounts.filter(a => a.stage !== 'evaluation' && !a.isEvaluation); } else if (globalStageFilter === 'evaluation') { allPropAccounts = allPropAccounts.filter(a => a.stage === 'evaluation' || a.isEvaluation); } // Apply global account multi-select filter if (selectedAllFirmsAccounts.length > 0) { allPropAccounts = allPropAccounts.filter(a => selectedAllFirmsAccounts.includes(a.id)); } if (allPropAccounts.length === 0) { content.innerHTML = `
Total Balance
$0.00
0 accounts
Net Profit
$0.00
from trading
Days Traded
0
total days
Payout Eligible
0/0
not yet
Available
$0.00
to payout
ACCOUNT DETAILS
📊
No Prop Firm Accounts Yet
Add a prop firm account to start tracking your compliance, payouts, and performance.
+ Add Connection in Settings
`; return; } // Aggregate across ALL prop firms const fundedAccounts = allPropAccounts.filter(a => a.stage !== 'evaluation' && !a.isEvaluation); const evalAccounts = allPropAccounts.filter(a => a.stage === 'evaluation' || a.isEvaluation); // Get date-filtered trades so account metrics respect global date range const dashFilteredTrades = getFilteredTrades(); // Calculate metrics for each account using its respective firm config const allFundedMetrics = fundedAccounts.map(acc => { const config = propFirmConfigs[acc.propFirm] || {}; return calculateAccountMetrics(acc, config, dashFilteredTrades); }); // Calculate metrics for eval accounts too const allEvalMetrics = evalAccounts.map(acc => { const config = propFirmConfigs[acc.propFirm] || {}; return calculateAccountMetrics(acc, config, dashFilteredTrades); }); // Combine all accounts and metrics (already filtered by global filters upstream) const allAccounts = [...fundedAccounts, ...evalAccounts]; const allMetrics = [...allFundedMetrics, ...allEvalMetrics]; const statsAccounts = allAccounts; const statsMetrics = allMetrics; // Aggregate totals (based on selection) // Use acc.startingBalance (actual account size) for display balance, // NOT m.currentBalance which may be zeroed by fundedStartsAtZero const totalBalance = profitOnlyMode ? statsMetrics.reduce((sum, m) => sum + (m.profit || 0), 0) : statsMetrics.reduce((sum, m) => { const acc = statsAccounts.find(a => a.id === m.accountId); return sum + (acc?.startingBalance || 0) + (m.profit || 0) - (m.totalWithdrawnGross || 0); }, 0); const totalProfit = statsMetrics.reduce((sum, m) => sum + m.profit, 0); const totalDaysTraded = new Set(statsMetrics.flatMap(m => m.tradingDays || [])).size; // Payout eligibility only applies to funded accounts (already filtered upstream) const fundedStatsMetrics = allFundedMetrics; const fundedStatsAccounts = fundedAccounts; const eligibleCount = fundedStatsMetrics.filter(m => m.isEligible).length; const totalAvailableToPayout = fundedStatsMetrics.reduce((sum, m) => sum + (m.isEligible ? (m.availableToPayout || 0) : 0), 0); // Aggregate trade stats (based on selection) const allTrades = statsAccounts.flatMap(acc => dashFilteredTrades.filter(t => t.accountId === acc.id)); const winners = allTrades.filter(t => getNetPnl(t) > 0); const losers = allTrades.filter(t => getNetPnl(t) < 0); const winRate = allTrades.length > 0 ? (winners.length / allTrades.length * 100) : 0; const avgWin = winners.length > 0 ? winners.reduce((sum, t) => sum + getNetPnl(t), 0) / winners.length : 0; const avgLoss = losers.length > 0 ? Math.abs(losers.reduce((sum, t) => sum + getNetPnl(t), 0) / losers.length) : 0; const grossProfit = winners.reduce((sum, t) => sum + getNetPnl(t), 0); const grossLoss = Math.abs(losers.reduce((sum, t) => sum + getNetPnl(t), 0)); const profitFactor = grossLoss > 0 ? grossProfit / grossLoss : grossProfit > 0 ? Infinity : 0; const winLossRatio = avgLoss > 0 ? avgWin / avgLoss : avgWin > 0 ? Infinity : 0; // Day win rate - aggregate by UNIQUE trading session date across all accounts // Build a map of date -> total P&L across ALL accounts for that date // Uses trading session dates (trades after 5pm CT count as next day) const uniqueDailyPnl = {}; allTrades.forEach(t => { const day = t.date || getTradingSessionDate(t.exitTime) || getDateKey(t.exitTime); if (day) { uniqueDailyPnl[day] = (uniqueDailyPnl[day] || 0) + getNetPnl(t); } }); const tradingDays = Object.keys(uniqueDailyPnl).length; const winDays = Object.values(uniqueDailyPnl).filter(v => v > 0).length; const lossDays = Object.values(uniqueDailyPnl).filter(v => v < 0).length; const dayWinRate = tradingDays > 0 ? (winDays / tradingDays * 100) : 0; // Group FUNDED accounts by firm for display const accountsByFirm = {}; fundedAccounts.forEach(acc => { if (!accountsByFirm[acc.propFirm]) accountsByFirm[acc.propFirm] = []; accountsByFirm[acc.propFirm].push(acc); }); // Group EVAL accounts by firm for display const evalAccountsByFirm = {}; evalAccounts.forEach(acc => { if (!evalAccountsByFirm[acc.propFirm]) evalAccountsByFirm[acc.propFirm] = []; evalAccountsByFirm[acc.propFirm].push(acc); }); // Get all unique firms (both funded and eval) const allFirms = [...new Set([...Object.keys(accountsByFirm), ...Object.keys(evalAccountsByFirm)])]; // Header text showing account count const totalAccountCount = fundedAccounts.length + evalAccounts.length; const headerText = `${totalAccountCount} account${totalAccountCount !== 1 ? 's' : ''} (${fundedAccounts.length} funded, ${evalAccounts.length} eval)`; // Render All Firms view content.innerHTML = `
Total Balance
${formatCurrency(profitOnlyMode ? totalProfit : totalBalance)}
${headerText}
Net Profit
${formatCurrency(totalProfit)}
from trading
Days Traded
${totalDaysTraded}
total days
Payout Eligible
${eligibleCount}/${fundedStatsAccounts.length}
${eligibleCount > 0 ? 'ready' : 'not yet'}
Available
${formatCurrency(totalAvailableToPayout)}
to payout
Trade Win %
${winRate.toFixed(1)}%
${winners.length}W / ${losers.length}L
Day Win %
${dayWinRate.toFixed(1)}%
${winDays}W / ${lossDays}L
Profit Factor
${profitFactor === Infinity ? '∞' : profitFactor.toFixed(2)}
Gross ${formatCurrency(grossProfit)}
Avg Win/Loss
${winLossRatio === Infinity ? '∞' : winLossRatio.toFixed(2)}
${formatCurrency(avgWin)} / ${formatCurrency(avgLoss)}
Account Details
${allFirms.map(firmKey => { const config = propFirmConfigs[firmKey] || {}; const firmName = config.name || firmKey; const firmFundedAccounts = accountsByFirm[firmKey] || []; const firmEvalAccounts = evalAccountsByFirm[firmKey] || []; // Skip firm section if no accounts if (firmFundedAccounts.length === 0 && firmEvalAccounts.length === 0) return ''; return `
${getFirmLogoHtml(firmKey, 44)} ${firmName}
${(() => { // Calculate firm totals // Use acc.startingBalance (actual account size) for display balance, // NOT m.currentBalance which may be zeroed by fundedStartsAtZero let fundedBalance = 0, fundedPnl = 0, evalBalance = 0, evalPnl = 0; firmFundedAccounts.forEach(acc => { const m = allFundedMetrics.find(m => m.accountId === acc.id) || {}; fundedBalance += (acc.startingBalance || 0) + (m.profit || 0) - (m.totalWithdrawnGross || 0); fundedPnl += m.profit || 0; }); firmEvalAccounts.forEach(acc => { const m = allEvalMetrics.find(m => m.accountId === acc.id) || {}; evalBalance += (acc.startingBalance || 0) + (m.profit || 0); evalPnl += m.profit || 0; }); const totalBalance = fundedBalance + evalBalance; const totalPnl = fundedPnl + evalPnl; const totalCount = firmFundedAccounts.length + firmEvalAccounts.length; const pnlColor = totalPnl >= 0 ? 'var(--green)' : 'var(--red)'; const pnlSign = totalPnl >= 0 ? '+' : ''; const hasBoth = firmFundedAccounts.length > 0 && firmEvalAccounts.length > 0; const fPnlColor = fundedPnl >= 0 ? 'var(--green)' : 'var(--red)'; const ePnlColor = evalPnl >= 0 ? 'var(--green)' : 'var(--red)'; const displayTotalBalance = profitOnlyMode ? totalPnl : totalBalance; return `
Balance
${formatCurrency(displayTotalBalance)}
Net P&L
${formatCurrency(totalPnl)}
${hasBoth ? `
F: ${formatCurrency(fundedPnl, 0)} · E: ${formatCurrency(evalPnl, 0)}
` : ''}
Accts
${totalCount}
`; })()}
${firmFundedAccounts.length > 0 ? `
FUNDED (${firmFundedAccounts.length})
${firmFundedAccounts.map(acc => { const metrics = allFundedMetrics.find(m => m.accountId === acc.id) || {}; const topstepFundedZeroStart = config.fundedStartsAtZero && acc.stage === 'funded'; const topstepRuleSize = topstepFundedZeroStart ? (acc.accountSize || acc.startingBalance) : acc.startingBalance; const drawdownVal = getAccountConfig(config, topstepRuleSize, acc.plan)?.drawdown || 0; const mllFallback = topstepFundedZeroStart ? -drawdownVal : (acc.startingBalance - drawdownVal); const mllDisplay = acc.currentMLL !== undefined && acc.currentMLL !== null ? parseFloat(acc.currentMLL) : (metrics.maximumLossLimit !== undefined ? metrics.maximumLossLimit : mllFallback); const bufferToMLL = (metrics.currentBalance !== undefined ? metrics.currentBalance : (acc.startingBalance || 0)) - mllDisplay; const accTrades = dashFilteredTrades.filter(t => t.accountId === acc.id); const accWinners = accTrades.filter(t => getNetPnl(t) > 0); const accLosers = accTrades.filter(t => getNetPnl(t) < 0); const accWinRate = accTrades.length > 0 ? (accWinners.length / accTrades.length * 100) : 0; const accDailyPnl = {}; accTrades.forEach(t => { const day = t.date || getTradingSessionDate(t.exitTime) || getDateKey(t.exitTime); if (day) accDailyPnl[day] = (accDailyPnl[day] || 0) + getNetPnl(t); }); const accTradingDays = Object.keys(accDailyPnl).length; const accWinDays = Object.values(accDailyPnl).filter(v => v > 0).length; const accLossDays = Object.values(accDailyPnl).filter(v => v < 0).length; const accDayWinRate = accTradingDays > 0 ? (accWinDays / accTradingDays * 100) : 0; const actualProfit = metrics.profit || 0; const profitAboveStart = actualProfit; const rawBuffer = metrics.bufferRequired || 0; const safetyNetAbs = metrics.safetyNetAbsolute; const bufferReqdForPayout = metrics.bufferRequiredForPayoutAbsolute; // Use payout buffer (absolute - starting) when available, else fall back to config buffer const bufferRequired = (bufferReqdForPayout !== undefined && acc.startingBalance > 0) ? bufferReqdForPayout - (acc.startingBalance || 0) : rawBuffer; const bufferMet = bufferReqdForPayout !== undefined ? metrics.currentBalance >= bufferReqdForPayout : safetyNetAbs !== undefined ? metrics.currentBalance >= safetyNetAbs : profitAboveStart >= bufferRequired; const bufferNeeded = bufferReqdForPayout !== undefined ? Math.max(0, bufferReqdForPayout - metrics.currentBalance) : safetyNetAbs !== undefined ? Math.max(0, safetyNetAbs - metrics.currentBalance) : Math.max(0, bufferRequired - actualProfit); const availableToWithdraw = safetyNetAbs !== undefined ? metrics.maxWithdrawal : Math.max(0, profitAboveStart - bufferRequired); // Payout-aware progress: uses currentBalance (which reflects withdrawals) so the // ring/bar don't show falsely full after a payout depletes the buffer. const effectiveProgress = bufferReqdForPayout !== undefined ? metrics.currentBalance - (acc.startingBalance || 0) : actualProfit - (metrics.totalWithdrawnGross || 0); const filledBufferWidth = bufferRequired > 0 ? Math.min(100, (Math.max(0, effectiveProgress) / bufferRequired) * 100) : 0; const maxProfitBarWidth = 30; const profitBarWidth = bufferMet && availableToWithdraw > 0 ? Math.min(maxProfitBarWidth, (availableToWithdraw / (bufferRequired || 1)) * 30) : 0; const profitPct = acc.startingBalance > 0 ? ((profitAboveStart / acc.startingBalance) * 100).toFixed(1) : 0; const totalProfitFromTrading = accTrades.reduce((sum, t) => sum + getNetPnl(t), 0); const accBestDay = Object.values(accDailyPnl).length > 0 ? Math.max(...Object.values(accDailyPnl)) : 0; const accWorstDay = Object.values(accDailyPnl).length > 0 ? Math.min(...Object.values(accDailyPnl)) : 0; const firmPayoutData = window.payoutStatusData?.[firmKey]; const isPayoutReady = metrics.isEligible; // Profit factor const accGrossProfit = accWinners.reduce((s, t) => s + getNetPnl(t), 0); const accGrossLoss = Math.abs(accLosers.reduce((s, t) => s + getNetPnl(t), 0)); const accPF = accGrossLoss > 0 ? accGrossProfit / accGrossLoss : accGrossProfit > 0 ? Infinity : 0; const accAvgWin = accWinners.length > 0 ? accGrossProfit / accWinners.length : 0; const accAvgLoss = accLosers.length > 0 ? accGrossLoss / accLosers.length : 0; // Sparkline: daily cumulative P&L const sortedDays = Object.keys(accDailyPnl).sort(); let cumPnl = 0; const sparkData = sortedDays.map(d => { cumPnl += accDailyPnl[d]; return cumPnl; }); const sparkMin = sparkData.length > 0 ? Math.min(...sparkData, 0) : 0; const sparkMax = sparkData.length > 0 ? Math.max(...sparkData, 0) : 0; const sparkRange = sparkMax - sparkMin || 1; const sparkW = 100, sparkH = 28; let sparkPoints; if (sparkData.length > 1) { sparkPoints = sparkData.map((v, i) => { const x = (i / (sparkData.length - 1)) * sparkW; const y = sparkH - ((v - sparkMin) / sparkRange) * sparkH; return `${x.toFixed(1)},${y.toFixed(1)}`; }).join(' '); } else if (sparkData.length === 1) { const y = sparkH - ((sparkData[0] - sparkMin) / sparkRange) * sparkH; sparkPoints = `0,${y.toFixed(1)} ${sparkW},${y.toFixed(1)}`; } else { sparkPoints = `0,${sparkH / 2} ${sparkW},${sparkH / 2}`; } const sparkColor = cumPnl >= 0 ? '#00d4aa' : '#ff6b6b'; // Recent trades (last 5) const recentTrades = [...accTrades].sort((a, b) => new Date(b.exitTime || b.date) - new Date(a.exitTime || a.date)).slice(0, 5); // MLL values const mllVal = metrics.maximumLossLimit !== undefined ? metrics.maximumLossLimit : mllDisplay; const mllDist = metrics.distanceToMLL || bufferToMLL; // === Compliance data === const compAccConfig = getAccountConfig(config, topstepRuleSize, acc.plan); const compFundedRules = config.fundedRules || {}; // Apex PA funded accounts use tier-based max_contracts + DLL (updates daily at market close). // Other firms / plans / stages fall through to the flat compAccConfig.maxContracts. const apexPAScalingTiers = propFirmConfigs?.apex?.pa_scaling_tiers || null; const isApexPA = acc.propFirm === 'apex' && acc.plan === 'pa' && acc.stage === 'funded'; const apexPATierSize = acc.accountSize || acc.startingBalance || 0; const apexPATier = isApexPA && apexPAScalingTiers ? getApexPATier(apexPATierSize, (metrics.currentBalance - (acc.startingBalance || 0)), apexPAScalingTiers) : null; const compMaxContracts = apexPATier ? apexPATier.max_contracts : (compAccConfig?.maxContracts || 10); const compDLL = apexPATier ? apexPATier.dll : null; const compTierNum = apexPATier ? apexPATier.tier : null; const compTierTotal = apexPATier ? (apexPAScalingTiers?.[String(apexPATierSize)]?.length || null) : null; // Use THE SAME bufferRequired for ring as for bar — single source of truth const compHasBuffer = bufferRequired > 0; const compSafetyNet = compHasBuffer ? bufferRequired : 0; const compSafetyReached = compHasBuffer ? actualProfit >= compSafetyNet : true; const compBufferPct = compHasBuffer ? Math.min(100, Math.max(0, (effectiveProgress / compSafetyNet) * 100)) : 100; const compRingColor = compBufferPct >= 100 ? 'var(--green)' : compBufferPct >= 50 ? 'var(--cyan)' : compBufferPct >= 25 ? 'var(--yellow)' : 'var(--orange)'; const compAllowed = (compHasBuffer && !compSafetyReached) ? Math.floor(compMaxContracts / 2) : compMaxContracts; // Payout eligibility progress (reuse same calc as Payout Status Overview) const payoutElig = calculateEligibilityScore(metrics, config); const payoutEligPct = payoutElig.score; const payoutRingColor = payoutEligPct >= 100 ? themeGreen() : payoutEligPct >= 70 ? '#f59e0b' : themeRed(); const payoutRingR = 12, payoutRingCirc = 2 * Math.PI * payoutRingR; // Header ring shows buffer progress when a buffer requirement exists, else payout eligibility const ringDisplayPct = compHasBuffer ? compBufferPct : payoutEligPct; const ringDisplayColor = compHasBuffer ? compRingColor : payoutRingColor; const ringTitle = compHasBuffer ? `Buffer Progress: ${compBufferPct.toFixed(0)}%\n${bufferMet ? 'Buffer met ✓' : formatCurrency(bufferNeeded) + ' needed'}` : `Payout Eligibility: ${payoutEligPct.toFixed(0)}%${payoutElig.missingItems.length > 0 ? '\n' + payoutElig.missingItems.join('\n') : ''}`; const compMllOverride = acc.currentMLL !== undefined && acc.currentMLL !== null; const compDispMLL = compMllOverride ? parseFloat(acc.currentMLL) : mllVal; const compStartBal = topstepFundedZeroStart ? 0 : (acc.startingBalance || 0); const compCurBal = metrics.currentBalance !== undefined ? metrics.currentBalance : compStartBal; // Scaling const compHasScaling = compFundedRules.scalingRequired === true; const compScaling = compHasScaling ? checkContractScaling(acc.id, compAllowed) : { compliant: true, maxUsed: 0 }; // MAE const compHasMAE = compFundedRules.maxLossPerTrade != null; let compMLPT; if (compHasMAE) { if (!compSafetyReached) compMLPT = drawdownVal * 0.30; else if (actualProfit >= compSafetyNet * 2) compMLPT = actualProfit * 0.50; else compMLPT = actualProfit * 0.30; } // Consistency const compPayoutCount = (payouts || []).filter(p => p.accountId === acc.id).length; let compConsType = 'fixed', compConsRule = config.consistency, compConsProg = null; if (config.rulesByPlan && acc.plan && config.rulesByPlan[acc.plan]) { const pp = config.rulesByPlan[acc.plan].payout; if (pp) { if (pp.consistencyRule !== undefined) compConsRule = pp.consistencyRule; if (pp.consistencyType !== undefined) compConsType = pp.consistencyType; if (pp.consistencyProgression !== undefined) compConsProg = pp.consistencyProgression; } } const compEffCons = getEffectiveConsistencyPct(compConsType, compConsRule, compConsProg, compPayoutCount); const compHasCons = compEffCons !== null; const compConsPct = compHasCons ? compEffCons : 0; const compBestDay = metrics.bestDay || Math.max(0, ...Object.values(accDailyPnl)); const compConsMinProfit = compHasCons && compBestDay > 0 ? compBestDay / (compConsPct / 100) : 0; const compConsOK = !compHasCons || compPayoutCount >= 6 || actualProfit >= compConsMinProfit || compBestDay === 0; // Rule checklist const compRules = []; // Drawdown type badge — always first, reads per-plan per-size from Firestore const compDDType = compAccConfig?.drawdownType || config.drawdownType || null; const compDDLabel = !compDDType ? '⚠ DD Type?' : compDDType === 'static' ? 'Static DD' : (compDDType === 'realtime' || compDDType === 'intraday') ? 'Intraday Trailing DD' : 'EOD Trailing DD'; const compDDColor = !compDDType ? '#ef4444' : compDDType === 'static' ? '#f59e0b' : (compDDType === 'realtime' || compDDType === 'intraday') ? '#8b5cf6' : '#00d4aa'; compRules.push({ icon: '📊', text: compDDLabel, ok: true, color: compDDColor }); if (compHasScaling) { let sd = apexPATier ? `Scaling · Tier ${compTierNum}/${compTierTotal} (${compAllowed} max)` : `Scaling ${compSafetyReached ? '(full)' : `(${compAllowed} max)`}`; if (compScaling.maxUsed > 0) sd += ` · used ${compScaling.maxUsed}`; compRules.push({ icon: compScaling.compliant ? '✅' : '❌', text: sd, ok: compScaling.compliant, title: apexPATier ? 'Apex PA tier — updates daily at market close based on closing balance.' : undefined, }); } // Apex PA DLL chip — only rendered when tier data sets compDLL (other firms unaffected). if (compDLL !== null && compDLL !== undefined) { compRules.push({ icon: '🛑', text: `DLL $${compDLL.toLocaleString()}`, ok: true, title: 'Daily Loss Limit — resets daily at 6PM ET. Updates at market close based on closing balance.', }); } if (compHasMAE) compRules.push({ icon: '✅', text: `MAE ${(compFundedRules.maxLossPerTrade * 100).toFixed(0)}% (${formatCurrency(compMLPT)})`, ok: true }); if (compHasCons) compRules.push({ icon: compConsOK ? '✅' : '⬜', text: `${compConsPct}% Consistency ${compConsOK ? '✓' : `(need ${formatCurrency(compConsMinProfit)})`}`, ok: compConsOK }); if (compFundedRules.newsTrading !== undefined) compRules.push({ icon: compFundedRules.newsTrading ? '✅' : '❌', text: compFundedRules.newsTrading ? 'News OK' : 'No News', ok: compFundedRules.newsTrading }); const compIsCompliant = (!compHasScaling || compScaling.compliant) && (compCurBal >= compDispMLL); return `
${compHasBuffer ? `
${ringDisplayPct.toFixed(0)}%
` : ''}
${acc.name || 'Unnamed'} Funded${acc.accountStatus === 'blown' ? 'Blown' : acc.accountStatus === 'passed' ? 'Passed' : ''}${isPayoutReady ? `✓ Payout Eligible${(metrics.maxWithdrawal || 0) > 0 ? `${formatCurrency(metrics.maxWithdrawal)}` : ''}` : ''}
Balance
${formatCurrency(getDisplayBalance(metrics.currentBalance !== undefined ? metrics.currentBalance : (acc.startingBalance || 0), acc.startingBalance || 0))}
Net P&L
${actualProfit >= 0 ? '+' : ''}${formatCurrency(actualProfit)}
Win %
${accWinRate.toFixed(0)}%
Day Win%
${accTradingDays > 0 ? accDayWinRate.toFixed(0) + '%' : '-'}
PF
${accPF === Infinity ? '∞' : accPF.toFixed(1)}
W/L
${accAvgLoss > 0 ? (accAvgWin / accAvgLoss).toFixed(1) : '∞'}
${(() => { const _accPayoutsTotal = (payouts || []).filter(p => p.accountId === acc.id && p.type === 'withdrawal').reduce((s,p) => s + (p.amount || 0), 0); return _accPayoutsTotal > 0 ? `
Payouts
${formatCurrency(_accPayoutsTotal, 0)}
` : ''; })()}
`; }).join('')}
` : ''} ${firmEvalAccounts.length > 0 ? `
EVALUATIONS (${firmEvalAccounts.length})
${firmEvalAccounts.map(acc => { const metrics = allEvalMetrics.find(m => m.accountId === acc.id) || {}; const accConfig = getAccountConfig(config, acc.startingBalance, acc.plan); // Resolve eval rules from propFirmConfig (config-driven, no hardcoding). // evalRulesByPlan schema: [planId][size] → rules object. // Fallback chain: account plan → 'default' plan → evalAccounts → account overrides → formula defaults. const _evalSize = parseInt(acc.startingBalance) || acc.startingBalance; const _planEvalMap = config.evalRulesByPlan?.[acc.plan] || config.evalRulesByPlan?.['default'] || {}; const planEvalRules = _planEvalMap[_evalSize] || _planEvalMap[String(acc.startingBalance)] || {}; const evalRules = config.evalAccounts?.[_evalSize] || config.evalAccounts?.[acc.startingBalance] || {}; const profitTarget = acc.profitTarget || planEvalRules.profitTarget || evalRules.profitTarget || null; const evalProfitTargetMissing = profitTarget === null; if (evalProfitTargetMissing) { console.warn(`[Profit] No profitTarget config found for eval account ${acc.name} (${acc.propFirm} ${acc.plan} ${acc.startingBalance}) — profit target cannot be displayed`); } const currentProfit = metrics.profit || 0; const progress = profitTarget > 0 ? Math.max(0, Math.min(100, (currentProfit / profitTarget) * 100)) : 0; const maxDrawdown = acc.maxDrawdown || planEvalRules.maxDrawdown || evalRules.drawdown || accConfig?.drawdown || null; const evalDrawdownMissing = maxDrawdown === null; if (evalDrawdownMissing) { console.warn(`[MLL] No drawdown config found for eval account ${acc.name} (${acc.propFirm} ${acc.plan} ${acc.startingBalance}) — drawdown cannot be calculated`); } const evalTrades = dashFilteredTrades.filter(t => t.accountId === acc.id); let runningPnL = 0, peakPnL = 0; [...evalTrades].sort((a, b) => new Date(a.entryTime || a.date) - new Date(b.entryTime || b.date)).forEach(t => { runningPnL += getNetPnl(t); if (runningPnL > peakPnL) peakPnL = runningPnL; }); const drawdownUsed = peakPnL - runningPnL; const drawdownRemaining = evalDrawdownMissing ? null : (maxDrawdown > 0 ? Math.max(0, ((maxDrawdown - drawdownUsed) / maxDrawdown) * 100) : 100); const mll = evalDrawdownMissing ? null : (acc.startingBalance + peakPnL - maxDrawdown); const progressBarW = Math.min(100, Math.max(0, progress)); // Eval progression metrics const evalMinDays = planEvalRules.minTradingDays || evalRules.minTradingDays || metrics.requiredDays || 0; const evalTradingDays = metrics.totalTradingDays || 0; const evalDaysMet = evalMinDays > 0 ? evalTradingDays >= evalMinDays : true; const evalDaysProg = evalMinDays > 0 ? Math.min(100, (evalTradingDays / evalMinDays) * 100) : 100; const profitMet = evalProfitTargetMissing ? null : currentProfit >= profitTarget; const ddSafe = evalDrawdownMissing ? null : drawdownRemaining > 0; const evalConsistencyRule = planEvalRules.consistencyRule || evalRules.consistencyRule || (metrics.hasConsistencyRule ? metrics.effectiveConsistency : null); const evalConsistencyPct = evalConsistencyRule ? parseInt(evalConsistencyRule) : 0; const evalMaxDayProfit = metrics.maxDayProfit || 0; const evalConsistencyActual = currentProfit > 0 && evalConsistencyPct > 0 ? (evalMaxDayProfit / currentProfit * 100) : 0; const evalConsistencyMet = !evalConsistencyPct || (currentProfit > 0 && evalConsistencyActual <= evalConsistencyPct); const dailyLossLimit = planEvalRules.dailyLossLimit || evalRules.dailyLossLimit || 0; // Eval sparkline: daily cumulative P&L const evalDailyPnl = {}; evalTrades.forEach(t => { const day = t.date || getTradingSessionDate(t.exitTime) || getDateKey(t.exitTime); if (day) evalDailyPnl[day] = (evalDailyPnl[day] || 0) + getNetPnl(t); }); const evalSortedDays = Object.keys(evalDailyPnl).sort(); let evalCumPnl = 0; const evalSparkData = evalSortedDays.map(d => { evalCumPnl += evalDailyPnl[d]; return evalCumPnl; }); const evalSparkMin = evalSparkData.length > 0 ? Math.min(...evalSparkData, 0) : 0; const evalSparkMax = evalSparkData.length > 0 ? Math.max(...evalSparkData, 0) : 0; const evalSparkRange = evalSparkMax - evalSparkMin || 1; const eSpkW = 100, eSpkH = 28; let evalSparkPoints; if (evalSparkData.length > 1) { evalSparkPoints = evalSparkData.map((v, i) => { const x = (i / (evalSparkData.length - 1)) * eSpkW; const y = eSpkH - ((v - evalSparkMin) / evalSparkRange) * eSpkH; return `${x.toFixed(1)},${y.toFixed(1)}`; }).join(' '); } else if (evalSparkData.length === 1) { const y = eSpkH - ((evalSparkData[0] - evalSparkMin) / evalSparkRange) * eSpkH; evalSparkPoints = `0,${y.toFixed(1)} ${eSpkW},${y.toFixed(1)}`; } else { evalSparkPoints = `0,${eSpkH / 2} ${eSpkW},${eSpkH / 2}`; } const evalSparkColor = evalCumPnl >= 0 ? '#00d4aa' : '#ff6b6b'; // Eval trade stats for header tiles const evalWinners = evalTrades.filter(t => getNetPnl(t) > 0); const evalLosers = evalTrades.filter(t => getNetPnl(t) < 0); const evalWinRate = evalTrades.length > 0 ? (evalWinners.length / evalTrades.length * 100) : 0; const evalDaysCount = Object.keys(evalDailyPnl).length; const evalWinDays = Object.values(evalDailyPnl).filter(v => v > 0).length; const evalDayWinRate = evalDaysCount > 0 ? (evalWinDays / evalDaysCount * 100) : 0; const evalGrossProfit = evalWinners.reduce((s, t) => s + getNetPnl(t), 0); const evalGrossLoss = Math.abs(evalLosers.reduce((s, t) => s + getNetPnl(t), 0)); const evalPF = evalGrossLoss > 0 ? evalGrossProfit / evalGrossLoss : evalGrossProfit > 0 ? Infinity : 0; const evalAvgWin = evalWinners.length > 0 ? evalGrossProfit / evalWinners.length : 0; const evalAvgLoss = evalLosers.length > 0 ? evalGrossLoss / evalLosers.length : 0; // Overall eval pass checklist const reqsMet = [profitMet, ddSafe, evalMinDays > 0 ? evalDaysMet : null, evalConsistencyPct > 0 ? evalConsistencyMet : null].filter(v => v !== null); const totalReqs = reqsMet.length; const metCount = reqsMet.filter(Boolean).length; const hasActivity = evalTrades.length > 0 || evalTradingDays > 0; const overallProg = (totalReqs > 0 && hasActivity) ? Math.round((metCount / totalReqs) * 100) : 0; const allMet = metCount === totalReqs; return `
${overallProg}%
${acc.name || 'Unnamed'} ${(acc.accountStatus === 'blown' || acc.status === 'failed') ? 'Failed' : allMet ? '✓ Pass' : 'Eval'}
Balance
${formatCurrency(getDisplayBalance(metrics.currentBalance !== undefined ? metrics.currentBalance : (acc.startingBalance || 0), acc.startingBalance || 0))}
Net P&L
${currentProfit >= 0 ? '+' : ''}${formatCurrency(currentProfit)}
Win %
${evalWinRate.toFixed(0)}%
Day Win%
${evalDaysCount > 0 ? evalDayWinRate.toFixed(0) + '%' : '-'}
PF
${evalPF === Infinity ? '∞' : evalPF.toFixed(1)}
W/L
${evalAvgLoss > 0 ? (evalAvgWin / evalAvgLoss).toFixed(1) : '∞'}
`; }).join('')}
` : ''}
`; }).join('')}
`; // Apply streamer mode if enabled if (streamerMode) applySensitiveBlur(); // Default all firms to collapsed, expand only user-expanded firms document.querySelectorAll('[id^="firm-body-"]').forEach(body => { const firmKey = body.id.replace('firm-body-', ''); const chevron = document.getElementById('firm-chevron-' + firmKey); if (_expandedFirms.has(firmKey)) { body.style.display = 'block'; if (chevron) chevron.style.transform = 'rotate(0deg)'; } else { body.style.display = 'none'; if (chevron) chevron.style.transform = 'rotate(-90deg)'; } }); } // Resolve minProfitPerDay which may be a scalar (e.g. 200) or a per-size // object (e.g. {"50000":200,"100000":200,"150000":250,"default":200}). // Used by every site that reads rulesByPlan[plan].payout.minProfitPerDay. function resolveMinProfitPerDay(raw, accountSize) { if (raw === null || raw === undefined) return 0; if (typeof raw === 'object' && !Array.isArray(raw)) { const sz = String(parseInt(accountSize) || 0); return raw[sz] ?? raw['default'] ?? 0; } return raw; } // Same per-size lookup pattern as resolveMinProfitPerDay, applied to the // qualifying-days count. Scalar firms (the current state for every firm in // production) pass through unchanged; per-size object support is dormant // until a firm's data is migrated to that shape. function resolveMinProfitableDays(raw, accountSize) { if (raw === null || raw === undefined) return 0; if (typeof raw === 'object' && !Array.isArray(raw)) { const sz = String(parseInt(accountSize) || 0); return raw[sz] ?? raw['default'] ?? 0; } return raw; } // Helper function to get plan-specific rules from firmConfig // Reads from rulesByPlan (from Firestore propFirmConfig) // Structure: rulesByPlan[planId].payout contains consistencyRule, consistencyType, consistencyProgression, etc. function getPlanSpecificConfig(firmConfig, account) { if (!firmConfig) return {}; if (!account) return { ...firmConfig }; // Start with base firm config let effectiveConfig = { ...firmConfig }; // Check if there are plan-specific rules from rulesByPlan (from Firestore propFirmConfig) if (firmConfig.rulesByPlan && account.plan && firmConfig.rulesByPlan[account.plan]) { const planRules = firmConfig.rulesByPlan[account.plan]; // Merge payout rules if available if (planRules.payout) { const pr = planRules.payout; // Override consistency with plan-specific value if (pr.consistencyRule !== undefined) { effectiveConfig.consistency = pr.consistencyRule; } // Pass through consistency type and progression for progressive rules if (pr.consistencyType !== undefined) { effectiveConfig.consistencyType = pr.consistencyType; } if (pr.consistencyProgression !== undefined) { effectiveConfig.consistencyProgression = pr.consistencyProgression; } // Override payout timing with plan-specific values if (pr.minTradingDays !== undefined || pr.minProfitableDays !== undefined) { const planDays = pr.minTradingDays ?? resolveMinProfitableDays(pr.minProfitableDays, account.startingBalance || account.accountSize); const planQualDays = resolveMinProfitableDays(pr.minProfitableDays, account.startingBalance || account.accountSize); effectiveConfig.payoutTiming = { ...effectiveConfig.payoutTiming, type: planQualDays > 0 ? 'winningDays' : (planDays > 0 ? 'tradingDays' : 'anytime'), days: planDays, qualifyingDays: planQualDays, minProfitPerDay: resolveMinProfitPerDay(pr.minProfitPerDay, account.startingBalance || account.accountSize) }; } if (pr.minWithdrawal !== undefined) { effectiveConfig.minWithdrawal = pr.minWithdrawal; } if (pr.maxWithdrawalAmt !== undefined) { effectiveConfig.maxWithdrawalAmt = pr.maxWithdrawalAmt; } if (pr.maxWithdrawalPct !== undefined) { effectiveConfig.maxWithdrawalPct = pr.maxWithdrawalPct; } } console.log(`[Plan Config] Using plan-specific rules for ${account.name}: plan=${account.plan}`, planRules.payout); } // Fallback: check hardcoded planRules if not set from Firestore config if (account.plan && firmConfig.planRules && firmConfig.planRules[account.plan]) { const hcRules = firmConfig.planRules[account.plan]; if (effectiveConfig.maxWithdrawalAmt === undefined && hcRules.maxWithdrawalAmt !== undefined) { effectiveConfig.maxWithdrawalAmt = hcRules.maxWithdrawalAmt; } if (effectiveConfig.maxWithdrawalPct === undefined && hcRules.maxWithdrawalPct !== undefined) { effectiveConfig.maxWithdrawalPct = hcRules.maxWithdrawalPct; } } return effectiveConfig; } // Helper to calculate effective consistency percentage based on payout count // Handles progressive consistency (e.g., 20% -> 25% -> 30% after each payout) function getEffectiveConsistencyPct(consistencyType, consistencyRule, consistencyProgression, payoutCount) { // No consistency rule if (!consistencyRule || consistencyRule === 'none' || consistencyRule === 'None' || consistencyType === 'none') { return null; } // Progressive consistency - use payout count to determine current percentage if (consistencyType === 'progressive' && consistencyProgression && Array.isArray(consistencyProgression)) { // payoutCount 0 = working toward 1st payout, use index 0 (20%) // payoutCount 1 = working toward 2nd payout, use index 1 (25%) // payoutCount 2+ = working toward 3rd+ payout, use last index (30%) const index = Math.min(payoutCount, consistencyProgression.length - 1); return consistencyProgression[index]; } // Fixed consistency - parse the percentage return parseInt(consistencyRule) || null; } function calculateAccountMetrics(account, firmConfig, filteredTradesList) { // Per-render cache: when no custom trade list is passed, return cached result if available. // Cache is keyed on accountId and cleared at the start of each renderAll() call. if (!filteredTradesList && _metricsCache && _metricsCache[account.id] !== undefined) { return _metricsCache[account.id]; } // Get plan-specific config (merges rulesByPlan overrides if available) const effectiveFirmConfig = getPlanSpecificConfig(firmConfig, account); const accountTrades = getAccountTrades(account, filteredTradesList || trades); const profit = accountTrades.reduce((sum, t) => sum + getNetPnl(t), 0); // Use account's starting balance - check both field names for backward compatibility // startingBalance is the current field, balance was used in older versions let startingBal = parseFloat(account.startingBalance) || parseFloat(account.balance) || 0; // Firms like TopStep: funded accounts start at $0, combine size is for rule lookups only const isFundedZeroStart = firmConfig?.fundedStartsAtZero && account.stage === 'funded'; if (isFundedZeroStart) startingBal = 0; // Admin balance override — replaces computed balance AND MLL for ALL downstream calculations // (eligibility, buffer gates, consistency, etc). Only triggers when both balanceOverride and currentBalance are set. let _adminBalanceOverride = false; let _overrideBalance = null; let _overrideMll = null; if (account.balanceOverride === true && account.currentBalance !== undefined && account.currentBalance !== null) { _adminBalanceOverride = true; _overrideBalance = parseFloat(account.currentBalance); _overrideMll = (account.mll !== undefined && account.mll !== null) ? parseFloat(account.mll) : null; } // Debug warning if startingBalance is missing (helps identify data issues) if (account.startingBalance === undefined && account.balance === undefined) { console.warn(`Account ${account.name} (${account.id}) missing startingBalance - MLL calculations may be incorrect`); } // Get withdrawals for this account FIRST - match by ID, name, or propFirm as fallback const accountWithdrawals = payouts.filter(p => { if (p.type !== 'withdrawal') return false; // Match by accountId (exact match) - preferred if (p.accountId && p.accountId === account.id) return true; // Match by accountName (exact match) if (p.accountName && p.accountName === account.name) return true; // Fallback: Match by propFirm ONLY if payout has no accountId/accountName set // This handles older payouts that were recorded before account linking was added if (!p.accountId && !p.accountName && p.propFirm && p.propFirm === account.propFirm) { // Only match if there's exactly one account for this prop firm const firmAccounts = accounts.filter(a => a.propFirm === p.propFirm); if (firmAccounts.length === 1) return true; } return false; }); // For account balance: use grossAmount (full withdrawal from account, before prop firm split) // Handle various scenarios: // 1. grossAmount properly set and different from amount → use grossAmount // 2. profitSplit < 100 but grossAmount = amount → recalculate gross from net // 3. No split info → use amount as-is const totalWithdrawnGross = accountWithdrawals.reduce((sum, p) => { // If we have a valid profitSplit less than 100%, calculate gross from the stored amount // This handles cases where amount is the NET received if (p.profitSplit && p.profitSplit < 100) { // If grossAmount was properly set (different from amount), use it if (p.grossAmount && Math.abs(p.grossAmount - p.amount) > 0.01) { return sum + p.grossAmount; } // Otherwise, assume amount is NET and calculate gross // gross = net / (split / 100) const calculatedGross = p.amount / (p.profitSplit / 100); console.log(`[Payout Calc] Split ${p.profitSplit}%, Net: $${p.amount}, Calculated Gross: ${formatCurrency(calculatedGross)}`); return sum + calculatedGross; } // For 100% split or no split info, use grossAmount or amount return sum + (p.grossAmount || p.amount || 0); }, 0); // For display: use net amount (what trader actually received) const totalWithdrawnNet = accountWithdrawals.reduce((sum, p) => sum + (p.amount || 0), 0); // Current balance = Starting + Profit - Gross Withdrawals // This reflects the ACTUAL balance in the account after payouts const currentBalance = _adminBalanceOverride ? _overrideBalance : (startingBal + profit - totalWithdrawnGross); // Gross balance (before withdrawals) - used for MLL/drawdown calculations const grossBalance = startingBal + profit; // Debug: Log payout matching for troubleshooting if (accountWithdrawals.length === 0 && payouts.filter(p => p.type === 'withdrawal').length > 0) { console.log(`[Payout Debug] Account "${account.name}" (id: ${account.id}, firm: ${account.propFirm}) found no matching payouts`); console.log(`[Payout Debug] Total withdrawals in system:`, payouts.filter(p => p.type === 'withdrawal').map(p => ({ accountId: p.accountId, accountName: p.accountName, propFirm: p.propFirm, amount: p.amount, date: p.date }))); } // Days since last payout let daysSinceLastPayout = Infinity; let lastPayoutDate = null; if (accountWithdrawals.length > 0) { const lastPayout = accountWithdrawals.sort((a, b) => new Date(b.date) - new Date(a.date))[0]; lastPayoutDate = new Date(lastPayout.date); const today = new Date(); daysSinceLastPayout = Math.floor((today - lastPayoutDate) / (1000 * 60 * 60 * 24)); } // Calculate daily P&L and running balance using trading session dates const dailyPnl = {}; const dailyEndBalance = {}; let runningBalance = startingBal; // Sort trades by date to calculate running balance const sortedTrades = [...accountTrades].sort((a, b) => new Date(a.exitTime) - new Date(b.exitTime)); sortedTrades.forEach(t => { const date = t.date || getTradingSessionDate(t.exitTime) || getDateKey(t.exitTime); dailyPnl[date] = (dailyPnl[date] || 0) + getNetPnl(t); }); // Calculate end of day balances const sortedDates = Object.keys(dailyPnl).sort(); sortedDates.forEach(date => { runningBalance += dailyPnl[date]; dailyEndBalance[date] = runningBalance; }); // Highest EOD balance (for trailing drawdown) const highestBalance = Math.max(startingBal, ...Object.values(dailyEndBalance)); // Intraday/per-trade peak — tracks running cumulative P&L after each closed trade. // Used for drawdownType 'intraday' and 'realtime' plans. Approximation: trade-close // peaks only (not unrealized mid-trade equity — broker real-time feed not available). let intradayRunning = startingBal; let highestIntradayBalance = startingBal; sortedTrades.forEach(t => { intradayRunning += getNetPnl(t); if (intradayRunning > highestIntradayBalance) highestIntradayBalance = intradayRunning; }); // Account config and drawdown - use helper that handles type mismatches // For config lookups, always use account.startingBalance (raw Firestore value = combine size) // startingBal may be 0 for zero-start firms, but account.startingBalance retains the original size const configLookupSize = parseFloat(account.startingBalance) || parseFloat(account.accountSize) || startingBal; const accountConfig = getAccountConfig(firmConfig, configLookupSize, account.plan); // Absolute thresholds from Firestore (Apex EOD/Intraday plans) const safetyNetAbsolute = accountConfig?.safetyNet; // e.g. 26100 for 25K EOD const bufferRequiredForPayoutAbsolute = accountConfig?.bufferRequiredForPayout; // e.g. 26600 // Drawdown amount — sourced exclusively from propFirmConfig (override system removed). const drawdownAmount = accountConfig?.drawdown ?? null; // Flag missing drawdown data — surface warning instead of guessing const drawdownMissing = drawdownAmount === null; if (drawdownMissing) { console.warn(`[MLL] No drawdown config found for ${account.name} (${account.propFirm} ${account.plan} ${account.startingBalance}) — MLL cannot be calculated`); } const drawdownType = accountConfig?.drawdownType || firmConfig.drawdownType || null; if (!drawdownType) { console.warn(`[MLL] No drawdownType configured for ${account.name} (${account.propFirm} ${account.plan}) — drawdown behavior cannot be determined`); } // Buffer requirement — sourced exclusively from propFirmConfig. // For fundedStartsAtZero firms, buffer is ALWAYS 0 regardless of stored value. let configBuffer = accountConfig?.buffer; // Fallback: if plan-specific config didn't have buffer, check general accounts config if (configBuffer === undefined && account.plan && firmConfig.accounts) { const generalConfig = getAccountConfig(firmConfig, configLookupSize, null); if (generalConfig?.buffer !== undefined) configBuffer = generalConfig.buffer; } let bufferRequired; if (firmConfig.fundedStartsAtZero && account.stage === 'funded') { bufferRequired = 0; // TopStep/FundedNext: no buffer concept } else { bufferRequired = (configBuffer !== undefined && configBuffer !== null) ? configBuffer : 0; } // Drawdown lock offset — per-size from accountConfig, fall back to firm-level, default 0. // Firestore stores per-size drawdownLocksAtOffset (0, 10, or 100) on accountsByPlan.{plan}.{size}. // Use ?? so a legitimate 0 offset (no lock) is not overridden by the firm-level fallback. const drawdownLockOffset = accountConfig?.drawdownLocksAtOffset ?? firmConfig?.drawdownLockOffset ?? 0; if (bufferRequired === 0) bufferRequired += drawdownLockOffset; const profitAboveStart = currentBalance - startingBal; const bufferMet = bufferRequired === 0 || profitAboveStart >= bufferRequired; // Payout count for this account (needed for MLL calculation) const payoutCountForMLL = accountWithdrawals.length; // Calculate Maximum Loss Limit (MLL) // Buffer and drawdown are separate concepts: // - Drawdown defines the MLL floor (e.g., $25K start - $1,500 drawdown = $23,500 initial MLL) // - Buffer defines when the MLL locks (e.g., must reach $X profit before MLL locks at startingBal) // - No buffer requirement means MLL always trails — it does NOT mean MLL = startingBal // BEFORE BUFFER MET (or no buffer): MLL trails based on drawdown type // - 'realtime': Trails with peak unrealized balance (Apex PA) // - 'eod' / 'intraday': Trails with highest end-of-day balance // - 'static': Fixed floor at startingBal - drawdown (never trails) // AFTER BUFFER MET (buffer > 0 only): MLL locks at Starting Balance + offset let maximumLossLimit; let mllDescription; // TopStep special case: Funded accounts start at $0, MLL is negative drawdown value // e.g., $50K combine -> funded starts at $0, MLL = -$2,000 const topstepFundedZeroStart = firmConfig.fundedStartsAtZero && account.stage === 'funded'; if (drawdownMissing) { // No drawdown config — MLL cannot be calculated maximumLossLimit = null; mllDescription = 'No Config'; } else if (bufferRequired > 0 && bufferMet) { // Buffer requirement exists AND has been met — MLL locks if (firmConfig.mllResetsToZero && payoutCountForMLL > 0) { // TopStep: After first payout, MLL = Starting Balance (must stay positive) maximumLossLimit = topstepFundedZeroStart ? 0 : startingBal; mllDescription = 'Floor ($0)'; } else if (topstepFundedZeroStart) { // TopStep before first payout, buffer met: MLL = 0 maximumLossLimit = 0; mllDescription = 'Locked ($0)'; } else { // Apex/Tradeify: MLL locks at Starting Balance + offset maximumLossLimit = startingBal + drawdownLockOffset; mllDescription = `Locked (+$${drawdownLockOffset})`; } } else { // No buffer requirement, or buffer not yet met — MLL trails based on drawdown if (topstepFundedZeroStart) { // TopStep: MLL = negative drawdown (e.g., -$2,000) // Trails from highest balance but floor is -drawdownAmount const topstepMLL = highestBalance - drawdownAmount; maximumLossLimit = Math.max(topstepMLL, -drawdownAmount); mllDescription = highestBalance > 0 ? 'EOD Trailing' : `Floor (-$${drawdownAmount.toLocaleString()})`; } else if (drawdownType === 'static') { // Static drawdown: fixed floor at startingBal - drawdown, never trails maximumLossLimit = startingBal - drawdownAmount; mllDescription = 'Static DD'; } else if (drawdownType === 'realtime') { // Real-time trailing — uses intraday trade-close peak (Apex PA and similar) maximumLossLimit = highestIntradayBalance - drawdownAmount; mllDescription = 'Real-time Trailing'; } else if (drawdownType === 'intraday') { // Intraday trailing — peak updates after each closed trade, not just EOD maximumLossLimit = highestIntradayBalance - drawdownAmount; mllDescription = 'Intraday Trailing'; } else { // EOD trailing (default) — trails with highest end-of-day balance maximumLossLimit = highestBalance - drawdownAmount; mllDescription = 'EOD Trailing'; } } // Admin MLL override — applies after all branches resolve, before distance/safe calcs if (_adminBalanceOverride && _overrideMll !== null) { maximumLossLimit = _overrideMll; mllDescription = 'Admin Override'; } // Distance to MLL const distanceToMLL = currentBalance - maximumLossLimit; const mllSafe = distanceToMLL >= 0; // Best Day / Worst Day const dailyPnlValues = Object.values(dailyPnl); const bestDay = dailyPnlValues.length > 0 ? Math.max(...dailyPnlValues) : 0; const worstDay = dailyPnlValues.length > 0 ? Math.min(...dailyPnlValues) : 0; // Trade stats const winningTrades = accountTrades.filter(t => getNetPnl(t) > 0); const losingTrades = accountTrades.filter(t => getNetPnl(t) < 0); const winRate = accountTrades.length > 0 ? (winningTrades.length / accountTrades.length * 100) : 0; const avgWin = winningTrades.length > 0 ? winningTrades.reduce((sum, t) => sum + getNetPnl(t), 0) / winningTrades.length : 0; const avgLoss = losingTrades.length > 0 ? Math.abs(losingTrades.reduce((sum, t) => sum + getNetPnl(t), 0) / losingTrades.length) : 0; // Profit Factor const grossProfit = winningTrades.reduce((sum, t) => sum + getNetPnl(t), 0); const grossLoss = Math.abs(losingTrades.reduce((sum, t) => sum + getNetPnl(t), 0)); const profitFactor = grossLoss > 0 ? grossProfit / grossLoss : grossProfit > 0 ? Infinity : 0; // Profit Streak (consecutive profitable days) let currentStreak = 0; let maxStreak = 0; for (let i = sortedDates.length - 1; i >= 0; i--) { if (dailyPnl[sortedDates[i]] > 0) { currentStreak++; maxStreak = Math.max(maxStreak, currentStreak); } else { break; // Stop at first non-profitable day for current streak } } // Trading days (all days with trades) const tradingDays = Object.keys(dailyPnl); // Payout timing requirements - use nullish coalescing to allow 0 as valid value const timing = effectiveFirmConfig.payoutTiming || {}; const requiredDays = timing.days ?? 0; // Total trading days required (e.g., 8 for Apex) const requiredQualifyingDays = timing.qualifyingDays ?? requiredDays; // Qualifying days required (e.g., 5 for Apex), defaults to requiredDays const minProfitPerDay = timing.minProfitPerDay ?? 0; // Count qualifying days (days meeting profit threshold) const qualifyingDays = tradingDays.filter(date => dailyPnl[date] >= minProfitPerDay); // Count days SINCE LAST PAYOUT (for payout eligibility) let totalTradingDaysSinceLastPayout = 0; let qualifyingDaysSinceLastPayout = 0; if (lastPayoutDate) { const tradingDaysSinceLastPayout = tradingDays.filter(date => { const tradeDate = new Date(date + 'T12:00:00'); return tradeDate > lastPayoutDate; }); totalTradingDaysSinceLastPayout = tradingDaysSinceLastPayout.length; qualifyingDaysSinceLastPayout = qualifyingDays.filter(date => { const tradeDate = new Date(date + 'T12:00:00'); return tradeDate > lastPayoutDate; }).length; } else { totalTradingDaysSinceLastPayout = tradingDays.length; qualifyingDaysSinceLastPayout = qualifyingDays.length; } // Days requirement met? Check BOTH total days AND qualifying days const totalDaysRequirementMet = requiredDays === 0 || totalTradingDaysSinceLastPayout >= requiredDays; const qualifyingDaysRequirementMet = requiredQualifyingDays === 0 || qualifyingDaysSinceLastPayout >= requiredQualifyingDays; const daysRequirementMet = totalDaysRequirementMet && qualifyingDaysRequirementMet; // Calculate profit SINCE LAST PAYOUT (needed for firms that reset profit cycle) let profitSinceLastPayout = profit; let bestDaySinceLastPayout = bestDay; if (lastPayoutDate) { // Filter daily P&L to only include days after last payout const dailyPnlSinceLastPayout = {}; Object.keys(dailyPnl).forEach(date => { const tradeDate = new Date(date + 'T12:00:00'); if (tradeDate > lastPayoutDate) { dailyPnlSinceLastPayout[date] = dailyPnl[date]; } }); const pnlValuesSinceLastPayout = Object.values(dailyPnlSinceLastPayout); profitSinceLastPayout = pnlValuesSinceLastPayout.reduce((sum, pnl) => sum + pnl, 0); bestDaySinceLastPayout = pnlValuesSinceLastPayout.length > 0 ? Math.max(...pnlValuesSinceLastPayout) : 0; } // Min/Max withdrawal calculation // For firms with profitResetsAfterPayout, use profit since last payout instead of total profit const minWithdrawal = effectiveFirmConfig.minWithdrawal ?? firmConfig.minWithdrawal ?? 0; const profitResetsAfterPayout = firmConfig.profitResetsAfterPayout || false; let profitForEligibility = profit; // Total profit from starting balance // Use safetyNetAbsolute (Firestore absolute floor) when available, else relative formula let maxWithdrawal = safetyNetAbsolute !== undefined ? Math.max(0, currentBalance - safetyNetAbsolute) : currentBalance - startingBal - bufferRequired; // If firm resets profit cycle after payout AND there was a previous payout if (profitResetsAfterPayout && lastPayoutDate) { profitForEligibility = profitSinceLastPayout; maxWithdrawal = profitSinceLastPayout - bufferRequired; } // Apply max withdrawal percentage cap (e.g., TopStep 50% of account balance) const maxWithdrawalPct = effectiveFirmConfig.maxWithdrawalPct; if (maxWithdrawalPct && maxWithdrawalPct < 100 && currentBalance > 0) { const pctCap = currentBalance * (maxWithdrawalPct / 100); maxWithdrawal = Math.min(maxWithdrawal, pctCap); } // Apply max withdrawal amount cap (e.g., TopStep $5,000/$6,000 per payout) const maxWithdrawalAmt = effectiveFirmConfig.maxWithdrawalAmt; if (maxWithdrawalAmt && maxWithdrawalAmt > 0) { maxWithdrawal = Math.min(maxWithdrawal, maxWithdrawalAmt); } // Payout eligibility - check all conditions // When bufferRequiredForPayoutAbsolute is present, enforce the absolute balance gate if (bufferRequiredForPayoutAbsolute !== undefined && currentBalance < bufferRequiredForPayoutAbsolute) { maxWithdrawal = 0; // Cannot request payout until gate is reached } // Max gross amount payable IF eligible. Callers must gate with isEligible; this field reflects capacity only. let availableToPayout = Math.max(0, maxWithdrawal); const payoutCaps = accountConfig?.caps; if (Array.isArray(payoutCaps) && accountWithdrawals.length < payoutCaps.length) { const capForNext = payoutCaps[accountWithdrawals.length]; if (capForNext && capForNext > 0) { availableToPayout = Math.min(availableToPayout, capForNext); } } let isEligible = maxWithdrawal >= minWithdrawal && daysRequirementMet && bufferMet; let eligibilityReason = ''; if (!isEligible) { if (bufferRequiredForPayoutAbsolute !== undefined && currentBalance < bufferRequiredForPayoutAbsolute) { eligibilityReason = `Need ${formatCurrency(bufferRequiredForPayoutAbsolute - currentBalance)} more to unlock payout request`; } else if (!bufferMet && bufferRequired > 0) { const needed = bufferRequired - profitForEligibility; if (profitResetsAfterPayout && lastPayoutDate) { eligibilityReason = `Need ${formatCurrency(needed)} more profit since last payout (${formatCurrency(bufferRequired)} required)`; } else { eligibilityReason = `Need ${formatCurrency(needed)} more for buffer`; } } else if (maxWithdrawal < minWithdrawal) { eligibilityReason = `Need ${formatCurrency(minWithdrawal - maxWithdrawal)} more to reach minimum ${formatCurrency(minWithdrawal)} payout`; } else if (!daysRequirementMet) { // Determine which day requirement is not met if (!totalDaysRequirementMet && !qualifyingDaysRequirementMet) { const totalNeeded = requiredDays - totalTradingDaysSinceLastPayout; const qualifyingNeeded = requiredQualifyingDays - qualifyingDaysSinceLastPayout; eligibilityReason = `Need ${totalNeeded} more trading day${totalNeeded !== 1 ? 's' : ''} & ${qualifyingNeeded} $${minProfitPerDay}+ day${qualifyingNeeded !== 1 ? 's' : ''}`; } else if (!totalDaysRequirementMet) { const totalNeeded = requiredDays - totalTradingDaysSinceLastPayout; eligibilityReason = `Need ${totalNeeded} more trading day${totalNeeded !== 1 ? 's' : ''}`; } else if (!qualifyingDaysRequirementMet) { const qualifyingNeeded = requiredQualifyingDays - qualifyingDaysSinceLastPayout; eligibilityReason = `Need ${qualifyingNeeded} more $${minProfitPerDay}+ day${qualifyingNeeded !== 1 ? 's' : ''}`; } } } // Consistency rule check - RESET AFTER EACH PAYOUT // Uses plan-specific consistency from effectiveFirmConfig (from Firestore propFirmConfig) // Supports progressive consistency (e.g., 20% -> 25% -> 30% based on payout count) let consistencyProgress = null; let consistencyMet = true; let consistencyNeeded = 0; let maxDayProfit = Math.max(0, bestDaySinceLastPayout); // Get payout count for this account (already calculated above) const payoutCount = accountWithdrawals.length; // Calculate effective consistency percentage based on payout count // For progressive plans: 1st payout=20%, 2nd=25%, 3rd+=30% const effectiveConsistencyPct = getEffectiveConsistencyPct( effectiveFirmConfig.consistencyType, effectiveFirmConfig.consistency, effectiveFirmConfig.consistencyProgression, payoutCount ); const hasConsistencyRule = effectiveConsistencyPct !== null; // Store the effective consistency as a string for display (e.g., "25%") const effectiveConsistency = hasConsistencyRule ? `${effectiveConsistencyPct}%` : null; // Measured best-day % of profit-since-last-payout, clamp matches consistencyMet (Math.max(0, profitSinceLastPayout) at line 20365) const _profitForMeasured = Math.max(0, profitSinceLastPayout); const measuredConsistencyPct = (hasConsistencyRule && _profitForMeasured > 0 && maxDayProfit > 0) ? Math.floor((maxDayProfit / _profitForMeasured) * 10000) / 100 : null; const consistencyDisplay = hasConsistencyRule ? (measuredConsistencyPct !== null ? `${measuredConsistencyPct.toFixed(2)}% / ${effectiveConsistencyPct}%` : `— / ${effectiveConsistencyPct}%`) : null; if (hasConsistencyRule) { const consistencyPct = effectiveConsistencyPct / 100; const totalProfitForConsistency = Math.max(0, profitSinceLastPayout); consistencyNeeded = maxDayProfit / consistencyPct; if (totalProfitForConsistency > 0 && maxDayProfit > totalProfitForConsistency * consistencyPct) { consistencyMet = false; const stillNeeded = consistencyNeeded - totalProfitForConsistency; consistencyProgress = `Need ${formatCurrency(stillNeeded)} more profit`; isEligible = false; if (!eligibilityReason) eligibilityReason = 'Consistency rule not met'; } else { consistencyProgress = `✓ Met`; } } const metrics = { accountId: account.id, accountName: account.name, currentBalance, grossBalance, // Balance before withdrawals (for MLL calculations) startingBal, profit, profitSinceLastPayout, profitResetsAfterPayout, tradingDays, totalTradingDays: tradingDays.length, totalTradingDaysSinceLastPayout, qualifyingDays: qualifyingDays.length, qualifyingDaysSinceLastPayout, requiredDays, requiredQualifyingDays, minProfitPerDay, daysRequirementMet, totalDaysRequirementMet, qualifyingDaysRequirementMet, daysSinceLastPayout, lastPayoutDate, bufferRequired, safetyNetAbsolute, bufferRequiredForPayoutAbsolute, minWithdrawal, maxWithdrawal, availableToPayout, // Max gross amount payable IF eligible. Callers must gate with isEligible. isEligible, eligibilityReason, consistencyProgress, consistencyMet, consistencyNeeded, effectiveConsistency, // Plan-specific consistency rule (from synced data) hasConsistencyRule, // Whether this account has a consistency rule consistencyDisplay, // "measured% / limit%" formatted string (or "— / limit%" when no profit yet) measuredConsistencyPct, // Best day as % of profit since last payout (truncated to 2 decimals), null when not measurable maxDayProfit, totalWithdrawn: totalWithdrawnNet, // Net amount trader received (for display) totalWithdrawnGross, // Gross amount withdrawn from account (for balance calc) payoutCount: accountWithdrawals.length, // New metrics maximumLossLimit, mllDescription, drawdownAmount, distanceToMLL, mllSafe, highestBalance, drawdownType, bestDay, worstDay, winRate, avgWin, avgLoss, profitFactor, grossProfit, profitStreak: currentStreak, totalTrades: accountTrades.length, winningTrades: winningTrades.length, losingTrades: losingTrades.length, // Day win/loss stats winningDays: Object.values(dailyPnl).filter(pnl => pnl > 0).length, losingDays: Object.values(dailyPnl).filter(pnl => pnl < 0).length, dayWinRate: Object.values(dailyPnl).length > 0 ? (Object.values(dailyPnl).filter(pnl => pnl > 0).length / Object.values(dailyPnl).length * 100) : 0, avgWinLossRatio: avgLoss > 0 ? avgWin / avgLoss : avgWin > 0 ? Infinity : 0 }; // Store in per-render cache (only when using global trades, not a custom list) if (!filteredTradesList && _metricsCache) _metricsCache[account.id] = metrics; return metrics; } function getPayoutTimingText(timing) { if (!timing) return 'Anytime'; const minProfit = timing.minProfitPerDay ? ` ($${timing.minProfitPerDay}+ each)` : ''; switch (timing.type) { case 'tradingDays': // Handle case where qualifyingDays is different from total days if (timing.qualifyingDays && timing.qualifyingDays !== timing.days) { return `${timing.days} trading days, ${timing.qualifyingDays} must be $${timing.minProfitPerDay}+`; } return `${timing.days} trading days${minProfit}`; case 'winningDays': return `${timing.days} winning days${minProfit}`; case 'profitableDays': return `${timing.days} profitable days${minProfit}`; case 'weekly': return `Weekly (${timing.day || 'any day'})`; case 'benchmarkDays': return `${timing.days} benchmark days${minProfit}`; default: return 'Anytime'; } } function renderDonutCharts(winRate, pf, dayWinRate, avgRatio) { // Helper to draw donut function drawDonut(canvasId, percent, color) { const canvas = document.getElementById(canvasId); if (!canvas) return; const ctx = canvas.getContext('2d'); const size = canvas.width; const center = size / 2; const radius = (size / 2) - 4; const lineWidth = 6; ctx.clearRect(0, 0, size, size); // Background ring ctx.beginPath(); ctx.arc(center, center, radius, 0, 2 * Math.PI); ctx.strokeStyle = '#2a2f3a'; ctx.lineWidth = lineWidth; ctx.stroke(); // Colored arc const startAngle = -0.5 * Math.PI; const endAngle = startAngle + (2 * Math.PI * Math.min(percent, 100) / 100); ctx.beginPath(); ctx.arc(center, center, radius, startAngle, endAngle); ctx.strokeStyle = color; ctx.lineWidth = lineWidth; ctx.lineCap = 'round'; ctx.stroke(); } drawDonut('winrate-donut', winRate, winRate >= 50 ? themeGreen() : themeRed()); drawDonut('pf-donut', Math.min(pf * 25, 100), pf >= 1.5 ? themeGreen() : pf >= 1 ? '#f59e0b' : themeRed()); drawDonut('daywin-donut', dayWinRate, dayWinRate >= 50 ? themeGreen() : themeRed()); drawDonut('avgratio-donut', Math.min(avgRatio * 50, 100), avgRatio >= 1 ? themeGreen() : themeRed()); } function renderRadarChart(winPct, pfScore, recoveryScore, avgWLScore, consistencyScore) { const ctx = document.getElementById('radar-chart'); if (!ctx) return; if (window.radarChart) { window.radarChart.destroy(); window.radarChart = null; } window.radarChart = new Chart(ctx, { type: 'radar', data: { labels: ['Win %', 'Profit Factor', 'Recovery', 'Avg Win/Loss', 'Consistency'], datasets: [{ data: [winPct, pfScore, recoveryScore, avgWLScore, consistencyScore], backgroundColor: themeGreenBg(0.2), borderColor: themeGreen(), borderWidth: 2, pointBackgroundColor: themeGreen(), pointRadius: 4 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { r: { beginAtZero: true, max: 100, ticks: { display: false }, grid: { color: '#2a2f3a' }, angleLines: { color: '#2a2f3a' }, pointLabels: { color: '#9ca3af', font: { size: 10 } } } } } }); } function renderTimeScatterChart() { const ctx = document.getElementById('time-scatter-chart'); if (!ctx) return; const trades = getMetricsEligibleTrades(); // Calculate duration and P&L for each trade const winnerData = []; const loserData = []; trades.forEach(t => { try { const entry = new Date(t.entryTime); const exit = new Date(t.exitTime); const durationMin = (exit - entry) / (1000 * 60); const pnl = getNetPnl(t); if (durationMin >= 0 && durationMin < 60) { // Only trades under 60 min const point = { x: durationMin, y: pnl }; if (pnl >= 0) { winnerData.push(point); } else { loserData.push(point); } } } catch (e) {} }); if (window.timeScatterChart) { window.timeScatterChart.destroy(); window.timeScatterChart = null; } window.timeScatterChart = new Chart(ctx, { type: 'scatter', data: { datasets: [ { label: 'Winners', data: winnerData, backgroundColor: themeGreen(), pointRadius: 6 }, { label: 'Losers', data: loserData, backgroundColor: themeRed(), pointRadius: 6 } ] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: true, position: 'top', labels: { color: '#9ca3af', boxWidth: 12 } } }, scales: { x: { title: { display: true, text: 'Duration', color: '#6b7280' }, grid: { color: '#2a2f3a' }, ticks: { color: '#6b7280', callback: v => v + 'm' } }, y: { grid: { color: '#2a2f3a' }, ticks: { color: '#6b7280', callback: v => formatCurrency(v, 0) } } } } }); } let payoutHistoryChart = null; function renderPayoutHistoryChart() { const ctx = document.getElementById('payout-history-chart'); if (!ctx) return; if (payoutHistoryChart) { payoutHistoryChart.destroy(); payoutHistoryChart = null; } // Get all withdrawals sorted by date const withdrawals = payouts.filter(p => p.type === 'withdrawal').sort((a, b) => new Date(a.date) - new Date(b.date)); if (withdrawals.length === 0) { // No payouts yet - show empty state const totalEl = document.getElementById('payout-history-total'); if (totalEl) totalEl.textContent = '$0.00'; payoutHistoryChart = new Chart(ctx, { type: 'bar', data: { labels: ['No payouts yet'], datasets: [{ label: 'Payouts', data: [0], backgroundColor: themeGreenBg(0.3), borderColor: themeGreen(), borderWidth: 1 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, grid: { color: '#2a2f3a' }, ticks: { color: '#6b7280' } }, x: { grid: { display: false }, ticks: { color: '#6b7280' } } } } }); return; } // Build cumulative data let cumulative = 0; const labels = []; const payoutAmounts = []; const cumulativeData = []; withdrawals.forEach((w, i) => { const date = new Date(w.date); labels.push(date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })); payoutAmounts.push(w.amount); cumulative += w.amount; cumulativeData.push(cumulative); }); // Update total const totalEl = document.getElementById('payout-history-total'); if (totalEl) totalEl.textContent = formatCurrency(cumulative); payoutHistoryChart = new Chart(ctx, { type: 'bar', data: { labels: labels, datasets: [ { type: 'line', label: 'Cumulative', data: cumulativeData, borderColor: themeGreen(), backgroundColor: themeGreenBg(0.1), fill: true, tension: 0.3, pointRadius: 4, pointBackgroundColor: themeGreen(), yAxisID: 'y' }, { type: 'bar', label: 'Payout', data: payoutAmounts, backgroundColor: 'rgba(59, 130, 246, 0.7)', borderColor: '#3b82f6', borderWidth: 1, yAxisID: 'y' } ] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: true, position: 'top', labels: { color: '#9ca3af', boxWidth: 12 } } }, scales: { y: { beginAtZero: true, grid: { color: '#2a2f3a' }, ticks: { color: '#6b7280', callback: v => formatCurrency(v, 0) } }, x: { grid: { display: false }, ticks: { color: '#6b7280' } } } } }); } function renderTrendChart() { const ctx = document.getElementById('trend-chart'); if (!ctx) return; // Group trades by trading session date (trades after 5pm CT count as next day) const dailyTrades = {}; getMetricsEligibleTrades().forEach(t => { const date = t.date || getTradingSessionDate(t.exitTime) || getDateKey(t.exitTime); if (!dailyTrades[date]) dailyTrades[date] = []; dailyTrades[date].push(t); }); const sortedDates = Object.keys(dailyTrades).sort(); const labels = sortedDates.map(d => formatDate(d)); const winRateData = []; const avgWinData = []; const avgLossData = []; sortedDates.forEach(date => { const dayTrades = dailyTrades[date]; const winners = dayTrades.filter(t => getNetPnl(t) > 0); const losers = dayTrades.filter(t => getNetPnl(t) < 0); const wr = dayTrades.length > 0 ? (winners.length / dayTrades.length * 100) : 0; const avgW = winners.length > 0 ? winners.reduce((s, t) => s + getNetPnl(t), 0) / winners.length : 0; const avgL = losers.length > 0 ? Math.abs(losers.reduce((s, t) => s + getNetPnl(t), 0)) / losers.length : 0; winRateData.push(wr); avgWinData.push(avgW); avgLossData.push(avgL); }); if (window.trendChart) { window.trendChart.destroy(); window.trendChart = null; } if (labels.length === 0) return; window.trendChart = new Chart(ctx, { type: 'line', data: { labels: labels, datasets: [ { label: 'Win %', data: winRateData, borderColor: '#3b82f6', backgroundColor: 'transparent', yAxisID: 'y', tension: 0.3, pointRadius: 2 }, { label: 'Avg Win', data: avgWinData, borderColor: themeGreen(), backgroundColor: 'transparent', yAxisID: 'y1', tension: 0.3, pointRadius: 2 }, { label: 'Avg Loss', data: avgLossData, borderColor: themeRed(), backgroundColor: 'transparent', yAxisID: 'y1', tension: 0.3, pointRadius: 2 } ] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: true, position: 'top', labels: { color: '#9ca3af', boxWidth: 12 } } }, scales: { x: { grid: { color: '#2a2f3a' }, ticks: { color: '#6b7280', maxTicksLimit: 8 } }, y: { type: 'linear', position: 'left', min: 0, max: 100, grid: { color: '#2a2f3a' }, ticks: { color: '#6b7280', callback: v => v + '%' } }, y1: { type: 'linear', position: 'right', grid: { display: false }, ticks: { color: '#6b7280', callback: v => formatCurrency(v, 0) } } } } }); } function renderDrawdownChart() { const ctx = document.getElementById('drawdown-chart'); if (!ctx) return; const sortedTrades = [...getMetricsEligibleTrades()].sort((a, b) => new Date(a.exitTime) - new Date(b.exitTime)); let peak = 0; let cumulative = 0; const data = sortedTrades.map((t, i) => { cumulative += getNetPnl(t); if (cumulative > peak) peak = cumulative; const drawdown = peak - cumulative; return { x: i + 1, y: -drawdown }; }); if (window.drawdownChart) { window.drawdownChart.destroy(); window.drawdownChart = null; } if (data.length === 0) return; window.drawdownChart = new Chart(ctx, { type: 'line', data: { datasets: [{ label: 'Drawdown', data: data, borderColor: themeRed(), backgroundColor: themeRedBg(0.1), fill: true, tension: 0.3, pointRadius: 0 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { type: 'linear', title: { display: true, text: 'Trade #', color: '#6b7280' }, grid: { color: '#2a2f3a' }, ticks: { color: '#6b7280' } }, y: { grid: { color: '#2a2f3a' }, ticks: { color: '#6b7280', callback: v => formatCurrency(v, 0) } } } } }); } function renderTimeChart() { const ctx = document.getElementById('time-chart'); if (!ctx) return; // Group P&L by hour const hourlyPnl = {}; for (let h = 0; h < 24; h++) hourlyPnl[h] = { pnl: 0, count: 0 }; getMetricsEligibleTrades().forEach(t => { try { const hour = getDateInTZ(t.entryTime).hours; hourlyPnl[hour].pnl += getNetPnl(t); hourlyPnl[hour].count++; } catch (e) {} }); const labels = []; const data = []; const colors = []; // Only show market hours (8 AM - 4 PM CT roughly) for (let h = 8; h <= 16; h++) { labels.push(`${h > 12 ? h - 12 : h}${h >= 12 ? 'PM' : 'AM'}`); const pnl = hourlyPnl[h].pnl; data.push(pnl); colors.push(themePnlColor(pnl)); } if (window.timeChart) { window.timeChart.destroy(); window.timeChart = null; } window.timeChart = new Chart(ctx, { type: 'bar', data: { labels: labels, datasets: [{ label: 'P&L by Hour', data: data, backgroundColor: colors }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { grid: { color: '#2a2f3a' }, ticks: { color: '#6b7280' } }, y: { grid: { color: '#2a2f3a' }, ticks: { color: '#6b7280', callback: v => formatCurrency(v, 0) } } } } }); } function renderDailyBarChart() { const ctx = document.getElementById('daily-bar-chart'); if (!ctx) return; // Group by trading session date (trades after 5pm CT count as next day) const dailyPnl = {}; getMetricsEligibleTrades().forEach(t => { const date = t.date || getTradingSessionDate(t.exitTime) || getDateKey(t.exitTime); dailyPnl[date] = (dailyPnl[date] || 0) + getNetPnl(t); }); const sortedDates = Object.keys(dailyPnl).sort(); const labels = sortedDates.map(d => formatDate(d)); const data = sortedDates.map(d => dailyPnl[d]); const colors = data.map(v => themePnlColor(v)); // Calculate cumulative let cum = 0; const cumData = data.map(v => { cum += v; return cum; }); if (window.dailyBarChart) { window.dailyBarChart.destroy(); window.dailyBarChart = null; } if (data.length === 0) return; window.dailyBarChart = new Chart(ctx, { type: 'bar', data: { labels: labels, datasets: [ { type: 'bar', label: 'Daily P&L', data: data, backgroundColor: colors, order: 2 }, { type: 'line', label: 'Cumulative', data: cumData, borderColor: '#3b82f6', backgroundColor: 'transparent', tension: 0.3, pointRadius: 2, order: 1 } ] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: true, labels: { color: '#9ca3af' } } }, scales: { x: { grid: { color: '#2a2f3a' }, ticks: { color: '#6b7280', maxTicksLimit: 10 } }, y: { grid: { color: '#2a2f3a' }, ticks: { color: '#6b7280', callback: v => formatCurrency(v, 0) } } } } }); } function renderWinRateTrendChart() { const ctx = document.getElementById('winrate-trend-chart'); if (!ctx) return; // Group trades by trading session date (trades after 5pm CT count as next day) const dailyTrades = {}; getMetricsEligibleTrades().forEach(t => { const date = t.date || getTradingSessionDate(t.exitTime) || getDateKey(t.exitTime); if (!dailyTrades[date]) dailyTrades[date] = []; dailyTrades[date].push(t); }); const sortedDates = Object.keys(dailyTrades).sort(); // Calculate 7-day rolling win rate const labels = []; const data = []; for (let i = 0; i < sortedDates.length; i++) { const windowStart = Math.max(0, i - 6); let wins = 0; let total = 0; for (let j = windowStart; j <= i; j++) { const trades = dailyTrades[sortedDates[j]]; wins += trades.filter(t => getNetPnl(t) > 0).length; total += trades.length; } labels.push(formatDate(sortedDates[i])); data.push(total > 0 ? (wins / total * 100) : 0); } if (window.winRateTrendChart) { window.winRateTrendChart.destroy(); window.winRateTrendChart = null; } if (data.length === 0) return; window.winRateTrendChart = new Chart(ctx, { type: 'line', data: { labels: labels, datasets: [{ label: '7-Day Win Rate', data: data, borderColor: '#8b5cf6', backgroundColor: 'rgba(139, 92, 246, 0.1)', fill: true, tension: 0.3, pointRadius: 2 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { grid: { color: '#2a2f3a' }, ticks: { color: '#6b7280', maxTicksLimit: 10 } }, y: { min: 0, max: 100, grid: { color: '#2a2f3a' }, ticks: { color: '#6b7280', callback: v => v + '%' } } } } }); } function showDepositModal() { const amount = prompt('Enter deposit amount:'); if (amount && !isNaN(parseFloat(amount))) { const deposit = { type: 'deposit', amount: parseFloat(amount), date: new Date().toISOString(), note: 'Manual deposit' }; payouts.push(deposit); savePayouts(); renderPayoutTracker(); renderDashboard(); renderROITracker(); alert(`Deposited ${formatCurrency(deposit.amount)}`); } } function showWithdrawModal() { const amount = prompt('Enter withdrawal amount:'); if (amount && !isNaN(parseFloat(amount))) { const withdrawal = { type: 'withdrawal', amount: parseFloat(amount), date: new Date().toISOString(), note: 'Manual withdrawal' }; payouts.push(withdrawal); savePayouts(); renderPayoutTracker(); renderDashboard(); renderROITracker(); alert(`Withdrew ${formatCurrency(withdrawal.amount)}`); } } async function savePayouts() { if (!currentUser) return; try { await labSafeFirestoreSet( db.collection('users').doc(currentUser.uid), { payouts: payouts, payoutSettings: payoutSettings }, { merge: true } ); } catch (error) { console.error('Error saving payouts:', error); throw error; } } // ── Log Past Payout ── function toggleLogPastPayout() { const form = document.getElementById('log-past-payout-form'); const arrow = document.getElementById('lpp-arrow'); if (!form) return; const isHidden = form.style.display === 'none'; form.style.display = isHidden ? 'block' : 'none'; if (arrow) arrow.textContent = isHidden ? '▾' : '▸'; } function populateLogPastPayoutForm() { const sel = document.getElementById('lpp-account'); const dateInput = document.getElementById('lpp-date'); const symEl = document.getElementById('lpp-currency-symbol'); const firmSel = document.getElementById('lpp-custom-firm'); if (!sel) return; const fundedAccounts = accounts.filter(a => !a.archived && a.stage !== 'evaluation'); sel.innerHTML = '' + fundedAccounts.map(a => { const firm = propFirmNames[a.propFirm] || a.propFirm || ''; return ``; }).join('') + ''; if (dateInput && !dateInput.value) dateInput.value = getTodayKey(); if (symEl) symEl.textContent = getCurrencySymbol(); // Populate firm dropdown for custom accounts if (firmSel) { firmSel.innerHTML = '' + Object.entries(propFirmNames).sort((a, b) => a[1].localeCompare(b[1])).map(([key, name]) => ``).join(''); } } function onLppAccountChange() { const val = document.getElementById('lpp-account')?.value; const customFields = document.getElementById('lpp-custom-fields'); if (customFields) customFields.style.display = val === '__custom__' ? 'block' : 'none'; } async function submitLogPastPayout() { const accountVal = document.getElementById('lpp-account')?.value; const amountRaw = parseFloat(document.getElementById('lpp-amount')?.value) || 0; const dateVal = document.getElementById('lpp-date')?.value; const note = document.getElementById('lpp-note')?.value?.trim() || ''; const isCustom = accountVal === '__custom__'; if (!accountVal) { showToast('Select an account', 'error'); return; } if (amountRaw <= 0) { showToast('Enter a payout amount', 'error'); return; } if (isCustom) { const customFirm = document.getElementById('lpp-custom-firm')?.value; const customLabel = document.getElementById('lpp-custom-label')?.value?.trim(); if (!customFirm) { showToast('Select a prop firm', 'error'); return; } if (!customLabel) { showToast('Enter an account label', 'error'); return; } } // Convert display currency back to USD for storage const amount = _userCurrency === 'USD' || !_exchangeRates || !_exchangeRates[_userCurrency] ? amountRaw : amountRaw / _exchangeRates[_userCurrency]; const selectedDate = dateVal ? new Date(dateVal + 'T12:00:00') : new Date(); const dateStr = selectedDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); let payoutRecord; if (isCustom) { const customFirm = document.getElementById('lpp-custom-firm').value; const customLabel = document.getElementById('lpp-custom-label').value.trim(); const firmConfig = propFirmConfigs[customFirm] || {}; payoutRecord = { type: 'withdrawal', amount: Math.round(amount * 100) / 100, grossAmount: Math.round(amount * 100) / 100, profitSplit: 100, date: selectedDate.toISOString(), note: note || `${firmConfig.name || propFirmNames[customFirm] || customFirm} (logged)`, propFirm: customFirm, accountSize: 0, accountId: null, accountName: customLabel, accountLabel: customLabel, wasOverride: false, manualLog: true }; } else { const account = accounts.find(a => a.id === accountVal); const propFirm = account?.propFirm || ''; const firmConfig = propFirmConfigs[propFirm] || {}; payoutRecord = { type: 'withdrawal', amount: Math.round(amount * 100) / 100, grossAmount: Math.round(amount * 100) / 100, profitSplit: 100, date: selectedDate.toISOString(), note: note || `${firmConfig.name || propFirm || 'Payout'} (logged)`, propFirm: propFirm, accountSize: account?.startingBalance || account?.accountSize || 0, accountId: accountVal, accountName: account?.name || account?.accountNumber || '', wasOverride: false, manualLog: true }; } payouts.push(payoutRecord); try { await savePayouts(); } catch (saveError) { payouts.pop(); console.error('[Log Past Payout] Save failed:', saveError); showToast(`Failed to log payout: ${saveError?.message || saveError}`, 'error'); renderPayoutTracker(); renderDashboard(); renderROITracker(); return; } showToast(`Payout of ${formatCurrency(amount)} logged for ${dateStr}`, 'success'); // Reset form document.getElementById('lpp-amount').value = ''; document.getElementById('lpp-note').value = ''; document.getElementById('lpp-account').value = ''; onLppAccountChange(); if (isCustom) { document.getElementById('lpp-custom-firm').value = ''; document.getElementById('lpp-custom-label').value = ''; } // Re-render renderPayoutTracker(); renderDashboard(); renderROITracker(); } // Prop Firm Configuration Functions function onPropFirmChange() { const firmKey = document.getElementById('payout-prop-firm').value; // Reset payout override toggle const overrideToggle = document.getElementById('payout-override-toggle'); if (overrideToggle) { overrideToggle.checked = false; togglePayoutOverride(); } // Update account dropdown filtered by account type populatePayoutAccountDropdown(); const config = propFirmConfigs[firmKey]; if (!config) return; if (!config.accounts) { // Firm uses accountsByPlan structure — skip accounts dropdown rebuild updatePropFirmSettings(); renderPayoutTracker(); return; } // Update plans dropdown const planSelect = document.getElementById('payout-plan'); planSelect.innerHTML = ''; config.plans.forEach(plan => { planSelect.innerHTML += ``; }); // Update account sizes dropdown const sizeSelect = document.getElementById('payout-account-size'); const currentSize = sizeSelect.value; const currentPlan = planSelect.value; sizeSelect.innerHTML = ''; Object.keys(config.accounts).forEach(size => { const selected = parseInt(size) === parseInt(currentSize) ? 'selected' : ''; sizeSelect.innerHTML += ``; }); // If current size not available, select first available if (!getAccountConfig(config, currentSize, currentPlan)) { sizeSelect.value = Object.keys(config.accounts)[0]; } // Sync plan from the selected account BEFORE updatePropFirmSettings // so the correct plan is active when rules are loaded const payoutAccountSelect = document.getElementById('payout-account'); const payoutSelectedAccount = accounts.find(a => a.id === payoutAccountSelect?.value); if (payoutSelectedAccount && payoutSelectedAccount.plan) { const planOption = Array.from(planSelect.options).find(o => o.value === payoutSelectedAccount.plan); if (planOption) { planSelect.value = payoutSelectedAccount.plan; } } // Update auto-filled values updatePropFirmSettings(); // Update payout timing display and calendar renderPayoutTracker(); } function onPlanChange() { updatePropFirmSettings(); } function onAccountSizeChange() { updatePropFirmSettings(); } function onPayoutAccountChange() { const accountSelect = document.getElementById('payout-account'); const selectedAccount = accounts.find(a => a.id === accountSelect?.value); if (selectedAccount) { // Save selected account ID payoutSettings.accountId = selectedAccount.id; // Sync plan from account if (selectedAccount.plan) { const planSelect = document.getElementById('payout-plan'); if (planSelect) { // Check if the plan option exists in the dropdown const planOption = Array.from(planSelect.options).find(o => o.value === selectedAccount.plan); if (planOption) { planSelect.value = selectedAccount.plan; } } } // Sync account size from the account's startingBalance syncAccountSizeFromAccount(); // Always refresh rules/caps after plan+size sync // (syncAccountSizeFromAccount only calls updatePropFirmSettings if size changed) updatePropFirmSettings(); } renderPayoutTracker(); } function toggleOverride() { const override = document.getElementById('override-defaults').checked; payoutSettings.overrideDefaults = override; // Enable/disable inputs const inputs = ['payout-buffer', 'payout-profit-split', 'payout-min-withdrawal', 'payout-consistency', 'payout-cap-1', 'payout-cap-2', 'payout-cap-3', 'payout-cap-4', 'payout-cap-5']; inputs.forEach(id => { const el = document.getElementById(id); if (el) el.disabled = !override; }); } function renderAccountPnlBreakdown() { // This function is now replaced by renderAccountAvailabilityBreakdown renderAccountAvailabilityBreakdown(); } function renderAccountAvailabilityBreakdown() { const container = document.getElementById('account-availability-list'); const totalWithdrawnEl = document.getElementById('payout-total-withdrawn'); const totalCountEl = document.getElementById('payout-total-count'); const totalAvailableEl = document.getElementById('payout-total-available'); const eligibleCountEl = document.getElementById('payout-eligible-count'); if (!container) return; // Get all funded accounts (not archived, not evaluation) const fundedAccounts = accounts.filter(a => !a.archived && a.stage !== 'evaluation'); // Calculate totals across ALL accounts let grandTotalWithdrawn = 0; let grandTotalAvailable = 0; let grandTotalPayoutAvailable = 0; let grandTotalEligiblePayout = 0; let totalPayoutCount = 0; let eligibleAccountCount = 0; // Group accounts by prop firm const accountsByFirm = {}; fundedAccounts.forEach(account => { const firmKey = account.propFirm || 'other'; if (!accountsByFirm[firmKey]) { accountsByFirm[firmKey] = []; } // Calculate metrics for this account // Normalize key to lowercase to handle capitalization mismatches (e.g., 'TopStep' vs 'topstep') const normalizedKey = firmKey.toLowerCase(); const firmConfig = propFirmConfigs[firmKey] || propFirmConfigs[normalizedKey] || {}; const metrics = calculateAccountMetrics(account, firmConfig); // Get account withdrawals const accountWithdrawals = payouts.filter(p => { if (p.type !== 'withdrawal') return false; if (p.accountId && p.accountId === account.id) return true; if (p.accountName && p.accountName === account.name) return true; return false; }); const withdrawn = accountWithdrawals.reduce((sum, p) => { // Use gross amount (what left the account) not net (what trader received) if (p.profitSplit && p.profitSplit < 100) { if (p.grossAmount && Math.abs(p.grossAmount - p.amount) > 0.01) { return sum + p.grossAmount; } return sum + (p.amount / (p.profitSplit / 100)); } return sum + (p.grossAmount || p.amount || 0); }, 0); const withdrawnNet = accountWithdrawals.reduce((sum, p) => sum + (p.amount || 0), 0); const payoutCount = accountWithdrawals.length; // For config lookups, always use account.startingBalance (what user entered in modal) // For zero-start firms this is the combine size (e.g., 50000), not $0 const configLookupSize = account.startingBalance || account.accountSize || null; const accountConfig = getAccountConfig(firmConfig, configLookupSize, account.plan); const accountConfigMissing = !accountConfig; if (accountConfigMissing) { console.warn('[Firestore Gap] No account config found for firm:', account.propFirm, 'size:', configLookupSize, 'plan:', account.plan, '— add accounts config to Firestore propFirmConfig'); } const accountTrades = trades.filter(t => t.accountId === account.id); const profit = accountTrades.reduce((sum, t) => sum + getNetPnl(t), 0); let buffer = null; let available = null; let availableToPayout = null; if (accountConfigMissing) { // Skip payout eligibility calculation — config missing // — ⚠ will show via existing missing-data rendering } else { // Only add drawdownLockOffset if buffer is 0 (e.g. TPT) — firms like Apex already include it in their buffer value. // Offset sourced per-size from accountConfig.drawdownLocksAtOffset (0, 10, or 100), with firm-level and 0 fallbacks. const bufferBase = (accountConfig.buffer !== undefined ? accountConfig.buffer : 0); buffer = bufferBase === 0 ? (accountConfig?.drawdownLocksAtOffset ?? firmConfig?.drawdownLockOffset ?? 0) : bufferBase; const profitAboveBuffer = Math.max(0, profit - buffer); available = Math.max(0, profitAboveBuffer - withdrawn); // Calculate what can actually be paid out NOW (firm caps & pct limits only, no split) const effectiveConfig = getPlanSpecificConfig(firmConfig, account); let startingBal = parseFloat(account.startingBalance) || 0; if (firmConfig?.fundedStartsAtZero && account.stage === 'funded') startingBal = 0; const currentBal = startingBal + profit - withdrawn; const maxPct = effectiveConfig.maxWithdrawalPct ?? 100; const maxAmt = effectiveConfig.maxWithdrawalAmt ?? Infinity; const caps = accountConfig.caps; // null means no per-payout cap // Caps only apply for the first N payouts — after that, unlimited. null caps = no cap ever. const capForNext = (caps && payoutCount < caps.length) ? (caps[payoutCount] || Infinity) : Infinity; availableToPayout = available; if (maxPct < 100 && currentBal > 0) { availableToPayout = Math.min(availableToPayout, currentBal * (maxPct / 100)); } if (maxAmt < Infinity) availableToPayout = Math.min(availableToPayout, maxAmt); availableToPayout = Math.min(availableToPayout, capForNext); } // Determine eligibility — use metrics.isEligible which already checks // maxWithdrawal >= minWithdrawal and daysRequirementMet const isEligible = !accountConfigMissing && metrics.isEligible; // Add to totals grandTotalWithdrawn += withdrawnNet; totalPayoutCount += payoutCount; grandTotalAvailable += (available || 0); grandTotalPayoutAvailable += (availableToPayout || 0); if (isEligible) { eligibleAccountCount++; grandTotalEligiblePayout += availableToPayout; } accountsByFirm[firmKey].push({ account, withdrawn, withdrawnNet, payoutCount, available, availableToPayout, profit, buffer, isEligible, metrics }); }); // Update global totals if (totalWithdrawnEl) totalWithdrawnEl.textContent = formatCurrency(grandTotalWithdrawn); if (totalCountEl) totalCountEl.textContent = `${totalPayoutCount} payout${totalPayoutCount !== 1 ? 's' : ''}`; if (totalAvailableEl) totalAvailableEl.textContent = formatCurrency(grandTotalEligiblePayout); const totalProfitAvailableEl = document.getElementById('payout-total-profit-available'); if (totalProfitAvailableEl) totalProfitAvailableEl.textContent = formatCurrency(grandTotalAvailable); if (eligibleCountEl) eligibleCountEl.textContent = `${eligibleAccountCount} account${eligibleAccountCount !== 1 ? 's' : ''} eligible`; // Update account count label const countLabel = document.getElementById('payout-account-count-label'); if (countLabel) countLabel.textContent = `${eligibleAccountCount} of ${fundedAccounts.length} eligible`; // Render accounts grouped by prop firm — eligible first (sorted by highest payout), then ineligible (sorted by closest to eligible) if (Object.keys(accountsByFirm).length === 0) { container.innerHTML = '
No funded accounts found. Add accounts in Settings.
'; return; } const selectedAccountId = document.getElementById('payout-account')?.value; // Flatten all entries and split into eligible vs ineligible const allEntries = Object.values(accountsByFirm).flat(); const eligibleEntries = allEntries.filter(e => e.isEligible).sort((a, b) => b.availableToPayout - a.availableToPayout); const ineligibleEntries = allEntries.filter(e => !e.isEligible); // Sort ineligible by "closeness to eligible" — compute a 0-1 progress score ineligibleEntries.forEach(entry => { const m = entry.metrics; let scores = []; // Buffer progress if (m.bufferRequired > 0) { const profit = m.profitResetsAfterPayout ? (m.profitSinceLastPayout || 0) : (m.currentBalance - m.startingBal); scores.push(Math.min(1, Math.max(0, profit / m.bufferRequired))); } // Trading days progress if (m.requiredDays > 0) { scores.push(Math.min(1, m.totalTradingDaysSinceLastPayout / m.requiredDays)); } // Qualifying days progress if (m.requiredQualifyingDays > 0) { scores.push(Math.min(1, m.qualifyingDaysSinceLastPayout / m.requiredQualifyingDays)); } // Consistency progress if (m.hasConsistencyRule && !m.consistencyMet) { scores.push(0.5); // partial credit } else if (m.hasConsistencyRule && m.consistencyMet) { scores.push(1); } entry.closenessScore = scores.length > 0 ? scores.reduce((a, b) => a + b, 0) / scores.length : 0; }); ineligibleEntries.sort((a, b) => b.closenessScore - a.closenessScore); // Determine the best default account to auto-select const bestEligible = eligibleEntries[0] || null; const closestIneligible = ineligibleEntries[0] || null; // Store for use in renderPayoutTracker window._payoutBestEligibleId = bestEligible ? bestEligible.account.id : null; window._payoutClosestIneligible = closestIneligible ? { id: closestIneligible.account.id, name: closestIneligible.account.name, reason: closestIneligible.metrics.eligibilityReason || 'Not yet eligible' } : null; // Auto-select best eligible account on first load (no account selected yet or selected account doesn't exist) const currentSelect = document.getElementById('payout-account'); const currentSelectedExists = currentSelect?.value && accounts.find(a => a.id === currentSelect.value); if (!currentSelectedExists && bestEligible) { // Will be picked up by renderPayoutTracker if (currentSelect) currentSelect.value = bestEligible.account.id; payoutSettings.accountId = bestEligible.account.id; } // Regroup eligible entries by firm const eligibleByFirm = {}; eligibleEntries.forEach(entry => { const firmKey = entry.account.propFirm || 'other'; if (!eligibleByFirm[firmKey]) eligibleByFirm[firmKey] = []; eligibleByFirm[firmKey].push(entry); }); // Regroup ineligible entries by firm const ineligibleByFirm = {}; ineligibleEntries.forEach(entry => { const firmKey = entry.account.propFirm || 'other'; if (!ineligibleByFirm[firmKey]) ineligibleByFirm[firmKey] = []; ineligibleByFirm[firmKey].push(entry); }); // Helper to render a single account row const renderAccountRow = (entry, showEligible = true) => { const { account, payoutCount, availableToPayout, isEligible: eligible, metrics } = entry; const isSelected = account.id === selectedAccountId; const badge = eligible ? `Ready` : `Almost`; const amountColor = eligible ? 'var(--cyan)' : 'var(--text-muted)'; const reasonHtml = !eligible && metrics.eligibilityReason ? `
${metrics.eligibilityReason}
` : ''; return `
${account.name}
${badge}
${formatCurrency(availableToPayout)} ${payoutCount} payout${payoutCount !== 1 ? 's' : ''}
${reasonHtml}
`; }; // Helper to render a firm group card const renderFirmGroup = (firmKey, firmEntries, isEligibleGroup = true) => { const fConfig = propFirmConfigs[firmKey] || propFirmConfigs[firmKey.toLowerCase()] || {}; const firmName = fConfig.name || propFirmNames[firmKey] || firmKey; const firmTotal = firmEntries.reduce((sum, e) => sum + e.availableToPayout, 0); return `
${getFirmLogoHtml(firmKey, 20)} ${firmName} ${formatCurrency(firmTotal)}
${firmEntries.map(e => renderAccountRow(e, isEligibleGroup)).join('')}
`; }; let html = ''; // Eligible firms first const eligibleFirmKeys = Object.keys(eligibleByFirm).sort(); if (eligibleFirmKeys.length === 0) { html += '
No accounts are ready for payout yet.
'; } else { eligibleFirmKeys.forEach(firmKey => { html += renderFirmGroup(firmKey, eligibleByFirm[firmKey], true); }); } container.innerHTML = html; populateLogPastPayoutForm(); // Re-apply streamer mode blur to dynamically rendered elements if (streamerMode) applySensitiveBlur(); } function selectPayoutAccount(accountId) { const accountSelect = document.getElementById('payout-account'); const account = accounts.find(a => a.id === accountId); if (account && accountSelect) { // Mark as user-initiated selection so empty state doesn't override window._payoutUserSelectedAccount = true; // Set prop firm first const propFirmSelect = document.getElementById('payout-prop-firm'); if (propFirmSelect && account.propFirm) { propFirmSelect.value = account.propFirm; onPropFirmChange(); } // Then set account accountSelect.value = accountId; onPayoutAccountChange(); } } function togglePayoutSettingsDetails() { const details = document.getElementById('payout-settings-details'); const arrow = document.getElementById('payout-settings-arrow'); if (!details) return; const isHidden = details.style.display === 'none'; details.style.display = isHidden ? 'block' : 'none'; if (arrow) arrow.textContent = isHidden ? '▴' : '▾'; } function togglePayoutHistory() { const collapsible = document.getElementById('payout-history-collapsible'); const arrow = document.getElementById('payout-history-arrow'); if (!collapsible) return; const isHidden = collapsible.style.display === 'none'; collapsible.style.display = isHidden ? 'block' : 'none'; if (arrow) arrow.textContent = isHidden ? '▴' : '▾'; } // Generate payout rules dynamically from API data function buildProfitSplitNote(planPayoutRules) { const initialPct = planPayoutRules.profitSplitInitialPct ?? 100; const initialAmt = planPayoutRules.profitSplitInitialAmount ?? 0; const afterPct = planPayoutRules.profitSplitAfterPct ?? 90; // If initial and after are the same, or no initial amount, just show the split if (initialPct === afterPct || !initialAmt || initialAmt <= 0) { return `${afterPct}/${100 - afterPct} split`; } return `${initialPct}% on first $${initialAmt.toLocaleString()}, then ${afterPct}/${100 - afterPct}`; } function generatePayoutRules(config, accountConfig, planRules = null) { const rules = []; // Use plan-specific rules if provided, otherwise fall back to config const effectiveConsistency = planRules?.consistency || config.consistency; const effectiveTiming = planRules?.payoutTiming || config.payoutTiming; const effectiveProfitSplit = planRules?.profitSplitNote || config.profitSplitNote; const effectiveBuffer = planRules?.buffer !== undefined ? planRules.buffer : (accountConfig?.buffer || 0); // Profit split rule if (effectiveProfitSplit) { rules.push(effectiveProfitSplit); } else if (config.profitSplit) { rules.push(`Profit split: ${config.profitSplit}`); } // Payout timing rules if (effectiveTiming) { if (effectiveTiming.days > 0) { const dayType = effectiveTiming.type === 'winningDays' ? 'profitable' : 'trading'; rules.push(`${effectiveTiming.days} ${dayType} days between payouts`); } if (effectiveTiming.qualifyingDays > 0 && effectiveTiming.minProfitPerDay > 0) { rules.push(`${effectiveTiming.qualifyingDays} profitable days required ($${effectiveTiming.minProfitPerDay}+ each)`); } else if (effectiveTiming.qualifyingDays > 0) { rules.push(`${effectiveTiming.qualifyingDays} qualifying days required`); } } // Buffer requirement if (effectiveBuffer === 0) { rules.push('No buffer requirement'); } else if (config.bufferRequiredPayouts > 0) { rules.push(`Safety net buffer for first ${config.bufferRequiredPayouts} payouts`); } // MLL resets to zero if (config.mllResetsToZero) { rules.push('MLL resets to $0 after first payout'); } // Consistency rule if (effectiveConsistency && effectiveConsistency !== 'none' && effectiveConsistency !== 'None') { rules.push(`Consistency rule: ${effectiveConsistency} max per day`); } else if (effectiveConsistency === 'none' || effectiveConsistency === 'None') { rules.push('No consistency rule'); } // Payout caps — only show if not already covered by maxWithdrawalAmt rule const effectiveMaxAmtForCaps = planRules?.maxWithdrawalAmt || config.maxWithdrawalAmt; const caps = config.payoutCaps; if (caps && caps.length > 0 && caps[0] !== null && caps[0] > 0 && !effectiveMaxAmtForCaps) { rules.push(`Payout caps apply (first: $${caps[0].toLocaleString()})`); } // Max withdrawal percentage (e.g., 50% of balance) const effectiveMaxPct = planRules?.maxWithdrawalPct || config.maxWithdrawalPct; const effectiveMaxAmt = planRules?.maxWithdrawalAmt || config.maxWithdrawalAmt; if (effectiveMaxPct && effectiveMaxPct < 100) { let rule = `Max ${effectiveMaxPct}% of account balance per payout`; if (effectiveMaxAmt) rule += ` (up to $${effectiveMaxAmt.toLocaleString()})`; rules.push(rule); } else if (effectiveMaxAmt) { rules.push(`Max $${effectiveMaxAmt.toLocaleString()} per payout`); } return rules.length > 0 ? rules : ['See firm rules for payout details']; } function updatePropFirmSettings() { const firmKey = document.getElementById('payout-prop-firm')?.value || 'apex'; const accountSize = parseInt(document.getElementById('payout-account-size')?.value) || 50000; const plan = document.getElementById('payout-plan')?.value || ''; const config = propFirmConfigs[firmKey]; if (!config) return; const accountConfig = getAccountConfig(config, accountSize, plan) || (config.accounts ? Object.values(config.accounts)[0] : null); if (!accountConfig) { // accountsByPlan-only firm with no matching account config — update what we can from rulesByPlan const planPayoutRulesOnly = config.rulesByPlan?.[plan]?.payout; if (planPayoutRulesOnly) { document.getElementById('payout-min-withdrawal').value = planPayoutRulesOnly.minWithdrawal || config.minWithdrawal || ''; document.getElementById('payout-consistency').value = planPayoutRulesOnly.consistencyRule || config.consistency || ''; } payoutSettings.propFirm = firmKey; payoutSettings.plan = plan; payoutSettings.accountSize = accountSize; return; } // Get plan-specific rules from Firestore config const planPayoutRules = config.rulesByPlan?.[plan]?.payout; // Update buffer document.getElementById('payout-buffer').value = accountConfig.buffer; // Update profit split dropdown - prefer plan-specific const splitSelect = document.getElementById('payout-profit-split'); if (planPayoutRules?.profitSplitAfterPct) { splitSelect.value = `${planPayoutRules.profitSplitAfterPct}-${100 - planPayoutRules.profitSplitAfterPct}`; if (!splitSelect.value || splitSelect.selectedIndex === -1) { splitSelect.value = config.profitSplit; } } else { splitSelect.value = config.profitSplit; } // Update min withdrawal - prefer plan-specific document.getElementById('payout-min-withdrawal').value = planPayoutRules?.minWithdrawal || config.minWithdrawal; // Update consistency - prefer plan-specific document.getElementById('payout-consistency').value = planPayoutRules?.consistencyRule || config.consistency; // Update payout caps - prefer plan-specific from Firestore config // payoutCaps can be a flat array or a size-keyed map { "25000": [...], "50000": [...] } const rawCaps = planPayoutRules?.payoutCaps; let planCaps = null; if (Array.isArray(rawCaps) && rawCaps.length > 0 && rawCaps[0] !== null) { planCaps = rawCaps; } else if (rawCaps && typeof rawCaps === 'object') { const sz = String(accountSize); const sizedCaps = rawCaps[sz] || rawCaps[Object.keys(rawCaps)[0]]; if (Array.isArray(sizedCaps) && sizedCaps.length > 0) planCaps = sizedCaps; } const effectiveCaps = planCaps || accountConfig.caps; if (effectiveCaps) { for (let i = 0; i < 5; i++) { const capEl = document.getElementById(`payout-cap-${i + 1}`); if (capEl) capEl.value = effectiveCaps[i] || 0; } } // Build planRules for rules display let planRules = null; if (planPayoutRules) { planRules = { consistency: planPayoutRules.consistencyRule || null, payoutTiming: { type: resolveMinProfitableDays(planPayoutRules.minProfitableDays, accountSize) ? 'winningDays' : 'tradingDays', days: planPayoutRules.minTradingDays ?? resolveMinProfitableDays(planPayoutRules.minProfitableDays, accountSize), qualifyingDays: resolveMinProfitableDays(planPayoutRules.minProfitableDays, accountSize), minProfitPerDay: resolveMinProfitPerDay(planPayoutRules.minProfitPerDay, accountSize) }, maxWithdrawalAmt: planPayoutRules.maxWithdrawalAmt || null, maxWithdrawalPct: planPayoutRules.maxWithdrawalPct || null, profitSplitNote: buildProfitSplitNote(planPayoutRules) }; } // Update rules display const rulesContent = document.getElementById('prop-firm-rules-content'); if (rulesContent) { const rules = generatePayoutRules(config, accountConfig, planRules); rulesContent.innerHTML = rules.map(rule => `• ${rule}`).join('
'); } // Update payoutSettings object payoutSettings.propFirm = firmKey; payoutSettings.plan = plan; payoutSettings.accountSize = accountSize; const _buf = accountConfig.buffer ?? null; if (_buf === null) console.warn('[Firestore Gap]', firmKey, '/', plan, '— no buffer on size block'); payoutSettings.buffer = _buf; payoutSettings.profitSplit = splitSelect.value; const _minWith = planPayoutRules?.minWithdrawal ?? config.minWithdrawal ?? null; if (_minWith === null) console.warn('[Firestore Gap]', firmKey, '/', plan, '— no minWithdrawal in plan or firm config'); payoutSettings.minWithdrawal = _minWith; const _cons = planPayoutRules?.consistencyRule ?? config.consistency ?? null; if (_cons === null) console.warn('[Firestore Gap]', firmKey, '/', plan, '— no consistency in plan or firm config'); payoutSettings.consistency = _cons; payoutSettings.payoutCaps = effectiveCaps || [0, 0, 0, 0, 0]; } let payoutSettingsInitialized = false; function initPayoutSettings() { // Only initialize once if (payoutSettingsInitialized) return; payoutSettingsInitialized = true; // Set dropdowns from saved settings const firmSelect = document.getElementById('payout-prop-firm'); const planSelect = document.getElementById('payout-plan'); const sizeSelect = document.getElementById('payout-account-size'); if (firmSelect && payoutSettings.propFirm) { firmSelect.value = payoutSettings.propFirm; onPropFirmChange(); } if (planSelect && payoutSettings.plan) { planSelect.value = payoutSettings.plan; } // Sync account size from the selected account's startingBalance // This overrides saved settings to always match the actual account syncAccountSizeFromAccount(); // Set override checkbox const overrideCheckbox = document.getElementById('override-defaults'); if (overrideCheckbox) { overrideCheckbox.checked = payoutSettings.overrideDefaults || false; toggleOverride(); } // If override is enabled, restore saved custom values if (payoutSettings.overrideDefaults) { document.getElementById('payout-buffer').value = payoutSettings.buffer; document.getElementById('payout-min-withdrawal').value = payoutSettings.minWithdrawal; document.getElementById('payout-consistency').value = payoutSettings.consistency; if (payoutSettings.payoutCaps) { for (let i = 0; i < 5; i++) { const capEl = document.getElementById(`payout-cap-${i + 1}`); if (capEl) capEl.value = payoutSettings.payoutCaps[i] || 0; } } } } function computePayoutEligibility() { // Pre-compute best eligible account and closest ineligible for default selection const fundedAccounts = accounts.filter(a => !a.archived && a.stage !== 'evaluation'); let bestEligibleId = null; let bestEligiblePayout = -1; let closestIneligible = null; let closestScore = -1; fundedAccounts.forEach(account => { const firmKey = account.propFirm || 'other'; const normalizedKey = firmKey.toLowerCase(); const firmConfig = propFirmConfigs[firmKey] || propFirmConfigs[normalizedKey] || {}; const metrics = calculateAccountMetrics(account, firmConfig); // Calculate available payout const accountTrades = trades.filter(t => t.accountId === account.id); const profit = accountTrades.reduce((sum, t) => sum + getNetPnl(t), 0); const accountWithdrawals = payouts.filter(p => { if (p.type !== 'withdrawal') return false; if (p.accountId && p.accountId === account.id) return true; if (p.accountName && p.accountName === account.name) return true; return false; }); const withdrawn = accountWithdrawals.reduce((sum, p) => { if (p.profitSplit && p.profitSplit < 100) { if (p.grossAmount && Math.abs(p.grossAmount - p.amount) > 0.01) return sum + p.grossAmount; return sum + (p.amount / (p.profitSplit / 100)); } return sum + (p.grossAmount || p.amount || 0); }, 0); const configLookupSize = account.startingBalance || account.accountSize || null; const accountConfig = configLookupSize ? getAccountConfig(firmConfig, configLookupSize, account.plan) : null; if (!accountConfig) { console.warn('[Firestore Gap] No account config for', account.propFirm, configLookupSize, account.plan, '— skipping payout eligibility for this account'); return; } const bufferBase = (accountConfig.buffer !== undefined ? accountConfig.buffer : 0); const buffer = bufferBase === 0 ? (accountConfig?.drawdownLocksAtOffset ?? firmConfig?.drawdownLockOffset ?? 0) : bufferBase; const profitAboveBuffer = Math.max(0, profit - buffer); const available = Math.max(0, profitAboveBuffer - withdrawn); const isEligible = metrics.isEligible && available > 0; if (isEligible) { const effectiveConfig = getPlanSpecificConfig(firmConfig, account); let startingBal = parseFloat(account.startingBalance) || 0; if (firmConfig?.fundedStartsAtZero && account.stage === 'funded') startingBal = 0; const currentBal = startingBal + profit - withdrawn; const maxPct = effectiveConfig.maxWithdrawalPct ?? 100; const maxAmt = effectiveConfig.maxWithdrawalAmt ?? Infinity; const caps = accountConfig.caps; const payoutCount = accountWithdrawals.length; const capForNext = (caps && payoutCount < caps.length) ? (caps[payoutCount] || Infinity) : Infinity; let availableToPayout = available; if (maxPct < 100 && currentBal > 0) availableToPayout = Math.min(availableToPayout, currentBal * (maxPct / 100)); if (maxAmt < Infinity) availableToPayout = Math.min(availableToPayout, maxAmt); availableToPayout = Math.min(availableToPayout, capForNext); if (availableToPayout > bestEligiblePayout) { bestEligiblePayout = availableToPayout; bestEligibleId = account.id; } } else { // Compute closeness score let scores = []; if (metrics.bufferRequired > 0) { const p = metrics.profitResetsAfterPayout ? (metrics.profitSinceLastPayout || 0) : (metrics.currentBalance - metrics.startingBal); scores.push(Math.min(1, Math.max(0, p / metrics.bufferRequired))); } if (metrics.requiredDays > 0) scores.push(Math.min(1, metrics.totalTradingDaysSinceLastPayout / metrics.requiredDays)); if (metrics.requiredQualifyingDays > 0) scores.push(Math.min(1, metrics.qualifyingDaysSinceLastPayout / metrics.requiredQualifyingDays)); if (metrics.hasConsistencyRule) scores.push(metrics.consistencyMet ? 1 : 0.5); const score = scores.length > 0 ? scores.reduce((a, b) => a + b, 0) / scores.length : 0; if (score > closestScore) { closestScore = score; closestIneligible = { id: account.id, name: account.name, reason: metrics.eligibilityReason || 'Not yet eligible' }; } } }); window._payoutBestEligibleId = bestEligibleId; window._payoutClosestIneligible = closestIneligible; } function renderPayoutTracker() { // Initialize prop firm settings if not already done initPayoutSettings(); // Pre-compute eligibility before selecting an account computePayoutEligibility(); // Get selected account — prefer best eligible, never fall back to ineligible const accountSelect = document.getElementById('payout-account'); let selectedAccount = accounts.find(a => a.id === accountSelect?.value); // If no account selected yet, try to pick the best eligible one if (!selectedAccount && window._payoutBestEligibleId) { selectedAccount = accounts.find(a => a.id === window._payoutBestEligibleId); if (selectedAccount && accountSelect) accountSelect.value = selectedAccount.id; } // Only fall back to accounts[0] if there IS an eligible account (to avoid selecting ineligible) if (!selectedAccount && window._payoutBestEligibleId) { selectedAccount = accounts[0]; } // Show empty state if no eligible accounts exist (unless user manually selected an account) const noEligibleState = !window._payoutBestEligibleId && !window._payoutUserSelectedAccount; const stepGuideEl = document.getElementById('payout-step-guide'); const calcGridEl = document.getElementById('payout-calc-grid'); const emptyStateEl = document.getElementById('payout-empty-state'); if (noEligibleState) { // Hide calculator content, leave card interior completely blank if (stepGuideEl) stepGuideEl.style.display = 'none'; if (calcGridEl) calcGridEl.style.display = 'none'; if (emptyStateEl) { emptyStateEl.style.display = 'none'; } // Still render the left panel and bottom sections renderAccountPnlBreakdown(); renderRecentPayouts(); renderPayoutCalendar(); renderPayoutHistory(); return; } else { // Make sure empty state is hidden and calculator is visible if (emptyStateEl) emptyStateEl.style.display = 'none'; if (stepGuideEl) stepGuideEl.style.display = 'flex'; if (calcGridEl) calcGridEl.style.display = 'grid'; } // Use account's actual prop firm — not the dropdown's stale value const propFirm = selectedAccount?.propFirm || payoutSettings.propFirm || 'apex'; const firmConfig = propFirmConfigs[propFirm] || propFirmConfigs[propFirm.toLowerCase()] || {}; const plan = selectedAccount?.plan || document.getElementById('payout-plan')?.value || ''; // Use getAccountConfig for proper plan+size lookup (matches Dashboard) const configLookupSize = selectedAccount?.startingBalance || selectedAccount?.accountSize || null; const accountConfig = configLookupSize ? getAccountConfig(firmConfig, configLookupSize, plan) : null; if (!accountConfig) { console.warn('[Firestore Gap] No account config for', selectedAccount?.propFirm, configLookupSize, plan, '— payout tracker cannot render'); document.getElementById('payout-available')?.closest('.payout-stats-grid') && (document.getElementById('payout-available').textContent = '—'); return; } // Get plan-specific rules from Firestore propFirmConfig (matches Dashboard's calculateAccountMetrics) const effectiveConfig = selectedAccount ? getPlanSpecificConfig(firmConfig, selectedAccount) : firmConfig; // Calculate values — filter to SELECTED ACCOUNT only (matches Dashboard) const accountTrades = selectedAccount ? trades.filter(t => t.accountId === selectedAccount.id) : []; const netPnl = accountTrades.reduce((sum, t) => sum + getNetPnl(t), 0); // Starting balance with TopStep zero-start handling (matches Dashboard) let startingBalance = parseFloat(selectedAccount?.startingBalance) || parseFloat(selectedAccount?.balance) || 0; if (firmConfig?.fundedStartsAtZero && selectedAccount?.stage === 'funded') startingBalance = 0; // Withdrawals for THIS account only (matches Dashboard) const accountWithdrawals = payouts.filter(p => { if (p.type !== 'withdrawal') return false; if (p.accountId && p.accountId === selectedAccount?.id) return true; if (p.accountName && p.accountName === selectedAccount?.name) return true; return false; }); const totalWithdrawnGross = accountWithdrawals.reduce((sum, p) => { if (p.profitSplit && p.profitSplit < 100) { if (p.grossAmount && Math.abs(p.grossAmount - p.amount) > 0.01) return sum + p.grossAmount; return sum + (p.amount / (p.profitSplit / 100)); } return sum + (p.grossAmount || p.amount || 0); }, 0); const totalWithdrawnNet = accountWithdrawals.reduce((sum, p) => sum + (p.amount || 0), 0); const currentBalance = startingBalance + netPnl - totalWithdrawnGross; // Tier threshold is lifetime across all accounts for this firm (e.g. TopStep first $10K at 100%) const firmWithdrawnNet = payouts.reduce((sum, p) => { if (p.type !== 'withdrawal') return sum; const acct = accounts.find(a => a.id === p.accountId); if (!acct || acct.propFirm !== selectedAccount?.propFirm) return sum; return sum + (p.amount || 0); }, 0); // Buffer from account config (read from UI override if user has overridden) const overrideOn = document.getElementById('override-defaults')?.checked; const bufferInputVal = document.getElementById('payout-buffer')?.value; const buffer = overrideOn && bufferInputVal !== '' && bufferInputVal !== undefined ? parseInt(bufferInputVal) || 0 : (accountConfig?.buffer !== undefined ? accountConfig.buffer : 0); // Profit split from Firestore rulesByPlan let profitSplitPct = 100; const planPayoutRules = effectiveConfig?.rulesByPlan?.[plan]?.payout || effectiveConfig?.rulesByPlan?.[Object.keys(effectiveConfig?.rulesByPlan || {})[0]]?.payout; if (planPayoutRules) { const initialPct = planPayoutRules.profitSplitInitialPct ?? 100; const initialAmt = planPayoutRules.profitSplitInitialAmount ?? 0; const afterPct = planPayoutRules.profitSplitAfterPct ?? 90; profitSplitPct = (initialAmt > 0 && firmWithdrawnNet >= initialAmt) ? afterPct : initialPct; } // Caps from account config (null = no per-payout cap for this firm) const configCaps = accountConfig?.caps || null; const caps = overrideOn ? [ parseInt(document.getElementById('payout-cap-1')?.value) || 0, parseInt(document.getElementById('payout-cap-2')?.value) || 0, parseInt(document.getElementById('payout-cap-3')?.value) || 0, parseInt(document.getElementById('payout-cap-4')?.value) || 0, parseInt(document.getElementById('payout-cap-5')?.value) || 0 ] : configCaps; // Calculate available to withdraw const profitAboveBuffer = Math.max(0, netPnl - buffer); const payoutCount = accountWithdrawals.length; const remaining = Math.max(0, profitAboveBuffer - totalWithdrawnGross); // Max withdrawal percentage and amount from plan-specific rules const maxPct = effectiveConfig?.maxWithdrawalPct ?? 100; const maxAmt = effectiveConfig?.maxWithdrawalAmt ?? Infinity; // Apply percentage cap (e.g., TopStep 50% of account balance) let cappedRemaining = remaining; if (maxPct < 100 && currentBalance > 0) { cappedRemaining = Math.min(remaining, currentBalance * (maxPct / 100)); } let availableToWithdraw = 0; { const perPayoutCap = (caps && payoutCount < caps.length && caps[payoutCount] > 0) ? caps[payoutCount] : Infinity; let maxPayout = Math.min(perPayoutCap, cappedRemaining); if (maxAmt < Infinity) maxPayout = Math.min(maxPayout, maxAmt); availableToWithdraw = maxPayout * (profitSplitPct / 100); } // Log rules for debugging console.log('[Payout Rules]', firmConfig?.name || propFirm, { plan, accountSize: configLookupSize, buffer, caps, profitSplitPct, maxPct, maxAmt, startingBalance, netPnl, totalWithdrawnGross, currentBalance, remaining, availableToWithdraw, planPayoutRules: planPayoutRules || 'none' }); // Update stats document.getElementById('payout-starting').textContent = formatCurrency(startingBalance); document.getElementById('payout-netpnl').textContent = formatCurrency(netPnl); document.getElementById('payout-netpnl').className = netPnl >= 0 ? 'positive' : 'negative'; document.getElementById('payout-withdrawn').textContent = formatCurrency(totalWithdrawnNet); document.getElementById('payout-count').textContent = `(${accountWithdrawals.length})`; document.getElementById('payout-balance').textContent = formatCurrency(currentBalance); document.getElementById('payout-balance').className = currentBalance >= startingBalance ? 'positive' : 'negative'; document.getElementById('payout-available').textContent = formatCurrency(availableToWithdraw); document.getElementById('payout-buffer-display').textContent = buffer.toLocaleString(); const splitDisplayEl = document.getElementById('payout-split-display'); if (splitDisplayEl) splitDisplayEl.textContent = profitSplitPct < 100 ? `${profitSplitPct}/${100-profitSplitPct} profit split` : ''; // Update MLL bar and distance if (selectedAccount && selectedAccount.propFirm) { const mllFirmConfig = propFirmConfigs[selectedAccount.propFirm]; if (mllFirmConfig) { const mllMetrics = calculateAccountMetrics(selectedAccount, mllFirmConfig); const distEl = document.getElementById('payout-mll-distance'); const labelEl = document.getElementById('payout-mll-label'); const barEl = document.getElementById('payout-mll-bar'); const barLabelEl = document.getElementById('payout-mll-bar-label'); if (distEl && mllMetrics.distanceToMLL !== undefined) { const dist = mllMetrics.distanceToMLL; const drawdown = mllMetrics.drawdownAmount || 1; // Store baseline for live withdrawal preview _baselineMllDist = dist; _baselineMllDrawdown = drawdown; const pct = Math.min(100, Math.max(0, (dist / drawdown) * 100)); distEl.textContent = formatCurrency(dist); distEl.style.color = pct > 50 ? 'var(--green)' : (pct > 25 ? 'var(--orange)' : 'var(--red)'); if (labelEl) labelEl.textContent = 'above Max Loss Limit'; if (barEl) { barEl.style.width = pct + '%'; barEl.style.background = pct > 50 ? 'var(--green)' : (pct > 25 ? 'var(--orange)' : 'var(--red)'); } if (barLabelEl) barLabelEl.textContent = `${formatCurrency(dist)} buffer · ${pct.toFixed(0)}% safe`; } } } // Render account P&L breakdown renderAccountPnlBreakdown(); // Update title with account name const titleEl = document.getElementById('calc-title'); if (titleEl) titleEl.textContent = selectedAccount?.name || 'Payout Calculator'; // Update timing display based on prop firm updatePayoutTimingDisplay(propFirm, firmConfig, accountWithdrawals); // Update request label const requestLabel = document.getElementById('request-label'); if (requestLabel) { requestLabel.textContent = 'Withdraw Amount:'; } // Update payout status updatePayoutStatus(accountWithdrawals, propFirm); // Calculate payout calculatePayout(); // ===================================================== // ELIGIBILITY CHECK - Sync with Prop Firm Status widget // ===================================================== const eligibilityWarning = document.getElementById('eligibility-warning'); const eligibilityReason = document.getElementById('eligibility-reason'); const recordBtn = document.getElementById('record-withdrawal-btn'); // Check if we have a prop firm account selected let accountEligible = true; let eligibilityMessage = ''; let allReasons = []; if (selectedAccount && selectedAccount.propFirm) { // Use the same metrics calculation as Prop Firm Status widget const accountFirmConfig = propFirmConfigs[selectedAccount.propFirm]; if (accountFirmConfig) { const metrics = calculateAccountMetrics(selectedAccount, accountFirmConfig); accountEligible = metrics.isEligible; // Collect all reasons why not eligible if (!accountEligible) { // Buffer/Profit check - different messaging for profit reset firms if (metrics.profitResetsAfterPayout && metrics.lastPayoutDate) { // For firms like Tradeify where profit cycle resets after payout if (metrics.profitSinceLastPayout < metrics.bufferRequired) { const needed = metrics.bufferRequired - metrics.profitSinceLastPayout; allReasons.push(`Profit Cycle: Need ${formatCurrency(needed)} more profit since last payout (${formatCurrency(metrics.bufferRequired)} required)`); } } else { // Standard buffer check if (metrics.currentBalance - metrics.startingBal < metrics.bufferRequired) { const needed = metrics.bufferRequired - (metrics.currentBalance - metrics.startingBal); allReasons.push(`Buffer: Need ${formatCurrency(needed)} more profit to meet ${formatCurrency(metrics.bufferRequired)} buffer`); } } // Minimum withdrawal check if (metrics.maxWithdrawal < metrics.minWithdrawal && allReasons.length === 0) { allReasons.push(`Minimum: Need ${formatCurrency(metrics.minWithdrawal)} available (currently ${formatCurrency(Math.max(0, metrics.maxWithdrawal))})`); } // Trading days check - distinguish between total days and qualifying days if (!metrics.daysRequirementMet && metrics.requiredDays > 0) { const totalDaysNeeded = metrics.requiredDays - metrics.totalTradingDaysSinceLastPayout; const qualifyingDaysNeeded = metrics.requiredQualifyingDays - metrics.qualifyingDaysSinceLastPayout; // Build appropriate message based on what's missing if (totalDaysNeeded > 0 && qualifyingDaysNeeded > 0) { allReasons.push(`Trading Days: Need ${totalDaysNeeded} more trading day${totalDaysNeeded !== 1 ? 's' : ''} (${metrics.totalTradingDaysSinceLastPayout}/${metrics.requiredDays}) & ${qualifyingDaysNeeded} more $${metrics.minProfitPerDay}+ day${qualifyingDaysNeeded !== 1 ? 's' : ''} (${metrics.qualifyingDaysSinceLastPayout}/${metrics.requiredQualifyingDays})`); } else if (totalDaysNeeded > 0) { allReasons.push(`Trading Days: Need ${totalDaysNeeded} more trading day${totalDaysNeeded !== 1 ? 's' : ''} (${metrics.totalTradingDaysSinceLastPayout}/${metrics.requiredDays})`); } else if (qualifyingDaysNeeded > 0) { allReasons.push(`Trading Days: Need ${qualifyingDaysNeeded} more day${qualifyingDaysNeeded !== 1 ? 's' : ''} with $${metrics.minProfitPerDay}+ profit (${metrics.qualifyingDaysSinceLastPayout}/${metrics.requiredQualifyingDays})`); } } // Consistency check if (metrics.consistencyProgress && !metrics.consistencyProgress.includes('✓')) { allReasons.push(`Consistency: ${metrics.consistencyProgress}`); } eligibilityMessage = allReasons.join('
'); } } } // Update UI based on eligibility if (eligibilityWarning && eligibilityReason) { if (!accountEligible && allReasons.length > 0) { eligibilityWarning.style.display = 'block'; eligibilityReason.innerHTML = eligibilityMessage; } // Note: We don't hide timing-based warnings set by updatePayoutTimingDisplay } // Check if there's a timing-based warning showing (from updatePayoutTimingDisplay) const hasTimingWarning = eligibilityWarning && eligibilityWarning.style.display === 'block'; // Disable/enable Record Withdrawal button based on eligibility // Button should be disabled if EITHER metrics OR timing makes user ineligible _eligibilityDisabled = !accountEligible || hasTimingWarning; if (recordBtn) { if (_eligibilityDisabled) { recordBtn.disabled = true; recordBtn.style.opacity = '0.5'; recordBtn.style.cursor = 'not-allowed'; recordBtn.innerHTML = '🚫 Not Yet Eligible'; } else { recordBtn.disabled = false; recordBtn.style.opacity = '1'; recordBtn.style.cursor = 'pointer'; recordBtn.innerHTML = '💸 Record Withdrawal'; } } // Update eligibility badge const eligBadge = document.getElementById('payout-eligibility-badge'); const finalEligible = accountEligible && !hasTimingWarning; if (eligBadge) { if (finalEligible) { eligBadge.textContent = 'ELIGIBLE'; eligBadge.style.background = 'rgba(16, 185, 129, 0.15)'; eligBadge.style.color = 'var(--green)'; } else { eligBadge.textContent = 'NOT YET ELIGIBLE'; eligBadge.style.background = 'rgba(245, 158, 11, 0.15)'; eligBadge.style.color = 'var(--orange)'; } } // Show/hide eligibility-ready banner const eligReadyEl = document.getElementById('eligibility-ready'); if (eligReadyEl) { eligReadyEl.style.display = finalEligible ? 'flex' : 'none'; } // Render eligibility progress bars const progressBarsEl = document.getElementById('eligibility-progress-bars'); if (progressBarsEl && selectedAccount && selectedAccount.propFirm) { const acctFirmConfig = propFirmConfigs[selectedAccount.propFirm]; if (acctFirmConfig && !accountEligible) { const metrics = calculateAccountMetrics(selectedAccount, acctFirmConfig); let barsHtml = ''; // Trading days progress if (metrics.requiredDays > 0) { const daysPct = Math.min(100, (metrics.totalTradingDaysSinceLastPayout / metrics.requiredDays) * 100); barsHtml += `
Trading Days ${metrics.totalTradingDaysSinceLastPayout} / ${metrics.requiredDays}
`; } // Qualifying days progress if (metrics.requiredQualifyingDays > 0) { const qualPct = Math.min(100, (metrics.qualifyingDaysSinceLastPayout / metrics.requiredQualifyingDays) * 100); barsHtml += `
Qualifying Days ($${metrics.minProfitPerDay || 0}+) ${metrics.qualifyingDaysSinceLastPayout} / ${metrics.requiredQualifyingDays}
`; } // Buffer progress if (metrics.bufferRequired > 0) { const profit = metrics.profitResetsAfterPayout ? (metrics.profitSinceLastPayout || 0) : (metrics.currentBalance - metrics.startingBal); const bufPct = Math.min(100, Math.max(0, (profit / metrics.bufferRequired) * 100)); barsHtml += `
Buffer Progress ${formatCurrency(profit)} / ${formatCurrency(metrics.bufferRequired)}
`; } progressBarsEl.innerHTML = barsHtml; } else { progressBarsEl.innerHTML = ''; } } // Render inline payout history (last 3 for selected account) const inlineHistoryEl = document.getElementById('payout-inline-history'); const inlineHistoryList = document.getElementById('payout-inline-history-list'); if (inlineHistoryEl && inlineHistoryList) { const accountPayouts = payouts.filter(p => p.type === 'withdrawal' && p.accountId === selectedAccount?.id) .sort((a, b) => new Date(b.date) - new Date(a.date)) .slice(0, 3); if (accountPayouts.length > 0) { inlineHistoryEl.style.display = 'block'; inlineHistoryList.innerHTML = accountPayouts.map(p => { const d = parseLocalDate(p.date); const dateStr = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); return `
${dateStr} ${formatCurrency(p.amount)}
`; }).join(''); } else { inlineHistoryEl.style.display = 'none'; } } // Render recent payouts renderRecentPayouts(); // Render payout calendar renderPayoutCalendar(); // Render payout history list renderPayoutHistory(); // Render payout forecast renderPayoutForecast(selectedAccount, accountWithdrawals, accountTrades); // Update step indicator updatePayoutStepIndicator(); // Re-apply streamer mode blur to dynamically rendered elements if (streamerMode) applySensitiveBlur(); } let _activePayoutStep = 1; function updatePayoutStepIndicator(activeStep) { if (activeStep !== undefined) _activePayoutStep = activeStep; const amt = parseFloat(document.getElementById('withdrawal-amount')?.value) || 0; // Auto-detect active step from state if not explicitly set if (activeStep === undefined) { _activePayoutStep = amt > 0 ? 2 : 1; } for (let i = 1; i <= 3; i++) { const circle = document.querySelector(`#payout-step-${i} span:first-child`); const label = document.querySelector(`#payout-step-${i} span:last-child`); if (!circle || !label) continue; const isActive = i <= _activePayoutStep || (i === 2 && amt > 0); circle.style.background = isActive ? 'var(--cyan)' : 'var(--border-color)'; circle.style.color = isActive ? '#fff' : 'var(--text-muted)'; circle.style.boxShadow = i === _activePayoutStep ? '0 0 6px rgba(0, 212, 170, 0.4)' : 'none'; label.style.color = isActive ? 'var(--text-secondary)' : 'var(--text-muted)'; } } function navigatePayoutStep(step) { updatePayoutStepIndicator(step); // Helper to apply glow highlight to an element const glowHighlight = (el) => { if (!el) return; el.classList.remove('payout-step-highlight'); // Force reflow to restart animation void el.offsetWidth; el.classList.add('payout-step-highlight'); setTimeout(() => el.classList.remove('payout-step-highlight'), 900); }; if (step === 1) { // Scroll to and highlight stats row + max available box const statsRow = document.getElementById('payout-stats-row'); const maxBox = document.getElementById('payout-max-available-box'); if (statsRow) { statsRow.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); glowHighlight(statsRow); } if (maxBox) { setTimeout(() => glowHighlight(maxBox), 200); } } else if (step === 2) { // Scroll to and focus the withdraw amount input const input = document.getElementById('withdrawal-amount'); if (input) { input.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); glowHighlight(input.parentElement); setTimeout(() => { input.focus(); input.select(); }, 300); } } else if (step === 3) { // Scroll to and highlight the record withdrawal button const btn = document.getElementById('record-withdrawal-btn'); if (btn) { btn.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); glowHighlight(btn); } } } function togglePayoutForecast() { const section = document.getElementById('payout-forecast-section'); const arrow = document.getElementById('payout-forecast-arrow'); if (!section) return; const isOpen = section.style.display !== 'none'; section.style.display = isOpen ? 'none' : 'block'; if (arrow) arrow.style.transform = isOpen ? 'rotate(0deg)' : 'rotate(90deg)'; } function renderPayoutForecast(selectedAccount, accountWithdrawals, accountTrades) { const contentEl = document.getElementById('payout-forecast-content'); if (!contentEl) return; // Need at least 1 payout to project const sortedPayouts = (accountWithdrawals || []) .filter(p => p.date) .sort((a, b) => new Date(a.date) - new Date(b.date)); if (sortedPayouts.length === 0) { // Check trading days for "trade X more days" message const tradingDays = new Set(); (accountTrades || []).forEach(t => { const d = normalizeDateToString(t.date) || getTradingSessionDate(t.exitTime); if (d) tradingDays.add(d); }); const firmConfig = propFirmConfigs[selectedAccount?.propFirm] || {}; const effectiveConfig = selectedAccount ? getPlanSpecificConfig(firmConfig, selectedAccount) : firmConfig; const plan = selectedAccount?.plan || ''; const planPayoutRules = effectiveConfig?.rulesByPlan?.[plan]?.payout || effectiveConfig?.rulesByPlan?.[Object.keys(effectiveConfig?.rulesByPlan || {})[0]]?.payout; const requiredDays = planPayoutRules?.minTradingDays || firmConfig?.minTradingDays || 0; if (requiredDays > 0 && tradingDays.size < requiredDays) { const daysNeeded = requiredDays - tradingDays.size; contentEl.innerHTML = `
Not enough data yet
Trade for ${daysNeeded} more day${daysNeeded !== 1 ? 's' : ''} to unlock your forecast
`; } else { contentEl.innerHTML = `
Record your first payout to unlock forecast projections
`; } return; } // Calculate average interval between payouts (in days) let avgIntervalDays = 14; // default bi-weekly if (sortedPayouts.length >= 2) { let totalInterval = 0; for (let i = 1; i < sortedPayouts.length; i++) { const diff = (new Date(sortedPayouts[i].date) - new Date(sortedPayouts[i - 1].date)) / (1000 * 60 * 60 * 24); totalInterval += diff; } avgIntervalDays = Math.round(totalInterval / (sortedPayouts.length - 1)); if (avgIntervalDays < 1) avgIntervalDays = 7; } // Calculate average payout amount (use last 3 if available) const recentPayouts = sortedPayouts.slice(-3); const avgAmount = recentPayouts.reduce((sum, p) => sum + (p.amount || 0), 0) / recentPayouts.length; const basisLabel = recentPayouts.length >= 3 ? 'avg of last 3 payouts' : (recentPayouts.length === 2 ? 'avg of last 2 payouts' : 'based on last payout'); // Project next 3 payout dates const lastPayoutDate = new Date(sortedPayouts[sortedPayouts.length - 1].date); const projections = []; for (let i = 1; i <= 3; i++) { const projDate = new Date(lastPayoutDate); projDate.setDate(projDate.getDate() + avgIntervalDays * i); // Skip weekends while (projDate.getDay() === 0 || projDate.getDay() === 6) { projDate.setDate(projDate.getDate() + 1); } projections.push({ date: projDate, amount: avgAmount, basis: basisLabel }); } // Build the mini timeline const rows = projections.map((p, idx) => { const dateStr = p.date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); const isPast = p.date < new Date(); return `
${idx < 2 ? '
' : ''}
${dateStr}${isPast ? ' (overdue)' : ''}
${p.basis}
~${formatCurrency(p.amount)}
`; }).join(''); contentEl.innerHTML = `
Next 3 Projected Payouts
${rows}
Based on ~${avgIntervalDays}-day cycle · ${formatCurrency(avgAmount)} avg payout
`; } function updatePayoutTimingDisplay(propFirm, firmConfig, withdrawals) { const timing = firmConfig?.payoutTiming || { type: 'anytime' }; // Get selected account for filtering trades const accountSelect = document.getElementById('payout-account'); const selectedAccountId = accountSelect?.value; // Hide all timing sections first const tradingDaysEl = document.getElementById('timing-trading-days'); const windowsEl = document.getElementById('timing-windows'); const anytimeEl = document.getElementById('timing-anytime'); if (tradingDaysEl) tradingDaysEl.style.display = 'none'; if (windowsEl) windowsEl.style.display = 'none'; if (anytimeEl) anytimeEl.style.display = 'none'; // Hide eligibility warning by default const warningEl = document.getElementById('eligibility-warning'); if (warningEl) warningEl.style.display = 'none'; if (timing.type === 'tradingDays') { // Show trading days section (Apex) if (tradingDaysEl) tradingDaysEl.style.display = 'block'; // Calculate trading days since last payout (or all time if no payouts) let tradingDaysSince = 0; let isEligible = false; let nextEligibleText = 'Need more trading days'; const requiredDays = timing.days ?? 0; // Use nullish coalescing — 0 is valid (no requirement) // If required days is 0, this firm has no trading day requirement — skip entirely if (requiredDays === 0) { // No trading day requirement — treat as eligible, don't show warning if (tradingDaysEl) tradingDaysEl.style.display = 'none'; } else { // Filter trades by selected account const accountTrades = selectedAccountId ? trades.filter(t => t.accountId === selectedAccountId) : trades; // Get the reference date (last payout date, or null if no payouts yet) let referenceDate = null; if (withdrawals.length > 0) { referenceDate = new Date(Math.max(...withdrawals.map(p => parseLocalDate(p.date).getTime()))); } // Get unique trading days for this account const tradingDates = new Set(); accountTrades.forEach(t => { const tradeDate = new Date(t.exitTime); // If we have a reference date (last payout), only count days after it // Otherwise count all trading days for this account if (!referenceDate || tradeDate > referenceDate) { const dateKey = `${tradeDate.getFullYear()}-${String(tradeDate.getMonth() + 1).padStart(2, '0')}-${String(tradeDate.getDate()).padStart(2, '0')}`; tradingDates.add(dateKey); } }); tradingDaysSince = tradingDates.size; // Check if eligible if (tradingDaysSince >= requiredDays) { isEligible = true; nextEligibleText = 'Now'; } else { const daysNeeded = requiredDays - tradingDaysSince; nextEligibleText = `${daysNeeded} more trading day${daysNeeded > 1 ? 's' : ''}`; } // Update display const tradingDaysText = document.getElementById('apex-trading-days'); if (tradingDaysText) { tradingDaysText.textContent = `${tradingDaysSince} / ${requiredDays}`; tradingDaysText.style.color = isEligible ? 'var(--green)' : 'var(--orange)'; } const progressEl = document.getElementById('apex-days-progress'); if (progressEl) { const pct = Math.min(100, (tradingDaysSince / requiredDays) * 100); progressEl.style.width = pct + '%'; progressEl.style.background = isEligible ? 'var(--green)' : 'var(--orange)'; } const nextEligibleEl = document.getElementById('apex-next-eligible'); if (nextEligibleEl) { nextEligibleEl.textContent = nextEligibleText; nextEligibleEl.style.color = isEligible ? 'var(--green)' : 'var(--text-secondary)'; } // Show eligibility warning if not eligible if (warningEl && !isEligible) { warningEl.style.display = 'block'; document.getElementById('eligibility-reason').textContent = `Need ${requiredDays - tradingDaysSince} more trading days (${tradingDaysSince}/${requiredDays} complete)`; } } // end else (requiredDays > 0) } else if (timing.type === 'windows') { // Show payout windows section (Tradeify) if (windowsEl) windowsEl.style.display = 'block'; const today = new Date(); const dayOfMonth = today.getDate(); const windows = timing.windows || [[1,4], [11,14], [21,24]]; let inWindow = false; let currentWindowEnd = null; let nextWindowStart = null; for (const [start, end] of windows) { if (dayOfMonth >= start && dayOfMonth <= end) { inWindow = true; currentWindowEnd = end; break; } if (dayOfMonth < start && !nextWindowStart) { nextWindowStart = start; } } // If past all windows this month, next is 1st of next month if (!inWindow && !nextWindowStart) { nextWindowStart = windows[0][0]; } const statusEl = document.getElementById('window-status'); if (statusEl) { if (inWindow) { statusEl.textContent = `✓ Window open until ${currentWindowEnd}${getOrdinalSuffix(currentWindowEnd)}`; statusEl.style.color = 'var(--green)'; } else { statusEl.textContent = `Next window: ${nextWindowStart}${getOrdinalSuffix(nextWindowStart)}`; statusEl.style.color = 'var(--orange)'; // Show warning if (warningEl) { warningEl.style.display = 'block'; document.getElementById('eligibility-reason').textContent = `Payout window opens on the ${nextWindowStart}${getOrdinalSuffix(nextWindowStart)}`; } } } } else { // Show anytime section (TPT, Lucid, FFN, etc.) if (anytimeEl) anytimeEl.style.display = 'block'; const noteEl = document.getElementById('timing-note'); if (noteEl) { noteEl.textContent = timing.note || 'Withdraw anytime'; } } } function getOrdinalSuffix(n) { const s = ['th', 'st', 'nd', 'rd']; const v = n % 100; return s[(v - 20) % 10] || s[v] || s[0]; } function updateApexStatus(withdrawals) { // Calculate trading days since last payout let tradingDaysSince = 0; let isEligible = true; let nextEligibleText = 'Now'; if (withdrawals.length > 0) { const lastPayout = new Date(Math.max(...withdrawals.map(p => parseLocalDate(p.date).getTime()))); // Get unique trading days since last payout const tradingDates = new Set(); trades.forEach(t => { const tradeDate = new Date(t.exitTime); if (tradeDate > lastPayout) { const dateKey = `${tradeDate.getFullYear()}-${String(tradeDate.getMonth() + 1).padStart(2, '0')}-${String(tradeDate.getDate()).padStart(2, '0')}`; tradingDates.add(dateKey); } }); tradingDaysSince = tradingDates.size; if (tradingDaysSince < 8) { isEligible = false; const daysNeeded = 8 - tradingDaysSince; nextEligibleText = `${daysNeeded} more trading day${daysNeeded > 1 ? 's' : ''}`; } } // Update Apex status in calculator const tradingDaysEl = document.getElementById('apex-trading-days'); if (tradingDaysEl) { tradingDaysEl.textContent = `${tradingDaysSince} / 8`; tradingDaysEl.style.color = isEligible ? 'var(--green)' : 'var(--orange)'; } const progressEl = document.getElementById('apex-days-progress'); if (progressEl) { const pct = Math.min(100, (tradingDaysSince / 8) * 100); progressEl.style.width = pct + '%'; progressEl.style.background = isEligible ? 'var(--green)' : 'var(--orange)'; } const nextEligibleEl = document.getElementById('apex-next-eligible'); if (nextEligibleEl) { nextEligibleEl.textContent = nextEligibleText; nextEligibleEl.style.color = isEligible ? 'var(--green)' : 'var(--text-secondary)'; } // Show/hide eligibility warning const warningEl = document.getElementById('eligibility-warning'); if (warningEl) { if (!isEligible) { warningEl.style.display = 'block'; document.getElementById('eligibility-reason').textContent = `Need ${8 - tradingDaysSince} more trading days (${tradingDaysSince}/8 complete)`; } else { warningEl.style.display = 'none'; } } return { tradingDaysSince, isEligible }; } function updatePayoutStatus(withdrawals, propFirm) { const payoutCount = withdrawals.length; const lifetimeTotal = withdrawals.reduce((s, p) => s + p.amount, 0); const firmConfig = propFirmConfigs[propFirm]; // Update UI const completedEl = document.getElementById('payout-completed-count'); if (completedEl) { if (payoutCount >= 5) { completedEl.innerHTML = `${payoutCount} (uncapped)`; } else { completedEl.textContent = `${payoutCount} of 5`; } } // Trading days row - hide for now since it's complex const tradingDaysRow = document.getElementById('trading-days-since')?.parentElement; const nextEligibleRow = document.getElementById('next-payout-eligible')?.parentElement; if (tradingDaysRow) tradingDaysRow.style.display = 'none'; if (nextEligibleRow) nextEligibleRow.style.display = 'none'; const lifetimeEl = document.getElementById('lifetime-payouts'); if (lifetimeEl) { lifetimeEl.textContent = formatCurrency(lifetimeTotal); } // Determine profit split threshold based on prop firm let splitThreshold = 25000; let splitBefore = 100; let splitAfter = 90; // Get profit split from Firestore rulesByPlan const payoutAcctSelect = document.getElementById('payout-account'); const payoutSelectedAccount = accounts.find(a => a.id === payoutAcctSelect?.value) || accounts[0]; const planForSplit = payoutSelectedAccount?.plan || ''; const effectiveFirmConfig = firmConfig ? getPlanSpecificConfig(firmConfig, payoutSelectedAccount) : null; const planPayoutRules = effectiveFirmConfig?.rulesByPlan?.[planForSplit]?.payout || effectiveFirmConfig?.rulesByPlan?.[Object.keys(effectiveFirmConfig?.rulesByPlan || {})[0]]?.payout; if (planPayoutRules) { splitBefore = planPayoutRules.profitSplitInitialPct ?? 100; splitThreshold = planPayoutRules.profitSplitInitialAmount ?? 0; splitAfter = planPayoutRules.profitSplitAfterPct ?? 90; if (splitThreshold === 0 && splitBefore === splitAfter) { splitThreshold = Infinity; // No threshold, always same split } } // Tier threshold is lifetime across all accounts for this firm (e.g. TopStep first $10K at 100%) const firmLifetimeTotal = payouts.reduce((sum, p) => { if (p.type !== 'withdrawal') return sum; const acct = accounts.find(a => a.id === p.accountId); if (!acct || acct.propFirm !== payoutSelectedAccount?.propFirm) return sum; return sum + (p.amount || 0); }, 0); const splitNoteEl = document.getElementById('profit-split-note'); if (splitNoteEl) { if (splitThreshold === Infinity) { splitNoteEl.innerHTML = '✅ 100% profit split (always!)'; } else if (splitThreshold === 0) { splitNoteEl.innerHTML = `${splitAfter}/${100-splitAfter} profit split with prop firm`; } else if (firmLifetimeTotal >= splitThreshold) { splitNoteEl.innerHTML = `⚠️ Over ${formatCurrency(splitThreshold)} lifetime - now at ${splitAfter}% profit split`; } else { const remaining = splitThreshold - firmLifetimeTotal; splitNoteEl.innerHTML = `Currently at ${splitBefore}% profit split
${formatCurrency(remaining)} until ${splitAfter}% split kicks in`; } } } function calculatePayout() { const propFirm = payoutSettings.propFirm || 'apex'; calculateGenericPayout(propFirm); } // Toggle payout rules override - bypasses ALL restrictions except available balance function togglePayoutOverride() { const isOverride = document.getElementById('payout-override-toggle').checked; const maxPayoutDisplay = document.getElementById('calc-max-payout'); const maxPayoutInput = document.getElementById('max-payout-override-input'); const profitSplitDisplay = document.getElementById('apex-profit-split'); const profitSplitInput = document.getElementById('profit-split-override-input'); const eligibilityWarning = document.getElementById('eligibility-warning'); const recordBtn = document.getElementById('record-withdrawal-btn'); const timingTradingDays = document.getElementById('timing-trading-days'); const timingWindows = document.getElementById('timing-windows'); if (isOverride) { // Show input, hide display for max payout maxPayoutDisplay.style.display = 'none'; maxPayoutInput.style.display = 'block'; // Show profit split dropdown, hide display if (profitSplitDisplay) profitSplitDisplay.style.display = 'none'; if (profitSplitInput) { profitSplitInput.style.display = 'block'; // Set dropdown to current value const currentSplit = parseInt(profitSplitDisplay?.textContent) || 100; profitSplitInput.value = currentSplit.toString(); } // Get remaining available balance (max they can actually withdraw) const remainingText = document.getElementById('calc-remaining')?.textContent || '$0.00'; const remaining = parseFloat(remainingText.replace(/[^0-9.-]/g, '')) || 0; // Set input to remaining available (no cap when overriding) maxPayoutInput.value = remaining.toFixed(2); // Hide eligibility warning if (eligibilityWarning) eligibilityWarning.style.display = 'none'; // Hide timing displays (trading days, windows) if (timingTradingDays) timingTradingDays.style.opacity = '0.3'; if (timingWindows) timingWindows.style.opacity = '0.3'; // Enable record button if (recordBtn) { recordBtn.disabled = false; recordBtn.style.opacity = '1'; recordBtn.style.cursor = 'pointer'; recordBtn.innerHTML = '💸 Record Withdrawal (Override)'; recordBtn.style.background = 'var(--orange)'; } // Recalculate when override value changes maxPayoutInput.addEventListener('input', recalculateWithOverride); // Initial calculation with override recalculateWithOverride(); } else { // Show display, hide input for max payout maxPayoutDisplay.style.display = 'inline'; maxPayoutInput.style.display = 'none'; maxPayoutInput.removeEventListener('input', recalculateWithOverride); // Show display, hide dropdown for profit split if (profitSplitDisplay) profitSplitDisplay.style.display = 'inline'; if (profitSplitInput) profitSplitInput.style.display = 'none'; // Restore timing displays if (timingTradingDays) timingTradingDays.style.opacity = '1'; if (timingWindows) timingWindows.style.opacity = '1'; // Restore record button style if (recordBtn) { recordBtn.style.background = ''; } // Recalculate with normal rules (this will also re-check eligibility) renderPayoutTracker(); } } function recalculateWithOverride() { const overrideValue = parseFloat(document.getElementById('max-payout-override-input').value) || 0; // Get remaining available from display const remainingText = document.getElementById('calc-remaining')?.textContent || '$0.00'; const remaining = parseFloat(remainingText.replace(/[^0-9.-]/g, '')) || 0; // Use override value as max, but cap at remaining available // This is the GROSS amount (withdrawn from account) const maxThisPayout = Math.min(overrideValue, remaining); // Update suggested amount (show gross in calc-net) const calcNetEl = document.getElementById('calc-net'); if (calcNetEl) { calcNetEl.textContent = formatCurrency(maxThisPayout); calcNetEl.dataset.amount = maxThisPayout.toFixed(2); } // Update withdrawal input with GROSS amount const withdrawalInput = document.getElementById('withdrawal-amount'); if (withdrawalInput) { withdrawalInput.value = maxThisPayout.toFixed(2); } // Update net display (shows what you'll receive after split) updateNetDisplay(); } // Update the "You'll Receive" display when user enters withdrawal amount (gross) window.updateNetDisplay = function() { const grossAmount = parseFloat(document.getElementById('withdrawal-amount')?.value) || 0; const netDisplay = document.getElementById('net-amount-display'); const calcNet = document.getElementById('calc-net-receive'); const splitDisplay = document.getElementById('split-display'); if (!netDisplay || !calcNet) return; // Get profit split const isOverride = document.getElementById('payout-override-toggle')?.checked || false; let profitSplit = 100; if (isOverride) { const splitInput = document.getElementById('profit-split-override-input'); profitSplit = parseInt(splitInput?.value) || 100; } else { const splitPct = document.getElementById('apex-profit-split')?.textContent || '100%'; profitSplit = parseInt(splitPct) || 100; } // Calculate net (what you actually receive after split) if (profitSplit < 100 && grossAmount > 0) { const netAmount = grossAmount * (profitSplit / 100); calcNet.textContent = formatCurrency(netAmount); if (splitDisplay) splitDisplay.textContent = `${profitSplit}/${100 - profitSplit}`; netDisplay.style.display = 'block'; } else { netDisplay.style.display = 'none'; } // Update MLL Safety preview in real time updateMllSafetyPreview(grossAmount); // --- Fix #2: Update "This payout" in breakdown to reflect what user typed --- const calcRemainingEl = document.getElementById('calc-remaining'); if (calcRemainingEl) { calcRemainingEl.textContent = formatCurrency(grossAmount > 0 ? grossAmount : (_maxAvailablePayout || 0)); } // Update the "You receive" section in left column (payout-net-receive-row) to reflect typed amount const leftNetReceiveRow = document.getElementById('payout-net-receive-row'); const leftNetReceiveDisplay = document.getElementById('payout-net-receive-display'); if (leftNetReceiveRow && leftNetReceiveDisplay) { if (profitSplit < 100 && grossAmount > 0) { leftNetReceiveDisplay.textContent = formatCurrency(grossAmount * (profitSplit / 100)); leftNetReceiveRow.style.display = 'block'; } else { leftNetReceiveRow.style.display = 'none'; } } // --- Fix #1: Validate withdrawal amount does not exceed Maximum Available --- const isOverrideActive = document.getElementById('payout-override-toggle')?.checked || false; const withdrawalInput = document.getElementById('withdrawal-amount'); const recordBtn = document.getElementById('record-withdrawal-btn'); const hintEl = document.getElementById('withdrawal-hint'); const exceedsMax = !isOverrideActive && _maxAvailablePayout !== null && grossAmount > _maxAvailablePayout && grossAmount > 0; if (exceedsMax) { if (withdrawalInput) { withdrawalInput.style.borderColor = 'var(--red)'; withdrawalInput.style.color = 'var(--red)'; } if (hintEl) { hintEl.textContent = `Amount exceeds maximum available (${formatCurrency(_maxAvailablePayout)})`; hintEl.style.color = 'var(--red)'; } if (recordBtn) { recordBtn.disabled = true; recordBtn.style.opacity = '0.5'; recordBtn.style.cursor = 'not-allowed'; recordBtn.innerHTML = '🚫 Exceeds Maximum Available'; recordBtn.style.background = ''; } } else { if (withdrawalInput) { withdrawalInput.style.borderColor = 'var(--cyan)'; withdrawalInput.style.color = 'var(--cyan)'; } if (hintEl) { hintEl.textContent = 'Enter up to your max available. Your profit split is applied automatically.'; hintEl.style.color = ''; } if (recordBtn) { if (_eligibilityDisabled) { recordBtn.disabled = true; recordBtn.style.opacity = '0.5'; recordBtn.style.cursor = 'not-allowed'; recordBtn.innerHTML = '🚫 Not Yet Eligible'; recordBtn.style.background = ''; } else { recordBtn.disabled = false; recordBtn.style.opacity = '1'; recordBtn.style.cursor = 'pointer'; recordBtn.innerHTML = '💸 Record Withdrawal'; recordBtn.style.background = ''; } } } }; function updateMllSafetyPreview(grossAmount) { if (_baselineMllDist === null || _baselineMllDrawdown === null) return; const distEl = document.getElementById('payout-mll-distance'); const barEl = document.getElementById('payout-mll-bar'); const barLabelEl = document.getElementById('payout-mll-bar-label'); if (!distEl) return; const previewDist = _baselineMllDist - (grossAmount || 0); const drawdown = _baselineMllDrawdown; const pct = Math.min(100, Math.max(0, (previewDist / drawdown) * 100)); const color = pct > 50 ? 'var(--green)' : (pct > 25 ? 'var(--orange)' : 'var(--red)'); distEl.textContent = formatCurrency(previewDist); distEl.style.color = color; if (barEl) { barEl.style.width = pct + '%'; barEl.style.background = color; } if (barLabelEl) barLabelEl.textContent = `${formatCurrency(previewDist)} buffer · ${pct.toFixed(0)}% safe`; } function calculateGenericPayout(propFirm) { // Get selected account — all calculations scoped to this account const calcAcctSelect = document.getElementById('payout-account'); const calcSelectedAccount = accounts.find(a => a.id === calcAcctSelect?.value) || accounts[0]; // Use account's actual prop firm for rules lookup const actualFirm = calcSelectedAccount?.propFirm || propFirm || 'apex'; const firmConfig = propFirmConfigs[actualFirm] || propFirmConfigs[actualFirm.toLowerCase()] || {}; const acctPlan = calcSelectedAccount?.plan || ''; const configLookupSize = calcSelectedAccount?.startingBalance || calcSelectedAccount?.accountSize || null; const accountConfig = configLookupSize ? getAccountConfig(firmConfig, configLookupSize, acctPlan) : null; if (!accountConfig) { console.warn('[Firestore Gap] No account config for', calcSelectedAccount?.propFirm, configLookupSize, acctPlan, '— payout calculator cannot run'); return; } const effectiveConfig = calcSelectedAccount ? getPlanSpecificConfig(firmConfig, calcSelectedAccount) : firmConfig; // Trades scoped to selected account only const accountTrades = calcSelectedAccount ? trades.filter(t => t.accountId === calcSelectedAccount.id) : []; const totalPnl = accountTrades.reduce((sum, t) => sum + getNetPnl(t), 0); // Starting balance with TopStep zero-start handling let startingBal = parseFloat(calcSelectedAccount?.startingBalance) || parseFloat(calcSelectedAccount?.balance) || 0; if (firmConfig?.fundedStartsAtZero && calcSelectedAccount?.stage === 'funded') startingBal = 0; // Buffer from config (or UI override) — mirrors sidebar renderPayoutCards logic exactly const overrideOn = document.getElementById('override-defaults')?.checked; const bufferInputVal2 = document.getElementById('payout-buffer')?.value; const bufferBase = accountConfig?.buffer !== undefined ? accountConfig.buffer : 0; const bufferFromConfig = bufferBase === 0 ? (accountConfig?.drawdownLocksAtOffset ?? firmConfig?.drawdownLockOffset ?? 0) : bufferBase; const buffer = overrideOn && bufferInputVal2 !== '' && bufferInputVal2 !== undefined ? parseInt(bufferInputVal2) || 0 : bufferFromConfig; // Caps from config (null = no per-payout cap) const configCaps = accountConfig?.caps || null; const caps = overrideOn ? [ parseInt(document.getElementById('payout-cap-1')?.value) || 0, parseInt(document.getElementById('payout-cap-2')?.value) || 0, parseInt(document.getElementById('payout-cap-3')?.value) || 0, parseInt(document.getElementById('payout-cap-4')?.value) || 0, parseInt(document.getElementById('payout-cap-5')?.value) || 0 ] : configCaps; // Withdrawals scoped to selected account only const withdrawals = payouts.filter(p => { if (p.type !== 'withdrawal') return false; if (p.accountId && p.accountId === calcSelectedAccount?.id) return true; if (p.accountName && p.accountName === calcSelectedAccount?.name) return true; return false; }); const alreadyWithdrawnGross = withdrawals.reduce((sum, p) => { if (p.profitSplit && p.profitSplit < 100) { if (p.grossAmount && Math.abs(p.grossAmount - p.amount) > 0.01) return sum + p.grossAmount; return sum + (p.amount / (p.profitSplit / 100)); } return sum + (p.grossAmount || p.amount || 0); }, 0); const alreadyWithdrawnNet = withdrawals.reduce((s,p) => s + (p.amount || 0), 0); // Tier threshold is lifetime across all accounts for this firm (e.g. TopStep first $10K at 100%) const firmWithdrawnNet = payouts.reduce((sum, p) => { if (p.type !== 'withdrawal') return sum; const acct = accounts.find(a => a.id === p.accountId); if (!acct || acct.propFirm !== calcSelectedAccount?.propFirm) return sum; return sum + (p.amount || 0); }, 0); const availableAfterBuffer = Math.max(0, totalPnl - buffer); const remaining = Math.max(0, availableAfterBuffer - alreadyWithdrawnGross); const payoutCount = withdrawals.length; const currentBalance = startingBal + totalPnl - alreadyWithdrawnGross; // Profit split from Firestore rulesByPlan let profitSplit = 100; const planPayoutRules = effectiveConfig?.rulesByPlan?.[acctPlan]?.payout || effectiveConfig?.rulesByPlan?.[Object.keys(effectiveConfig?.rulesByPlan || {})[0]]?.payout; if (planPayoutRules) { const initPct = planPayoutRules.profitSplitInitialPct ?? 100; const initAmt = planPayoutRules.profitSplitInitialAmount ?? 0; const afterPct = planPayoutRules.profitSplitAfterPct ?? 90; profitSplit = (initAmt > 0 && firmWithdrawnNet >= initAmt) ? afterPct : initPct; } // Max withdrawal pct/amt from plan-specific rules const calcMaxPct = effectiveConfig?.maxWithdrawalPct ?? 100; const calcMaxAmt = effectiveConfig?.maxWithdrawalAmt ?? Infinity; // Apply percentage cap let cappedRemaining = remaining; if (calcMaxPct < 100 && currentBalance > 0) { cappedRemaining = Math.min(remaining, currentBalance * (calcMaxPct / 100)); } let suggestedGross = 0; let maxThisPayout = 0; { const perPayoutCap = (caps && payoutCount < caps.length && caps[payoutCount] > 0) ? caps[payoutCount] : Infinity; maxThisPayout = Math.min(perPayoutCap, cappedRemaining); if (calcMaxAmt < Infinity) maxThisPayout = Math.min(maxThisPayout, calcMaxAmt); suggestedGross = maxThisPayout; } // Store cap-aware max for live input validation _maxAvailablePayout = maxThisPayout; // Update UI document.getElementById('calc-pnl').textContent = formatCurrency(totalPnl); document.getElementById('calc-pnl').className = totalPnl >= 0 ? 'positive' : 'negative'; document.getElementById('calc-buffer').textContent = `-${formatCurrency(buffer)}`; document.getElementById('calc-buffer').parentElement.style.display = buffer > 0 ? 'flex' : 'none'; document.getElementById('calc-available').textContent = formatCurrency(availableAfterBuffer); document.getElementById('calc-available').parentElement.querySelector('span:first-child').textContent = buffer > 0 ? 'Available After Buffer:' : 'Available to Withdraw:'; document.getElementById('calc-withdrawn').textContent = `-${formatCurrency(alreadyWithdrawnGross)}`; document.getElementById('calc-remaining').textContent = formatCurrency(remaining); const profitSplitEl = document.getElementById('apex-profit-split'); if (profitSplitEl) { profitSplitEl.textContent = profitSplit + '%'; profitSplitEl.style.color = profitSplit === 100 ? 'var(--green)' : 'var(--orange)'; } const capNoteEl = document.getElementById('apex-cap-note'); if (capNoteEl) { if (payoutCount >= 5) { capNoteEl.textContent = 'No cap - unlimited withdrawals'; } else { capNoteEl.textContent = `Payout #${payoutCount + 1} max: ${formatCurrency(maxThisPayout)}`; } } // Update payout number display const payoutNumberEl = document.getElementById('calc-payout-number'); if (payoutNumberEl) { payoutNumberEl.textContent = payoutCount + 1; } // Update max payout display const maxPayoutEl = document.getElementById('calc-max-payout'); if (maxPayoutEl) { maxPayoutEl.textContent = formatCurrency(maxThisPayout); } // Update payout request display - show GROSS (what to withdraw from account) const calcNetEl = document.getElementById('calc-net'); if (calcNetEl) { calcNetEl.textContent = formatCurrency(suggestedGross); calcNetEl.dataset.amount = suggestedGross.toFixed(2); } // Update max available display const maxAvailEl = document.getElementById('max-available-display'); if (maxAvailEl) { maxAvailEl.textContent = formatCurrency(remaining); } // Set withdrawal amount to suggested GROSS — always update on account change const withdrawalInput = document.getElementById('withdrawal-amount'); if (withdrawalInput) { withdrawalInput.value = suggestedGross.toFixed(2); } // Update net display to show what they'll receive updateNetDisplay(); // Update the hero "Maximum Available This Payout" display const maxThisPayoutEl = document.getElementById('payout-max-this-payout'); if (maxThisPayoutEl) { maxThisPayoutEl.textContent = formatCurrency(suggestedGross); } // Update profit split visual in Section B const netReceiveRow = document.getElementById('payout-net-receive-row'); const netReceiveDisplay = document.getElementById('payout-net-receive-display'); const splitBarYou = document.getElementById('payout-split-bar-you'); const splitBarFirm = document.getElementById('payout-split-bar-firm'); const splitYouLabel = document.getElementById('payout-split-you-label'); const splitFirmLabel = document.getElementById('payout-split-firm-label'); if (netReceiveRow) { if (profitSplit < 100) { const netAmount = suggestedGross * (profitSplit / 100); netReceiveRow.style.display = 'block'; if (netReceiveDisplay) netReceiveDisplay.textContent = formatCurrency(netAmount); if (splitBarYou) splitBarYou.style.flex = profitSplit; if (splitBarFirm) splitBarFirm.style.flex = 100 - profitSplit; if (splitYouLabel) splitYouLabel.textContent = profitSplit + '%'; if (splitFirmLabel) splitFirmLabel.textContent = (100 - profitSplit) + '%'; } else { netReceiveRow.style.display = 'none'; } } // Set default withdrawal date to today const dateInput = document.getElementById('withdrawal-date'); if (dateInput && !dateInput.value) { const today = new Date(); dateInput.value = today.toISOString().split('T')[0]; } } function renderRecentPayouts() { const container = document.getElementById('recent-payouts'); if (!container) return; // Section removed from UI const recentWithdrawals = payouts.filter(p => { if (p.type !== 'withdrawal') return false; return true; }).slice(-5).reverse(); if (recentWithdrawals.length === 0) { container.innerHTML = '
No payouts yet
'; return; } container.innerHTML = recentWithdrawals.map(p => { // Parse date properly let dateDisplay; if (typeof p.date === 'string' && p.date.match(/^\d{4}-\d{2}-\d{2}$/)) { const [year, month, day] = p.date.split('-').map(Number); dateDisplay = new Date(year, month - 1, day).toLocaleDateString('en-US', {month: 'short', day: 'numeric'}); } else { dateDisplay = new Date(p.date).toLocaleDateString('en-US', {month: 'short', day: 'numeric'}); } return `
${dateDisplay} ${formatCurrency(p.amount)}
`}).join(''); // Re-apply streamer mode blur to new elements if (streamerMode) applySensitiveBlur(); } function renderPayoutCalendar() { // Show two months: current and next const year1 = currentPayoutMonth.getFullYear(); const month1 = currentPayoutMonth.getMonth(); const nextMonth = new Date(year1, month1 + 1, 1); const year2 = nextMonth.getFullYear(); const month2 = nextMonth.getMonth(); document.getElementById('payout-cal-month').textContent = `${currentPayoutMonth.toLocaleDateString('en-US', { month: 'short', year: 'numeric' })} - ${nextMonth.toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}`; // Get prop firm config for timing rules const propFirm = payoutSettings.propFirm || 'apex'; const firmConfig = propFirmConfigs[propFirm]; const timing = firmConfig?.payoutTiming || { type: 'anytime' }; // Group payouts by date const payoutsByDate = {}; const sortedPayouts = payouts.filter(p => { if (p.type !== 'withdrawal') return false; return true; }).sort((a, b) => new Date(a.date) - new Date(b.date)); sortedPayouts.forEach(p => { const d = parseLocalDate(p.date); const date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; payoutsByDate[date] = (payoutsByDate[date] || 0) + p.amount; }); // Calculate projected/eligible dates based on prop firm timing rules const projectedDates = new Set(); const windowDates = new Set(); if (timing.type === 'tradingDays' && (timing.days ?? 0) > 0) { const requiredDays = timing.days; const lastPayout = sortedPayouts.length > 0 ? sortedPayouts[sortedPayouts.length - 1] : null; if (lastPayout) { const lastPayoutDate = parseLocalDate(lastPayout.date); let tradingDays = 0; let checkDate = new Date(lastPayoutDate); while (tradingDays < requiredDays) { checkDate.setDate(checkDate.getDate() + 1); const dayOfWeek = checkDate.getDay(); if (dayOfWeek !== 0 && dayOfWeek !== 6) { tradingDays++; } } // Calculate projected future dates for both months let projDate = new Date(checkDate); const month2End = new Date(year2, month2 + 1, 0); while (projDate <= month2End) { const projKey = `${projDate.getFullYear()}-${String(projDate.getMonth() + 1).padStart(2, '0')}-${String(projDate.getDate()).padStart(2, '0')}`; if (!payoutsByDate[projKey]) { projectedDates.add(projKey); } let tradingDaysCount = 0; while (tradingDaysCount < requiredDays) { projDate.setDate(projDate.getDate() + 1); const dow = projDate.getDay(); if (dow !== 0 && dow !== 6) tradingDaysCount++; } } } } else if (timing.type === 'windows') { const windows = timing.windows || [[1,4], [11,14], [21,24]]; // Apply windows for both months [{ year: year1, month: month1 }, { year: year2, month: month2 }].forEach(({ year, month }) => { const daysInMonth = new Date(year, month + 1, 0).getDate(); for (let day = 1; day <= daysInMonth; day++) { for (const [start, end] of windows) { if (day >= start && day <= end) { const dateKey = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; if (!payoutsByDate[dateKey]) { windowDates.add(dateKey); } break; } } } }); } const today = getTodayKey(); const todayDate = new Date(); // Helper to render a single month with weekly totals function renderMonth(year, month) { const firstDay = new Date(year, month, 1); const lastDay = new Date(year, month + 1, 0); const startPad = firstDay.getDay(); const daysInMonth = lastDay.getDate(); const monthName = firstDay.toLocaleDateString('en-US', { month: 'long', year: 'numeric' }); // Calculate month total let monthTotal = 0; for (let day = 1; day <= daysInMonth; day++) { const dateKey = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; if (payoutsByDate[dateKey]) { monthTotal += payoutsByDate[dateKey]; } } // Build grid data with weekly totals const rows = []; let currentRow = []; let weekTotal = 0; // Previous month padding for (let i = 0; i < startPad; i++) { const d = new Date(year, month, -(startPad - i - 1)); currentRow.push({ type: 'other', day: d.getDate(), payout: 0 }); } // Current month days for (let day = 1; day <= daysInMonth; day++) { const dateKey = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; const payout = payoutsByDate[dateKey] || 0; const isProjected = projectedDates.has(dateKey); const isWindow = windowDates.has(dateKey); const isToday = dateKey === today; const dateObj = new Date(year, month, day); const isFuture = dateObj >= todayDate; weekTotal += payout; currentRow.push({ type: 'current', day, payout, isProjected: (isProjected || isWindow) && isFuture, isToday }); // End of week (Saturday) - push row with weekly total if (currentRow.length === 7) { rows.push({ days: currentRow, weekTotal }); currentRow = []; weekTotal = 0; } } // Next month padding if (currentRow.length > 0) { let nextDay = 1; while (currentRow.length < 7) { currentRow.push({ type: 'other', day: nextDay++, payout: 0 }); } rows.push({ days: currentRow, weekTotal }); } // Build HTML let html = `
${monthName}
${monthTotal > 0 ? formatCurrency(monthTotal) + ' total' : 'No payouts'}
`; // Day headers + Week Total header ['S', 'M', 'T', 'W', 'T', 'F', 'S'].forEach(day => { html += `
${day}
`; }); html += `
WEEK
`; // Render rows rows.forEach((row, rowIdx) => { row.days.forEach(cell => { let cellStyle = 'padding: 8px 4px; text-align: center; min-height: 60px; display: flex; flex-direction: column; justify-content: center; align-items: center;'; let content = ''; if (cell.type === 'other') { cellStyle += 'color: var(--text-muted); opacity: 0.4;'; content = `${cell.day}`; } else { if (cell.payout > 0) { cellStyle += 'background: var(--green); color: white;'; content = `${cell.day} ${formatCurrency(cell.payout)}`; } else if (cell.isProjected) { cellStyle += 'background: var(--yellow); color: #000;'; content = `${cell.day}`; } else { content = `${cell.day}`; } if (cell.isToday) { cellStyle += 'box-shadow: inset 0 0 0 2px var(--orange);'; } } html += `
${content}
`; }); // Weekly total column const weekTotalStyle = row.weekTotal > 0 ? 'background: var(--green-bg); color: var(--green); font-weight: 600;' : 'background: var(--bg-card); color: var(--text-muted);'; html += `
${row.weekTotal > 0 ? formatCurrency(row.weekTotal) : '-'}
`; }); html += '
'; return html; } // Render both months side by side let html = '
'; html += renderMonth(year1, month1); html += renderMonth(year2, month2); html += '
'; document.getElementById('payout-calendar').innerHTML = html; } function renderPayoutHistory() { const container = document.getElementById('payout-history-list'); if (!container) return; const withdrawals = payouts .map((p, idx) => ({ ...p, originalIndex: idx })) .filter(p => p.type === 'withdrawal') .sort((a, b) => new Date(b.date) - new Date(a.date)); // Update count label and collapsible state const countEl = document.getElementById('payout-history-count'); const collapsible = document.getElementById('payout-history-collapsible'); if (countEl) countEl.textContent = withdrawals.length > 0 ? `(${withdrawals.length})` : ''; if (collapsible) { // Default: expanded if 0 payouts (shows placeholder), collapsed if >0 if (withdrawals.length === 0) { collapsible.style.display = 'block'; const arrow = document.getElementById('payout-history-arrow'); if (arrow) arrow.textContent = '▴'; } } if (withdrawals.length === 0) { container.innerHTML = '
No payouts recorded yet
'; return; } let html = ''; withdrawals.forEach((p, idx) => { const d = parseLocalDate(p.date); const dateStr = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); // Use stored accountName first, then lookup by ID, then show Unknown const account = accounts.find(a => a.id === p.accountId); const accountName = p.accountName || (account ? account.name : null); const hasAccountLink = p.accountId || p.accountName; // Calculate gross if split exists let grossDisplay = ''; if (p.profitSplit && p.profitSplit < 100) { const grossAmt = p.grossAmount || (p.amount / (p.profitSplit / 100)); if (grossAmt > p.amount + 0.01) { grossDisplay = `
(${formatCurrency(grossAmt)} gross, ${p.profitSplit}%)
`; } } html += `
${dateStr}
${accountName || '⚠️ No account linked'}
${formatCurrency(p.amount)} ${!hasAccountLink ? `` : ''}
${grossDisplay}
`; }); container.innerHTML = html; // Re-apply streamer mode blur to dynamically rendered elements if (streamerMode) applySensitiveBlur(); } window.linkPayoutToAccount = async function(payoutIndex) { const propAccounts = accounts.filter(a => a.propFirm); if (propAccounts.length === 0) { alert('No prop firm accounts found. Add an account in Settings first.'); return; } const accountOptions = propAccounts.map(a => `${a.name} (${propFirmNames[a.propFirm] || a.propFirm})`).join('\n'); const selection = prompt(`Link this payout to which account?\n\nAvailable accounts:\n${accountOptions}\n\nEnter the account name:`); if (!selection) return; const matchedAccount = propAccounts.find(a => a.name.toLowerCase() === selection.toLowerCase() || a.name.toLowerCase().includes(selection.toLowerCase()) ); if (!matchedAccount) { alert('Account not found. Please enter the exact account name.'); return; } const _prevAcctId = payouts[payoutIndex].accountId; const _prevAcctName = payouts[payoutIndex].accountName; payouts[payoutIndex].accountId = matchedAccount.id; payouts[payoutIndex].accountName = matchedAccount.name; try { await savePayouts(); } catch (saveError) { payouts[payoutIndex].accountId = _prevAcctId; payouts[payoutIndex].accountName = _prevAcctName; console.error('[Link Payout] Save failed:', saveError); showNotification(`Failed to link payout: ${saveError?.message || saveError}`, 'error'); renderPayoutHistory(); renderDashboard(); renderROITracker(); return; } renderPayoutHistory(); renderDashboard(); renderROITracker(); showNotification(`Payout linked to ${matchedAccount.name}`, 'success'); }; window.deletePayout = async function(payoutIndex) { if (!confirm('Delete this payout record?')) return; const removed = payouts[payoutIndex]; try { payouts.splice(payoutIndex, 1); await savePayouts(); renderPayoutTracker(); renderPayoutHistory(); renderDashboard(); renderROITracker(); showNotification('Payout deleted', 'success'); } catch (error) { if (removed !== undefined) payouts.splice(payoutIndex, 0, removed); console.error('Error deleting payout:', error); showNotification(`Failed to delete payout: ${error?.message || error}`, 'error'); renderPayoutTracker(); renderPayoutHistory(); renderDashboard(); renderROITracker(); } }; // ── Discipline & Process scorecard — scoring core ──────────────────────────── // FIVE components: routine + journal (always-on), plus three N/A-aware trade/review // components — setupTagging, scorecardReview, weeklyReview. Each new one is excluded // from the score (numerator AND denominator) when not applicable (N/A). The earlier // AUTO-GRADED trade rules (maxTrades/tradingWindow/lossLimit/qualityMin) stay removed — // scaling in/out of positions makes per-trade counting unreliable; these three measure // deliberate review behavior (tagging, journaling, weekly reflection), not trade math. const PROCESS_WEIGHTS = { routine: 20, journal: 15, setupTagging: 15, scorecardReview: 15, weeklyReview: 15 }; // Boolean "did the user complete their ENTIRE pre-market checklist on this date?" — the // single source of truth shared by the binary consumers (Rule Adherence Streak, checklist // achievements, completion-correlation analytics) and the weekly panel. Reads the SAME // source as the gauge routine: today = live checklistChecked, past = saved // checklistCompletionsByDate[date]. NOT the legacy sc.checklistComplete flag (which is // permanently false for custom-template users). The gauge routine keeps its own // checked/total count for PARTIAL credit; this helper is the all-or-nothing boolean. // (DEFINED in Stage 2 of the scorecard promotion; not yet CALLED. checklistCompletionsByDate // is added with the loadAllData routine-bridge in a later stage — until then this is only // safe to call for today's key, which reads checklistChecked.) function isChecklistCompleteForDate(dateKey) { const totalItems = checklistTemplates.reduce((s, t) => s + ((t.items || []).length), 0); if (totalItems === 0) return false; // no checklist configured → not "complete" const completion = (dateKey === getTodayKeyTZ()) ? checklistChecked : (checklistCompletionsByDate[dateKey] || {}); return checklistTemplates.every(t => (t.items || []).every(it => completion[it.id] === true)); } function computeProcessComponents(dateKey) { const todayKey = getTodayKeyTZ(); // Today reads the live in-memory checkboxes (instant feedback); history reads the // saved completion doc. Same data shape either way: { itemId: true }. const completion = (dateKey === todayKey) ? checklistChecked : (checklistCompletionsByDate[dateKey] || {}); const components = []; // 1. routine — % of the user's pre-market checklist completed (ALWAYS enabled) const totalItems = checklistTemplates.reduce((s, t) => s + ((t.items || []).length), 0); if (totalItems > 0) { const checkedItems = checklistTemplates.reduce( (s, t) => s + (t.items || []).filter(it => completion[it.id] === true).length, 0); const frac = checkedItems / totalItems; components.push({ key: 'routine', label: 'Pre-market routine', weight: PROCESS_WEIGHTS.routine, applicable: true, passFraction: frac, state: checkedItems === totalItems ? 'pass' : (checkedItems > 0 ? 'partial' : 'fail'), detail: `${checkedItems}/${totalItems} · ${Math.round(frac * 100)}%` }); } else { // No checklist templates exist → nothing to measure; surface, don't score 0/0. components.push({ key: 'routine', label: 'Pre-market routine', weight: PROCESS_WEIGHTS.routine, applicable: false, state: 'unconfigured', detail: 'No checklist configured' }); } // 2. journal — entry exists for the date (ALWAYS enabled) const journaled = !!journal[dateKey]; components.push({ key: 'journal', label: 'Daily journal entry', weight: PROCESS_WEIGHTS.journal, applicable: true, passFraction: journaled ? 1 : 0, state: journaled ? 'pass' : 'fail' }); // dayTrades — metrics-stable, view-independent subset for this date (re-activates // getDisciplineTrades, its only caller). Excludes archived / YOLO / pre-earliest trades. const dayTrades = getDisciplineTrades().filter(t => getDateKey(t.exitTime) === dateKey); // 3. setupTagging — did the user tag each trade with a playbook setup? // Signal per trade: tradeNotes[t.id].setupId (NOT the dead t.setup field). if (dayTrades.length === 0) { components.push({ key: 'setupTagging', label: 'Setups assigned', weight: PROCESS_WEIGHTS.setupTagging, applicable: false, state: 'na', detail: 'no trades' }); } else { const tagged = dayTrades.filter(t => !!(tradeNotes[t.id] && tradeNotes[t.id].setupId)).length; const frac = tagged / dayTrades.length; components.push({ key: 'setupTagging', label: 'Setups assigned', weight: PROCESS_WEIGHTS.setupTagging, applicable: true, passFraction: frac, state: tagged === dayTrades.length ? 'pass' : (tagged > 0 ? 'partial' : 'fail'), detail: `${tagged}/${dayTrades.length}` }); } // 4. scorecardReview — did the user make at least one manual scorecard entry? // Scorecard entries are NOT 1:1 with real trades, so this measures "a review entry // was made", NOT per-trade coverage. N/A on no-trade days (no review expected). if (dayTrades.length === 0) { components.push({ key: 'scorecardReview', label: 'Trades reviewed in scorecard', weight: PROCESS_WEIGHTS.scorecardReview, applicable: false, state: 'na', detail: 'no trades' }); } else { const entryCount = ((scorecards[dateKey] && scorecards[dateKey].trades) || []).length; const reviewed = entryCount > 0; components.push({ key: 'scorecardReview', label: 'Trades reviewed in scorecard', weight: PROCESS_WEIGHTS.scorecardReview, applicable: true, passFraction: reviewed ? 1 : 0, state: reviewed ? 'pass' : 'fail', detail: `${entryCount} ${entryCount === 1 ? 'entry' : 'entries'}` }); } // 5. weeklyReview — was the PRIOR week's review completed? Grades the most recently // COMPLETED week relative to dateKey (not the in-progress week), so it contributes to // "today" and the history curve grades each past day against ITS prior week. // Thursday-keyed (Sunday-start week + 4 days), mirroring the existing convention at // ~17890-17892 / ~24505. Existence == completion (per decision). // GRACE WINDOW: if dateKey is a Sunday, the user can still file last week's review today → N/A. // N/A is decided purely by dateKey's weekday — does NOT consult getTodayKeyTZ(). const _wkBase = parseLocalDate(dateKey); const _priorSunday = new Date(_wkBase); _priorSunday.setDate(_wkBase.getDate() - _wkBase.getDay() - 7); // prior week's Sunday const _priorSundayKey = `${_priorSunday.getFullYear()}-${String(_priorSunday.getMonth() + 1).padStart(2, '0')}-${String(_priorSunday.getDate()).padStart(2, '0')}`; const _priorThu = new Date(_priorSunday); _priorThu.setDate(_priorSunday.getDate() + 4); // prior week's Thursday (mirrors :17890-17892) const priorThuKey = `${_priorThu.getFullYear()}-${String(_priorThu.getMonth() + 1).padStart(2, '0')}-${String(_priorThu.getDate()).padStart(2, '0')}`; if (_wkBase.getDay() === 0) { // Sunday grace — last week's review can still be filed today. components.push({ key: 'weeklyReview', label: 'Weekly review completed', weight: PROCESS_WEIGHTS.weeklyReview, applicable: false, state: 'na', detail: 'review window open' }); } else { const done = !!weeklyReviews[priorThuKey]; components.push({ key: 'weeklyReview', label: 'Weekly review completed', weight: PROCESS_WEIGHTS.weeklyReview, applicable: true, passFraction: done ? 1 : 0, state: done ? 'pass' : 'fail', detail: done ? `wk of ${_priorSundayKey}` : undefined }); } // Normalized score: weighted pass-fraction over APPLICABLE components only. // unconfigured contributes to neither numerator nor denominator. const applicable = components.filter(c => c.applicable); const denom = applicable.reduce((s, c) => s + c.weight, 0); const numer = applicable.reduce((s, c) => s + c.weight * (c.passFraction || 0), 0); const percent = denom > 0 ? Math.round((numer / denom) * 100) : 0; return { components, percent, denom }; } function renderProcessPage() { const todayKey = getTodayKeyTZ(); const { components, percent, denom } = computeProcessComponents(todayKey); // ONE value (percent) drives text, ring fill, ring color, and message. const hasApplicable = denom > 0; const ring = document.getElementById('process-ring'); const circumference = 2 * Math.PI * 45; const color = percent >= 70 ? 'var(--green)' : percent >= 40 ? 'var(--yellow)' : 'var(--red)'; document.getElementById('process-score-value').textContent = hasApplicable ? percent : '—'; ring.style.strokeDashoffset = circumference - (hasApplicable ? percent / 100 : 0) * circumference; ring.style.stroke = hasApplicable ? color : 'var(--text-muted)'; const msg = document.getElementById('process-message'); if (!hasApplicable) { msg.textContent = 'No rules to evaluate yet'; msg.style.color = 'var(--text-muted)'; } else if (percent >= 80) { msg.textContent = 'Excellent discipline!'; msg.style.color = 'var(--green)'; } else if (percent >= 60) { msg.textContent = 'Good progress'; msg.style.color = 'var(--yellow)'; } else { msg.textContent = 'Focus on your rules'; msg.style.color = 'var(--red)'; } // Dynamic checklist lines (built from the active components) updateProcessChecklist(components); // Update streaks updateStreaks(); // Update achievements updateAchievements(); // Render Discipline Wins log renderDisciplineWins(); // Render Process Equity Curve renderProcessEquityCurve(); // Render Weekly Process Review renderWeeklyProcessReview(); // Render Past Weekly Reviews renderPastReviews(); // Render analytics (moved from separate page) renderAnalytics(); } // ── Discipline Wins (un-scored motivational log) ───────────────────────────── // Stored inside the userData doc as `disciplineWins`. Persisted FIELD-LEVEL via // { merge: true } (saveDisciplineWins) so it can never clobber sibling userData // fields (payouts, expenses, subscription, ...). Deliberately NOT wired into the // process score — purely a motivational record. Drives the win-based achievements. function openLogWinModal() { document.getElementById('log-win-text').value = ''; document.getElementById('log-win-date').value = getTodayKeyTZ(); document.getElementById('log-win-modal').style.display = 'flex'; document.getElementById('log-win-text').focus(); } function closeLogWinModal() { document.getElementById('log-win-modal').style.display = 'none'; } // Field-level persistence — mirrors savePayouts()/saveExpenses(). { merge: true } // guarantees only the disciplineWins field is written. async function saveDisciplineWins() { if (!currentUser) return; try { await labSafeFirestoreSet( db.collection('users').doc(currentUser.uid), { disciplineWins: disciplineWins }, { merge: true } ); } catch (error) { console.error('Error saving discipline wins:', error); throw error; } } async function saveDisciplineWin() { const text = (document.getElementById('log-win-text').value || '').trim().slice(0, 280); if (!text) { showNotification('Please describe your win first.', 'warning'); return; } const date = document.getElementById('log-win-date').value || getTodayKeyTZ(); const win = { id: 'win-' + Date.now() + '-' + Math.random().toString(36).substr(2, 5), text, date, createdAt: Date.now() }; disciplineWins.push(win); try { await saveDisciplineWins(); } catch (e) { // Roll back the in-memory push if the write failed — keep state and Firestore in sync. disciplineWins = disciplineWins.filter(w => w.id !== win.id); showNotification('Failed to save win. Please try again.', 'error'); return; } closeLogWinModal(); renderDisciplineWins(); updateAchievements(); showNotification('Discipline win logged 🏆', 'success'); } async function deleteDisciplineWin(id) { const idx = disciplineWins.findIndex(w => w.id === id); if (idx === -1) return; const removed = disciplineWins[idx]; disciplineWins.splice(idx, 1); try { await saveDisciplineWins(); } catch (e) { disciplineWins.splice(idx, 0, removed); // roll back on failure renderDisciplineWins(); showNotification('Failed to delete win. Please try again.', 'error'); return; } renderDisciplineWins(); updateAchievements(); } // Escape user-entered win text before it reaches innerHTML. function escapeWinText(s) { return String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } function renderDisciplineWins() { const container = document.getElementById('discipline-wins'); if (!container) return; const wins = Array.isArray(disciplineWins) ? disciplineWins : []; if (wins.length === 0) { // Restore the original empty-state placeholder styling + copy. container.style.color = 'var(--text-muted)'; container.style.textAlign = 'center'; container.style.padding = '20px'; container.textContent = 'No discipline wins logged yet. Click "+ Log Win" to record your first!'; return; } // Newest-first by createdAt (fallback 0 for any legacy entry without it). const ordered = wins.slice().sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0)); container.style.color = ''; container.style.textAlign = 'left'; container.style.padding = '0'; container.innerHTML = ordered.map(w => `
${escapeWinText(w.text)}
${formatDate(w.date)}
`).join(''); } function calculateDayProcessScore(dateKey) { // Same config-driven engine as the live gauge, reading the same checklistCompletions // source → the history curve and today's gauge always agree. Returns 0–100 percent. return computeProcessComponents(dateKey).percent; } function renderProcessEquityCurve() { // Get all dates with scorecards or journal entries const allDates = new Set([ ...Object.keys(scorecards), ...Object.keys(journal), ...trades.map(t => getDateKey(t.exitTime)) ]); const sortedDates = Array.from(allDates).sort(); // Calculate daily scores and cumulative let cumulative = 0; const dailyData = []; sortedDates.forEach(date => { const dayScore = calculateDayProcessScore(date); cumulative += dayScore; dailyData.push({ date, score: dayScore, cumulative }); }); // Calculate totals const todayKey = getTodayKey(); const todayData = dailyData.find(d => d.date === todayKey); const todayScore = todayData ? todayData.score : 0; // This week const today = new Date(); const weekStart = new Date(today); weekStart.setDate(today.getDate() - today.getDay()); // Start of week (Sunday) const weekStartKey = `${weekStart.getFullYear()}-${String(weekStart.getMonth() + 1).padStart(2, '0')}-${String(weekStart.getDate()).padStart(2, '0')}`; const weekScore = dailyData .filter(d => d.date >= weekStartKey) .reduce((sum, d) => sum + d.score, 0); // All time const allTimeScore = cumulative; // Update displays document.getElementById('process-alltime').textContent = allTimeScore; document.getElementById('process-week').textContent = weekScore; document.getElementById('process-today').textContent = todayScore; // Render chart const ctx = document.getElementById('process-chart'); if (!ctx) return; // Destroy existing chart if (window.processEquityChart) { window.processEquityChart.destroy(); window.processEquityChart = null; } if (dailyData.length === 0) { return; // No data to show } // Take last 30 days for chart const chartData = dailyData.slice(-30); window.processEquityChart = new Chart(ctx, { type: 'line', data: { labels: chartData.map(d => { // Parse YYYY-MM-DD as local time const [year, month, day] = d.date.split('-').map(Number); const date = new Date(year, month - 1, day); return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); }), datasets: [{ label: 'Cumulative Process Points', data: chartData.map(d => d.cumulative), borderColor: themeGreen(), backgroundColor: themeGreenBg(0.1), borderWidth: 2, fill: true, tension: 0.3, pointRadius: 2, pointHoverRadius: 5 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: { callbacks: { label: (ctx) => `Total: ${ctx.raw} pts` } } }, scales: { x: { grid: { color: 'rgba(255,255,255,0.05)' }, ticks: { color: '#9ca3af', maxTicksLimit: 7 } }, y: { grid: { color: 'rgba(255,255,255,0.05)' }, ticks: { color: '#9ca3af' }, beginAtZero: true } } } }); } function renderWeeklyProcessReview() { // Get this week's date range (Sunday to Saturday) const today = new Date(); const dayOfWeek = today.getDay(); // 0 = Sunday const weekStart = new Date(today); weekStart.setDate(today.getDate() - dayOfWeek); weekStart.setHours(0, 0, 0, 0); const weekEnd = new Date(weekStart); weekEnd.setDate(weekStart.getDate() + 6); weekEnd.setHours(23, 59, 59, 999); // Get last week's date range const lastWeekStart = new Date(weekStart); lastWeekStart.setDate(lastWeekStart.getDate() - 7); const lastWeekEnd = new Date(weekStart); lastWeekEnd.setDate(lastWeekEnd.getDate() - 1); lastWeekEnd.setHours(23, 59, 59, 999); // Helper to check if date is in range const isInWeek = (dateStr, start, end) => { // Parse YYYY-MM-DD as local time const [year, month, day] = dateStr.split('-').map(Number); const d = new Date(year, month - 1, day, 12, 0, 0); // Noon to avoid edge cases return d >= start && d <= end; }; // This week's scorecards const thisWeekScorecards = Object.entries(scorecards).filter(([date]) => isInWeek(date, weekStart, weekEnd) ); // Last week's scorecards const lastWeekScorecards = Object.entries(scorecards).filter(([date]) => isInWeek(date, lastWeekStart, lastWeekEnd) ); // Calculate this week's stats let thisWeekTrades = []; let thisWeekQualityTrades = 0; let thisWeekTotalScore = 0; let thisWeekTotalPoints = 0; let thisWeekChecklistDays = 0; thisWeekScorecards.forEach(([date, sc]) => { const trades = sc.trades || []; thisWeekTrades.push(...trades); trades.forEach(t => { const tradeScore = t.score || 0; thisWeekTotalScore += tradeScore; thisWeekTotalPoints += (t.totalPoints || getTotalConfluencePoints()); const thresholds = getGradeThresholds(t.totalPoints || getTotalConfluencePoints()); if (!t.grayCandles && tradeScore > thresholds.aggressive) { thisWeekQualityTrades++; } }); // Pre-market checklist days are computed below from checklistCompletions (the source // the Discipline gauge uses), NOT the legacy sc.checklistComplete flag. }); // This week's journal entries const thisWeekJournalDays = Object.keys(journal).filter(date => isInWeek(date, weekStart, weekEnd) ).length; // Calculate rates const thisWeekQualityRate = thisWeekTrades.length > 0 ? (thisWeekQualityTrades / thisWeekTrades.length * 100) : 0; const thisWeekAvgScore = thisWeekTrades.length > 0 ? (thisWeekTotalScore / thisWeekTrades.length) : 0; // Stored week-average denominator (avg of each trade's own totalPoints), mirroring the // avgTotal pattern at ~16898/17092 — NOT the live setup dropdown (which is 0 when no // setup is selected, producing the "8.5/0" bug). const thisWeekAvgTotal = thisWeekTrades.length > 0 ? (thisWeekTotalPoints / thisWeekTrades.length) : 0; // Pre-market checklist days completed this week — weekdays (Mon-Fri) only, from // week-start through today. Reads the SAME source as the Discipline gauge's routine // component: today = live checklistChecked, past = saved checklistCompletionsByDate[date]. // A day counts only when EVERY checklistTemplates item is true (mirrors // computeProcessComponents ~23531-23544). NOT the legacy sc.checklistComplete flag. const todayKeyTZ = getTodayKeyTZ(); for (let i = 0; i < 7; i++) { const d = new Date(weekStart); d.setDate(weekStart.getDate() + i); const dow = d.getDay(); if (dow < 1 || dow > 5) continue; // weekdays only (Mon-Fri) const dKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; if (dKey > todayKeyTZ) continue; // don't count days the week hasn't reached yet if (isChecklistCompleteForDate(dKey)) thisWeekChecklistDays++; } // Trading days this week (Mon-Fri that have passed) const tradingDaysPassed = Math.min(5, Math.max(0, dayOfWeek === 0 ? 5 : dayOfWeek === 6 ? 5 : dayOfWeek)); // Update this week's displays document.getElementById('weekly-quality-rate').textContent = `${thisWeekQualityRate.toFixed(0)}%`; document.getElementById('weekly-quality-bar').style.width = `${thisWeekQualityRate}%`; document.getElementById('weekly-confluence').textContent = `${thisWeekAvgScore.toFixed(1)}/${thisWeekAvgTotal.toFixed(0)}`; document.getElementById('weekly-confluence-bar').style.width = `${thisWeekAvgTotal > 0 ? (thisWeekAvgScore / thisWeekAvgTotal) * 100 : 0}%`; document.getElementById('weekly-journal').textContent = `${thisWeekJournalDays}/5`; document.getElementById('weekly-journal-bar').style.width = `${(thisWeekJournalDays / 5) * 100}%`; document.getElementById('weekly-checklist').textContent = `${thisWeekChecklistDays}/5`; document.getElementById('weekly-checklist-bar').style.width = `${(thisWeekChecklistDays / 5) * 100}%`; // Calculate last week's stats for comparison let lastWeekTrades = []; let lastWeekQualityTrades = 0; let lastWeekTotalScore = 0; lastWeekScorecards.forEach(([date, sc]) => { const trades = sc.trades || []; lastWeekTrades.push(...trades); trades.forEach(t => { const tradeScore = t.score || 0; lastWeekTotalScore += tradeScore; const thresholds = getGradeThresholds(t.totalPoints || getTotalConfluencePoints()); if (!t.grayCandles && tradeScore > thresholds.aggressive) { lastWeekQualityTrades++; } }); }); const lastWeekQualityRate = lastWeekTrades.length > 0 ? (lastWeekQualityTrades / lastWeekTrades.length * 100) : 0; const lastWeekAvgScore = lastWeekTrades.length > 0 ? (lastWeekTotalScore / lastWeekTrades.length) : 0; // Calculate quality streak for comparison const dates = Object.keys(scorecards).sort().reverse(); let currentStreak = 0; for (const date of dates) { const sc = scorecards[date]; if (!sc.trades || sc.trades.length === 0) continue; const allQuality = sc.trades.every(t => { const thresholds = getGradeThresholds(t.totalPoints || getTotalConfluencePoints()); return !t.grayCandles && (t.score || 0) > thresholds.aggressive; }); if (allQuality) currentStreak++; else break; } // Update VS LAST WEEK comparison const qualityDiff = thisWeekQualityRate - lastWeekQualityRate; const scoreDiff = thisWeekAvgScore - lastWeekAvgScore; const vsQuality = document.getElementById('vs-quality'); const vsScore = document.getElementById('vs-score'); const vsStreak = document.getElementById('vs-streak'); if (lastWeekTrades.length > 0 || thisWeekTrades.length > 0) { vsQuality.textContent = `${qualityDiff >= 0 ? '+' : ''}${qualityDiff.toFixed(0)}%`; vsQuality.style.color = qualityDiff >= 0 ? 'var(--green)' : 'var(--red)'; vsScore.textContent = `${scoreDiff >= 0 ? '+' : ''}${scoreDiff.toFixed(1)}`; vsScore.style.color = scoreDiff >= 0 ? 'var(--green)' : 'var(--red)'; } else { vsQuality.textContent = '--'; vsQuality.style.color = 'var(--text-muted)'; vsScore.textContent = '--'; vsScore.style.color = 'var(--text-muted)'; } vsStreak.textContent = `${currentStreak} days`; vsStreak.style.color = currentStreak >= 3 ? 'var(--green)' : 'var(--text-primary)'; } function updateStreaks() { const dates = Object.keys(scorecards).sort().reverse(); // Quality Setup Streak (days with only MODERATE+ trades) let qualityStreak = 0; for (const date of dates) { const sc = scorecards[date]; if (!sc.trades || sc.trades.length === 0) continue; const allQuality = sc.trades.every(t => { const tradeScore = t.score || 0; const thresholds = getGradeThresholds(t.totalPoints || getTotalConfluencePoints()); return !t.grayCandles && tradeScore > thresholds.aggressive; }); if (allQuality) qualityStreak++; else break; } document.getElementById('streak-quality').innerHTML = `${qualityStreak} days`; // Journal Streak const journalDates = Object.keys(journal).sort().reverse(); let journalStreak = 0; const today = new Date(); for (let i = 0; i < 365; i++) { const d = new Date(today); d.setDate(d.getDate() - i); const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; if (journal[key]) journalStreak++; else if (i > 0) break; // Allow today to be incomplete } document.getElementById('streak-journal').innerHTML = `${journalStreak} days`; // Rule Adherence Streak (days with checklist complete + quality trades) let ruleStreak = 0; for (const date of dates) { const sc = scorecards[date]; if (!isChecklistCompleteForDate(date)) break; if (sc.trades && sc.trades.length > 0) { const allQuality = sc.trades.every(t => { const tradeScore = t.score || 0; const thresholds = getGradeThresholds(t.totalPoints || getTotalConfluencePoints()); return !t.grayCandles && tradeScore > thresholds.aggressive; }); if (!allQuality) break; } ruleStreak++; } document.getElementById('streak-rules').innerHTML = `${ruleStreak} days`; // No Gray Candle Streak let grayStreak = 0; for (const date of dates) { const sc = scorecards[date]; if (!sc.trades || sc.trades.length === 0) { grayStreak++; // No trades = no gray candles continue; } const hasGray = sc.trades.some(t => t.grayCandles); if (hasGray) break; grayStreak++; } if (grayStreak === 0) grayStreak = 1; // At least 1 for today document.getElementById('streak-gray').innerHTML = `${grayStreak} days`; } function updateAchievements() { const disciplineWinCount = disciplineWins ? disciplineWins.length : 0; const scorecardDates = Object.keys(scorecards); const journalDates = Object.keys(journal); // Count various things let totalScorecardTrades = 0; let hasScreenshot = false; let hasConservative = false; let hasPerfectDay = false; let checklistCompleteDays = 0; let qualityDays = 0; scorecardDates.forEach(date => { const sc = scorecards[date]; if (sc.trades && sc.trades.length > 0) { totalScorecardTrades += sc.trades.length; sc.trades.forEach(t => { if (t.screenshot) hasScreenshot = true; const tradeScore = t.score || 0; const thresholds = getGradeThresholds(t.totalPoints || getTotalConfluencePoints()); if (!t.grayCandles && tradeScore > thresholds.moderate) hasConservative = true; }); // Check if all trades were conservative const allConservative = sc.trades.every(t => { const tradeScore = t.score || 0; const thresholds = getGradeThresholds(t.totalPoints || getTotalConfluencePoints()); return !t.grayCandles && tradeScore > thresholds.moderate; }); if (allConservative) { hasPerfectDay = true; qualityDays++; } } }); // Count distinct days with a COMPLETE pre-market checklist — union of trade days, // saved completion docs, and today (live state) — so a no-trade checklist day also // earns the "first checklist" achievement. (The legacy flag only existed on trade days.) const _allChecklistDates = new Set([...scorecardDates, ...Object.keys(checklistCompletionsByDate), getTodayKeyTZ()]); _allChecklistDates.forEach(date => { if (isChecklistCompleteForDate(date)) checklistCompleteDays++; }); // Calculate streaks for achievements let qualityStreak = 0; let journalStreak = 0; let checklistStreak = 0; let grayStreak = 0; const sortedDates = scorecardDates.sort().reverse(); for (const date of sortedDates) { const sc = scorecards[date]; if (sc.trades && sc.trades.length > 0) { const allQuality = sc.trades.every(t => { const thresholds = getGradeThresholds(t.totalPoints || getTotalConfluencePoints()); return !t.grayCandles && (t.score || 0) > thresholds.aggressive; }); if (allQuality) qualityStreak++; else break; } } const today = new Date(); for (let i = 0; i < 30; i++) { const d = new Date(today); d.setDate(d.getDate() - i); const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; if (journal[key]) journalStreak++; else if (i > 0) break; } // Note: iterates scorecard (trade) dates only — a no-trade checklist day isn't in this // list, so it neither extends nor breaks the streak. Source migrated off the legacy flag. for (const date of sortedDates) { if (isChecklistCompleteForDate(date)) checklistStreak++; else break; } for (const date of sortedDates) { const sc = scorecards[date]; if (!sc.trades || sc.trades.length === 0 || !sc.trades.some(t => t.grayCandles)) { grayStreak++; } else break; } // Update achievement states const achievements = { 'ach-first-trade': totalScorecardTrades >= 1, 'ach-first-checklist': checklistCompleteDays >= 1, 'ach-first-journal': journalDates.length >= 1, 'ach-first-screenshot': hasScreenshot, 'ach-first-conservative': hasConservative, 'ach-first-win': disciplineWinCount >= 1, 'ach-streak-3': qualityStreak >= 3, 'ach-streak-5': qualityStreak >= 5, 'ach-streak-7': qualityStreak >= 7, 'ach-journal-streak-3': journalStreak >= 3, 'ach-checklist-streak-5': checklistStreak >= 5, 'ach-5-wins': disciplineWinCount >= 5, 'ach-10-wins': disciplineWinCount >= 10, 'ach-10-scorecards': scorecardDates.length >= 10, 'ach-no-gray-week': grayStreak >= 7, 'ach-perfect-day': hasPerfectDay }; Object.entries(achievements).forEach(([id, unlocked]) => { const el = document.getElementById(id); if (el) { if (unlocked) { el.classList.add('unlocked'); el.querySelector('.achievement-icon').textContent = '🏆'; } else { el.classList.remove('unlocked'); el.querySelector('.achievement-icon').textContent = '🏅'; } } }); } // Render the Process checklist lines dynamically from the active scorecard components. // pass → green (✓), fail → red (✗), partial → yellow (◐), na/unconfigured → muted (○/–). // All label/detail values are app-generated from validated config (no untrusted free text). function updateProcessChecklist(components) { const container = document.getElementById('process-checklist'); if (!container || !Array.isArray(components)) return; const GLYPH = { pass: '✓', fail: '✗', partial: '◐', na: '○', unconfigured: '–' }; container.innerHTML = components.map(c => { const glyph = GLYPH[c.state] || '○'; let cls; if (c.state === 'pass') cls = 'complete'; else if (c.state === 'fail') cls = 'warning'; else if (c.state === 'partial') cls = 'partial'; else cls = 'muted'; // na + unconfigured const detail = c.detail ? ` (${c.detail})` : ''; return `
` + `${glyph}` + `${c.label}${detail}
`; }).join(''); } // ── Shared metrics-integrity predicates ────────────────────────────────────── // Single source for getFilteredTrades(), getMetricsEligibleTrades(), and // getDisciplineTrades() so the archived / excludeFromMetrics / earliestTradeDate // detection logic can't drift between callers. // Account IDs whose trades are excluded because the account is archived. // Empty when the user has toggled includeArchivedInMetrics on. function getArchivedExcludedAccountIds() { if (includeArchivedInMetrics) return new Set(); return new Set(accounts.filter(a => a.archived).map(a => a.id)); } // Account IDs the user flagged excludeFromMetrics ("YOLO") when archiving. function getMetricsExcludedAccountIds() { return new Set(accounts.filter(a => a.excludeFromMetrics).map(a => a.id)); } // Per-account earliestTradeDate window guard (recycled Rithmic accounts), routed // through getAccountTrades for single-source-of-truth date semantics. Orphan trades // (accountId matching no account) pass through unchanged. function passesEarliestTradeDateWindow(t, accountById) { const acc = accountById.get(t.accountId); if (acc && getAccountTrades(acc, [t]).length === 0) return false; return true; } function getFilteredTrades() { const fromDate = document.getElementById('filter-from').value; const toDate = document.getElementById('filter-to').value; // Debug: log date filter state if (fromDate || toDate) { const evalAccountIds = accounts.filter(a => a.stage === 'evaluation' || a.isEvaluation).map(a => a.id); const evalTradesBefore = trades.filter(t => evalAccountIds.includes(t.accountId)); if (evalTradesBefore.length > 0) { console.log('[DateFilter] fromDate:', fromDate, 'toDate:', toDate); console.log('[DateFilter] Eval trades before filter:', evalTradesBefore.length); // Log first 3 eval trade dates for diagnosis evalTradesBefore.slice(0, 3).forEach((t, i) => { console.log(`[DateFilter] Eval trade ${i}: date=${JSON.stringify(t.date)} (type: ${typeof t.date}${t.date && typeof t.date === 'object' && t.date.toDate ? ', Timestamp' : ''}), exitTime=${t.exitTime}, normalized=${normalizeTradeDate(t)}`); }); } } // Get prop firm filter value const globalPropFirmFilter = document.getElementById('global-prop-firm-filter')?.value || ''; // Get instrument filter value const globalInstrumentFilter = document.getElementById('global-instrument-filter')?.value || ''; // Get archived account IDs for filtering const archivedAccountIds = new Set( accounts.filter(a => a.archived).map(a => a.id) ); // Build stage-based account ID sets for filtering const fundedIds = globalStageFilter !== 'all' ? new Set( accounts.filter(a => a.stage !== 'evaluation' && !a.isEvaluation && a.propFirm && a.propFirm !== 'personal' && a.propFirm !== 'other').map(a => a.id) ) : null; const evalIds = globalStageFilter !== 'all' ? new Set( accounts.filter(a => a.stage === 'evaluation' || a.isEvaluation).map(a => a.id) ) : null; // Per-account earliestTradeDate lookup (Rithmic-recycled-account fix). // Routes per-trade through getAccountTrades for single-source-of-truth on the date check. const accountById = new Map(accounts.map(a => [a.id, a])); return trades.filter(t => { // Stage filter (All/Funded/Eval) if (globalStageFilter === 'funded' && (!fundedIds || !fundedIds.has(t.accountId))) return false; if (globalStageFilter === 'evaluation' && (!evalIds || !evalIds.has(t.accountId))) return false; // Filter out archived accounts unless toggle is on if (!includeArchivedInMetrics && archivedAccountIds.has(t.accountId)) { return false; } // Instrument/symbol filter if (globalInstrumentFilter) { const baseSymbol = (t.symbol || '').replace(/[FGHJKMNQUVXZ]\d{1,2}$/i, ''); if (baseSymbol !== globalInstrumentFilter) return false; } // Multi-select account selection (from checkboxes or dropdown) if (selectedAllFirmsAccounts.length > 0) { if (!selectedAllFirmsAccounts.includes(t.accountId)) return false; } // Prop firm filter (show all accounts for selected firm) else if (globalPropFirmFilter) { const account = accounts.find(a => a.id === t.accountId); if (!account || account.propFirm !== globalPropFirmFilter) return false; } // Legacy multi-select account filter else if (selectedAccountIds.length > 0) { if (selectedAccountIds[0] === 'none') return false; // No accounts selected if (!selectedAccountIds.includes(t.accountId)) return false; } // If no filters, include all accounts // Date filter — normalize to YYYY-MM-DD string for comparison if (fromDate || toDate) { let tradeDate = normalizeTradeDate(t); if (!tradeDate) return false; if (fromDate && tradeDate < fromDate) return false; if (toDate && tradeDate > toDate) return false; } // Per-account earliestTradeDate filter — routed through getAccountTrades // for single-source-of-truth date semantics. const acc = accountById.get(t.accountId); if (acc && getAccountTrades(acc, [t]).length === 0) return false; return true; }); } // Get trades eligible for metrics (excludes trades from accounts marked excludeFromMetrics) function getMetricsEligibleTrades(tradeList = null) { const sourceTrades = tradeList || getFilteredTrades(); // Get account IDs that are excluded from metrics (user chose to exclude when archiving) const excludedAccountIds = new Set( accounts.filter(a => a.excludeFromMetrics).map(a => a.id) ); // If no accounts are excluded, return all trades if (excludedAccountIds.size === 0) { return sourceTrades; } // Filter out trades from excluded accounts return sourceTrades.filter(t => !excludedAccountIds.has(t.accountId)); } // Metrics-stable, VIEW-INDEPENDENT trade subset for discipline scoring. // Starts from raw `trades` (already !deleted at load) and applies ONLY the three // data-integrity exclusions shared with getFilteredTrades()/getMetricsEligibleTrades(): // - archived-account trades (toggle-gated by includeArchivedInMetrics) // - excludeFromMetrics ("YOLO") account trades // - per-account earliestTradeDate window (recycled-account guard) // Deliberately does NOT apply date-range / stage / instrument / prop-firm / account-select // view filters, so the discipline score never shifts when the user changes their view. // Orphan trades (accountId matching no account) are KEPT, matching getFilteredTrades. function getDisciplineTrades() { const archivedExcludedIds = getArchivedExcludedAccountIds(); const metricsExcludedIds = getMetricsExcludedAccountIds(); const accountById = new Map(accounts.map(a => [a.id, a])); return trades.filter(t => { if (archivedExcludedIds.has(t.accountId)) return false; if (metricsExcludedIds.has(t.accountId)) return false; if (!passesEarliestTradeDateWindow(t, accountById)) return false; return true; }); } function renderCalendar() { const year = currentMonth.getFullYear(); const month = currentMonth.getMonth(); document.getElementById('cal-month').textContent = currentMonth.toLocaleDateString('en-US', { month: 'long', year: 'numeric' }); const firstDay = new Date(year, month, 1); const lastDay = new Date(year, month + 1, 0); const startPad = firstDay.getDay(); const daysInMonth = lastDay.getDate(); // Get funded and eval account IDs (include archived when toggle is on so their trades keep classification) const fundedAccountIds = accounts.filter(a => (!a.archived || includeArchivedInMetrics) && a.stage !== 'evaluation' && !a.isEvaluation && a.propFirm && a.propFirm !== 'personal' && a.propFirm !== 'other').map(a => a.id); const evalAccountIds = accounts.filter(a => (!a.archived || includeArchivedInMetrics) && (a.stage === 'evaluation' || a.isEvaluation)).map(a => a.id); // Group filtered trades by date - track P&L for funded and eval separately // Use trading session date (trades after 5pm CT count as next day) const dailyPnl = {}; const dailyFundedPnl = {}; const dailyEvalPnl = {}; const dailyCount = {}; const filteredTrades = getMetricsEligibleTrades(); // Read header date range so we can enforce it against the trading session date // (getFilteredTrades filters by raw exitTime date, but session date may roll +1 day) const calFromDate = document.getElementById('filter-from').value; const calToDate = document.getElementById('filter-to').value; filteredTrades.forEach(t => { const date = normalizeDateToString(t.date) || getTradingSessionDate(t.exitTime) || getDateKey(t.exitTime); // Enforce date range against the session date the calendar will actually display if (calFromDate && date < calFromDate) return; if (calToDate && date > calToDate) return; const pnl = getNetPnl(t); dailyPnl[date] = (dailyPnl[date] || 0) + pnl; dailyCount[date] = (dailyCount[date] || 0) + 1; // Track funded vs eval if (fundedAccountIds.includes(t.accountId)) { dailyFundedPnl[date] = (dailyFundedPnl[date] || 0) + pnl; } else if (evalAccountIds.includes(t.accountId)) { dailyEvalPnl[date] = (dailyEvalPnl[date] || 0) + pnl; } }); // Calculate month total P&L (funded and eval) let monthTotal = 0; let monthFundedTotal = 0; let monthEvalTotal = 0; for (let day = 1; day <= daysInMonth; day++) { const dateKey = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; if (dailyPnl[dateKey]) { monthTotal += dailyPnl[dateKey]; } if (dailyFundedPnl[dateKey]) { monthFundedTotal += dailyFundedPnl[dateKey]; } if (dailyEvalPnl[dateKey]) { monthEvalTotal += dailyEvalPnl[dateKey]; } } // Update month total display const monthTotalEl = document.getElementById('cal-month-total'); if (monthTotalEl) { monthTotalEl.textContent = formatCurrency(monthTotal); monthTotalEl.className = monthTotal >= 0 ? 'positive' : 'negative'; } // Update Funded month total const monthFundedEl = document.getElementById('cal-month-funded'); if (monthFundedEl) { monthFundedEl.textContent = formatCurrency(monthFundedTotal); monthFundedEl.style.color = monthFundedTotal >= 0 ? 'var(--green)' : 'var(--red)'; } // Update Eval month total const monthEvalEl = document.getElementById('cal-month-eval'); if (monthEvalEl) { monthEvalEl.textContent = formatCurrency(monthEvalTotal); monthEvalEl.style.color = monthEvalTotal >= 0 ? 'var(--purple)' : 'var(--red)'; } let html = '
'; // Header row with WEEK column ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].forEach(day => { html += `
${day}
`; }); html += `
Week
`; // Build all cells including padding let allCells = []; // Previous month days for (let i = 0; i < startPad; i++) { const d = new Date(year, month, -(startPad - i - 1)); const dateKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; allCells.push({ type: 'other', date: dateKey, day: d.getDate(), pnl: dailyPnl[dateKey] || 0, fundedPnl: dailyFundedPnl[dateKey] || 0, evalPnl: dailyEvalPnl[dateKey] || 0, count: dailyCount[dateKey] || 0 }); } // Current month days for (let day = 1; day <= daysInMonth; day++) { const dateKey = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; const pnl = dailyPnl[dateKey]; const count = dailyCount[dateKey] || 0; allCells.push({ type: 'current', date: dateKey, day: day, pnl: pnl, fundedPnl: dailyFundedPnl[dateKey] || 0, evalPnl: dailyEvalPnl[dateKey] || 0, count: count, hasTrades: pnl !== undefined }); } // Next month days to complete the grid const totalCells = Math.ceil((startPad + daysInMonth) / 7) * 7; const remaining = totalCells - (startPad + daysInMonth); for (let i = 1; i <= remaining; i++) { const d = new Date(year, month + 1, i); const dateKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; allCells.push({ type: 'other', date: dateKey, day: i, pnl: dailyPnl[dateKey] || 0, fundedPnl: dailyFundedPnl[dateKey] || 0, evalPnl: dailyEvalPnl[dateKey] || 0, count: dailyCount[dateKey] || 0 }); } // Render cells in rows of 7 + week total for (let i = 0; i < allCells.length; i += 7) { const weekCells = allCells.slice(i, i + 7); let weekTotal = 0; let weekFundedTotal = 0; let weekEvalTotal = 0; let weekHasTrades = false; let weekTradeCount = 0; weekCells.forEach(cell => { if (cell.type === 'other') { const hasOtherData = cell.count > 0; const otherPnlClass = hasOtherData ? (cell.pnl >= 0 ? 'positive' : 'negative') : ''; const hasOtherFunded = cell.fundedPnl !== 0; const hasOtherEval = cell.evalPnl !== 0; const otherBg = hasOtherData ? (cell.pnl >= 0 ? 'background: rgba(16,185,129,0.06); border-color: rgba(16,185,129,0.15);' : 'background: rgba(239,68,68,0.06); border-color: rgba(239,68,68,0.15);') : ''; html += `
${cell.day} ${hasOtherData ? ` ${hasOtherFunded || hasOtherEval ? `
${hasOtherFunded ? `
Funded: ${cell.fundedPnl >= 0 ? '+' : ''}${formatCurrency(cell.fundedPnl)}
` : ''} ${hasOtherEval ? `
Eval: ${cell.evalPnl >= 0 ? '+' : ''}${formatCurrency(cell.evalPnl)}
` : ''}
` : ''} ${formatCurrency(cell.pnl)} ${cell.count > 0 ? `${cell.count} trade${cell.count !== 1 ? 's' : ''}` : ''} ` : ''}
`; } else { const pnlClass = cell.hasTrades ? (cell.pnl >= 0 ? 'positive' : 'negative') : ''; const hasFunded = cell.fundedPnl !== 0; const hasEval = cell.evalPnl !== 0; html += `
${cell.day} ${cell.hasTrades ? ` ${hasFunded || hasEval ? `
${hasFunded ? `
Funded: ${cell.fundedPnl >= 0 ? '+' : ''}${formatCurrency(cell.fundedPnl)}
` : ''} ${hasEval ? `
Eval: ${cell.evalPnl >= 0 ? '+' : ''}${formatCurrency(cell.evalPnl)}
` : ''}
` : ''} ${formatCurrency(cell.pnl)} ${cell.count > 0 ? `${cell.count} trade${cell.count !== 1 ? 's' : ''}` : ''} ` : ''} ${(() => { const cs = getCalendarScoreForDate(cell.date); if (!cs) return ''; return `${cs.grade}`; })()}
`; } // Sum week total for ALL 7 days in the row regardless of month weekTotal += cell.pnl || 0; weekFundedTotal += cell.fundedPnl || 0; weekEvalTotal += cell.evalPnl || 0; weekTradeCount += cell.count || 0; if (cell.count > 0) weekHasTrades = true; }); // Week total cell with Funded/Eval breakdown const weekClass = weekHasTrades ? (weekTotal >= 0 ? 'positive' : 'negative') : ''; const hasFundedWeek = weekFundedTotal !== 0; const hasEvalWeek = weekEvalTotal !== 0; // Calculate Thursday date for this week row (index 4 = Thursday) const thuCell = weekCells[4]; // Thu is index 4 (Sun=0) const weekEndingDate = thuCell ? thuCell.date : ''; const hasWeeklyReview = weekEndingDate && weeklyReviews[weekEndingDate]; const sunCell = weekCells[0]; const weekStartKey = sunCell ? sunCell.date : ''; const isSelected = window._selectedWeekStart === weekStartKey; html += `
Total ${weekHasTrades ? ` ${hasFundedWeek || hasEvalWeek ? `
${hasFundedWeek ? `
Funded: ${weekFundedTotal >= 0 ? '+' : ''}${formatCurrency(weekFundedTotal)}
` : ''} ${hasEvalWeek ? `
Eval: ${weekEvalTotal >= 0 ? '+' : ''}${formatCurrency(weekEvalTotal)}
` : ''}
` : ''} ${formatCurrency(weekTotal)} ${weekTradeCount > 0 ? `${weekTradeCount} trade${weekTradeCount !== 1 ? 's' : ''}` : ''} ` : ''} ${hasWeeklyReview ? `✅ Reviewed` : '' }
`; } html += '
'; document.getElementById('calendar').innerHTML = html; // Init trade count toggle (once — header is static, not re-rendered) const calToggleEl = document.getElementById('cal-trades-toggle'); if (calToggleEl && !calToggleEl._toggleInitialized) { calToggleEl._toggleInitialized = true; calToggleEl.parentElement.addEventListener('click', () => { const current = localStorage.getItem('cal_show_trades') !== 'false'; localStorage.setItem('cal_show_trades', current ? 'false' : 'true'); renderCalendar(); }); } // Apply toggle state const _calShowTrades = localStorage.getItem('cal_show_trades') !== 'false'; document.querySelectorAll('.cal-trade-count').forEach(el => { el.style.display = _calShowTrades ? '' : 'none'; }); if (calToggleEl) calToggleEl.classList.toggle('active', _calShowTrades); // Add click handlers for all calendar days (including overflow days with trades) document.querySelectorAll('.calendar-day').forEach(el => { el.addEventListener('click', (e) => { // If scorecard badge was clicked, navigate to scorecard page for that day const badge = e.target.closest('.calendar-score-badge'); if (badge && badge.dataset.scorecardDate) { e.stopPropagation(); loadScorecard(badge.dataset.scorecardDate); document.querySelector('[data-page=scorecard]').click(); return; } const dateKey = el.dataset.date; if (dateKey) showDayDetail(dateKey); }); }); } function showDayDetail(dateKey) { const filteredTrades = getMetricsEligibleTrades(); // Use trading session date (trades after 5pm CT count as next day) const dayTrades = filteredTrades.filter(t => { const tradeSessionDate = normalizeDateToString(t.date) || getTradingSessionDate(t.exitTime) || getDateKey(t.exitTime); return tradeSessionDate === dateKey; }).sort((a, b) => new Date(b.exitTime) - new Date(a.exitTime)); // Sort by most recent first const totalPnl = dayTrades.reduce((sum, t) => sum + getNetPnl(t), 0); const winners = dayTrades.filter(t => getNetPnl(t) > 0); const losers = dayTrades.filter(t => getNetPnl(t) < 0); const winRate = dayTrades.length > 0 ? (winners.length / dayTrades.length * 100) : 0; const grossProfit = winners.reduce((sum, t) => sum + getNetPnl(t), 0); const grossLoss = Math.abs(losers.reduce((sum, t) => sum + getNetPnl(t), 0)); const profitFactor = grossLoss > 0 ? grossProfit / grossLoss : grossProfit > 0 ? Infinity : 0; // Update modal title document.getElementById('day-detail-title').textContent = `📅 ${formatDate(dateKey)}`; // Update stats const pnlEl = document.getElementById('day-detail-pnl'); pnlEl.textContent = formatCurrency(totalPnl); pnlEl.className = totalPnl >= 0 ? 'positive' : 'negative'; document.getElementById('day-detail-trades').textContent = dayTrades.length; const winrateEl = document.getElementById('day-detail-winrate'); winrateEl.textContent = winRate.toFixed(0) + '%'; winrateEl.style.color = winRate >= 50 ? 'var(--green)' : 'var(--red)'; const pfEl = document.getElementById('day-detail-pf'); pfEl.textContent = profitFactor === Infinity ? '∞' : profitFactor.toFixed(2); pfEl.style.color = profitFactor >= 1 ? 'var(--green)' : 'var(--red)'; // Render equity curve renderDayEquityCurve(dayTrades); // Render trades list const tradesListEl = document.getElementById('day-detail-trades-list'); if (dayTrades.length === 0) { tradesListEl.innerHTML = '
No trades this day
'; } else { tradesListEl.innerHTML = dayTrades.map((t, i) => { const pnl = parseFloat(t.netPnl ?? t.pnl) || 0; // Handle Rithmic YYYYMMDDTHH:MM:SS format let exitTs = t.exitTime; if (typeof exitTs === 'string' && exitTs.match(/^\d{8}T/)) { exitTs = exitTs.substring(0, 4) + '-' + exitTs.substring(4, 6) + '-' + exitTs.substring(6, 8) + 'T' + exitTs.substring(9); } const time = new Date(exitTs).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', timeZone: getUserTimeZone() }); // Get account and prop firm info const account = accounts.find(a => a.id === t.accountId); const propFirmKey = account?.propFirm || ''; const propFirmConfig = propFirmConfigs[propFirmKey] || {}; const propFirmName = propFirmConfig.name || propFirmKey || 'Unknown'; const accountName = account?.name || 'Unknown'; return `
${time} ${propFirmName} ${accountName} ${t.side} ${t.symbol || 'MNQ'}
${formatCurrency(pnl)}
`; }).join(''); } // Journal section (text + screenshots — reads via getJournalScreenshots normalizer) const journalEntry = journal[dateKey]; const journalPreviewEl = document.getElementById('day-detail-journal-preview'); const journalActionsEl = document.getElementById('day-detail-journal-actions'); const ddShots = getJournalScreenshots(journalEntry, dateKey); const ddThumbsHtml = ddShots.length ? `
${ddShots.map(s => ``).join('')}
` : ''; if (journalEntry && (journalEntry.entry || ddShots.length)) { if (journalEntry.entry) { journalPreviewEl.textContent = journalEntry.entry; } else { journalPreviewEl.innerHTML = '(No text — screenshots only)'; } journalPreviewEl.style.color = 'var(--text-secondary)'; journalActionsEl.innerHTML = ddThumbsHtml + `
`; } else { journalPreviewEl.innerHTML = 'No journal entry for this day. Document what you learned!'; journalActionsEl.innerHTML = ` `; } // Show scorecard grade in day detail if scorecard data exists const dayGrade = getCalendarScoreForDate(dateKey); if (dayGrade) { journalActionsEl.innerHTML += `
🎯 Scorecard Grade: ${dayGrade.grade}
`; } // Show modal document.getElementById('day-detail-modal').classList.add('active'); } function closeDayDetailModal() { document.getElementById('day-detail-modal').classList.remove('active'); // Destroy the chart to prevent memory leaks if (window.dayDetailChart) { window.dayDetailChart.destroy(); window.dayDetailChart = null; } } function renderDayEquityCurve(dayTrades) { // Destroy existing chart if (window.dayDetailChart) { window.dayDetailChart.destroy(); window.dayDetailChart = null; } // Get chart container const chartContainer = document.getElementById('day-detail-chart-container'); if (!chartContainer) return; // Reset the container to include canvas chartContainer.innerHTML = ''; const ctx = document.getElementById('day-detail-chart'); if (dayTrades.length === 0) { chartContainer.innerHTML = '
No trades to display
'; return; } // Sort trades by time const sortedTrades = [...dayTrades].sort((a, b) => new Date(a.exitTime) - new Date(b.exitTime)); // Build equity curve data let cumulative = 0; const data = [{ x: 0, y: 0, label: 'Start' }]; sortedTrades.forEach((t, i) => { cumulative += getNetPnl(t); data.push({ x: i + 1, y: cumulative, label: `Trade ${i + 1}` }); }); const finalPnl = cumulative; const lineColor = themePnlColor(finalPnl); const bgColor = themePnlBg(finalPnl, 0.1); window.dayDetailChart = new Chart(ctx, { type: 'line', data: { labels: data.map(d => d.label), datasets: [{ data: data.map(d => d.y), borderColor: lineColor, backgroundColor: bgColor, fill: true, tension: 0.3, pointRadius: 4, pointBackgroundColor: lineColor, pointBorderColor: '#fff', pointBorderWidth: 1 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: { callbacks: { label: (ctx) => formatCurrency(ctx.raw) } } }, scales: { x: { display: false }, y: { grid: { color: '#2a2f3a' }, ticks: { color: '#9ca3af', font: { size: 10 }, callback: (v) => formatCurrency(v, 0) } } } } }); } function openJournalForDay(dateKey) { closeDayDetailModal(); // Navigate to journal page document.querySelectorAll('.nav-item').forEach(i => i.classList.remove('active')); document.querySelectorAll('.page').forEach(p => p.classList.remove('active')); const journalNavItem = document.querySelector('.nav-item[data-page="journal"]'); if (journalNavItem) journalNavItem.classList.add('active'); document.getElementById('page-journal').classList.add('active'); // Set the date and trigger the change handler to load existing entry document.getElementById('journal-date').value = dateKey; document.getElementById('journal-date').dispatchEvent(new Event('change')); // Render mini-calendar and focus textarea renderJournalMiniCal(); setTimeout(() => { document.getElementById('journal-entry').focus(); }, 100); } function renderCumulativeChart() { const ctx = document.getElementById('cumulative-chart'); if (!ctx) return; // Sort filtered trades by date const sortedTrades = [...getMetricsEligibleTrades()].sort((a, b) => new Date(a.exitTime) - new Date(b.exitTime)); let cumulative = 0; const data = sortedTrades.map(t => { cumulative += getNetPnl(t); return { x: new Date(t.exitTime), y: cumulative }; }); // Properly destroy existing chart if (cumulativeChart) { cumulativeChart.destroy(); cumulativeChart = null; } if (data.length === 0) return; // Determine final color based on ending value const finalValue = data[data.length - 1]?.y || 0; const lineColor = themePnlColor(finalValue); const bgColor = themePnlBg(finalValue, 0.1); cumulativeChart = new Chart(ctx, { type: 'line', data: { datasets: [{ label: 'Cumulative P&L', data: data, borderColor: lineColor, backgroundColor: bgColor, fill: true, tension: 0.3, pointRadius: 0 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { type: 'time', time: { unit: 'day' }, grid: { color: '#2a2f3a' }, ticks: { color: '#6b7280' } }, y: { grid: { color: '#2a2f3a' }, ticks: { color: '#6b7280', callback: v => formatCurrency(v, 0) } } } } }); } function renderCumulativeDailyChart() { const ctx = document.getElementById('cumulative-daily-chart'); if (!ctx) return; // Group filtered trades by day const dailyPnl = {}; getMetricsEligibleTrades().forEach(t => { const dateKey = getDateKey(t.exitTime); if (!dailyPnl[dateKey]) dailyPnl[dateKey] = 0; dailyPnl[dateKey] += getNetPnl(t); }); // Sort dates and build cumulative const sortedDates = Object.keys(dailyPnl).sort(); let cumulative = 0; const data = sortedDates.map(dateKey => { cumulative += dailyPnl[dateKey]; return { x: new Date(dateKey), y: cumulative }; }); // Properly destroy existing chart if (cumulativeDailyChart) { cumulativeDailyChart.destroy(); cumulativeDailyChart = null; } if (data.length === 0) return; // Determine final color based on ending value const finalValue = data[data.length - 1]?.y || 0; const lineColor = themePnlColor(finalValue); const bgColor = themePnlBg(finalValue, 0.15); cumulativeDailyChart = new Chart(ctx, { type: 'line', data: { datasets: [{ label: 'Cumulative P&L (Daily)', data: data, borderColor: lineColor, backgroundColor: bgColor, fill: true, tension: 0.2, pointRadius: 3, pointBackgroundColor: lineColor }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { type: 'time', time: { unit: 'day' }, grid: { color: '#2a2f3a' }, ticks: { color: '#6b7280' } }, y: { grid: { color: '#2a2f3a' }, ticks: { color: '#6b7280', callback: v => formatCurrency(v, 0) } } }, interaction: { intersect: false, mode: 'index' }, plugins: { tooltip: { callbacks: { label: (ctx) => `Net: ${formatCurrency(ctx.raw.y)}` } } } } }); } function exportTradeLog() { const filtered = getFilteredTrades(); if (filtered.length === 0) { alert('No trades to export.'); return; } // Sort by date/time const sorted = [...filtered].sort((a, b) => new Date(a.exitTime || a.date) - new Date(b.exitTime || b.date)); // CSV headers const headers = ['Date', 'Time', 'Prop Firm', 'Account', 'Symbol', 'Side', 'Quantity', 'Entry Price', 'Exit Price', 'Commission', 'Fees', 'Gross P&L', 'Net P&L', 'Source']; const rows = sorted.map(t => { const acc = accounts.find(a => a.id === t.accountId); const firmName = propFirmNames[acc?.propFirm || t.propFirm] || acc?.propFirm || t.propFirm || ''; const acctName = acc?.name || t.accountId || ''; const date = t.date || (t.exitTime ? getTradingSessionDate(t.exitTime) : '') || ''; const time = t.exitTime ? (parseTradeTime(t.exitTime) || new Date(t.exitTime)).toLocaleTimeString('en-US', { hour12: false, timeZone: getUserTimeZone() }) : ''; const grossPnl = t.pnl || 0; const netPnl = getNetPnl(t); return [ date, time, `"${firmName}"`, `"${acctName}"`, t.symbol || '', t.side || '', t.qty || t.quantity || 1, t.entryPrice || '', t.exitPrice || '', (t.commission || 0).toFixed(2), (t.fees || 0).toFixed(2), grossPnl.toFixed(2), netPnl.toFixed(2), t.source || '' ].join(','); }); const csv = [headers.join(','), ...rows].join('\n'); const blob = new Blob([csv], { type: 'text/csv' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; // Filename with date range const dates = sorted.map(t => t.date).filter(Boolean).sort(); const from = dates[0] || 'all'; const to = dates[dates.length - 1] || 'trades'; a.download = `PayoutLab_Trades_${from}_to_${to}.csv`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } function renderTradesTable() { const tbody = document.getElementById('trades-tbody'); const sideFilter = document.getElementById('side-filter').value; const resultFilter = document.getElementById('result-filter').value; const setupFilterVal = document.getElementById('setup-filter')?.value || ''; // Populate setup filter dropdown const setupFilterEl = document.getElementById('setup-filter'); if (setupFilterEl && setupFilterEl.options.length <= 1) { playbook.forEach(s => { const opt = document.createElement('option'); opt.value = s.id; opt.textContent = s.name; setupFilterEl.appendChild(opt); }); } // Use the same base filter as Dashboard (prop firm, account, instrument, date, archived) let filtered = getFilteredTrades(); // Apply stage filter when no specific account is selected const globalAccountFilter = document.getElementById('global-account-filter'); const selectedAccountId = globalAccountFilter?.value || ''; if (!selectedAccountId && globalStageFilter !== 'all') { if (globalStageFilter === 'funded') { const fundedAccountIds = new Set(accounts.filter(a => a.stage !== 'evaluation' && !a.isEvaluation).map(a => a.id)); filtered = filtered.filter(t => fundedAccountIds.has(t.accountId)); } else if (globalStageFilter === 'evaluation') { const evalAccountIds = new Set(accounts.filter(a => a.stage === 'evaluation' || a.isEvaluation).map(a => a.id)); filtered = filtered.filter(t => evalAccountIds.has(t.accountId)); } } // Apply Trade Log-specific filters (side, result, setup) if (sideFilter) filtered = filtered.filter(t => t.side === sideFilter); if (resultFilter === 'win') filtered = filtered.filter(t => getNetPnl(t) > 0); if (resultFilter === 'loss') filtered = filtered.filter(t => getNetPnl(t) < 0); if (setupFilterVal) filtered = filtered.filter(t => { const noteData = tradeNotes[t.id] || {}; return noteData.setupId === setupFilterVal; }); filtered.sort((a, b) => { const dateA = new Date(a.date || a.exitTime); const dateB = new Date(b.date || b.exitTime); if (dateB.getTime() !== dateA.getTime()) return dateB - dateA; return new Date(b.exitTime || 0) - new Date(a.exitTime || 0); }); tbody.innerHTML = filtered.map(t => { const pnl = parseFloat(t.netPnl ?? t.pnl) || 0; const noteData = tradeNotes[t.id] || {}; const hasTags = noteData.tags && noteData.tags.length > 0; const hasNotes = noteData.notes && noteData.notes.trim(); const setupName = noteData.setupId ? (playbook.find(s => s.id === noteData.setupId)?.name || '') : ''; // Get account and prop firm info const account = accounts.find(a => a.id === t.accountId); const propFirmKey = account?.propFirm || ''; const propFirmConfig = propFirmConfigs[propFirmKey] || {}; const propFirmName = propFirmConfig.name || propFirmKey || '-'; const accountName = account?.name || '-'; return ` ${(() => { // Prefer t.date if valid YYYY-MM-DD, otherwise derive from exitTime/entryTime const rawDate = t.date && /^\d{4}-\d{2}-\d{2}/.test(t.date) ? t.date : (t.exitTime || t.entryTime || ''); return formatDate(rawDate); })()} ${(() => { const ts = parseTradeTime(t.exitTime || t.entryTime); return ts ? ts.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: true, timeZone: getUserTimeZone() }) : '-'; })()} ${propFirmName} ${accountName} ${t.symbol || '-'} ${t.side} ${t.qty || 1} ${t.entryPrice || '-'} ${t.exitPrice || '-'} ${t.commission ? formatCurrency(t.commission) : t.fees ? formatCurrency(t.fees) : '-'} ${t.exchangeFees ? formatCurrency(t.exchangeFees) : '-'} ${setupName || '-'} ${formatCurrency(pnl)} ${hasTags || hasNotes ? '📝' : ''} `; }).join(''); // Re-apply streamer mode blur to new elements if (streamerMode) applySensitiveBlur(); } // Extracted helpers for reuse by both renderAccountsList and renderConnectionAccountsTable function connTypeIcon(type) { if (type === 'rithmic') return ` Rithmic`; if (type === 'tradovate') return ` Tradovate`; if (type === 'topstepx') return ` TopstepX`; if (type === 'dxfeed') return ` DXFeed`; return ` CSV`; } function connStageBadge(acc) { if (acc.isEvaluation || acc.stage === 'evaluation') { if (acc.accountStatus === 'blown' || acc.status === 'failed') return 'Failed'; if (acc.accountStatus === 'passed' || acc.status === 'passed') return 'Passed'; return 'Eval'; } // Stage badge let stageBadge; if (!acc.stage) stageBadge = 'Not Set'; else if (acc.stage === 'exhibition') stageBadge = 'Exhibition'; else if (acc.stage === 'live_funded') stageBadge = 'Live'; else stageBadge = 'Funded'; // Account status badge (for non-eval) if (acc.accountStatus === 'blown') return stageBadge + ' Blown'; if (acc.accountStatus === 'passed') return stageBadge + ' Passed'; return stageBadge; } // Compute live balance: startingBalance + tradePnL - grossWithdrawals function getAccountCurrentBalance(acc) { let sb = parseFloat(acc.startingBalance) || parseFloat(acc.balance) || 0; const fc = propFirmConfigs[acc.propFirm] || propFirmConfigs[(acc.propFirm || '').toLowerCase()] || {}; if (fc.fundedStartsAtZero && acc.stage === 'funded') sb = 0; const pnl = getAccountTrades(acc, trades).reduce((s, t) => s + getNetPnl(t), 0); const matchedPayouts = payouts.filter(p => { if (p.type !== 'withdrawal') return false; if (p.accountId && p.accountId === acc.id) return true; if (p.accountName && (p.accountName === acc.accountName || p.accountName === acc.name)) return true; if (p.accountLabel && (p.accountLabel === acc.accountName || p.accountLabel === acc.name)) return true; if (!p.accountId && !p.accountName && p.propFirm && p.propFirm === acc.propFirm) { const firmAccounts = accounts.filter(a => a.propFirm === p.propFirm); if (firmAccounts.length === 1) return true; } return false; }); const wd = matchedPayouts.reduce((s, p) => { if (p.profitSplit && p.profitSplit < 100) { if (p.grossAmount && Math.abs(p.grossAmount - p.amount) > 0.01) return s + p.grossAmount; return s + (p.amount / (p.profitSplit / 100)); } return s + (p.grossAmount || p.amount || 0); }, 0); return sb + pnl - wd; } function renderAccountsList() { const container = document.getElementById('accounts-list'); const activeAccounts = accounts.filter(a => !a.archived); const archivedAccounts = accounts.filter(a => a.archived); const getPropFirmName = (key) => { const config = propFirmConfigs[key]; return config ? config.name : (propFirmNames[key] || key || 'Unknown'); }; // Use extracted helpers const connIcon = connTypeIcon; const stageBadge = connStageBadge; const sortAccounts = (a, b) => { const nameA = a.name || '', nameB = b.name || ''; const numA = nameA.match(/\d+/g), numB = nameB.match(/\d+/g); const prefixA = nameA.replace(/\d+/g, ''), prefixB = nameB.replace(/\d+/g, ''); if (prefixA === prefixB && numA && numB) return (parseInt(numA[numA.length-1])||0) - (parseInt(numB[numB.length-1])||0); return nameA.localeCompare(nameB); }; const groupByPropFirm = (list) => { const g = {}; list.forEach(a => { const f = a.propFirm || guessPropFirmFromName(a.name) || 'other'; (g[f] = g[f] || []).push(a); }); Object.values(g).forEach(arr => arr.sort(sortAccounts)); return g; }; if (accounts.length === 0) { container.innerHTML = '
📋
No accounts yet. Click "+ Add Connection" to get started.
'; return; } // Check which accounts need setup (must be before buildRows) const needsSetupCheck = (a) => !a.propFirm || a.propFirm === 'unknown' || !a.startingBalance || !a.plan || !a.stage; // Build account rows for a group const buildRows = (accs, isArchived) => accs.map(acc => { const isInactive = acc.accountStatus === 'blown' || acc.accountStatus === 'passed' || acc.accountStatus === 'archived'; const trClass = isArchived ? ' class="archived"' : (isInactive ? ` style="opacity: 0.55;"` : ''); const archBadges = isArchived ? `Archived${acc.excludeFromMetrics ? ' YOLO' : ''}` : ''; const actions = isArchived ? ` ` : ` `; const setupOk = !needsSetupCheck(acc); const setupCell = setupOk ? '' : '⚠ Setup'; return `
${acc.isEvaluation ? `
Target: ${formatCurrency(acc.profitTarget || 0)}
` : ''} ${connIcon(acc.connectionType)} ${formatCurrency(getAccountCurrentBalance(acc))} ${setupCell} ${stageBadge(acc)}${archBadges ? ' ' + archBadges : ''} ${actions} `; }).join(''); const setupNeededCount = activeAccounts.filter(needsSetupCheck).length; let html = ''; if (setupNeededCount > 0) { html += '
⚠ ' + setupNeededCount + ' account' + (setupNeededCount > 1 ? 's' : '') + ' need configuration to display correct payout and compliance data.Review
'; } html += ''; // Active accounts grouped by firm if (activeAccounts.length > 0) { const groups = groupByPropFirm(activeAccounts); const sortedFirms = Object.keys(groups).sort((a, b) => getPropFirmName(a).localeCompare(getPropFirmName(b))); sortedFirms.forEach(firm => { html += ``; html += buildRows(groups[firm], false); }); } // Archived accounts grouped by firm if (archivedAccounts.length > 0) { html += ``; html += `
AccountConnectionBalanceSetupStageActions
${getPropFirmName(firm)}
Archived (${archivedAccounts.length})
`; const archivedGroups = groupByPropFirm(archivedAccounts); const archivedFirms = Object.keys(archivedGroups).sort((a, b) => getPropFirmName(a).localeCompare(getPropFirmName(b))); archivedFirms.forEach(firm => { html += ``; html += buildRows(archivedGroups[firm], true); }); } html += ''; container.innerHTML = html; // Wire up archived toggle const details = document.getElementById('archived-accounts-details'); const archTable = document.getElementById('archived-accounts-table'); if (details && archTable) { details.addEventListener('toggle', () => { archTable.style.display = details.open ? 'table' : 'none'; }); } } // ===== ADD CONNECTION WIZARD MODAL FUNCTIONS ===== let _connWizardType = null; function resetAddConnectionModal() { _connWizardType = null; // Reset progress/results/error visibility ['rithmic-progress', 'rithmic-results', 'rithmic-error', 'tradovate-progress', 'tradovate-results'].forEach(id => { const el = document.getElementById(id); if (el) el.style.display = 'none'; }); // Reset Rithmic form fields for new connection const rithmicSystem = document.getElementById('rithmic-system'); if (rithmicSystem) rithmicSystem.value = ''; const rithmicUserId = document.getElementById('rithmic-user-id'); if (rithmicUserId) rithmicUserId.value = ''; const rithmicPassword = document.getElementById('rithmic-password'); if (rithmicPassword) rithmicPassword.value = ''; const rithmicSyncBtn = document.getElementById('rithmic-sync-btn'); if (rithmicSyncBtn) rithmicSyncBtn.style.display = ''; // Ensure creds section is visible const credsSection = document.getElementById('rithmic-creds-section'); if (credsSection) credsSection.style.display = ''; // Reset CSV form const importAccount = document.getElementById('import-account'); if (importAccount) importAccount.value = ''; const importFormat = document.getElementById('import-format'); if (importFormat) { importFormat.value = 'auto'; importFormat.dispatchEvent(new Event('change')); } const csvInput = document.getElementById('csv-input'); if (csvInput) csvInput.value = ''; const importStatus = document.getElementById('import-status'); if (importStatus) importStatus.innerHTML = ''; const importPreview = document.getElementById('import-preview'); if (importPreview) importPreview.style.display = 'none'; // Reset format help to auto ['auto', 'rithmic', 'tradovate_auto', 'topstepx', 'dxfeed'].forEach(f => { const el = document.getElementById('format-help-' + f); if (el) el.style.display = f === 'auto' ? 'block' : 'none'; }); // Reset Tradovate auth error const authErr = document.getElementById('tradovate-auth-error'); if (authErr) authErr.style.display = 'none'; } function openAddConnectionModal() { moveImportContentToSettings(); resetAddConnectionModal(); showConnWizardStep(1); document.getElementById('add-connection-modal').classList.add('active'); } function closeAddConnectionModal() { document.getElementById('add-connection-modal').classList.remove('active'); resetAddConnectionModal(); // Re-render dashboard and connections after import wizard closes renderAll(); if (document.getElementById('unified-accounts-table')) renderConnectionsPage(); } function showConnWizardStep(step, type) { const ind1 = document.getElementById('wizard-ind-1'); const ind2 = document.getElementById('wizard-ind-2'); const step1 = document.getElementById('wizard-step-1'); const step2 = document.getElementById('wizard-step-2'); if (step === 1) { ind1.classList.add('active'); ind1.classList.remove('completed'); ind2.classList.remove('active', 'completed'); step1.classList.add('active'); step2.classList.remove('active'); _connWizardType = null; } else if (step === 2 && type) { _connWizardType = type; ind1.classList.remove('active'); ind1.classList.add('completed'); ind2.classList.add('active'); step1.classList.remove('active'); step2.classList.add('active'); // Show only selected type's content ['rithmic', 'tradovate', 'csv'].forEach(t => { const el = document.getElementById('wizard-content-' + t); if (el) el.style.display = t === type ? 'block' : 'none'; }); // Trigger data loading for the selected type if (type === 'rithmic') loadRithmicConnections().then(() => { // Always show the new connection form when opening via Add Connection wizard const newConnForm = document.getElementById('rithmic-new-connection-form'); if (newConnForm) newConnForm.style.display = 'block'; }); if (type === 'tradovate') { // Always show fresh login form in Add Connection — never show existing connection state document.getElementById('tradovate-connected-state').style.display = 'none'; document.getElementById('tradovate-login-form').style.display = 'block'; const authErr = document.getElementById('tradovate-auth-error'); if (authErr) authErr.style.display = 'none'; if (typeof renderTradovateImportSyncStatus === 'function') renderTradovateImportSyncStatus(); } if (type === 'csv') updateImportPageStats(); } } function openAddAccountModal() { document.getElementById('account-prop-firm').value = ''; document.getElementById('account-connection-type').value = ''; document.getElementById('account-name').value = ''; document.getElementById('account-stage').value = ''; document.getElementById('account-balance').value = ''; document.getElementById('account-balance').style.display = 'block'; const balanceCustom = document.getElementById('account-balance-custom'); if (balanceCustom) { balanceCustom.value = ''; balanceCustom.style.display = 'none'; } document.getElementById('account-buffer').value = ''; const bufferHint = document.getElementById('buffer-hint'); if (bufferHint) bufferHint.textContent = 'Select account type to see buffer hints'; document.getElementById('add-account-modal').classList.add('active'); } function scrollToConnectionSection(type) { showPage('settings'); currentSettingsSection = 'accounts'; switchSettingsSection('accounts'); openAddConnectionModal(); showConnWizardStep(2, type); } // ===== UPDATE CREDENTIALS MODAL ===== let _updateCredsAccountId = null; let _updateCredsConnType = null; function openUpdateCredsModal(accountId, connType) { const account = accountId ? accounts.find(a => a.id === accountId) : null; _updateCredsAccountId = accountId || null; _updateCredsConnType = connType; document.getElementById('update-creds-account-name').textContent = account ? account.name : (connType === 'rithmic' ? 'Rithmic Connection' : 'Tradovate Connection'); document.getElementById('update-creds-rithmic').style.display = 'none'; document.getElementById('update-creds-tradovate').style.display = 'none'; if (connType === 'rithmic') { document.getElementById('update-creds-title').textContent = 'Update Rithmic Credentials'; // Pre-fill from existing saved connection if available let existingConn = null; if (account) { existingConn = (_savedRithmicConnections || []).find(c => c.lastSyncAccountIds && c.lastSyncAccountIds.some(id => account.name && account.name.includes(id)) ); } // If called from header with a connection ID, try to find that connection if (!existingConn && accountId && (_savedRithmicConnections || []).length > 0) { existingConn = _savedRithmicConnections.find(c => c.id === accountId) || _savedRithmicConnections[0]; } if (!existingConn && (_savedRithmicConnections || []).length > 0) { existingConn = _savedRithmicConnections[0]; } document.getElementById('update-creds-rithmic-system').value = existingConn?.systemName || ''; document.getElementById('update-creds-rithmic-gateway').value = existingConn?.gateway || 'Chicago'; document.getElementById('update-creds-rithmic-user').value = existingConn?.username || ''; document.getElementById('update-creds-rithmic-password').value = ''; document.getElementById('update-creds-rithmic').style.display = 'block'; } else if (connType === 'tradovate') { document.getElementById('update-creds-title').textContent = 'Update Tradovate Connection'; document.getElementById('update-creds-tradovate').style.display = 'block'; } document.getElementById('update-creds-modal').classList.add('active'); } function closeUpdateCredsModal() { document.getElementById('update-creds-modal').classList.remove('active'); _updateCredsAccountId = null; _updateCredsConnType = null; } async function saveUpdatedRithmicCreds() { const system = document.getElementById('update-creds-rithmic-system').value; const gateway = document.getElementById('update-creds-rithmic-gateway').value; const userId = document.getElementById('update-creds-rithmic-user').value.trim(); const password = document.getElementById('update-creds-rithmic-password').value; if (!system) { showNotification('Please select a Rithmic System', 'error'); return; } if (!userId || !password) { showNotification('Please enter both User ID and Password', 'error'); return; } const btn = document.getElementById('update-creds-save-btn'); btn.disabled = true; btn.textContent = 'Saving...'; try { const account = accounts.find(a => a.id === _updateCredsAccountId); const existingConn = (_savedRithmicConnections || []).find(c => c.lastSyncAccountIds && c.lastSyncAccountIds.some(id => account?.name?.includes(id)) ); const updFirmKey = existingConn?.firmKey || account?.propFirm || ''; await saveRithmicConnection(system, gateway, userId, password, existingConn?.autoSync || false, updFirmKey); closeUpdateCredsModal(); renderConnectionsPage(); showNotification('Credentials updated successfully', 'success'); } catch (e) { console.error('Update creds error:', e); showNotification('Failed to update credentials: ' + e.message, 'error'); } finally { btn.disabled = false; btn.textContent = 'Update Credentials'; } } function showRithmicNewConnectionForm() { openAddConnectionModal(); showConnWizardStep(2, 'rithmic'); } // ===== CONNECTIONS PAGE RENDERING ===== function renderConnectionsPage() { moveImportContentToSettings(); loadRithmicConnections(); renderAccountsList(); // backward compat for hidden accounts-list renderConnectionsSummaryBar(); try { renderUnifiedAccountsTable(); } catch (err) { console.error('[Connections] Render failed:', err); const container = document.getElementById('unified-accounts-container'); if (container) container.innerHTML = '

Error loading connections. Check console.

'; } } // ===== SUMMARY BAR ===== function renderConnectionsSummaryBar() { // Summary bar removed — action bar with Sync All is static HTML } async function syncAllConnections(btn) { const originalText = btn.textContent; btn.disabled = true; btn.textContent = 'Syncing...'; let totalSaved = 0; let totalErrors = 0; // Sync all saved Rithmic connections if (_savedRithmicConnections && _savedRithmicConnections.length > 0) { for (const conn of _savedRithmicConnections) { try { const result = await syncSavedConnection(conn.id, true); if (result?.savedCount) totalSaved += result.savedCount; } catch (e) { totalErrors++; console.error('Sync error for ' + conn.id + ':', e); } } } // Sync Tradovate if connected — sync each valid firm token try { const connState = document.getElementById('tradovate-connected-state'); if (connState && connState.style.display !== 'none') { const validFirms = Object.entries(_tradovateFirmTokens).filter(([fk, ft]) => ft.exists && !ft.expired && !_tradovateDisconnectedFirms.has(fk)); for (const [fk] of validFirms) { await startTradovateSync(true, fk); } } } catch (e) { totalErrors++; console.error('Tradovate sync error:', e); } btn.disabled = false; btn.textContent = originalText; renderConnectionsPage(); if (totalSaved > 0) renderAll(); const msg = totalErrors > 0 ? `Sync complete. ${totalSaved} new trades. ${totalErrors} error(s).` : totalSaved > 0 ? `Sync complete! ${totalSaved} new executions imported.` : 'Sync complete. No new trades.'; showNotification(msg, totalErrors > 0 ? 'warning' : 'success'); } // ===== UNIFIED ACCOUNTS TABLE ===== function renderUnifiedAccountsTable() { const container = document.getElementById('unified-accounts-table'); if (!container) return; const activeAccts = accounts.filter(a => !a.archived); const archivedAccts = accounts.filter(a => a.archived); const getPropFirmName = (key) => { const config = propFirmConfigs[key]; return config ? config.name : (propFirmNames[key] || key || 'Unknown'); }; const sortAccts = (a, b) => { const nameA = a.name || '', nameB = b.name || ''; const numA = nameA.match(/\d+/g), numB = nameB.match(/\d+/g); const prefixA = nameA.replace(/\d+/g, ''), prefixB = nameB.replace(/\d+/g, ''); if (prefixA === prefixB && numA && numB) return (parseInt(numA[numA.length-1])||0) - (parseInt(numB[numB.length-1])||0); return nameA.localeCompare(nameB); }; // Group by prop firm + connection type combo const groupByFirmAndConn = (list) => { const g = {}; list.forEach(a => { const f = a.propFirm || guessPropFirmFromName(a.name) || 'other'; const ct = a.connectionType || 'csv'; const key = f + '|||' + ct; (g[key] = g[key] || []).push(a); }); Object.values(g).forEach(arr => arr.sort(sortAccts)); return g; }; const connTypeLabel = { rithmic: 'Rithmic', tradovate: 'Tradovate', topstepx: 'TopstepX', dxfeed: 'DXFeed', csv: 'Manual / CSV' }; const getConnBadgeHtml = (ct) => { if (ct === 'rithmic') return 'Rithmic'; if (ct === 'tradovate') return 'Tradovate ℹ️'; if (ct === 'topstepx') return 'TopstepX'; if (ct === 'dxfeed') return 'DXFeed'; return 'MANUAL'; }; // Status cell for individual accounts — visual dot/icon indicators const getConnStatusCell = (acc) => { const ct = acc.connectionType || ''; if (ct === 'rithmic') { const conn = (_savedRithmicConnections || []).find(c => c.lastSyncAccountIds && c.lastSyncAccountIds.some(id => acc.name && acc.name.includes(id)) ); if (conn) { const dotClass = conn.lastSyncStatus === 'success' ? 'green' : conn.lastSyncStatus === 'error' ? 'red' : 'yellow'; const lastSync = conn.lastSyncAt ? new Date(conn.lastSyncAt.seconds ? conn.lastSyncAt.seconds * 1000 : conn.lastSyncAt).toLocaleDateString() : 'Never'; return `${lastSync}`; } if (acc.rithmicAccountId && _savedRithmicConnections && _savedRithmicConnections.length > 0) { return 'Connected'; } return '⚠ Not Connected'; } if (ct === 'tradovate') { const accFirm = acc.propFirm || 'unknown'; if (_tradovateDisconnectedFirms.has(accFirm)) { return 'Disconnected'; } if (acc.tradovateAccountId) { const tvConnected = document.getElementById('tradovate-connected-state'); const isActive = tvConnected && tvConnected.style.display === 'block'; if (isActive) { return 'Connected'; } return '⚠ Expired'; } // No tradovateAccountId — account not linked yet if (!_tradovateHasToken) { return 'Disconnected'; } return 'Not Linked'; } if (!ct || ct === 'csv') { const hasTrades = getAccountTrades(acc, trades).length > 0; if (hasTrades) return 'Manual'; return 'Manual'; } return 'Manual'; }; // Group header connection status + action button + ⋯ menu const headerMenuHtml = (menuId, ct, connId, firmKey) => { let items = ''; const firmName = propFirmConfigs[firmKey] ? propFirmConfigs[firmKey].name : (firmKey || 'Unknown'); if (ct === 'rithmic') { items = ` `; } else if (ct === 'tradovate') { const _tvFirmConnected = _tradovateHasToken && !_tradovateDisconnectedFirms.has(firmKey); items = `${_tvFirmConnected ? `` : ''} ${_tvFirmConnected ? `` : ''} `; } return `
${items}
`; }; const formatLastSync = (ts) => { if (!ts) return 'Never synced'; const d = ts.toDate ? ts.toDate() : new Date(ts); if (isNaN(d.getTime())) return 'Never synced'; const mon = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'][d.getMonth()]; const day = d.getDate(); const yr = d.getFullYear(); let h = d.getHours(), ampm = h >= 12 ? 'PM' : 'AM'; h = h % 12 || 12; const min = String(d.getMinutes()).padStart(2, '0'); return `${mon} ${day}, ${yr} ${h}:${min} ${ampm}`; }; const lastSyncHtml = (ts) => { const txt = formatLastSync(ts); return `Last sync: ${txt}`; }; // Subtitle under firm name showing last sync/import time const lastSyncSubtitle = (ct, firmAccts) => { let ts = null; let label = 'Last synced'; if (ct === 'rithmic') { const conn = (_savedRithmicConnections || []).find(c => c.lastSyncAccountIds && c.lastSyncAccountIds.some(id => firmAccts.some(a => a.name && a.name.includes(id)) ) ); ts = conn ? conn.lastSyncAt : (_savedRithmicConnections && _savedRithmicConnections.length > 0 ? _savedRithmicConnections[0].lastSyncAt : null); } else if (ct === 'tradovate') { ts = _tradovateLastSync; } else { label = 'Last imported'; const importDates = firmAccts.flatMap(a => trades.filter(t => t.accountId === a.id && t.importedAt).map(t => t.importedAt)); if (importDates.length > 0) ts = importDates.sort().pop(); } const txt = ts ? formatLastSync(ts) : 'Never'; return `
${label}: ${txt}
`; }; const getGroupHeaderRight = (ct, firmAccts, groupIdx, firmKey) => { const menuId = 'group-menu-' + groupIdx; if (ct === 'rithmic') { const conn = (_savedRithmicConnections || []).find(c => c.lastSyncAccountIds && c.lastSyncAccountIds.some(id => firmAccts.some(a => a.name && a.name.includes(id)) ) ); if (conn) { const syncing = _syncingConnections.has(conn.id); return `Connected ${headerMenuHtml(menuId, 'rithmic', conn.id, firmKey)}`; } if (_savedRithmicConnections && _savedRithmicConnections.length > 0) { const syncing0 = _syncingConnections.has(_savedRithmicConnections[0].id); return `Connected ${headerMenuHtml(menuId, 'rithmic', _savedRithmicConnections[0].id, firmKey)}`; } return ` ${headerMenuHtml(menuId, 'rithmic', '', firmKey)}`; } if (ct === 'tradovate') { const isFirmDisconnected = _tradovateDisconnectedFirms.has(firmKey); if (isFirmDisconnected) { return `Disconnected ${headerMenuHtml(menuId, 'tradovate', '', firmKey)}`; } // Only show "Connected" if token exists AND at least one account in this group has a tradovateAccountId const hasLinkedAccount = firmAccts.some(a => a.tradovateAccountId); // Expired: token exists but is confirmed expired — show Expired + Reconnect, never Sync if (_tradovateHasToken && _tradovateTokenExpired) { return `Expired ${headerMenuHtml(menuId, 'tradovate', '', firmKey)}`; } if (_tradovateHasToken && hasLinkedAccount) { const unconfCount = unconfiguredTradovateCount(firmKey); if (unconfCount > 0 && !_syncingTradovateFirms.has(firmKey)) { return `Setup needed ${headerMenuHtml(menuId, 'tradovate', '', firmKey)}`; } return `Connected ${headerMenuHtml(menuId, 'tradovate', '', firmKey)}`; } if (_tradovateHasToken && !hasLinkedAccount) { const unconfCount = unconfiguredTradovateCount(firmKey); if (unconfCount > 0 && !_syncingTradovateFirms.has(firmKey)) { return `Setup needed ${headerMenuHtml(menuId, 'tradovate', '', firmKey)}`; } // Token exists but no accounts in this group are linked — show as not yet linked return `Not Linked ${headerMenuHtml(menuId, 'tradovate', '', firmKey)}`; } return ` ${headerMenuHtml(menuId, 'tradovate', '', firmKey)}`; } return ''; }; if (activeAccts.length === 0 && archivedAccts.length === 0) { container.innerHTML = '
No accounts yet. Add a connection to get started.
'; return; } const needsSetup = (a) => { const reasons = []; if (!a.propFirm) reasons.push('missing propFirm'); if (a.propFirm === 'unknown') reasons.push('propFirm=unknown'); if (!a.startingBalance) reasons.push('missing startingBalance'); if (!a.plan) reasons.push('missing plan'); if (!a.stage) reasons.push('missing stage'); if (reasons.length > 0) console.log('[needsSetup]', a.id || a.accountId || a.name, reasons); return reasons.length > 0; }; const buildRows = (accs, isArchived) => accs.map(acc => { const menuId = 'acct-menu-' + acc.id; const isInactive = acc.accountStatus === 'blown' || acc.accountStatus === 'passed' || acc.accountStatus === 'archived'; const archBadges = isArchived ? `Archived${acc.excludeFromMetrics ? ' YOLO' : ''}` : ''; const curBal = getAccountCurrentBalance(acc); const setupCell = needsSetup(acc) ? `⚠ Setup` : ''; return `
${connStageBadge(acc)}${archBadges ? ' ' + archBadges : ''} ${setupCell} ${formatCurrency(curBal)}
${isArchived ? `` : ``}
`; }).join(''); const setupCount = activeAccts.filter(needsSetup).length; const thRow = 'AccountStageSetupCurrent Bal'; let html = ''; if (setupCount > 0) { html += '
⚠ ' + setupCount + ' account' + (setupCount > 1 ? 's' : '') + ' need configuration for correct payout and compliance data.Review
'; } if (activeAccts.length > 0) { const groups = groupByFirmAndConn(activeAccts); const sortedKeys = Object.keys(groups).sort((a, b) => { const [firmA, ctA] = a.split('|||'); const [firmB, ctB] = b.split('|||'); const nameComp = getPropFirmName(firmA).localeCompare(getPropFirmName(firmB)); if (nameComp !== 0) return nameComp; // rithmic before tradovate before csv const connOrder = ['rithmic', 'tradovate', 'topstepx', 'dxfeed', 'csv']; return connOrder.indexOf(ctA) - connOrder.indexOf(ctB); }); sortedKeys.forEach((key, idx) => { const [firm, ct] = key.split('|||'); const firmAccts = groups[key]; const headerRight = getGroupHeaderRight(ct, firmAccts, idx, firm); html += `
`; html += `
${getFirmLogoHtml(firm, 44)}
${getPropFirmName(firm)}${getConnBadgeHtml(ct)}${lastSyncSubtitle(ct, firmAccts)}
${headerRight}
`; html += `${thRow}`; html += buildRows(firmAccts, false); html += '
'; }); } if (archivedAccts.length > 0) { html += `
Archived (${archivedAccts.length})
`; html += `'; } // Manual CSV Upload card at the bottom html += `
📄
Manual CSV Upload
Import trades from Rithmic (Quantower/DXFeed) or Tradovate CSV exports
`; // Single Tradovate historical data notice at the bottom of the page if (accounts.some(a => !a.archived && a.connectionType === 'tradovate')) { html += `
📋
Tradovate users: Historical trades are not synced automatically
The Tradovate API only provides today's trading activity going forward. To import your full trade history, export a CSV from your Tradovate dashboard.
View Import Walkthrough →
`; } container.innerHTML = html; // Wire up archived toggle const det = document.getElementById('unified-archived-details'); const archTbl = document.getElementById('unified-archived-table'); if (det && archTbl) { det.addEventListener('toggle', () => { archTbl.style.display = det.open ? 'block' : 'none'; }); } if (typeof streamerMode !== 'undefined' && streamerMode) applySensitiveBlur(); } // ===== THREE-DOT MENU HELPERS ===== function toggleAcctMenu(menuId, event) { event.stopPropagation(); const menu = document.getElementById(menuId); if (!menu) return; const wasOpen = menu.classList.contains('open'); closeAllAcctMenus(); if (!wasOpen) { const btn = event.currentTarget; const rect = btn.getBoundingClientRect(); menu.classList.add('open'); const menuHeight = menu.offsetHeight; const openUpward = rect.top > window.innerHeight / 2; if (openUpward) { menu.style.top = (rect.top - menuHeight - 4) + 'px'; } else { menu.style.top = (rect.bottom + 4) + 'px'; } let leftPos = rect.right - menu.offsetWidth; if (leftPos < 8) leftPos = 8; menu.style.left = leftPos + 'px'; } } function closeAllAcctMenus() { document.querySelectorAll('.acct-actions-dropdown.open').forEach(m => { m.classList.remove('open'); m.style.top = ''; m.style.left = ''; }); } document.addEventListener('click', closeAllAcctMenus); window.addEventListener('scroll', closeAllAcctMenus, true); function renderConnectionAccountsTable(containerId, activeAccts, archivedAccts) { const container = document.getElementById(containerId); if (!container) return; const getPropFirmName = (key) => { const config = propFirmConfigs[key]; return config ? config.name : (propFirmNames[key] || key || 'Unknown'); }; const sortAccts = (a, b) => { const nameA = a.name || '', nameB = b.name || ''; const numA = nameA.match(/\d+/g), numB = nameB.match(/\d+/g); const prefixA = nameA.replace(/\d+/g, ''), prefixB = nameB.replace(/\d+/g, ''); if (prefixA === prefixB && numA && numB) return (parseInt(numA[numA.length-1])||0) - (parseInt(numB[numB.length-1])||0); return nameA.localeCompare(nameB); }; const groupByPropFirm = (list) => { const g = {}; list.forEach(a => { const f = a.propFirm || guessPropFirmFromName(a.name) || 'other'; (g[f] = g[f] || []).push(a); }); Object.values(g).forEach(arr => arr.sort(sortAccts)); return g; }; if (activeAccts.length === 0 && archivedAccts.length === 0) { container.innerHTML = '
No accounts in this section.
'; return; } const buildRows = (accs, isArchived) => accs.map(acc => { const isInactive = acc.accountStatus === 'blown' || acc.accountStatus === 'passed' || acc.accountStatus === 'archived'; const trClass = isArchived ? ' class="archived"' : (isInactive ? ' style="opacity: 0.55;"' : ''); const archBadges = isArchived ? `Archived${acc.excludeFromMetrics ? ' YOLO' : ''}` : ''; const actions = isArchived ? ` ` : ` `; return `
${acc.isEvaluation ? `
Target: ${formatCurrency(acc.profitTarget || 0)}
` : ''} ${formatCurrency(getAccountCurrentBalance(acc))} ${connStageBadge(acc)}${archBadges ? ' ' + archBadges : ''} ${actions} `; }).join(''); let html = ''; if (activeAccts.length > 0) { const groups = groupByPropFirm(activeAccts); const sortedFirms = Object.keys(groups).sort((a, b) => getPropFirmName(a).localeCompare(getPropFirmName(b))); sortedFirms.forEach(firm => { html += ``; html += buildRows(groups[firm], false); }); } if (archivedAccts.length > 0) { const uid = containerId + '-archived'; html += ``; html += `
AccountBalanceStageActions
${getPropFirmName(firm)}
Archived (${archivedAccts.length})
`; const archivedGroups = groupByPropFirm(archivedAccts); const archivedFirms = Object.keys(archivedGroups).sort((a, b) => getPropFirmName(a).localeCompare(getPropFirmName(b))); archivedFirms.forEach(firm => { html += ``; html += buildRows(archivedGroups[firm], true); }); } html += ''; container.innerHTML = html; // Wire up archived toggle const uid = containerId + '-archived'; const det = document.getElementById(uid + '-details'); const archTbl = document.getElementById(uid + '-table'); if (det && archTbl) { det.addEventListener('toggle', () => { archTbl.style.display = det.open ? 'table' : 'none'; }); } if (typeof streamerMode !== 'undefined' && streamerMode) applySensitiveBlur(); } let pendingArchiveAccountId = null; async function archiveAccount(accountId) { const account = accounts.find(a => a.id === accountId); if (!account) { console.warn('[archive] account not found in memory:', accountId); showNotification('Could not find account — try refreshing the page.', 'error'); return; } // Store pending account and show modal pendingArchiveAccountId = accountId; document.getElementById('archive-account-name').innerHTML = `Archive "${account.name}"?`; document.getElementById('archive-options-modal').style.display = 'flex'; } function closeArchiveModal() { document.getElementById('archive-options-modal').style.display = 'none'; pendingArchiveAccountId = null; } function closeRithmicSetupModal() { document.getElementById('rithmic-setup-modal').style.display = 'none'; } // --- Delete Account Confirmation Modal --- let pendingDeleteAccountId = null; function confirmDeleteAccount(accountId) { const account = accounts.find(a => a.id === accountId); if (!account) return; pendingDeleteAccountId = accountId; document.getElementById('delete-account-confirm-name').innerHTML = `${account.name}`; document.getElementById('delete-account-confirm-input').value = ''; document.getElementById('delete-account-confirm-btn').disabled = true; document.getElementById('delete-account-confirm-modal').style.display = 'flex'; } function closeDeleteAccountModal() { document.getElementById('delete-account-confirm-modal').style.display = 'none'; pendingDeleteAccountId = null; } // --- Delete Connection Confirmation Modal --- let _pendingDeleteConnection = null; function confirmDeleteConnection(firmKey, connectionType, connId) { const firmName = propFirmConfigs[firmKey] ? propFirmConfigs[firmKey].name : (firmKey || 'Unknown'); _pendingDeleteConnection = { firmKey, connectionType, connId }; document.getElementById('delete-connection-firm-name').innerHTML = `Remove ${firmName} (${connectionType}) and all its accounts and trades?`; document.getElementById('delete-connection-input').value = ''; const confirmBtn = document.getElementById('delete-connection-confirm-btn'); confirmBtn.disabled = true; confirmBtn.style.opacity = '0.5'; confirmBtn.style.cursor = 'not-allowed'; document.getElementById('delete-connection-modal').style.display = 'flex'; } function closeDeleteConnectionModal() { document.getElementById('delete-connection-modal').style.display = 'none'; _pendingDeleteConnection = null; } async function executeDeleteConnection() { if (!_pendingDeleteConnection) return; const { firmKey, connectionType, connId } = _pendingDeleteConnection; const user = firebase.auth().currentUser; if (!user) return; closeDeleteConnectionModal(); // Build backend request based on connection type. Backend handles cascade // (accounts + trades + staging + connection/token + audit log) and revoke // for Tradovate. Send raw firmKey/propFirm — backend normalizes server-side. let url, body; if (connectionType === 'rithmic') { url = `${RITHMIC_API_BASE}/rithmicDeleteConnection`; body = JSON.stringify({ connectionId: connId, propFirm: firmKey, cascade: true }); } else if (connectionType === 'tradovate') { url = `${TRADOVATE_CF_BASE}/tradovateDisconnect`; body = JSON.stringify({ firmKey, cascade: true }); } else { console.error('[executeDeleteConnection] Unsupported connectionType:', connectionType); showToast('Unsupported connection type', 'error'); return; } let response, data; try { const token = await user.getIdToken(); response = await fetch(url, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body }); data = await response.json().catch(() => ({})); } catch (netErr) { console.error('[executeDeleteConnection] network error:', netErr); showToast('Connection error. Please try again.', 'error'); return; } // Error branches if (!response.ok) { if (response.status === 401 || response.status === 403) { showToast('Auth error. Please sign in again.', 'error'); return; } if (response.status === 404) { showToast('Connection not found — refreshing', 'warning'); await fullDataRefresh(); return; } if (response.status === 500 && data.partial) { const p = data.partial; showToast(`Delete failed at phase ${data.phase || '?'}. ${p.deletedTrades || 0} trades and ${p.deletedAccounts || 0} accounts were deleted before failure. Refreshing — please try again.`, 'error'); await fullDataRefresh(); return; } showToast('Delete failed: ' + (data.error || `HTTP ${response.status}`), 'error'); return; } // Success — capture pre-filter account IDs so we can prune trades[] in memory // (uses the same normalization the backend used to scope the cascade). const targetFirm = (firmKey || '').toLowerCase().replace(/[^a-z0-9]/g, ''); const deletedAccountIds = new Set( accounts.filter(a => { const accFirm = (a.propFirm || '').toLowerCase().replace(/[^a-z0-9]/g, ''); const accConn = a.connectionType || a.connection || 'csv'; return accFirm === targetFirm && accConn === connectionType; }).map(a => a.id) ); accounts = accounts.filter(a => !deletedAccountIds.has(a.id)); trades = trades.filter(t => !deletedAccountIds.has(t.accountId)); // Connection-specific in-memory cleanup if (connectionType === 'rithmic') { _savedRithmicConnections = (_savedRithmicConnections || []).filter(c => c.id !== connId); } else if (connectionType === 'tradovate') { _tradovateDisconnectedFirms.add(firmKey); if (_tradovateFirmTokens) { delete _tradovateFirmTokens[firmKey]; if (Object.keys(_tradovateFirmTokens).length === 0) { _tradovateHasToken = false; } } try { await firebase.firestore().collection('users').doc(user.uid) .collection('settings').doc('tradovatePreferences') .set({ disconnectedFirms: [..._tradovateDisconnectedFirms] }, { merge: true }); } catch (e) { console.warn('[executeDeleteConnection] Failed to persist disconnectedFirms:', e.message); } } await fullDataRefresh(); const deleted = data.deleted || {}; const accountCount = deleted.accountCount || 0; const tradeCount = deleted.tradeCount || 0; showToast(`Deleted ${accountCount} account${accountCount === 1 ? '' : 's'} and ${tradeCount} trade${tradeCount === 1 ? '' : 's'}.`, 'success'); } // --- Clear Account Trades Confirmation Modal --- let pendingClearAccountId = null; function confirmClearAccountTrades(accountId) { const account = accounts.find(a => a.id === accountId); if (!account) return; pendingClearAccountId = accountId; document.getElementById('clear-account-trades-name').innerHTML = `${account.name}`; document.getElementById('clear-account-trades-label').textContent = account.name; document.getElementById('clear-account-trades-input').value = ''; const confirmBtn = document.getElementById('clear-account-trades-confirm-btn'); confirmBtn.disabled = true; confirmBtn.style.opacity = '0.5'; confirmBtn.style.cursor = 'not-allowed'; document.getElementById('clear-account-trades-modal').style.display = 'flex'; } function closeClearAccountTradesModal() { document.getElementById('clear-account-trades-modal').style.display = 'none'; pendingClearAccountId = null; } async function clearAccountTradeHistory(accountId) { const account = accounts.find(a => a.id === accountId); if (!account) return; const targetTrades = trades.filter(t => t.accountId === accountId); if (targetTrades.length === 0) { alert(`No trades found for ${account.name}.`); return; } if (!confirm(`Clear all ${targetTrades.length} trades from "${account.name}"?\n\nThis cannot be undone.`)) return; try { // Step 1: Purge staging docs for this account const stageSnap = await db.collection('users').doc(currentUser.uid) .collection('tradingStage').where('accountId', '==', accountId).get(); for (let i = 0; i < stageSnap.docs.length; i += 450) { const batch = db.batch(); stageSnap.docs.slice(i, i + 450).forEach(doc => batch.delete(doc.ref)); await batch.commit(); } // Step 2: Delete trades let cleared = 0; for (let i = 0; i < targetTrades.length; i += 400) { const chunk = targetTrades.slice(i, i + 400); const batch = db.batch(); for (const t of chunk) { batch.delete(db.collection('users').doc(currentUser.uid).collection('trades').doc(t.id)); } await batch.commit(); cleared += chunk.length; } trades = trades.filter(t => t.accountId !== accountId); showToast(`${cleared} trades deleted from ${account.name}`, 'success'); renderAll(); } catch (e) { console.error('Clear trade history error:', e); alert('Error: ' + e.message); } } async function resetRithmicPrefs() { const user = firebase.auth().currentUser; if (!user) return; if (!confirm('Reset Rithmic account preferences?\n\nNext sync will show the account picker again for all accounts.')) return; await db.collection('users').doc(user.uid).collection('settings').doc('rithmicPreferences').delete().catch(() => {}); alert('Rithmic preferences reset. Next sync will prompt you to select accounts.'); } // ===================================================== // RITHMIC ACCOUNT PICKER // ===================================================== let _rithmicPickerResolve = null; function showRithmicAccountPicker(allAccounts, savedPrefs, systemName) { return new Promise((resolve) => { _rithmicPickerResolve = resolve; const listEl = document.getElementById('rithmic-account-picker-list'); listEl.innerHTML = allAccounts.map(a => { const id = a.accountId; const isNew = !(id in savedPrefs); const wasIncluded = savedPrefs[id]?.include !== false; const checked = isNew || wasIncluded; const label = a.accountName || id; // All accounts returned by Rithmic are accessible const statusBadge = `ACTIVE`; return ` `; }).join(''); // Clear search const searchInput = document.getElementById('rithmic-account-search'); if (searchInput) searchInput.value = ''; document.getElementById('rithmic-account-picker-modal').style.display = 'flex'; }); } function updateRithmicRowHighlight(checkbox) { const row = checkbox.closest('.rithmic-acct-row'); if (checkbox.checked) { row.style.opacity = '1'; row.style.background = 'rgba(0, 212, 170, 0.08)'; } else { row.style.opacity = '0.5'; row.style.background = 'transparent'; } } function toggleAllRithmicAccounts(checked) { document.querySelectorAll('#rithmic-account-picker-list input[type="checkbox"]').forEach(cb => { cb.checked = checked; updateRithmicRowHighlight(cb); }); } function filterRithmicAccountList() { const query = (document.getElementById('rithmic-account-search')?.value || '').toLowerCase(); document.querySelectorAll('#rithmic-account-picker-list .rithmic-acct-row').forEach(row => { const id = (row.dataset.accountId || '').toLowerCase(); row.style.display = id.includes(query) ? 'flex' : 'none'; }); } function confirmRithmicAccountPicker() { const selected = Array.from( document.querySelectorAll('#rithmic-account-picker-list input[type="checkbox"]:checked') ).map(cb => cb.value); document.getElementById('rithmic-account-picker-modal').style.display = 'none'; if (_rithmicPickerResolve) { _rithmicPickerResolve(selected); _rithmicPickerResolve = null; } } function resolveRithmicAccountPicker(accts) { document.getElementById('rithmic-account-picker-modal').style.display = 'none'; document.getElementById('rithmic-progress').style.display = 'none'; document.getElementById('rithmic-sync-btn').style.display = 'block'; if (_rithmicPickerResolve) { _rithmicPickerResolve(accts); _rithmicPickerResolve = null; } } // ===================================================== // RITHMIC ACCOUNT SETUP WIZARD // ===================================================== let _rithmicSetupAccounts = []; let _rithmicSetupIndex = 0; let _rithmicSetupSystemName = ''; let _rithmicSetupPropFirm = ''; // Async wrapper: shows the setup wizard and returns a promise that resolves when the modal closes. // Does not modify the wizard itself — just watches for the modal to be hidden. function waitForSetupWizard(newAccountIds, systemName, propFirm) { return new Promise(resolve => { showRithmicSetupWizard(newAccountIds, systemName, propFirm); const modal = document.getElementById('rithmic-setup-modal'); const checkClosed = setInterval(() => { if (modal.style.display === 'none' || modal.style.display === '') { clearInterval(checkClosed); resolve(); } }, 300); }); } function showRithmicSetupWizard(newAccountIds, systemName, propFirm) { _rithmicSetupAccounts = newAccountIds; _rithmicSetupIndex = 0; _rithmicSetupSystemName = systemName; _rithmicSetupPropFirm = propFirm; if (_rithmicSetupAccounts.length === 0) return; // Dynamically populate prop firm dropdown from propFirmConfigs const firmSelect = document.getElementById('rithmic-setup-firm'); const currentVal = firmSelect.value; firmSelect.innerHTML = ''; const skipKeys = ['personal', 'other']; Object.entries(propFirmConfigs) .filter(([key, cfg]) => !skipKeys.includes(key) && cfg.name) .sort((a, b) => a[1].name.localeCompare(b[1].name)) .forEach(([key, cfg]) => { const opt = document.createElement('option'); opt.value = key; opt.textContent = cfg.name; firmSelect.appendChild(opt); }); // Always add Personal and Other at end const personalOpt = document.createElement('option'); personalOpt.value = 'personal'; personalOpt.textContent = 'Personal Account'; firmSelect.appendChild(personalOpt); const otherOpt = document.createElement('option'); otherOpt.value = 'other'; otherOpt.textContent = 'Other'; firmSelect.appendChild(otherOpt); // Always show prop firm field (pre-filled from detected system, user can change) document.getElementById('rithmic-setup-firm-group').style.display = 'block'; firmSelect.value = propFirm; showRithmicSetupAccount(0); document.getElementById('rithmic-setup-modal').style.display = 'flex'; } function showRithmicSetupAccount(index) { if (index >= _rithmicSetupAccounts.length) { closeRithmicSetupModal(); renderAll(); return; } _rithmicSetupIndex = index; const accountId = _rithmicSetupAccounts[index]; const account = accounts.find(a => a.rithmicAccountId === accountId || a.tradovateAccountId === accountId || a.name === accountId); document.getElementById('rithmic-setup-counter').textContent = `${index + 1} of ${_rithmicSetupAccounts.length}`; document.getElementById('rithmic-setup-account-name').textContent = accountId; // 1. Pre-fill stage const detectedStage = account?.stage || null; document.getElementById('rithmic-setup-stage').value = detectedStage || ''; // Toggle funded/eval fields document.getElementById('rithmic-setup-funded-fields').style.display = detectedStage === 'funded' ? 'block' : 'none'; document.getElementById('rithmic-setup-eval-fields').style.display = detectedStage === 'evaluation' ? 'block' : 'none'; // 2. Pre-fill firm FIRST — so balance dropdown populates with correct sizes const firmForSetup = (account?.propFirm && account.propFirm !== 'other') ? account.propFirm : _rithmicSetupPropFirm; document.getElementById('rithmic-setup-firm').value = firmForSetup; // 3. Populate balance dropdown for this firm (firm-specific sizes only) populateSetupBalanceDropdown(firmForSetup); // 4. Pre-fill balance — now uses the correctly filtered dropdown const bal = account?.startingBalance || 0; const balSelect = document.getElementById('rithmic-setup-balance'); const balCustom = document.getElementById('rithmic-setup-balance-custom'); if (bal > 0 && [...balSelect.options].some(o => o.value === String(bal))) { balSelect.value = String(bal); balCustom.style.display = 'none'; } else { balSelect.value = bal > 0 ? 'custom' : ''; balCustom.style.display = bal > 0 ? 'block' : 'none'; if (bal > 0) balCustom.value = bal; } // 5. Clear all rule fields ['rithmic-setup-drawdown', 'rithmic-setup-buffer', 'rithmic-setup-funded-dll', 'rithmic-setup-min-withdrawal', 'rithmic-setup-trading-days', 'rithmic-setup-profit-target', 'rithmic-setup-eval-drawdown', 'rithmic-setup-min-days', 'rithmic-setup-daily-loss', 'rithmic-setup-eval-cost', 'rithmic-setup-funded-cost'].forEach(id => { const el = document.getElementById(id); if (el) el.value = ''; }); document.getElementById('rithmic-setup-eval-date').value = ''; const fundedDateEl = document.getElementById('rithmic-setup-funded-date'); if (fundedDateEl) fundedDateEl.value = ''; // Hide payout rules/caps until repopulated const prEl = document.getElementById('rithmic-setup-payout-rules'); if (prEl) prEl.style.display = 'none'; const capsEl = document.getElementById('rithmic-setup-caps-section'); if (capsEl) capsEl.style.display = 'none'; // 6. Reset override // Override system removed — Rithmic setup modal no longer has an override checkbox. // 7. Populate plans (stage-filtered) and auto-fill rules onRithmicSetupFirmChange(); // 8. Restore plan selection if account already has one if (account?.plan) { const planSelect = document.getElementById('rithmic-setup-plan'); const planOpts = [...planSelect.options].map(o => o.value); if (planOpts.includes(account.plan)) { planSelect.value = account.plan; autoFillRithmicSetupRules(); } } } // Populate the setup wizard balance dropdown from propFirmConfig — mirrors populateBalanceDropdown() function populateSetupBalanceDropdown(firm) { const balSelect = document.getElementById('rithmic-setup-balance'); const config = propFirmConfigs[firm]; if (!config || firm === 'personal' || firm === 'other' || !config.accounts) { balSelect.innerHTML = ` `; return; } const accountSizes = Object.keys(config.accounts).map(Number).sort((a, b) => a - b); let options = ''; accountSizes.forEach(size => { options += ``; }); options += ''; balSelect.innerHTML = options; } function onRithmicSetupFirmChange() { const firm = document.getElementById('rithmic-setup-firm').value; const stage = document.getElementById('rithmic-setup-stage').value; const planGroup = document.getElementById('rithmic-setup-plan-group'); const planSelect = document.getElementById('rithmic-setup-plan'); // Repopulate balance dropdown — firm-specific sizes only const prevBalance = document.getElementById('rithmic-setup-balance').value; populateSetupBalanceDropdown(firm); // Restore selection if still valid in new options const balOpts = [...document.getElementById('rithmic-setup-balance').options].map(o => o.value); if (balOpts.includes(prevBalance)) { document.getElementById('rithmic-setup-balance').value = prevBalance; } else { document.getElementById('rithmic-setup-balance').value = ''; document.getElementById('rithmic-setup-balance-custom').style.display = 'none'; } // Stage-based plan filtering — mirrors manual Add Account modal const EVAL_ONLY_PLANS = { tradeify: ['growth', 'select'], topstep: ['combine'] }; const FUNDED_ONLY_PLANS = { tradeify: ['growth', 'select_flex', 'select_daily', 'lightning'], topstep: ['xfa_standard', 'xfa_consistency'] }; planSelect.innerHTML = ''; const config = propFirmConfigs[firm]; if (firm && config && config.plans && config.plans.length > 0) { let plans = config.plans; if (stage === 'evaluation' && EVAL_ONLY_PLANS[firm]) { plans = config.plans.filter(p => EVAL_ONLY_PLANS[firm].includes(p.id)); } else if (stage === 'funded' && FUNDED_ONLY_PLANS[firm]) { plans = config.plans.filter(p => FUNDED_ONLY_PLANS[firm].includes(p.id)); } plans.forEach(plan => { const opt = document.createElement('option'); opt.value = plan.id || plan.name; opt.textContent = plan.name; planSelect.appendChild(opt); }); planGroup.style.display = plans.length > 1 ? 'block' : 'none'; if (plans.length === 1) planSelect.value = plans[0].id || plans[0].name; } else { planGroup.style.display = 'none'; } autoFillRithmicSetupRules(); } function onRithmicSetupStageChange() { const stage = document.getElementById('rithmic-setup-stage').value; document.getElementById('rithmic-setup-funded-fields').style.display = stage === 'funded' ? 'block' : 'none'; document.getElementById('rithmic-setup-eval-fields').style.display = stage === 'evaluation' ? 'block' : 'none'; // Re-filter plans for the new stage and re-auto-fill onRithmicSetupFirmChange(); } function onRithmicSetupBalanceChange() { const balSelect = document.getElementById('rithmic-setup-balance'); document.getElementById('rithmic-setup-balance-custom').style.display = balSelect.value === 'custom' ? 'block' : 'none'; autoFillRithmicSetupRules(); } function autoFillRithmicSetupRules() { const firm = document.getElementById('rithmic-setup-firm').value; const balSelect = document.getElementById('rithmic-setup-balance').value; const balance = balSelect === 'custom' ? parseFloat(document.getElementById('rithmic-setup-balance-custom').value) || 0 : parseFloat(balSelect) || 0; const stage = document.getElementById('rithmic-setup-stage').value; const plan = document.getElementById('rithmic-setup-plan').value; const config = propFirmConfigs[firm]; if (!firm || !config || firm === 'personal' || firm === 'other') return; if (stage === 'evaluation') { // Try Firestore evalRulesByPlan first — mirrors updateAddAccountEvalRules() let matchedRules = null; if (config.evalRulesByPlan) { const planRules = config.evalRulesByPlan[plan] || config.evalRulesByPlan['default'] || Object.values(config.evalRulesByPlan)[0]; if (planRules) { const availableSizes = Object.keys(planRules).map(Number).sort((a, b) => a - b); if (balance > 0) { matchedRules = planRules[balance] || planRules[availableSizes.reduce((c, s) => Math.abs(s - balance) < Math.abs(c - balance) ? s : c)]; } else { matchedRules = planRules[availableSizes[0]]; } } } // No fallback — surface Firestore gap so we can fix it if (!matchedRules) { console.warn('[Firestore Gap] No eval rules found for firm:', firm, 'size:', balance, 'plan:', plan, '— add evalRulesByPlan to Firestore propFirmConfig'); } if (matchedRules) { document.getElementById('rithmic-setup-profit-target').value = matchedRules.profitTarget || ''; document.getElementById('rithmic-setup-eval-drawdown').value = matchedRules.maxDrawdown || ''; document.getElementById('rithmic-setup-min-days').value = matchedRules.minTradingDays || matchedRules.minDays || ''; document.getElementById('rithmic-setup-daily-loss').value = matchedRules.dailyLossLimit || matchedRules.dailyLoss || 0; const ddTypeRaw = matchedRules.drawdownType || ''; const ddType = ddTypeRaw === '' ? '' : ddTypeRaw === 'trailing' ? 'Trailing' : ddTypeRaw === 'eod' ? 'EOD' : 'Static'; document.getElementById('rithmic-setup-dd-type').value = ddType; const consistencyRaw = matchedRules.consistencyRule || matchedRules.consistency || 'none'; const consistency = (consistencyRaw === 'none' || consistencyRaw === 'None') ? 'None' : consistencyRaw.toString().replace('%', '') + '%'; document.getElementById('rithmic-setup-eval-consistency').value = consistency; } // Eval price suggestion const priceSuggestion = (typeof getEvalPricingSuggestion === 'function') ? getEvalPricingSuggestion(firm, balance) : null; const suggestionEl = document.getElementById('rithmic-setup-eval-price-suggestion'); if (priceSuggestion && balance > 0 && suggestionEl) { const firmName = config.name || firm; let html = `
`; html += `
💡 Typical ${firmName} ${(balance/1000).toFixed(0)}K Pricing:
`; html += `
`; html += `Full Price: $${priceSuggestion.fullPrice}`; html += `Sale Price: ~$${priceSuggestion.typicalSale}`; if (priceSuggestion.activationFee > 0) html += `PA Fee: $${priceSuggestion.activationFee}`; html += `
`; suggestionEl.innerHTML = html; suggestionEl.style.display = 'block'; const costInput = document.getElementById('rithmic-setup-eval-cost'); if (costInput && !costInput.value) costInput.placeholder = priceSuggestion.typicalSale.toFixed(2); } else if (suggestionEl) { suggestionEl.style.display = 'none'; } // Set default purchase date const evalDateEl = document.getElementById('rithmic-setup-eval-date'); if (evalDateEl && !evalDateEl.value) evalDateEl.value = new Date().toISOString().split('T')[0]; } else { // Funded path — mirrors updateAddAccountFromBalance() let accountConfig = null; if (plan && config.accountsByPlan && config.accountsByPlan[plan] && config.accountsByPlan[plan][balance]) { accountConfig = config.accountsByPlan[plan][balance]; } else if (plan && config.planAccounts && config.planAccounts[plan] && config.planAccounts[plan][balance]) { accountConfig = config.planAccounts[plan][balance]; } else { accountConfig = config.accounts?.[balance]; } if (accountConfig) { document.getElementById('rithmic-setup-drawdown').value = accountConfig.drawdown !== undefined ? accountConfig.drawdown : ''; document.getElementById('rithmic-setup-buffer').value = accountConfig.buffer !== undefined ? accountConfig.buffer : ''; const dll = accountConfig.dailyLossLimit || accountConfig.dailyLoss || 0; const dllEl = document.getElementById('rithmic-setup-funded-dll'); if (dllEl) { dllEl.value = dll || ''; dllEl.placeholder = dll ? dll : 'None'; } // Payout caps if (accountConfig.caps) { document.getElementById('rithmic-setup-caps-section').style.display = 'block'; for (let i = 0; i < 5; i++) { const capEl = document.getElementById(`rithmic-setup-cap-${i+1}`); if (capEl) capEl.value = accountConfig.caps[i] || ''; } } else { document.getElementById('rithmic-setup-caps-section').style.display = 'none'; } } const planPayoutRules = config.rulesByPlan?.[plan]?.payout; if (planPayoutRules) { const splitSelect = document.getElementById('rithmic-setup-profit-split'); const splitVal = planPayoutRules.profitSplitAfterPct ? `${planPayoutRules.profitSplitAfterPct}/${100 - planPayoutRules.profitSplitAfterPct}` : (planPayoutRules.profitSplit || config.profitSplit)?.replace('-', '/'); if (splitVal) { for (const option of splitSelect.options) { if (option.value === splitVal) { splitSelect.value = splitVal; break; } } } const consRaw = planPayoutRules.consistencyRule || config.consistency || 'None'; const consVal = (consRaw === 'none' || consRaw === 'None') ? 'None' : consRaw.toString().replace('%','') + '%'; document.getElementById('rithmic-setup-consistency').value = consVal; document.getElementById('rithmic-setup-min-withdrawal').value = planPayoutRules.minWithdrawal || config.minWithdrawal || ''; document.getElementById('rithmic-setup-trading-days').value = planPayoutRules.minTradingDays || resolveMinProfitableDays(planPayoutRules.minProfitableDays, balance) || config.payoutTiming?.days || ''; } else { if (config.profitSplit) { const splitSelect = document.getElementById('rithmic-setup-profit-split'); const splitValue = config.profitSplit.replace('-', '/'); for (const option of splitSelect.options) { if (option.value === splitValue) { splitSelect.value = splitValue; break; } } } if (config.consistency) document.getElementById('rithmic-setup-consistency').value = config.consistency; if (config.minWithdrawal) document.getElementById('rithmic-setup-min-withdrawal').value = config.minWithdrawal; if (config.payoutTiming?.days) document.getElementById('rithmic-setup-trading-days').value = config.payoutTiming.days; } // Plan-specific buffer override if (plan && config.accountsByPlan?.[plan]) { const planAcct = config.accountsByPlan[plan][balance]; if (planAcct && planAcct.buffer !== undefined) document.getElementById('rithmic-setup-buffer').value = planAcct.buffer; } // Payout rules display if (typeof generatePayoutRules === 'function' && accountConfig) { let planRules = null; if (plan && config.rulesByPlan?.[plan]?.payout) { const planPayoutRules = config.rulesByPlan[plan].payout; planRules = { consistency: planPayoutRules.consistencyRule || 'None', payoutTiming: { type: resolveMinProfitableDays(planPayoutRules.minProfitableDays, balance) ? 'winningDays' : 'tradingDays', days: planPayoutRules.minTradingDays ?? resolveMinProfitableDays(planPayoutRules.minProfitableDays, balance), qualifyingDays: resolveMinProfitableDays(planPayoutRules.minProfitableDays, balance), minProfitPerDay: resolveMinProfitPerDay(planPayoutRules.minProfitPerDay, balance) }, maxWithdrawalAmt: planPayoutRules.maxWithdrawalAmt || null, maxWithdrawalPct: planPayoutRules.maxWithdrawalPct || null, profitSplitNote: (typeof buildProfitSplitNote === 'function') ? buildProfitSplitNote(planPayoutRules) : null }; } const rules = generatePayoutRules(config, accountConfig, planRules); const rulesEl = document.getElementById('rithmic-setup-payout-rules'); if (rulesEl) { if (rules && rules.length > 0) { rulesEl.style.display = 'block'; document.getElementById('rithmic-setup-rules-list').innerHTML = rules.map(r => `• ${r}`).join('
'); } else { rulesEl.style.display = 'none'; } } } } } function toggleRithmicSetupOverride() { const override = document.getElementById('rithmic-setup-override').checked; const fundedFields = ['rithmic-setup-drawdown', 'rithmic-setup-buffer', 'rithmic-setup-funded-dll', 'rithmic-setup-profit-split', 'rithmic-setup-consistency', 'rithmic-setup-min-withdrawal', 'rithmic-setup-trading-days']; const evalFields = ['rithmic-setup-profit-target', 'rithmic-setup-eval-drawdown', 'rithmic-setup-min-days', 'rithmic-setup-dd-type', 'rithmic-setup-eval-consistency', 'rithmic-setup-daily-loss']; [...fundedFields, ...evalFields].forEach(id => { const el = document.getElementById(id); if (el) el.disabled = !override; }); } async function saveRithmicSetupAndNext() { const user = firebase.auth().currentUser; if (!user) return; const accountId = _rithmicSetupAccounts[_rithmicSetupIndex]; const account = accounts.find(a => a.rithmicAccountId === accountId || a.tradovateAccountId === accountId || a.name === accountId); if (!account) { showRithmicSetupAccount(_rithmicSetupIndex + 1); return; } const stage = document.getElementById('rithmic-setup-stage').value; const balSelect = document.getElementById('rithmic-setup-balance').value; const balCustom = document.getElementById('rithmic-setup-balance-custom').value; const balance = balSelect === 'custom' ? parseFloat(balCustom) || 0 : parseFloat(balSelect) || 0; const firm = document.getElementById('rithmic-setup-firm').value || _rithmicSetupPropFirm; const plan = document.getElementById('rithmic-setup-plan').value; if (balance <= 0) { alert('Please select or enter a starting balance.'); return; } // Build update object const updates = { startingBalance: balance, stage: stage, isEvaluation: stage === 'evaluation', propFirm: firm }; if (plan) updates.plan = plan; if (stage === 'funded') { const drawdown = parseFloat(document.getElementById('rithmic-setup-drawdown').value); const buffer = parseFloat(document.getElementById('rithmic-setup-buffer').value); const fundedDll = parseFloat(document.getElementById('rithmic-setup-funded-dll')?.value); const profitSplit = document.getElementById('rithmic-setup-profit-split').value; const fundedCost = parseFloat(document.getElementById('rithmic-setup-funded-cost')?.value); const fundedDate = document.getElementById('rithmic-setup-funded-date')?.value; // Cost-without-date validation: if a cost is entered, require a purchase date if (fundedCost > 0 && !fundedDate) { alert('Please enter a purchase date for this account cost'); var _rsFundedDateEl = document.getElementById('rithmic-setup-funded-date'); if (_rsFundedDateEl) _rsFundedDateEl.style.border = '1px solid #ffb400'; return; } if (drawdown) updates.drawdown = drawdown; if (buffer) updates.buffer = buffer; if (fundedDll) updates.dailyLossLimit = fundedDll; if (profitSplit) updates.profitSplit = profitSplit; // consistency, minWithdrawal, tradingDays, payoutCaps are sourced from // propFirmConfig at runtime — never persist them on the account doc. if (firm && propFirmConfigs[firm]?.drawdownType) { updates.drawdownType = propFirmConfigs[firm].drawdownType; } if (fundedCost) { updates.fundedCost = fundedCost; if (fundedDate) updates.fundedPurchaseDate = fundedDate; } } else { // Evaluation fields const profitTarget = parseFloat(document.getElementById('rithmic-setup-profit-target').value); const evalDrawdown = parseFloat(document.getElementById('rithmic-setup-eval-drawdown').value); const minDays = parseInt(document.getElementById('rithmic-setup-min-days').value); const ddType = document.getElementById('rithmic-setup-dd-type').value; const evalConsistency = document.getElementById('rithmic-setup-eval-consistency').value; const dailyLoss = parseFloat(document.getElementById('rithmic-setup-daily-loss').value); const evalCost = parseFloat(document.getElementById('rithmic-setup-eval-cost').value); const evalDate = document.getElementById('rithmic-setup-eval-date').value; // Cost-without-date validation: if a cost is entered, require a purchase date if (evalCost > 0 && !evalDate) { alert('Please enter a purchase date for this account cost'); var _rsEvalDateEl = document.getElementById('rithmic-setup-eval-date'); if (_rsEvalDateEl) _rsEvalDateEl.style.border = '1px solid #ffb400'; return; } if (profitTarget) updates.profitTarget = profitTarget; if (evalDrawdown) updates.drawdown = evalDrawdown; if (minDays) updates.minDays = minDays; if (ddType) updates.drawdownType = ddType; if (evalConsistency && evalConsistency !== 'None') updates.consistency = evalConsistency; if (dailyLoss) updates.dailyLossLimit = dailyLoss; if (evalCost) updates.cost = evalCost; if (evalDate) updates.purchaseDate = evalDate; } try { console.log(`[Rithmic Setup] Saving account ${accountId}:`, JSON.stringify(updates, null, 2)); await db.collection('users').doc(user.uid).collection('accounts').doc(account.id).update(updates); Object.assign(account, updates); // Upsert eval expense (deterministic id prevents duplicates on repeat setup) if (stage === 'evaluation' && updates.cost > 0) { const firmName = propFirmConfigs[firm]?.name || firm; const expId = 'exp_eval_' + account.id; const existingIdx = expenses.findIndex(e => e.id === expId); const expense = { id: expId, accountId: account.id, accountName: accountId, propFirm: firm, type: 'eval_fee', amount: updates.cost, description: `${firmName} ${(balance/1000).toFixed(0)}K Evaluation`, date: updates.purchaseDate || new Date().toISOString().slice(0, 10), createdAt: existingIdx !== -1 ? expenses[existingIdx].createdAt : new Date().toISOString(), updatedAt: new Date().toISOString() }; if (existingIdx !== -1) expenses[existingIdx] = expense; else expenses.push(expense); await saveExpenses(); } // Upsert funded account cost expense (deterministic id prevents duplicates on repeat setup) if (stage === 'funded' && updates.fundedCost > 0) { const firmName = propFirmConfigs[firm]?.name || firm; const expId = 'exp_act_' + account.id; const existingIdx = expenses.findIndex(e => e.id === expId); const expense = { id: expId, accountId: account.id, accountName: accountId, propFirm: firm, type: 'funded_fee', amount: updates.fundedCost, description: `${firmName} ${(balance/1000).toFixed(0)}K Account`, date: updates.fundedPurchaseDate || new Date().toISOString().slice(0, 10), createdAt: existingIdx !== -1 ? expenses[existingIdx].createdAt : new Date().toISOString(), updatedAt: new Date().toISOString() }; if (existingIdx !== -1) expenses[existingIdx] = expense; else expenses.push(expense); await saveExpenses(); } console.log(`Setup saved for ${accountId}:`, updates); } catch (e) { console.error('Error saving account setup:', e); alert('Error saving: ' + e.message); return; } showRithmicSetupAccount(_rithmicSetupIndex + 1); } async function skipOneRithmicSetup() { // Delete the skipped account from Firestore so it doesn't appear anywhere const accountName = _rithmicSetupAccounts[_rithmicSetupIndex]; const account = accounts.find(a => a.rithmicAccountId === accountName || a.tradovateAccountId === accountName || a.name === accountName); if (account && currentUser) { try { await db.collection('users').doc(currentUser.uid).collection('accounts').doc(account.id).delete(); // Determine if this is a Tradovate or Rithmic account and update the correct preferences const isTradovate = _rithmicSetupSystemName === 'Tradovate' || account.connectionType === 'tradovate'; const prefsDocName = isTradovate ? 'tradovatePreferences' : 'rithmicPreferences'; const accountKey = isTradovate ? (account.tradovateAccountId || account.name) : (account.rithmicAccountId || account.name); if (accountKey) { const prefsDoc = await db.collection('users').doc(currentUser.uid) .collection('settings').doc(prefsDocName).get(); const prefs = prefsDoc.exists ? (prefsDoc.data().accountPreferences || {}) : {}; prefs[accountKey] = { include: false, skippedAt: new Date().toISOString() }; await db.collection('users').doc(currentUser.uid) .collection('settings').doc(prefsDocName) .set({ accountPreferences: prefs }, { merge: true }); } // Remove from local accounts array const idx = accounts.findIndex(a => a.id === account.id); if (idx !== -1) accounts.splice(idx, 1); console.log('[Setup] Skipped and deleted account:', accountName, '(prefs:', prefsDocName + ')'); } catch (e) { console.error('[Setup] Error deleting skipped account:', e); } } showRithmicSetupAccount(_rithmicSetupIndex + 1); } function skipAllRithmicSetup() { closeRithmicSetupModal(); renderAll(); } async function confirmArchive(retainMetrics) { if (!pendingArchiveAccountId) { console.warn('[archive] no pending archive accountId'); showNotification('Archive request lost — please try again.', 'error'); return; } const accountId = pendingArchiveAccountId; const account = accounts.find(a => a.id === accountId); if (!account) { console.warn('[archive] account not found in memory:', accountId); showNotification('Could not find account — try refreshing the page.', 'error'); return; } try { account.archived = true; account.archivedAt = new Date().toISOString(); account.excludeFromMetrics = !retainMetrics; account.accountStatus = 'archived'; await db.collection('users').doc(currentUser.uid).collection('accounts').doc(accountId).update({ archived: true, archivedAt: account.archivedAt, excludeFromMetrics: !retainMetrics, accountStatus: 'archived' }); closeArchiveModal(); renderAll(); renderConnectionsPage(); const metricsMsg = retainMetrics ? 'Trades will remain in your performance metrics.' : 'Trades excluded from performance metrics.'; showNotification(`Account archived. ${metricsMsg}`, 'success'); } catch (error) { console.error('Error archiving account:', error); alert('Error archiving account'); } } async function unarchiveAccount(accountId) { const account = accounts.find(a => a.id === accountId); if (!account) { console.warn('[archive] account not found in memory:', accountId); showNotification('Could not find account — try refreshing the page.', 'error'); return; } try { account.archived = false; account.accountStatus = 'active'; delete account.archivedAt; delete account.excludeFromMetrics; await db.collection('users').doc(currentUser.uid).collection('accounts').doc(accountId).update({ archived: false, accountStatus: 'active', archivedAt: firebase.firestore.FieldValue.delete(), excludeFromMetrics: firebase.firestore.FieldValue.delete() }); renderAll(); renderConnectionsPage(); showNotification('Account restored. Trades now included in metrics.', 'success'); } catch (error) { console.error('Error restoring account:', error); alert('Error restoring account'); } } // ==================== ACCOUNT SETUP (AUTO-CREATED) ==================== // Filter helpers for Tradovate sync UI button state. firmKey scopes to a // single prop firm (Connections page per-firm groups); omit for global. const _tradovateUnconfiguredAccounts = (firmKey = null) => accounts.filter(a => a.connectionType === 'tradovate' && (a.needsSetup === true || a.propFirm === 'unknown' || !a.propFirm) && !a.archived && (firmKey === null || a.propFirm === firmKey) ); const hasUnconfiguredTradovateAccounts = (firmKey = null) => _tradovateUnconfiguredAccounts(firmKey).length > 0; const unconfiguredTradovateCount = (firmKey = null) => _tradovateUnconfiguredAccounts(firmKey).length; function relaunchPendingTradovateSetup(firmKey = null) { const pending = _tradovateUnconfiguredAccounts(firmKey); if (pending.length === 0) return; // Re-launch is an explicit user action — clear in-memory "seen" state // so the wizard walks through these accounts again. pending.forEach(a => _setupAccountsSeen.delete(a.id)); _pendingSetupAccounts = pending; showNextAccountSetup(); } let _pendingSetupAccounts = []; const _setupAccountsSeen = new Set(); function checkPendingAccountSetups() { // Only show setup modals on the tab that triggered the sync if (!sessionStorage.getItem('pl_sync_tab')) return; const pending = accounts.filter(a => a.needsSetup === true && !a.archived && !_setupAccountsSeen.has(a.id)); if (pending.length === 0) { // Accounts haven't been auto-created yet (typical for first-OAuth where // Tradovate sync is still running). Retry up to 15 times at 1s intervals. if (!window._setupCheckRetries) window._setupCheckRetries = 0; window._setupCheckRetries++; if (window._setupCheckRetries < 15) { setTimeout(checkPendingAccountSetups, 1000); } else { // Give up — no accounts arrived in 15s. Either the sync failed or there // really are no pending accounts. Clear the flag to avoid stale state. sessionStorage.removeItem('pl_sync_tab'); window._setupCheckRetries = 0; } return; } // Found pending accounts — proceed with wizard sessionStorage.removeItem('pl_sync_tab'); window._setupCheckRetries = 0; _pendingSetupAccounts = pending; showNextAccountSetup(); } function showNextAccountSetup() { const setupModal = document.getElementById('account-setup-modal'); if (setupModal && setupModal.style.display !== 'none') { console.log('[Setup] Modal already open — skipping re-trigger'); return; } if (_pendingSetupAccounts.length === 0) return; const acc = _pendingSetupAccounts[0]; document.getElementById('setup-account-id').value = acc.id; document.getElementById('setup-account-desc').innerHTML = `Your account ${acc.name} was automatically added. Complete its setup for accurate payout tracking.`; // Reset override to unchecked for each new account // Override system removed — setup modal no longer has an override checkbox. // Clear rule fields ['setup-account-drawdown', 'setup-account-buffer', 'setup-account-funded-dll', 'setup-account-min-withdrawal', 'setup-account-trading-days', 'setup-account-profit-target', 'setup-account-eval-drawdown', 'setup-account-min-days', 'setup-account-daily-loss', 'setup-account-eval-cost'].forEach(id => { const el = document.getElementById(id); if (el) el.value = ''; }); document.getElementById('setup-account-eval-date').value = ''; // Pre-fill with existing values if any const firmSelect = document.getElementById('setup-account-prop-firm'); if (firmSelect) firmSelect.value = (acc.propFirm && acc.propFirm !== 'unknown') ? acc.propFirm : ''; const balSelect = document.getElementById('setup-account-balance'); const balCustom = document.getElementById('setup-account-balance-custom'); const presetSizes = ['25000', '50000', '75000', '100000', '150000', '200000', '250000', '300000']; if (balSelect && balCustom) { balCustom.style.display = 'none'; balCustom.value = ''; if (acc.startingBalance > 0) { const balStr = String(acc.startingBalance); if (presetSizes.includes(balStr)) { balSelect.value = balStr; } else { balSelect.value = 'custom'; balCustom.style.display = 'block'; balCustom.value = balStr; } } else { balSelect.value = ''; } } const stageSelect = document.getElementById('setup-account-stage'); const detectedStage = acc.stage || null; if (stageSelect) stageSelect.value = detectedStage || ''; // Toggle funded/eval rule fields to match stage document.getElementById('setup-account-funded-fields').style.display = detectedStage === 'funded' ? 'block' : 'none'; document.getElementById('setup-account-eval-fields').style.display = detectedStage === 'evaluation' ? 'block' : 'none'; // Populate plan dropdown (also calls autoFillSetupRules) updateSetupPlanDropdown(); const planSelect = document.getElementById('setup-account-plan'); if (planSelect && acc.plan) { planSelect.value = acc.plan; autoFillSetupRules(); } // Show remaining count const remaining = document.getElementById('setup-account-remaining'); if (_pendingSetupAccounts.length > 1) { remaining.style.display = 'block'; remaining.textContent = `${_pendingSetupAccounts.length - 1} more account${_pendingSetupAccounts.length - 1 > 1 ? 's' : ''} to set up after this`; } else { remaining.style.display = 'none'; } document.getElementById('account-setup-modal').style.display = 'flex'; } function updateSetupPlanDropdown() { const firmKey = document.getElementById('setup-account-prop-firm').value; const planGroup = document.getElementById('setup-plan-group'); const planSelect = document.getElementById('setup-account-plan'); if (!planGroup || !planSelect) return; // Stage-based plan filtering — mirrors Add Account / Edit Account modals. // Without this, Evaluation stage offers funded-only plans (e.g. Tradeify Select Flex) // and Funded stage offers eval-only plans. Fourth copy of this map in the codebase; // pending hoist to module scope in a follow-up DRY cleanup. const stage = document.getElementById('setup-account-stage')?.value || ''; const EVAL_ONLY_PLANS = { tradeify: ['growth', 'select'], topstep: ['combine'] }; const FUNDED_ONLY_PLANS = { tradeify: ['growth', 'select_flex', 'select_daily', 'lightning'], topstep: ['xfa_standard', 'xfa_consistency'] }; const config = propFirmConfigs[firmKey]; if (!config || !config.plans || config.plans.length <= 1) { planGroup.style.display = 'none'; planSelect.innerHTML = ''; } else { let plans = config.plans.filter(p => !p._isLegacy && p.id !== '_status'); if (stage === 'evaluation' && EVAL_ONLY_PLANS[firmKey]) { plans = plans.filter(p => EVAL_ONLY_PLANS[firmKey].includes(p.id)); } else if ((stage === 'funded' || stage === 'live') && FUNDED_ONLY_PLANS[firmKey]) { plans = plans.filter(p => FUNDED_ONLY_PLANS[firmKey].includes(p.id)); } planGroup.style.display = plans.length > 1 ? '' : 'none'; planSelect.innerHTML = '' + plans.map(p => ``).join(''); if (plans.length === 1) planSelect.value = plans[0].id; } // Personal accounts: auto-switch balance to custom mode (preset prop firm sizes don't apply) if (firmKey === 'personal') { const balSelect = document.getElementById('setup-account-balance'); const balCustom = document.getElementById('setup-account-balance-custom'); if (balSelect && balCustom && balSelect.value !== 'custom') { // Preserve any pre-filled balance from the existing account doc const existingNumeric = parseFloat(balSelect.value) || 0; balSelect.value = 'custom'; balCustom.style.display = 'block'; if (existingNumeric > 0 && !balCustom.value) { balCustom.value = String(existingNumeric); } } } autoFillSetupRules(); } function onSetupStageChange() { const stage = document.getElementById('setup-account-stage').value; document.getElementById('setup-account-funded-fields').style.display = stage === 'funded' ? 'block' : 'none'; document.getElementById('setup-account-eval-fields').style.display = stage === 'evaluation' ? 'block' : 'none'; // Re-filter plan options for the new stage (also calls autoFillSetupRules internally) updateSetupPlanDropdown(); } function getSetupBalanceValue() { const balSelect = document.getElementById('setup-account-balance'); const balCustom = document.getElementById('setup-account-balance-custom'); if (balSelect && balSelect.value === 'custom') { return parseFloat(balCustom?.value) || 0; } return parseFloat(balSelect?.value) || 0; } function onSetupBalanceChange() { const balSelect = document.getElementById('setup-account-balance'); const balCustom = document.getElementById('setup-account-balance-custom'); if (!balSelect || !balCustom) return; if (balSelect.value === 'custom') { balCustom.style.display = 'block'; balCustom.focus(); } else { balCustom.style.display = 'none'; balCustom.value = ''; } autoFillSetupRules(); } function autoFillSetupRules() { const firm = document.getElementById('setup-account-prop-firm').value; const balance = getSetupBalanceValue(); const stage = document.getElementById('setup-account-stage').value; const config = propFirmConfigs[firm]; if (!firm || !config || firm === 'personal' || firm === 'other') return; if (stage === 'evaluation') { const plan = document.getElementById('setup-account-plan')?.value || ''; const evalRules = config.evalRulesByPlan?.[plan]?.[balance]; const evalConfig = config.accountsByPlan?.[plan]?.[balance] || config.accounts?.[balance]; const profitTargetEl = document.getElementById('setup-account-profit-target'); const maxDrawdownEl = document.getElementById('setup-account-eval-drawdown'); const minDaysEl = document.getElementById('setup-account-min-days'); const drawdownTypeEl = document.getElementById('setup-account-dd-type'); const consistencyEl = document.getElementById('setup-account-eval-consistency'); const dailyLossEl = document.getElementById('setup-account-daily-loss'); if (profitTargetEl) profitTargetEl.value = evalRules?.profitTarget || evalConfig?.profitTarget || ''; if (maxDrawdownEl) maxDrawdownEl.value = evalRules?.maxDrawdown ?? evalConfig?.drawdown ?? ''; if (minDaysEl) minDaysEl.value = evalRules?.minTradingDays || evalRules?.minDays || evalConfig?.minDays || ''; const ddRaw2 = evalRules?.drawdownType || evalConfig?.drawdownType || ''; const ddMap2 = { 'eod': 'EOD', 'trailing': 'Trailing', 'static': 'Static', 'realtime': 'Trailing', 'intraday': 'Trailing' }; if (drawdownTypeEl) drawdownTypeEl.value = ddMap2[ddRaw2?.toLowerCase()] || ddRaw2 || ''; if (consistencyEl) consistencyEl.value = evalRules?.consistencyRule || evalConfig?.consistencyRule || ''; if (dailyLossEl) dailyLossEl.value = evalRules?.dailyLossLimit || evalRules?.dailyLoss || evalConfig?.dailyLossLimit || 0; if (!evalRules && !evalConfig) { console.warn('[Firestore Gap] No eval rules found for firm:', firm, 'size:', balance, 'plan:', plan, '— add evalRulesByPlan to Firestore propFirmConfig'); } return; } else { // Mirror updateEditAccountFromBalance() exactly (funded path) const accountConfig = config.accounts?.[balance]; if (accountConfig) { document.getElementById('setup-account-drawdown').value = accountConfig.drawdown !== undefined ? accountConfig.drawdown : ''; document.getElementById('setup-account-buffer').value = accountConfig.buffer !== undefined ? accountConfig.buffer : ''; const sDll = accountConfig.dailyLossLimit || accountConfig.dailyLoss || 0; document.getElementById('setup-account-funded-dll').value = sDll || ''; document.getElementById('setup-account-funded-dll').placeholder = sDll ? sDll : 'None'; } const plan = document.getElementById('setup-account-plan').value; const planPayoutRules = config.rulesByPlan?.[plan]?.payout; if (planPayoutRules) { if (config.profitSplit || planPayoutRules.profitSplitAfterPct) { const splitSelect = document.getElementById('setup-account-profit-split'); const splitVal = planPayoutRules.profitSplitAfterPct ? `${planPayoutRules.profitSplitAfterPct}/${100 - planPayoutRules.profitSplitAfterPct}` : config.profitSplit?.replace('-', '/'); if (splitVal) { for (const option of splitSelect.options) { if (option.value === splitVal) { splitSelect.value = splitVal; break; } } } } document.getElementById('setup-account-consistency').value = planPayoutRules.consistencyRule || config.consistency || 'None'; document.getElementById('setup-account-min-withdrawal').value = planPayoutRules.minWithdrawal || config.minWithdrawal || ''; document.getElementById('setup-account-trading-days').value = planPayoutRules.minTradingDays || resolveMinProfitableDays(planPayoutRules.minProfitableDays, balance) || config.payoutTiming?.days || ''; } else { if (config.profitSplit) { const splitSelect = document.getElementById('setup-account-profit-split'); const splitValue = config.profitSplit.replace('-', '/'); for (const option of splitSelect.options) { if (option.value === splitValue) { splitSelect.value = splitValue; break; } } } if (config.consistency) document.getElementById('setup-account-consistency').value = config.consistency; if (config.minWithdrawal) document.getElementById('setup-account-min-withdrawal').value = config.minWithdrawal; if (config.payoutTiming?.days) document.getElementById('setup-account-trading-days').value = config.payoutTiming.days; } // Plan-specific buffer + DLL override from accountsByPlan if (plan && config.accountsByPlan?.[plan]) { const planAcct = config.accountsByPlan[plan][balance]; if (planAcct && planAcct.buffer !== undefined) { document.getElementById('setup-account-buffer').value = planAcct.buffer; } if (planAcct) { const pDll = planAcct.dailyLossLimit || planAcct.dailyLoss || 0; document.getElementById('setup-account-funded-dll').value = pDll || ''; document.getElementById('setup-account-funded-dll').placeholder = pDll ? pDll : 'None'; } } } } function toggleSetupOverride() { const override = document.getElementById('setup-account-override').checked; const fundedFields = ['setup-account-drawdown', 'setup-account-buffer', 'setup-account-funded-dll', 'setup-account-profit-split', 'setup-account-consistency', 'setup-account-min-withdrawal', 'setup-account-trading-days']; const evalFields = ['setup-account-profit-target', 'setup-account-eval-drawdown', 'setup-account-min-days', 'setup-account-dd-type', 'setup-account-eval-consistency', 'setup-account-daily-loss']; [...fundedFields, ...evalFields].forEach(id => { const el = document.getElementById(id); if (el) el.disabled = !override; }); } async function saveAccountSetup() { const accountId = document.getElementById('setup-account-id').value; const propFirm = document.getElementById('setup-account-prop-firm').value; const plan = document.getElementById('setup-account-plan')?.value || ''; const startingBalance = getSetupBalanceValue(); const stage = document.getElementById('setup-account-stage').value; if (!propFirm) { showToast('Please select a prop firm', 'error'); return; } if (!startingBalance) { showToast('Please select a starting balance', 'error'); return; } const saveBtn = document.getElementById('setup-account-save-btn'); if (saveBtn) { saveBtn.disabled = true; saveBtn.textContent = 'Saving...'; } try { const isEvaluation = stage === 'evaluation'; const updateData = { propFirm, startingBalance, stage, isEvaluation, needsSetup: false }; if (plan) updateData.plan = plan; if (stage === 'funded') { const drawdown = parseFloat(document.getElementById('setup-account-drawdown').value); const buffer = parseFloat(document.getElementById('setup-account-buffer').value); const profitSplit = document.getElementById('setup-account-profit-split').value; const setupDll = parseFloat(document.getElementById('setup-account-funded-dll').value) || 0; if (drawdown) updateData.drawdown = drawdown; if (buffer) updateData.buffer = buffer; updateData.dailyLossLimit = setupDll; if (profitSplit) updateData.profitSplit = profitSplit; // consistency, minWithdrawal, tradingDays, payoutCaps are sourced from // propFirmConfig at runtime — never persist them on the account doc. if (propFirmConfigs[propFirm]?.drawdownType) { updateData.drawdownType = propFirmConfigs[propFirm].drawdownType; } } else if (stage === 'evaluation') { const profitTarget = parseFloat(document.getElementById('setup-account-profit-target').value); const evalDrawdown = parseFloat(document.getElementById('setup-account-eval-drawdown').value); const minDays = parseInt(document.getElementById('setup-account-min-days').value); const ddType = document.getElementById('setup-account-dd-type').value; const evalConsistency = document.getElementById('setup-account-eval-consistency').value; const dailyLoss = parseFloat(document.getElementById('setup-account-daily-loss').value); const evalCost = parseFloat(document.getElementById('setup-account-eval-cost').value); const evalDate = document.getElementById('setup-account-eval-date').value; // Cost-without-date validation: if a cost is entered, require a purchase date if (evalCost > 0 && !evalDate) { showToast('Please enter a purchase date for this account cost', 'error'); if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = 'Save Setup'; } var _setupEvalDateEl = document.getElementById('setup-account-eval-date'); if (_setupEvalDateEl) _setupEvalDateEl.style.border = '1px solid #ffb400'; return; } if (profitTarget) updateData.profitTarget = profitTarget; if (evalDrawdown) updateData.drawdown = evalDrawdown; if (minDays) updateData.minDays = minDays; if (ddType) updateData.drawdownType = ddType; if (evalConsistency && evalConsistency !== 'None') updateData.consistency = evalConsistency; if (dailyLoss) updateData.dailyLossLimit = dailyLoss; if (evalCost) updateData.cost = evalCost; if (evalDate) updateData.purchaseDate = evalDate; } await db.collection('users').doc(currentUser.uid).collection('accounts').doc(accountId).update(updateData); // Promote any held staging trades for this account now that propFirm is set. // tradovateSync writes trades to staging with syncStatus='pending' for newly // auto-created accounts and skips inline promotion (commission would be 0). // With propFirm now configured, runStagingSync will load the firm's commission // table and promote held trades to the trades collection with commissions applied. let stagingPromoted = false; try { const idToken = await currentUser.getIdToken(); const resp = await fetch('https://us-central1-trade-journal-fc3ba.cloudfunctions.net/syncTradingStage', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + idToken }, body: JSON.stringify({ accountId, source: 'tradovate' }), }); try { await resp.json(); } catch(_) {} stagingPromoted = true; } catch (e) { console.warn('[Setup] Held-trade promotion call failed (non-blocking):', e); } // Update local state const acc = accounts.find(a => a.id === accountId); if (acc) Object.assign(acc, updateData); // If eval with cost, upsert the expense entry (deterministic id prevents duplicates on repeat setup) if (stage === 'evaluation' && updateData.cost > 0) { const firmName = propFirmConfigs[propFirm]?.name || propFirm; const expId = 'exp_eval_' + accountId; const existingIdx = expenses.findIndex(e => e.id === expId); const expense = { id: expId, accountId, accountName: acc?.name || accountId, propFirm, type: 'eval_fee', amount: updateData.cost, description: `${firmName} ${(startingBalance / 1000).toFixed(0)}K Evaluation`, date: updateData.purchaseDate || new Date().toISOString().slice(0, 10), createdAt: existingIdx !== -1 ? expenses[existingIdx].createdAt : new Date().toISOString(), updatedAt: new Date().toISOString() }; if (existingIdx !== -1) expenses[existingIdx] = expense; else expenses.push(expense); await saveExpenses(); } // Remove from pending list and show next or close _pendingSetupAccounts = _pendingSetupAccounts.filter(a => a.id !== accountId); _setupAccountsSeen.add(accountId); document.getElementById('account-setup-modal').style.display = 'none'; showToast(`${acc?.name || 'Account'} setup complete`, 'success'); if (stagingPromoted) { await new Promise(resolve => setTimeout(resolve, 2000)); } await fullDataRefresh(); // Auto-recalculate commissions for the just-configured account. // Trades may have been staged with commission=0 if propFirm was 'unknown' // at sync time (migrated shared-token users) or if there was a Firestore // index-consistency lag. recalculateForAccount is idempotent — it no-ops // when commissions are already correct. try { const acct = accounts.find(a => a.id === accountId); if (acct) { const result = await recalculateForAccount(acct, null); if (result.updated > 0) { console.log(`[Setup] Auto-recalculated ${result.updated} trade(s) for ${acct.name}`); renderAll(); } } } catch (e) { console.warn('[Setup] Auto-recalc failed:', e.message); showToast('Account configured. If trade P&L looks wrong, click Recalculate Commissions in Settings.', 'warning', 7000); } if (_pendingSetupAccounts.length > 0) { setTimeout(() => showNextAccountSetup(), 500); } } catch (e) { console.error('Error saving account setup:', e); showToast('Error saving setup — please try again', 'error'); } finally { if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = 'Save Setup'; } } } function dismissAccountSetup() { // "Remind Me Later" = genuine defer-for-now. The account stays needsSetup:true // (re-prompts on a future sync); it's only suppressed for THIS session via the // in-memory _setupAccountsSeen set. No network write — close must be instant. const accountId = document.getElementById('setup-account-id').value; // Remove from pending list + suppress for this session only _pendingSetupAccounts = _pendingSetupAccounts.filter(a => a.id !== accountId); if (accountId) _setupAccountsSeen.add(accountId); // Hide first so showNextAccountSetup's "modal already open" guard passes, then advance. document.getElementById('account-setup-modal').style.display = 'none'; if (_pendingSetupAccounts.length > 0) { setTimeout(() => showNextAccountSetup(), 300); } } // ==================== COMMISSION MANAGEMENT ==================== const instrumentCategories = { 'Equity Index - Micro': ['MES', 'MNQ', 'MYM', 'M2K'], 'Equity Index - Standard': ['ES', 'NQ', 'YM', 'RTY'], 'Energy': ['CL', 'MCL', 'QM', 'NG', 'QG', 'HO', 'RB'], 'Metals': ['GC', 'MGC', 'SI', 'SIL', 'HG', 'PL', 'PA'], 'Treasury': ['ZB', 'ZN', 'ZF', 'ZT', 'UB'], 'Currency': ['6E', 'M6E', '6J', '6B', '6A', '6C', '6S', '6N'], 'Agricultural': ['ZC', 'ZS', 'ZW', 'ZL', 'ZM', 'HE', 'LE', 'GF'], 'Crypto': ['MBT', 'MET', 'BTC', 'ETH'] }; function renderFirmCommissions() { const container = document.getElementById('firm-commissions-container'); if (!container) return; const firmKeys = Object.keys(firmCommissions).sort(); if (firmKeys.length === 0) { container.innerHTML = `

Prop firm commission data loading...

`; return; } let html = '

Prop Firm Commissions

'; firmKeys.forEach(firmKey => { const firmName = propFirmNames[firmKey] || firmKey; const items = firmCommissions[firmKey]; const count = items.length; // Group by platform const byPlatform = {}; items.forEach(c => { const p = c.platform || 'General'; if (!byPlatform[p]) byPlatform[p] = []; byPlatform[p].push(c); }); const platforms = Object.keys(byPlatform).sort(); html += `
${firmName} ${count} instrument${count !== 1 ? 's' : ''}
`; platforms.forEach(platform => { const instruments = byPlatform[platform].sort((a, b) => (a.instrument || '').localeCompare(b.instrument || '')); if (platforms.length > 1 || platform !== 'General') { html += `
${platform}
`; } html += ``; instruments.forEach(c => { const perSide = c.perSide != null ? `${formatCurrency(c.perSide)}` : '--'; const roundTrip = c.roundTrip != null ? `${formatCurrency(c.roundTrip)}` : '--'; let info = ''; if (c.includesExchangeNfa) info += 'Incl. Exchange/NFA '; if (c.notes) info += `${c.notes}`; const evalNote = c.evalVsFundedDiff ? `
${c.evalVsFundedDiff}
` : ''; html += ``; }); html += `
Instrument Per Side Round Trip Info
${c.instrument || '--'}${evalNote} ${perSide} ${roundTrip} ${info || '--'}
`; }); html += `
`; }); container.innerHTML = html; } function renderCommissionsList() { const container = document.getElementById('commissions-list'); if (commissions.length === 0) { container.innerHTML = `

No custom overrides configured. Prop firm defaults above are used for P&L calculations.

`; return; } // Group commissions by propFirm -> connectionType const grouped = {}; commissions.forEach(comm => { const firmKey = comm.propFirm || 'other'; const connKey = comm.connectionType || 'unknown'; if (!grouped[firmKey]) grouped[firmKey] = {}; if (!grouped[firmKey][connKey]) grouped[firmKey][connKey] = []; grouped[firmKey][connKey].push(comm); }); let html = ''; Object.keys(grouped).sort().forEach(firmKey => { const firmName = propFirmNames[firmKey] || firmKey; html += `
${firmName}
`; Object.keys(grouped[firmKey]).sort().forEach(connKey => { const connectionLabel = connKey === 'rithmic' ? 'Rithmic' : connKey === 'tradovate' ? 'Tradovate' : connKey; const instruments = grouped[firmKey][connKey].sort((a, b) => a.instrument.localeCompare(b.instrument)); html += `
📡 ${connectionLabel}
`; instruments.forEach(comm => { html += ` `; }); html += `
Instrument Per Side Round Trip Actions
${comm.instrument} ${formatCurrency(comm.perSide)} ${formatCurrency((comm.perSide * 2))}
`; }); html += '
'; }); container.innerHTML = html; } function openAddCommissionModal() { document.getElementById('commission-modal-title').textContent = '➕ Add Commission'; document.getElementById('commission-edit-id').value = ''; document.getElementById('commission-prop-firm').value = ''; document.getElementById('commission-connection-type').value = ''; document.getElementById('commission-instrument').value = ''; document.getElementById('commission-per-side').value = ''; document.getElementById('commission-modal').style.display = 'flex'; } function editCommission(commissionId) { const comm = commissions.find(c => c.id === commissionId); if (!comm) return; document.getElementById('commission-modal-title').textContent = '✏️ Edit Commission'; document.getElementById('commission-edit-id').value = comm.id; document.getElementById('commission-prop-firm').value = comm.propFirm || ''; document.getElementById('commission-connection-type').value = comm.connectionType || ''; document.getElementById('commission-instrument').value = comm.instrument || ''; document.getElementById('commission-per-side').value = comm.perSide; document.getElementById('commission-modal').style.display = 'flex'; } function closeCommissionModal() { document.getElementById('commission-modal').style.display = 'none'; } async function saveCommission() { const editId = document.getElementById('commission-edit-id').value; const propFirm = document.getElementById('commission-prop-firm').value; const connectionType = document.getElementById('commission-connection-type').value; const instrument = document.getElementById('commission-instrument').value; const perSide = parseFloat(document.getElementById('commission-per-side').value); if (!propFirm || !connectionType || !instrument || isNaN(perSide) || perSide < 0) { alert('Please fill in all required fields'); return; } // Check for duplicate (same prop firm + connection type + instrument) const existingComm = commissions.find(c => c.propFirm === propFirm && c.connectionType === connectionType && c.instrument === instrument && c.id !== editId ); if (existingComm) { alert(`Commission for ${instrument} with ${propFirm}/${connectionType} already exists. Please edit the existing entry.`); return; } try { if (editId) { // Update existing const index = commissions.findIndex(c => c.id === editId); if (index !== -1) { commissions[index] = { ...commissions[index], propFirm, connectionType, instrument, perSide }; await db.collection('users').doc(currentUser.uid).collection('commissions').doc(editId).update({ propFirm, connectionType, instrument, perSide }); } } else { // Add new const newComm = { propFirm, connectionType, instrument, perSide, createdAt: new Date().toISOString() }; const docRef = await db.collection('users').doc(currentUser.uid).collection('commissions').add(newComm); commissions.push({ id: docRef.id, ...newComm }); } closeCommissionModal(); renderCommissionsList(); showNotification('Commission saved', 'success'); } catch (error) { console.error('Error saving commission:', error); alert('Error saving commission'); } } async function deleteCommission(commissionId) { if (!confirm('Delete this commission?')) return; try { await db.collection('users').doc(currentUser.uid).collection('commissions').doc(commissionId).delete(); commissions = commissions.filter(c => c.id !== commissionId); renderCommissionsList(); showNotification('Commission deleted', 'success'); } catch (error) { console.error('Error deleting commission:', error); alert('Error deleting commission'); } } // Get available instruments for a prop firm + connection type combination function getInstrumentsForAccount(propFirm, connectionType) { return commissions.filter(c => c.propFirm === propFirm && c.connectionType === connectionType); } // Get commission for specific prop firm + connection type + instrument function getCommissionRate(propFirm, connectionType, instrument) { const conn = connectionType || ''; // 1. Check user's custom overrides first const userComm = commissions.find(c => c.propFirm === propFirm && c.connectionType === conn && c.instrument === instrument ); if (userComm) return userComm.perSide; // 2. Fall back to firm commissions const firmComms = firmCommissions[propFirm]; if (firmComms) { const firmComm = firmComms.find(c => (!c.platform || c.platform === conn) && c.instrument === instrument ); if (firmComm) return firmComm.perSide; // Try platform-agnostic match const anyPlatform = firmComms.find(c => c.instrument === instrument); if (anyPlatform) return anyPlatform.perSide; } return null; } // Update instrument dropdown based on prop firm and connection type selection function updateAddAccountInstruments() { const propFirm = document.getElementById('account-prop-firm').value; const connectionType = document.getElementById('account-connection-type').value; updateInstrumentDropdown('account-symbol', propFirm, connectionType, 'commission-hint'); } function updateEditAccountInstruments() { const propFirm = document.getElementById('edit-account-prop-firm').value; const connectionType = document.getElementById('edit-account-connection-type').value; updateInstrumentDropdown('edit-account-symbol', propFirm, connectionType, 'edit-commission-hint'); } function updateInstrumentDropdown(selectId, propFirm, connectionType, hintId) { const select = document.getElementById(selectId); const hintEl = document.getElementById(hintId); if (!select) return; const currentValue = select.value; select.innerHTML = ''; if (!propFirm || !connectionType) { if (hintEl) hintEl.textContent = 'Select prop firm and connection type first'; return; } // Get instruments configured for this prop firm + connection type const availableInstruments = getInstrumentsForAccount(propFirm, connectionType); if (availableInstruments.length > 0) { const optgroup = document.createElement('optgroup'); optgroup.label = 'Configured Instruments'; availableInstruments.sort((a, b) => a.instrument.localeCompare(b.instrument)).forEach(comm => { const opt = document.createElement('option'); opt.value = comm.instrument; opt.textContent = `${comm.instrument} (${formatCurrency(comm.perSide)}/side)`; opt.dataset.commission = comm.perSide; optgroup.appendChild(opt); }); select.appendChild(optgroup); if (hintEl) hintEl.textContent = ''; } else { if (hintEl) { hintEl.innerHTML = `No commissions set up for this combination. Add one →`; } } // Add manual entry option const manualOptgroup = document.createElement('optgroup'); manualOptgroup.label = 'Other'; const manualOpt = document.createElement('option'); manualOpt.value = 'MANUAL'; manualOpt.textContent = 'Enter manually...'; manualOptgroup.appendChild(manualOpt); select.appendChild(manualOptgroup); // Restore previous value if valid if (currentValue) { const optionExists = [...select.options].some(opt => opt.value === currentValue); if (optionExists) select.value = currentValue; } } function updateAccountCommissionFromInstrument(prefix) { const selectId = prefix === 'account' ? 'account-symbol' : 'edit-account-symbol'; const commissionId = prefix === 'account' ? 'account-commission' : 'edit-account-commission'; const hintId = prefix === 'account' ? 'commission-hint' : 'edit-commission-hint'; const propFirmId = prefix === 'account' ? 'account-prop-firm' : 'edit-account-prop-firm'; const connectionId = prefix === 'account' ? 'account-connection-type' : 'edit-account-connection-type'; const instrument = document.getElementById(selectId).value; const commissionInput = document.getElementById(commissionId); const hintEl = document.getElementById(hintId); const propFirm = document.getElementById(propFirmId).value; const connectionType = document.getElementById(connectionId).value; if (instrument === 'MANUAL' || !instrument) { commissionInput.value = ''; commissionInput.readOnly = false; if (hintEl && instrument === 'MANUAL') hintEl.textContent = 'Enter commission manually'; return; } const rate = getCommissionRate(propFirm, connectionType, instrument); if (rate !== null) { commissionInput.value = rate; if (hintEl) hintEl.innerHTML = `✓ Auto-filled from Commission Settings`; } else { commissionInput.value = ''; if (hintEl) hintEl.textContent = ''; } } // ==================== ACCOUNT FORM MANAGEMENT ==================== // Called when prop firm changes in Add Account modal function updateAddAccountForm() { const propFirm = document.getElementById('account-prop-firm').value; const stageSelect = document.getElementById('account-stage'); const currentStage = stageSelect.value; const config = propFirmConfigs[propFirm]; // Get account stages from config, or use defaults const defaultStages = [ { value: 'funded', label: 'Funded' }, { value: 'evaluation', label: 'Evaluation' } ]; // Use accountStages from config if available let stages = defaultStages; if (config && config.accountStages && config.accountStages.length > 0) { stages = config.accountStages; } // Rebuild the dropdown with placeholder stageSelect.innerHTML = '' + stages.map(s => ``).join(''); // Restore selection if still valid, otherwise keep placeholder const validValues = stages.map(s => s.value); stageSelect.value = validValues.includes(currentStage) ? currentStage : ''; if (stageSelect.value) stageSelect.style.border = ''; const stage = stageSelect.value; const planGroup = document.getElementById('account-plan-group'); const planSelect = document.getElementById('account-plan'); const rulesSection = document.getElementById('account-rules-section'); const costSection = document.getElementById('account-cost-section'); const fundedCostBlock = document.getElementById('funded-cost-block'); const evalCostBlock = document.getElementById('eval-cost-block'); const stageGroup = document.getElementById('account-stage-group'); const fundedFields = document.getElementById('funded-rules-fields'); const evalFields = document.getElementById('eval-rules-fields'); // Populate balance dropdown based on prop firm populateBalanceDropdown(propFirm); // Clear amber validation borders when form updates var _balEl = document.getElementById('account-balance'); var _planEl = document.getElementById('account-plan'); if (_balEl && _balEl.value) _balEl.style.border = ''; if (_planEl && _planEl.value) _planEl.style.border = ''; // Reset custom balance input const balanceCustom = document.getElementById('account-balance-custom'); if (balanceCustom) { balanceCustom.style.display = 'none'; balanceCustom.value = ''; } const balanceSelect = document.getElementById('account-balance'); if (balanceSelect) balanceSelect.style.display = 'block'; // Hide stage for personal accounts if (propFirm === 'personal') { stageGroup.style.display = 'none'; planGroup.style.display = 'none'; rulesSection.style.display = 'none'; if (costSection) costSection.style.display = 'none'; return; } else { stageGroup.style.display = 'block'; } if (propFirm === 'other' || !propFirm) { planGroup.style.display = 'none'; rulesSection.style.display = 'none'; if (costSection) costSection.style.display = 'none'; return; } // Show rules section + cost section for prop firms rulesSection.style.display = 'block'; if (costSection) costSection.style.display = stage ? 'block' : 'none'; if (fundedCostBlock) fundedCostBlock.style.display = stage === 'funded' ? 'block' : 'none'; if (evalCostBlock) evalCostBlock.style.display = stage === 'evaluation' ? 'block' : 'none'; // Plans that should only appear in specific stages // If a plan is not listed here, it shows in both stages (default behavior) const EVAL_ONLY_PLANS = { tradeify: ['growth', 'select'], topstep: ['combine'] }; const FUNDED_ONLY_PLANS = { tradeify: ['growth', 'select_flex', 'select_daily', 'lightning'], topstep: ['xfa_standard', 'xfa_consistency'] }; // Toggle between Funded and Evaluation fields if (stage === 'evaluation') { fundedFields.style.display = 'none'; evalFields.style.display = 'block'; document.getElementById('account-payout-rules').style.display = 'none'; document.getElementById('account-caps-section').style.display = 'none'; // Show plan dropdown for evaluation accounts - filter to eval-only plans if (config && config.plans && config.plans.length > 0) { const allowedEval = EVAL_ONLY_PLANS[propFirm]; const evalPlans = allowedEval ? config.plans.filter(p => allowedEval.includes(p.id)) : config.plans; if (evalPlans.length > 0) { planGroup.style.display = evalPlans.length > 1 ? 'block' : 'none'; planSelect.innerHTML = '' + evalPlans.map(p => ``).join(''); // Auto-select if only one plan if (evalPlans.length === 1) planSelect.value = evalPlans[0].id; } else { planGroup.style.display = 'none'; } } else { planGroup.style.display = 'none'; } // Auto-fill evaluation rules updateAddAccountEvalRules(); } else { fundedFields.style.display = 'block'; evalFields.style.display = 'none'; // Update plan dropdown for funded - filter to funded-only plans if (config && config.plans && config.plans.length > 0) { const allowedFunded = FUNDED_ONLY_PLANS[propFirm]; const fundedPlans = allowedFunded ? config.plans.filter(p => allowedFunded.includes(p.id)) : config.plans; if (fundedPlans.length > 0) { planGroup.style.display = 'block'; planSelect.innerHTML = '' + fundedPlans.map(p => ``).join(''); } else { planGroup.style.display = 'none'; } } else { planGroup.style.display = 'none'; } // Auto-fill from balance if already entered updateAddAccountFromBalance(); } } // Auto-fill evaluation rules based on prop firm, plan, and balance function updateAddAccountEvalRules() { const propFirm = document.getElementById('account-prop-firm').value; const plan = document.getElementById('account-plan').value; const balanceSelect = document.getElementById('account-balance'); const balanceCustom = document.getElementById('account-balance-custom'); // Use custom input if visible and has value, otherwise use select const balance = (balanceCustom && balanceCustom.style.display !== 'none' && balanceCustom.value) ? parseFloat(balanceCustom.value) || 0 : parseFloat(balanceSelect.value) || 0; // Get rules from propFirmConfigs (Firestore data) — no fallback, Firestore is source of truth const config = propFirmConfigs[propFirm]; let matchedRules = null; // Try to get from Firestore evalRulesByPlan first if (config && config.evalRulesByPlan) { const planRules = config.evalRulesByPlan[plan] || config.evalRulesByPlan['default'] || Object.values(config.evalRulesByPlan)[0]; if (planRules) { // Find matching account size const availableSizes = Object.keys(planRules).map(Number).sort((a, b) => a - b); if (balance > 0) { // Exact match first if (planRules[balance]) { matchedRules = planRules[balance]; } else { // Find closest size let closest = availableSizes[0]; let minDiff = Math.abs(balance - closest); for (const size of availableSizes) { const diff = Math.abs(balance - size); if (diff < minDiff) { minDiff = diff; closest = size; } } matchedRules = planRules[closest]; } } else if (availableSizes.length > 0) { matchedRules = planRules[availableSizes[0]]; } } } // No fallback — surface Firestore gap so we can fix it if (!matchedRules) { console.warn('[Firestore Gap] No eval rules found for firm:', propFirm, 'size:', balance, 'plan:', plan, '— add evalRulesByPlan to Firestore propFirmConfig'); } if (matchedRules) { document.getElementById('account-profit-target').value = matchedRules.profitTarget || ''; document.getElementById('account-eval-drawdown').value = matchedRules.maxDrawdown || ''; document.getElementById('account-min-days').value = matchedRules.minTradingDays || matchedRules.minDays || ''; document.getElementById('account-daily-loss').value = matchedRules.dailyLossLimit || matchedRules.dailyLoss || 0; // Drawdown type - convert to dropdown format const ddTypeRaw = matchedRules.drawdownType || ''; const ddType = ddTypeRaw === '' ? '' : ddTypeRaw === 'trailing' ? 'Trailing' : ddTypeRaw === 'eod' ? 'EOD' : 'Static'; document.getElementById('account-drawdown-type').value = ddType; // Consistency - convert to dropdown format const consistencyRaw = matchedRules.consistencyRule || matchedRules.consistency || 'none'; const consistency = (consistencyRaw === 'none' || consistencyRaw === 'None') ? 'None' : consistencyRaw.toString().replace('%', '') + '%'; document.getElementById('account-eval-consistency').value = consistency; } // Show price suggestion for evaluation accounts const priceSuggestion = getEvalPricingSuggestion(propFirm, balance); const suggestionEl = document.getElementById('eval-price-suggestion'); if (priceSuggestion && balance > 0) { const firmName = propFirmConfigs[propFirm]?.name || propFirm; let html = `
`; html += `
💡 Typical ${firmName} ${(balance/1000).toFixed(0)}K Pricing:
`; html += `
`; html += `Full Price: $${priceSuggestion.fullPrice}`; html += `Sale Price: ~$${priceSuggestion.typicalSale}`; if (priceSuggestion.activationFee > 0) { html += `PA Fee: $${priceSuggestion.activationFee}`; } html += `
`; html += `
Enter what you actually paid below
`; html += `
`; suggestionEl.innerHTML = html; suggestionEl.style.display = 'block'; // Pre-fill with typical sale price as suggestion const costInput = document.getElementById('account-eval-cost'); if (!costInput.value) { costInput.placeholder = priceSuggestion.typicalSale.toFixed(2); } } else { suggestionEl.style.display = 'none'; } // Set default purchase date to today const purchaseDateInput = document.getElementById('account-eval-purchase-date'); if (!purchaseDateInput.value) { purchaseDateInput.value = new Date().toISOString().split('T')[0]; } } // Called when balance changes in Add Account modal function updateAddAccountFromBalance() { const propFirm = document.getElementById('account-prop-firm').value; const balanceSelect = document.getElementById('account-balance'); const balanceCustom = document.getElementById('account-balance-custom'); // Use custom input if visible and has value, otherwise use select const balance = (balanceCustom.style.display !== 'none' && balanceCustom.value) ? parseFloat(balanceCustom.value) || 0 : parseFloat(balanceSelect.value) || 0; const stage = document.getElementById('account-stage').value; const plan = document.getElementById('account-plan')?.value || ''; const config = propFirmConfigs[propFirm]; if (!config || propFirm === 'personal' || propFirm === 'other') return; // If evaluation, update eval rules instead if (stage === 'evaluation') { updateAddAccountEvalRules(); return; } // Find matching account size - prioritize plan-specific data from accountsByPlan or planAccounts let accountConfig = null; if (plan && config.accountsByPlan && config.accountsByPlan[plan] && config.accountsByPlan[plan][balance]) { accountConfig = config.accountsByPlan[plan][balance]; } else if (plan && config.planAccounts && config.planAccounts[plan] && config.planAccounts[plan][balance]) { accountConfig = config.planAccounts[plan][balance]; } else { accountConfig = config.accounts?.[balance]; } if (accountConfig) { // Drawdown and buffer from config - check !== undefined to allow 0 values (TopStep has buffer: 0) const buffer = accountConfig.buffer !== undefined ? accountConfig.buffer : 0; const drawdown = accountConfig.drawdown || 0; document.getElementById('account-drawdown').value = drawdown; document.getElementById('account-buffer').value = buffer; const addDll = accountConfig.dailyLossLimit || accountConfig.dailyLoss || 0; document.getElementById('account-funded-dll').value = addDll || ''; document.getElementById('account-funded-dll').placeholder = addDll ? addDll : 'None'; // Fill caps if available if (accountConfig.caps) { document.getElementById('account-caps-section').style.display = 'block'; for (let i = 0; i < 5; i++) { document.getElementById(`account-cap-${i+1}`).value = accountConfig.caps[i] || ''; } } else { document.getElementById('account-caps-section').style.display = 'none'; } } // Fill profit split from plan-specific rules, then top-level config const planSplitPct = plan && config.rulesByPlan?.[plan]?.payout?.profitSplitInitialPct; const planSplitStr = plan && config.rulesByPlan?.[plan]?.payout?.profitSplit; let splitToSet = null; if (planSplitPct) splitToSet = planSplitPct + '/' + (100 - planSplitPct); else if (planSplitStr) splitToSet = planSplitStr.replace('-', '/'); else if (config.profitSplit) splitToSet = config.profitSplit.replace('-', '/'); if (splitToSet) { const splitSelect = document.getElementById('account-profit-split'); for (let option of splitSelect.options) { if (option.value === splitToSet) { splitSelect.value = splitToSet; break; } } } // Consistency: prefer plan-specific rulesByPlan, fallback to top-level config const planConsistency = plan && config.rulesByPlan?.[plan]?.payout?.consistencyRule; document.getElementById('account-consistency').value = planConsistency || config.consistency || 'None'; if (config.minWithdrawal) { document.getElementById('account-min-withdrawal').value = config.minWithdrawal; } if (config.payoutTiming && config.payoutTiming.days) { document.getElementById('account-trading-days').value = config.payoutTiming.days; } // Build plan-specific rules for payout rules display — sourced from Firestore propFirmConfig let planRules = null; if (plan && config.rulesByPlan && config.rulesByPlan[plan]) { const planPayoutRules = config.rulesByPlan[plan].payout; if (planPayoutRules) { planRules = { consistency: planPayoutRules.consistencyRule || null, payoutTiming: { type: resolveMinProfitableDays(planPayoutRules.minProfitableDays, balance) ? 'winningDays' : 'tradingDays', days: planPayoutRules.minTradingDays ?? resolveMinProfitableDays(planPayoutRules.minProfitableDays, balance), qualifyingDays: resolveMinProfitableDays(planPayoutRules.minProfitableDays, balance), minProfitPerDay: resolveMinProfitPerDay(planPayoutRules.minProfitPerDay, balance) }, maxWithdrawalAmt: planPayoutRules.maxWithdrawalAmt || null, maxWithdrawalPct: planPayoutRules.maxWithdrawalPct || null, profitSplitNote: buildProfitSplitNote(planPayoutRules) }; } } // Fallback to legacy payoutRulesByPlan if rulesByPlan not available if (!planRules && plan && config.payoutRulesByPlan && config.payoutRulesByPlan[plan]) { planRules = config.payoutRulesByPlan[plan]; } // Show payout rules const rules = generatePayoutRules(config, accountConfig, planRules); if (rules && rules.length > 0) { document.getElementById('account-payout-rules').style.display = 'block'; document.getElementById('account-rules-list').innerHTML = rules.map(r => `• ${r}`).join('
'); } else { document.getElementById('account-payout-rules').style.display = 'none'; } } function toggleCustomBalance() { const balanceSelect = document.getElementById('account-balance'); const balanceCustom = document.getElementById('account-balance-custom'); if (balanceCustom.style.display === 'none') { balanceCustom.style.display = 'block'; balanceSelect.style.display = 'none'; balanceCustom.focus(); } else { balanceCustom.style.display = 'none'; balanceSelect.style.display = 'block'; balanceCustom.value = ''; } } function toggleEditCustomBalance() { const balanceSelect = document.getElementById('edit-account-balance'); const balanceCustom = document.getElementById('edit-account-balance-custom'); if (balanceCustom.style.display === 'none') { balanceCustom.style.display = 'block'; balanceSelect.style.display = 'none'; balanceCustom.focus(); } else { balanceCustom.style.display = 'none'; balanceSelect.parentElement.querySelector('select').style.display = 'block'; balanceCustom.value = ''; } } function populateEditBalanceDropdown(propFirm) { const balanceSelect = document.getElementById('edit-account-balance'); const config = propFirmConfigs[propFirm]; if (!config || propFirm === 'personal' || propFirm === 'other' || !config.accounts) { balanceSelect.innerHTML = ` `; return; } const accountSizes = Object.keys(config.accounts).map(Number).sort((a, b) => a - b); let options = ''; accountSizes.forEach(size => { options += ``; }); balanceSelect.innerHTML = options; } function populateBalanceDropdown(propFirm) { const balanceSelect = document.getElementById('account-balance'); const config = propFirmConfigs[propFirm]; // Reset to default options for personal/other if (!config || propFirm === 'personal' || propFirm === 'other' || !config.accounts) { balanceSelect.innerHTML = ` `; return; } // Get available account sizes from config const accountSizes = Object.keys(config.accounts).map(Number).sort((a, b) => a - b); let options = ''; accountSizes.forEach(size => { options += ``; }); balanceSelect.innerHTML = options; } // Called when plan changes in Add Account modal function updateAddAccountFromPlan() { const propFirm = document.getElementById('account-prop-firm').value; const plan = document.getElementById('account-plan').value; const stage = document.getElementById('account-stage').value; const config = propFirmConfigs[propFirm]; if (!config || !plan) return; // If evaluation, update eval rules based on plan if (stage === 'evaluation') { updateAddAccountEvalRules(); return; } // Re-fetch values from accountsByPlan when plan changes updateAddAccountFromBalance(); // Lifted to function scope so resolveMinProfitableDays(..., balance) inside // the rulesByPlan block can see it; was previously declared only inside the // accountsByPlan block below, leaving the earlier reference unbound and // throwing ReferenceError on every plan change for any plan with falsy // minTradingDays (introduced 2026-05-21 in a2a78c3, fix 2026-05-25). const balance = parseFloat(document.getElementById('account-balance').value) || 0; // Plan-specific overrides from Firestore rulesByPlan if (plan && config.rulesByPlan && config.rulesByPlan[plan]) { const planPayout = config.rulesByPlan[plan].payout; if (planPayout) { if (planPayout.consistencyRule) { const consVal = (planPayout.consistencyRule === 'none' || planPayout.consistencyRule === 'None') ? 'None' : planPayout.consistencyRule.toString().replace('%', '') + '%'; const consEl = document.getElementById('account-consistency'); if (consEl && [...consEl.options].some(o => o.value === consVal)) consEl.value = consVal; } if (planPayout.minTradingDays || resolveMinProfitableDays(planPayout.minProfitableDays, balance)) { document.getElementById('account-trading-days').value = planPayout.minTradingDays || resolveMinProfitableDays(planPayout.minProfitableDays, balance) || ''; } } // Plan-specific buffer from accountsByPlan if (config.accountsByPlan && config.accountsByPlan[plan]) { const planAcct = config.accountsByPlan[plan][balance]; if (planAcct && planAcct.buffer !== undefined) { document.getElementById('account-buffer').value = planAcct.buffer; } } } } // Toggle override in Add Account modal function toggleAddAccountOverride() { const override = document.getElementById('account-override').checked; const stage = document.getElementById('account-stage').value; // Funded fields const fundedFields = ['account-drawdown', 'account-buffer', 'account-funded-dll', 'account-profit-split', 'account-consistency', 'account-min-withdrawal', 'account-trading-days', 'account-cap-1', 'account-cap-2', 'account-cap-3', 'account-cap-4', 'account-cap-5']; // Evaluation fields const evalFields = ['account-profit-target', 'account-eval-drawdown', 'account-min-days', 'account-drawdown-type', 'account-eval-consistency', 'account-daily-loss']; const fieldsToToggle = stage === 'evaluation' ? evalFields : fundedFields; fieldsToToggle.forEach(id => { const el = document.getElementById(id); if (el) el.disabled = !override; }); } // Called when prop firm changes in Edit Account modal function updateEditAccountForm() { const propFirm = document.getElementById('edit-account-prop-firm').value; const stageSelect = document.getElementById('edit-account-stage'); const currentStage = stageSelect.value; const config = propFirmConfigs[propFirm]; // Populate balance dropdown with firm-specific account sizes const savedBalance = document.getElementById('edit-account-balance').value; populateEditBalanceDropdown(propFirm); document.getElementById('edit-account-balance').value = savedBalance; // Get account stages from config, or use defaults const defaultStages = [ { value: 'funded', label: 'Funded' }, { value: 'evaluation', label: 'Evaluation' } ]; // Use accountStages from config if available let stages = defaultStages; if (config && config.accountStages && config.accountStages.length > 0) { stages = config.accountStages; } // Rebuild the dropdown stageSelect.innerHTML = stages.map(s => ``).join(''); // Restore selection if still valid, otherwise default to first option const validValues = stages.map(s => s.value); stageSelect.value = validValues.includes(currentStage) ? currentStage : stages[0].value; const stage = stageSelect.value; const planGroup = document.getElementById('edit-account-plan-group'); const planSelect = document.getElementById('edit-account-plan'); const rulesSection = document.getElementById('edit-account-rules-section'); const costSection = document.getElementById('edit-account-cost-section'); const evalCostBlock = document.getElementById('edit-eval-cost-block'); const stageGroup = document.getElementById('edit-account-stage-group'); const fundedFields = document.getElementById('edit-funded-rules-fields'); const evalFields = document.getElementById('edit-eval-rules-fields'); // Hide stage for personal accounts if (propFirm === 'personal') { stageGroup.style.display = 'none'; planGroup.style.display = 'none'; rulesSection.style.display = 'none'; if (costSection) costSection.style.display = 'none'; return; } else { stageGroup.style.display = 'block'; } if (propFirm === 'other' || !propFirm) { planGroup.style.display = 'none'; rulesSection.style.display = 'none'; if (costSection) costSection.style.display = 'none'; return; } // Show rules section + cost section for prop firms rulesSection.style.display = 'block'; // Cost section is shown only for evaluation accounts (Edit Account has no funded-cost input) if (costSection) costSection.style.display = stage === 'evaluation' ? 'block' : 'none'; if (evalCostBlock) evalCostBlock.style.display = stage === 'evaluation' ? 'block' : 'none'; // Plans that should only appear in specific stages const EVAL_ONLY_PLANS = { tradeify: ['growth', 'select'], topstep: ['combine'] }; const FUNDED_ONLY_PLANS = { tradeify: ['growth', 'select_flex', 'select_daily', 'lightning'], topstep: ['xfa_standard', 'xfa_consistency'] }; // Toggle between Funded and Evaluation fields if (stage === 'evaluation') { fundedFields.style.display = 'none'; evalFields.style.display = 'block'; document.getElementById('edit-account-payout-rules').style.display = 'none'; document.getElementById('edit-account-caps-section').style.display = 'none'; // Show plan dropdown for evaluation accounts - filter to eval-only plans if (config && config.plans && config.plans.length > 0) { const allowedEval = EVAL_ONLY_PLANS[propFirm]; const evalPlans = allowedEval ? config.plans.filter(p => allowedEval.includes(p.id)) : config.plans; if (evalPlans.length > 0) { planGroup.style.display = evalPlans.length > 1 ? 'block' : 'none'; planSelect.innerHTML = '' + evalPlans.map(p => ``).join(''); if (evalPlans.length === 1) planSelect.value = evalPlans[0].id; } else { planGroup.style.display = 'none'; } } else { planGroup.style.display = 'none'; } // Auto-fill evaluation rules updateEditAccountEvalRules(); } else { fundedFields.style.display = 'block'; evalFields.style.display = 'none'; // Update plan dropdown for funded - filter to funded-only plans if (config && config.plans && config.plans.length > 0) { const allowedFunded = FUNDED_ONLY_PLANS[propFirm]; const fundedPlans = allowedFunded ? config.plans.filter(p => allowedFunded.includes(p.id)) : config.plans; if (fundedPlans.length > 0) { planGroup.style.display = 'block'; planSelect.innerHTML = '' + fundedPlans.map(p => ``).join(''); } else { planGroup.style.display = 'none'; } } else { planGroup.style.display = 'none'; } // Auto-fill from balance if already entered updateEditAccountFromBalance(); } } // Auto-fill evaluation rules in Edit modal function updateEditAccountEvalRules() { const propFirm = document.getElementById('edit-account-prop-firm').value; const planEl = document.getElementById('edit-account-plan'); const plan = planEl?.value || ''; const _balC = document.getElementById('edit-account-balance-custom'); const balance = parseFloat(_balC?.style.display !== 'none' ? _balC?.value : document.getElementById('edit-account-balance')?.value) || 0; if (!propFirm || !plan || !balance) return; const config = propFirmConfigs?.[propFirm]; if (!config) return; // Read eval rules from Firestore evalRulesByPlan const evalRules = config.evalRulesByPlan?.[plan]?.[balance]; const evalConfig = config.accountsByPlan?.[plan]?.[balance] || config.accounts?.[balance]; const profitTargetEl = document.getElementById('edit-account-profit-target'); const maxDrawdownEl = document.getElementById('edit-account-eval-drawdown'); const minDaysEl = document.getElementById('edit-account-min-days'); const drawdownTypeEl = document.getElementById('edit-account-drawdown-type'); const consistencyEl = document.getElementById('edit-account-eval-consistency'); const dailyLossEl = document.getElementById('edit-account-daily-loss'); if (profitTargetEl) profitTargetEl.value = evalRules?.profitTarget || evalConfig?.profitTarget || ''; if (maxDrawdownEl) maxDrawdownEl.value = evalRules?.maxDrawdown ?? evalConfig?.drawdown ?? ''; if (minDaysEl) minDaysEl.value = evalRules?.minTradingDays || evalRules?.minDays || evalConfig?.minDays || ''; const ddRaw1 = evalRules?.drawdownType || evalConfig?.drawdownType || ''; const ddMap1 = { 'eod': 'EOD', 'trailing': 'Trailing', 'static': 'Static', 'realtime': 'Trailing', 'intraday': 'Trailing' }; if (drawdownTypeEl) drawdownTypeEl.value = ddMap1[ddRaw1?.toLowerCase()] || ddRaw1 || ''; if (consistencyEl) consistencyEl.value = evalRules?.consistencyRule || evalConfig?.consistencyRule || ''; if (dailyLossEl) dailyLossEl.value = evalRules?.dailyLossLimit || evalRules?.dailyLoss || evalConfig?.dailyLossLimit || 0; if (!evalRules && !evalConfig) { console.warn('[Firestore Gap] No eval rules found for firm:', propFirm, 'size:', balance, 'plan:', plan, '— add evalRulesByPlan to Firestore propFirmConfig'); } } // Called when balance changes in Edit Account modal function updateEditAccountFromBalance() { const propFirm = document.getElementById('edit-account-prop-firm').value; const balCustom = document.getElementById('edit-account-balance-custom'); const balance = parseFloat(balCustom.style.display !== 'none' ? balCustom.value : document.getElementById('edit-account-balance').value) || 0; const stage = document.getElementById('edit-account-stage').value; const config = propFirmConfigs[propFirm]; // Lifted from later in the function so the accountsByPlan priority lookup below // can use it (Bug 2b fix — see comment at accountConfig). const plan = document.getElementById('edit-account-plan')?.value || ''; if (!config || propFirm === 'personal' || propFirm === 'other') return; // If evaluation, update eval rules if (stage === 'evaluation') { updateEditAccountEvalRules(); return; } // Find matching account size — mirrors updateAddAccountFromBalance's prioritized // lookup (sandbox :27944-27951). For firms with per-plan account shapes (Tradeify), // reading only the firm-flat config.accounts misses the data — the bug fired when // changing the balance dropdown after modal open (drawdown/buffer/DLL stuck on // previous balance). let accountConfig = null; if (plan && config.accountsByPlan?.[plan]?.[balance]) { accountConfig = config.accountsByPlan[plan][balance]; } else if (plan && config.planAccounts?.[plan]?.[balance]) { accountConfig = config.planAccounts[plan][balance]; } else { accountConfig = config.accounts?.[balance]; } if (accountConfig) { const buffer = accountConfig.buffer !== undefined ? accountConfig.buffer : 0; const drawdown = accountConfig.drawdown || 0; document.getElementById('edit-account-drawdown').value = drawdown; document.getElementById('edit-account-buffer').value = buffer; const dll = accountConfig.dailyLossLimit || accountConfig.dailyLoss || 0; document.getElementById('edit-account-funded-dll').value = dll || ''; document.getElementById('edit-account-funded-dll').placeholder = dll ? dll : 'None'; // Fill caps if available if (accountConfig.caps) { document.getElementById('edit-account-caps-section').style.display = 'block'; for (let i = 0; i < 5; i++) { document.getElementById(`edit-account-cap-${i+1}`).value = accountConfig.caps[i] || ''; } } else { document.getElementById('edit-account-caps-section').style.display = 'none'; } } // Fill other defaults - prefer plan-specific from Firestore config const planPayoutRules = config.rulesByPlan?.[plan]?.payout; if (planPayoutRules) { // Plan-specific overrides if (config.profitSplit || planPayoutRules.profitSplitAfterPct) { const splitSelect = document.getElementById('edit-account-profit-split'); const splitVal = planPayoutRules.profitSplitAfterPct ? `${planPayoutRules.profitSplitAfterPct}/${100 - planPayoutRules.profitSplitAfterPct}` : config.profitSplit?.replace('-', '/'); if (splitVal) { for (let option of splitSelect.options) { if (option.value === splitVal) { splitSelect.value = splitVal; break; } } } } document.getElementById('edit-account-consistency').value = planPayoutRules.consistencyRule || config.consistency || 'None'; document.getElementById('edit-account-min-withdrawal').value = planPayoutRules.minWithdrawal || config.minWithdrawal || ''; document.getElementById('edit-account-trading-days').value = planPayoutRules.minTradingDays || resolveMinProfitableDays(planPayoutRules.minProfitableDays, balance) || ''; // Update caps from Firestore config — handle flat array or size-keyed map const rawEditCaps = planPayoutRules?.payoutCaps; let editPlanCaps = null; if (Array.isArray(rawEditCaps) && rawEditCaps.length > 0 && rawEditCaps[0] !== null) { editPlanCaps = rawEditCaps; } else if (rawEditCaps && typeof rawEditCaps === 'object') { const sz = String(parseInt(balance)); const sizedCaps = rawEditCaps[sz] || rawEditCaps[Object.keys(rawEditCaps)[0]]; if (Array.isArray(sizedCaps) && sizedCaps.length > 0) editPlanCaps = sizedCaps; } if (editPlanCaps && editPlanCaps.some(c => c > 0)) { document.getElementById('edit-account-caps-section').style.display = 'block'; for (let i = 0; i < 5; i++) { document.getElementById(`edit-account-cap-${i+1}`).value = editPlanCaps[i] || ''; } } } else { // Fallback to firm-level config if (config.profitSplit) { const splitSelect = document.getElementById('edit-account-profit-split'); const splitValue = config.profitSplit.replace('-', '/'); for (let option of splitSelect.options) { if (option.value === splitValue) { splitSelect.value = splitValue; break; } } } if (config.consistency) document.getElementById('edit-account-consistency').value = config.consistency; if (config.minWithdrawal) document.getElementById('edit-account-min-withdrawal').value = config.minWithdrawal; if (config.payoutTiming?.days) document.getElementById('edit-account-trading-days').value = config.payoutTiming.days; } // Build planRules for rules display let planRules = null; if (planPayoutRules) { planRules = { consistency: planPayoutRules.consistencyRule || null, payoutTiming: { type: resolveMinProfitableDays(planPayoutRules.minProfitableDays, balance) ? 'winningDays' : 'tradingDays', days: planPayoutRules.minTradingDays ?? resolveMinProfitableDays(planPayoutRules.minProfitableDays, balance), qualifyingDays: resolveMinProfitableDays(planPayoutRules.minProfitableDays, balance), minProfitPerDay: resolveMinProfitPerDay(planPayoutRules.minProfitPerDay, balance) }, maxWithdrawalAmt: planPayoutRules.maxWithdrawalAmt || null, maxWithdrawalPct: planPayoutRules.maxWithdrawalPct || null, profitSplitNote: buildProfitSplitNote(planPayoutRules) }; } // Show payout rules const rules = generatePayoutRules(config, accountConfig, planRules); if (rules && rules.length > 0) { document.getElementById('edit-account-payout-rules').style.display = 'block'; document.getElementById('edit-account-rules-list').innerHTML = rules.map(r => `• ${r}`).join('
'); } else { document.getElementById('edit-account-payout-rules').style.display = 'none'; } } // Called when plan changes in Edit Account modal function updateEditAccountFromPlan() { const propFirm = document.getElementById('edit-account-prop-firm').value; const plan = document.getElementById('edit-account-plan').value; const config = propFirmConfigs[propFirm]; if (!config || !plan) return; const stage = document.getElementById('edit-account-stage').value; if (stage === 'evaluation') { updateEditAccountEvalRules(); return; } // Re-fetch values from accountsByPlan when plan changes — mirrors Add's // updateAddAccountFromPlan calling updateAddAccountFromBalance() at :28138. // Without this call, Edit modal's profit-split / drawdown / DLL fields fall // back to HTML defaults (Bug 2a — profitSplit displayed as 100/0 even when // the account doc OR plan config says otherwise). updateEditAccountFromBalance(); // Lifted to function scope so resolveMinProfitableDays(..., balance) inside // the rulesByPlan block can see it; was previously declared only inside the // accountsByPlan block below, leaving the earlier reference unbound and // throwing ReferenceError on every plan change for any plan with falsy // minTradingDays (introduced 2026-05-21 in a2a78c3, fix 2026-05-25). Edit // must preserve the custom-balance-input toggle — do NOT replace with // Add's simpler expression. const _balC2 = document.getElementById('edit-account-balance-custom'); const balance = parseFloat(_balC2.style.display !== 'none' ? _balC2.value : document.getElementById('edit-account-balance').value) || 0; // Plan-specific overrides from Firestore rulesByPlan if (config.rulesByPlan && config.rulesByPlan[plan]) { const planPayout = config.rulesByPlan[plan].payout; if (planPayout) { if (planPayout.consistencyRule) { const consVal = (planPayout.consistencyRule === 'none' || planPayout.consistencyRule === 'None') ? 'None' : planPayout.consistencyRule.toString().replace('%', '') + '%'; const consEl = document.getElementById('edit-account-consistency'); if (consEl && [...consEl.options].some(o => o.value === consVal)) consEl.value = consVal; } if (planPayout.minTradingDays || resolveMinProfitableDays(planPayout.minProfitableDays, balance)) { document.getElementById('edit-account-trading-days').value = planPayout.minTradingDays || resolveMinProfitableDays(planPayout.minProfitableDays, balance) || ''; } } // Plan-specific buffer from accountsByPlan if (config.accountsByPlan && config.accountsByPlan[plan]) { const planAcct = config.accountsByPlan[plan][balance]; if (planAcct && planAcct.buffer !== undefined) { document.getElementById('edit-account-buffer').value = planAcct.buffer; } if (planAcct) { const pDll = planAcct.dailyLossLimit || planAcct.dailyLoss || 0; document.getElementById('edit-account-funded-dll').value = pDll || ''; document.getElementById('edit-account-funded-dll').placeholder = pDll ? pDll : 'None'; } } } } // Toggle override in Edit Account modal function toggleEditAccountOverride() { const override = document.getElementById('edit-account-override').checked; const fundedFields = ['edit-account-drawdown', 'edit-account-buffer', 'edit-account-funded-dll', 'edit-account-profit-split', 'edit-account-consistency', 'edit-account-min-withdrawal', 'edit-account-trading-days', 'edit-account-cap-1', 'edit-account-cap-2', 'edit-account-cap-3', 'edit-account-cap-4', 'edit-account-cap-5']; const evalFields = ['edit-account-profit-target', 'edit-account-eval-drawdown', 'edit-account-min-days', 'edit-account-drawdown-type', 'edit-account-eval-consistency', 'edit-account-daily-loss']; [...fundedFields, ...evalFields].forEach(id => { const el = document.getElementById(id); if (el) el.disabled = !override; }); } // ==================== END ACCOUNT FORM MANAGEMENT ==================== // Keep legacy functions for compatibility function updateAddAccountConnectionType() { updateAddAccountForm(); } function updateEditAccountConnectionType() { updateEditAccountForm(); } function updateAddAccountInstruments() {} function updateEditAccountInstruments() {} async function editAccount(accountId) { let account = accounts.find(a => a.id === accountId); if (!account) return; // Re-fetch from Firestore (source: server) so the form always reflects // the latest saved state, not stale in-memory data from page load. try { const snap = await db.collection('users').doc(currentUser.uid) .collection('accounts').doc(accountId).get({ source: 'server' }); if (snap.exists) { const fresh = { id: accountId, ...snap.data() }; const idx = accounts.findIndex(a => a.id === accountId); if (idx >= 0) accounts[idx] = fresh; account = fresh; } } catch (e) { console.warn('[Edit Account] Firestore re-fetch failed, using cached data:', e); } console.log('[Edit Account] Loading account:', account); console.log('[Edit Account] propFirm value:', account.propFirm); console.log('[Edit Account] connectionType value:', account.connectionType); // Populate edit modal fields document.getElementById('edit-account-id').value = accountId; document.getElementById('edit-account-prop-firm').value = account.propFirm || ''; document.getElementById('edit-account-connection-type').value = account.connectionType || ''; document.getElementById('edit-account-name').value = account.name || ''; // Only show MLL field for Rithmic accounts const _isRithmicAcct = account.connectionType === 'rithmic' || account.connectionType === 'rithmic_saved'; const mllGroup = document.getElementById('edit-account-mll-group'); if (mllGroup) { mllGroup.style.display = _isRithmicAcct ? 'block' : 'none'; document.getElementById('edit-account-current-mll').value = ''; } // Populate firm-specific balance options before setting value populateEditBalanceDropdown(account.propFirm || ''); // Set starting balance — try dropdown first, fall back to custom input const balVal = account.startingBalance || ''; const balSelect = document.getElementById('edit-account-balance'); const balCustom = document.getElementById('edit-account-balance-custom'); balSelect.value = balVal; if (balSelect.value != balVal && balVal) { // Balance not in dropdown — insert it as a selectable option const missingOpt = document.createElement('option'); missingOpt.value = balVal; missingOpt.textContent = '$' + Number(balVal).toLocaleString(); balSelect.insertBefore(missingOpt, balSelect.options[1]); balSelect.value = balVal; } balCustom.style.display = 'none'; balCustom.value = ''; // Set stage (evaluation, exhibition, funded, or live_funded) let stage = account.stage || ''; if (account.isEvaluation === true) stage = 'evaluation'; if (stage && !['evaluation', 'exhibition', 'funded', 'live_funded'].includes(stage)) stage = 'funded'; var stageEl = document.getElementById('edit-account-stage'); stageEl.value = stage; // Amber border if no stage selected stageEl.style.border = !stage ? '1px solid #ffb400' : ''; // Set account status (active, passed, blown, archived) // For eval accounts, map existing status field; for others, use accountStatus let accountStatus = account.accountStatus || 'active'; if (stage === 'evaluation' && account.status === 'failed') accountStatus = 'blown'; else if (stage === 'evaluation' && account.status === 'passed') accountStatus = 'passed'; if (account.archived) accountStatus = 'archived'; document.getElementById('edit-account-status').value = accountStatus; // Update form based on prop firm and stage selection. // Rule fields are populated inside updateEditAccountFromPlan from propFirmConfig — // the account doc's stored rule-field values (if any) are never read into the form. updateEditAccountForm(); // Restore plan after updateEditAccountForm() rebuilds the plan dropdown. // Applies to both eval and funded accounts. updateEditAccountFromPlan then // fills the rule-field inputs from firm config. if (account.plan) { setTimeout(() => { document.getElementById('edit-account-plan').value = account.plan; updateEditAccountFromPlan(); }, 50); } const isEval = stage === 'evaluation'; // Populate the eval cost input (standalone field — always editable, not a rule override) if (isEval) { document.getElementById('edit-account-eval-cost').value = account.cost || account.evalCost || ''; } // currentMLL is a live platform value, not a rule override — populate for funded Rithmic accounts if (!isEval) { const isRithmic = account.connectionType === 'rithmic' || account.connectionType === 'rithmic_saved'; if (isRithmic && account.currentMLL) document.getElementById('edit-account-current-mll').value = account.currentMLL; } // Show modal document.getElementById('edit-account-modal').classList.add('active'); } function closeEditAccountModal() { document.getElementById('edit-account-modal').classList.remove('active'); // Clean up setup queue UI if user closes mid-walkthrough if (window.setupQueue && window.setupQueue.length > 0) { exitSetupQueue(); } } function updateEditBufferHint(firmKey) { const config = propFirmConfigs[firmKey]; const hintEl = document.getElementById('edit-buffer-hint'); if (firmKey === 'personal') { hintEl.textContent = 'No buffer required for personal accounts'; } else if (config && config.accounts) { const hints = Object.entries(config.accounts) .map(([size, data]) => `${parseInt(size)/1000}K = $${data.buffer.toLocaleString()}`) .join(' | '); hintEl.textContent = hints; } else { hintEl.textContent = 'Select account type to see buffer hints'; } } // Update buffer hint when prop firm changes in edit modal if (document.getElementById('edit-account-prop-firm')) { document.getElementById('edit-account-prop-firm').addEventListener('change', (e) => { updateEditBufferHint(e.target.value); }); } async function saveEditedAccount() { const saveBtn = document.querySelector('#edit-account-modal .btn-primary[onclick*="saveEditedAccount"]'); if (saveBtn) { saveBtn.disabled = true; saveBtn.textContent = 'Saving...'; } const accountId = document.getElementById('edit-account-id').value; const propFirm = document.getElementById('edit-account-prop-firm').value; const connectionType = document.getElementById('edit-account-connection-type').value; const name = document.getElementById('edit-account-name').value.trim(); const balCustomEl = document.getElementById('edit-account-balance-custom'); const balance = parseFloat(balCustomEl.style.display !== 'none' ? balCustomEl.value : document.getElementById('edit-account-balance').value); const stage = document.getElementById('edit-account-stage').value; const isEvaluation = stage === 'evaluation'; const accountStatus = document.getElementById('edit-account-status').value || 'active'; // Funded account fields const plan = document.getElementById('edit-account-plan')?.value || ''; const drawdown = parseFloat(document.getElementById('edit-account-drawdown').value) || 0; const buffer = parseFloat(document.getElementById('edit-account-buffer').value) || 0; const profitSplit = document.getElementById('edit-account-profit-split').value || null; const currentMLL = parseFloat(document.getElementById('edit-account-current-mll').value) || 0; // consistency, minWithdrawal, tradingDays, payoutCaps: intentionally not read — // sourced from propFirmConfig at runtime, never persisted on the account doc. // Evaluation account fields const profitTarget = parseFloat(document.getElementById('edit-account-profit-target').value) || 0; const maxDrawdown = parseFloat(document.getElementById('edit-account-eval-drawdown').value) || 0; const minDays = parseInt(document.getElementById('edit-account-min-days').value) || 0; const drawdownType = document.getElementById('edit-account-drawdown-type').value || null; const evalConsistency = document.getElementById('edit-account-eval-consistency').value || 'None'; const dailyLoss = parseFloat(document.getElementById('edit-account-daily-loss').value) || 0; const fundedDll = parseFloat(document.getElementById('edit-account-funded-dll').value) || 0; const evalCost = parseFloat(document.getElementById('edit-account-eval-cost').value) || 0; // payoutCaps input values intentionally not read — caps come from propFirmConfig at runtime. if (!propFirm) { alert('Please select an account type'); if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = 'Save Changes'; } return; } // connectionType is optional for evaluation accounts (they may have been // created before this field was added, or via the eval-specific flow) if (!connectionType && !isEvaluation) { alert('Please select a connection type'); if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = 'Save Changes'; } return; } if (!name) { alert('Please enter an account name'); if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = 'Save Changes'; } return; } if (!balance || balance <= 0) { alert('Please enter a valid starting balance'); if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = 'Save Changes'; } return; } if (isEvaluation && !drawdownType) { showNotification('Please select a drawdown type for this evaluation account', 'error'); if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = 'Save Changes'; } return; } try { const account = accounts.find(a => a.id === accountId); if (!account) throw new Error('Account not found'); // Capture old propFirm/plan BEFORE local mutation so the post-save recalc // hook can detect a change and sweep trade commissions accordingly. const oldPropFirm = account.propFirm; const oldPlan = account.plan; // Update local object - common fields account.propFirm = propFirm; account.connectionType = connectionType; account.name = name; account.startingBalance = balance; account.stage = stage; account.isEvaluation = isEvaluation; account.accountStatus = accountStatus; account.archived = (accountStatus === 'archived'); // Sync accountStatus with eval-specific status field if (isEvaluation) { if (accountStatus === 'blown') account.status = 'failed'; else if (accountStatus === 'passed') account.status = 'passed'; else account.status = 'active'; } // Build update object. Override system removed: always purge overrideRules // and never persist rule-field overrides on the account doc — runtime display // and calculation source rule values from propFirmConfig. const updateData = { propFirm, connectionType, name, startingBalance: balance, stage, isEvaluation, accountStatus, archived: (accountStatus === 'archived'), needsSetup: false }; delete account.overrideRules; updateData.overrideRules = firebase.firestore.FieldValue.delete(); if (isEvaluation) { // Non-rule fields: always write // account.status was already set from accountStatus mapping above (blown→failed, passed→passed, else active) account.plan = plan; account.cost = evalCost; updateData.plan = plan; updateData.cost = evalCost; updateData.status = account.status; } else { // Funded account — only identity/stage + cost fields are persisted. // All rule fields (drawdown, buffer, profitSplit, consistency, minWithdrawal, // tradingDays, payoutCaps) come from propFirmConfig at runtime and are // never stored on the account doc. account.plan = plan; delete account.drawdown; delete account.buffer; delete account.profitSplit; delete account.consistency; delete account.minWithdrawal; delete account.tradingDays; delete account.payoutCaps; const _saveIsRithmic = connectionType === 'rithmic' || connectionType === 'rithmic_saved'; if (_saveIsRithmic && currentMLL > 0) account.currentMLL = currentMLL; else delete account.currentMLL; updateData.plan = plan; updateData.dailyLossLimit = fundedDll; updateData.drawdown = firebase.firestore.FieldValue.delete(); updateData.buffer = firebase.firestore.FieldValue.delete(); updateData.profitSplit = firebase.firestore.FieldValue.delete(); updateData.consistency = firebase.firestore.FieldValue.delete(); updateData.minWithdrawal = firebase.firestore.FieldValue.delete(); updateData.tradingDays = firebase.firestore.FieldValue.delete(); updateData.payoutCaps = firebase.firestore.FieldValue.delete(); if (_saveIsRithmic && currentMLL > 0) updateData.currentMLL = currentMLL; else updateData.currentMLL = firebase.firestore.FieldValue.delete(); } // Update in Firestore await db.collection('users').doc(currentUser.uid).collection('accounts').doc(accountId).update(updateData); // Sync eval fee expense with ROI Tracker let _expenseMsg = ''; if (isEvaluation) { const expId = 'exp_eval_' + accountId; const existingIdx = expenses.findIndex(e => e.id === expId); if (evalCost > 0) { const firmName = propFirmConfigs[propFirm]?.name || propFirm; const expenseRecord = { id: expId, date: existingIdx !== -1 ? expenses[existingIdx].date : new Date().toISOString().slice(0, 10), propFirm: propFirm, type: 'eval_fee', accountId: accountId, accountName: name, description: `${firmName} ${(balance/1000).toFixed(0)}K Evaluation`, amount: evalCost, expenseCurrency: 'USD', createdAt: existingIdx !== -1 ? expenses[existingIdx].createdAt : new Date().toISOString(), updatedAt: new Date().toISOString() }; if (existingIdx !== -1) { expenses[existingIdx] = expenseRecord; _expenseMsg = ' • Eval fee updated in ROI Tracker'; } else { expenses.push(expenseRecord); _expenseMsg = ' • Eval fee added to ROI Tracker'; } await saveExpenses(); } else if (existingIdx !== -1) { expenses.splice(existingIdx, 1); await saveExpenses(); _expenseMsg = ' • Eval fee removed from ROI Tracker'; } } // If in setup queue, advance to next account instead of closing if (window.setupQueue && window.setupQueue.length > 0) { await fullDataRefresh(); showNotification('Account updated' + _expenseMsg, 'success'); _advanceSetupQueue(); } else { closeEditAccountModal(); await fullDataRefresh(); showNotification('Account updated' + _expenseMsg, 'success'); } // Auto-recalculate commissions if propFirm or plan changed. Both branches // above have already run fullDataRefresh, so local trades[] is current. // recalculateForAccount is idempotent — no Firestore writes if values match. const propFirmChanged = oldPropFirm !== propFirm; const planChanged = oldPlan !== plan; if (propFirmChanged || planChanged) { try { const result = await recalculateForAccount(account, null); if (result.updated > 0) { console.log(`[Edit] Auto-recalculated ${result.updated} trade(s) for ${account.name}`); renderAll(); } } catch (e) { console.warn('[Edit] Auto-recalc failed:', e.message); } } } catch (error) { console.error('Error updating account:', error); alert('Error updating account'); } finally { if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = 'Save Changes'; } } } function editJournalEntry(dateKey) { const entry = journal[dateKey]; if (!entry) return; // Set the date picker to this date document.getElementById('journal-date').value = dateKey; // Load the entry text into the textarea document.getElementById('journal-entry').value = entry.entry || ''; // Load existing screenshots (handles legacy single imageUrl via normalizer) loadJournalScreenshotsIntoForm(entry, dateKey); // Update button states document.getElementById('save-journal-btn').textContent = 'Update Entry'; document.getElementById('cancel-journal-btn').style.display = 'inline-block'; // Scroll to the textarea document.getElementById('journal-entry').focus(); } function cancelJournalEdit() { document.getElementById('journal-date').value = getTodayKey(); document.getElementById('journal-entry').value = ''; document.getElementById('save-journal-btn').textContent = 'Save Entry'; document.getElementById('cancel-journal-btn').style.display = 'none'; const delBtn = document.getElementById('delete-journal-btn'); if (delBtn) delBtn.style.display = 'none'; clearJournalImage(); } // Clear journal screenshot working-set (used on new entry / cancel) function clearJournalImage() { journalScreenshots = []; _journalPendingStorageDeletes = []; _journalReplaceIdx = null; const input = document.getElementById('journal-image-input'); if (input) input.value = ''; renderJournalScreenshotGrid(); } // Load an entry's screenshots into the edit-form working set + render the grid. function loadJournalScreenshotsIntoForm(entry, dateKey) { const shots = getJournalScreenshots(entry, dateKey); journalScreenshots = shots.map(s => ({ url: s.url, path: (s.path != null ? s.path : null) })); _journalPendingStorageDeletes = []; _journalReplaceIdx = null; const input = document.getElementById('journal-image-input'); if (input) input.value = ''; renderJournalScreenshotGrid(); } // Read-time normalizer. Canonical shape = screenshots[]; legacy = single imageUrl. // NO SILENT FALLBACK on malformed data: surface a warning, render only the valid items. function getJournalScreenshots(entry, dateKey) { if (!entry) return []; if (entry.screenshots !== undefined) { if (!Array.isArray(entry.screenshots)) { _warnMalformedJournal(dateKey); return []; } const valid = entry.screenshots.filter(s => s && typeof s.url === 'string' && s.url); if (valid.length !== entry.screenshots.length) _warnMalformedJournal(dateKey); return valid.map(s => ({ url: s.url, path: (s && s.path != null ? s.path : null) })); } if (entry.imageUrl) return [{ url: entry.imageUrl, path: null }]; return []; } const _journalMalformedWarned = new Set(); function _warnMalformedJournal(dateKey) { console.warn('[Journal] malformed screenshots', currentUser && currentUser.uid, dateKey); const key = dateKey || '(unknown)'; if (!_journalMalformedWarned.has(key)) { _journalMalformedWarned.add(key); showNotification('A journal entry has malformed screenshot data — some images may not display.', 'warning', 5000); } } // Render the edit-form thumbnail grid from journalScreenshots. function renderJournalScreenshotGrid() { const grid = document.getElementById('journal-screenshot-grid'); if (!grid) return; const maxSpan = document.getElementById('journal-max-shots'); if (maxSpan) maxSpan.textContent = JOURNAL_MAX_SCREENSHOTS; const thumbs = journalScreenshots.map((s, i) => { const src = s.previewUrl || s.url || ''; return `
`; }).join(''); const atCap = journalScreenshots.length >= JOURNAL_MAX_SCREENSHOTS; const addTile = atCap ? '' : ` `; const hint = journalScreenshots.length === 0 ? `
Click “Add screenshot” or drag & drop. Up to ${JOURNAL_MAX_SCREENSHOTS}.
` : (atCap ? `
Maximum ${JOURNAL_MAX_SCREENSHOTS} screenshots reached.
` : ''); grid.innerHTML = thumbs + addTile + hint; } function expandJournalScreenshot(i) { const s = journalScreenshots[i]; if (!s) return; const src = s.url || s.previewUrl; if (src) window.open(src, '_blank'); } function removeJournalScreenshot(i) { const s = journalScreenshots[i]; if (!s) return; // Persisted object → schedule its Storage delete on next successful save (orphan fix). if (s.url && !s.file) _journalPendingStorageDeletes.push({ path: (s.path != null ? s.path : null), url: s.url }); journalScreenshots.splice(i, 1); renderJournalScreenshotGrid(); } function replaceJournalScreenshot(i) { _journalReplaceIdx = i; const input = document.getElementById('journal-image-input'); if (input) { input.value = ''; input.click(); } } function deleteCurrentJournalEntry() { const dateKey = document.getElementById('journal-date').value; if (dateKey && journal[dateKey]) deleteJournalEntry(dateKey); } async function deleteJournalEntry(dateKey) { if (!confirm('Are you sure you want to delete this journal entry? This cannot be undone.')) return; try { const entryBeingDeleted = journal[dateKey]; await db.collection('users').doc(currentUser.uid).collection('journal').doc(dateKey).delete(); delete journal[dateKey]; // ORPHAN FIX: remove this entry's Storage objects (best-effort, surfaces failures). const shots = getJournalScreenshots(entryBeingDeleted, dateKey); await deleteJournalStorageObjects(shots.map(s => ({ path: (s.path != null ? s.path : null), url: s.url }))); renderJournalMiniCal(); renderDayView(); renderCalendar(); // If we were editing this entry, reset the form if (document.getElementById('journal-date').value === dateKey) { cancelJournalEdit(); } } catch (err) { console.error('Error deleting journal entry:', err); alert('Failed to delete entry'); } } // ===================================================== // TRADE DETAILS FUNCTIONS // ===================================================== function openTradeDetail(tradeId) { const trade = trades.find(t => t.id === tradeId); if (!trade) return; currentTradeForDetail = trade; const noteData = tradeNotes[tradeId] || { notes: '', tags: [] }; // Populate modal document.getElementById('td-date').textContent = formatDate(trade.date || trade.exitTime); document.getElementById('td-symbol').textContent = trade.symbol || '--'; document.getElementById('td-side').textContent = trade.side || '--'; document.getElementById('td-side').style.color = trade.side === 'Long' ? 'var(--green)' : 'var(--red)'; document.getElementById('td-qty').textContent = trade.qty || 1; document.getElementById('td-entry').textContent = trade.entryPrice || '--'; document.getElementById('td-exit').textContent = trade.exitPrice || '--'; const entryTime = parseTradeTime(trade.entryTime); const exitTime = parseTradeTime(trade.exitTime); const dispTz = getUserTimeZone(); document.getElementById('td-entry-time').textContent = entryTime ? entryTime.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', timeZone: dispTz }) : '--'; document.getElementById('td-exit-time').textContent = exitTime ? exitTime.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', timeZone: dispTz }) : '--'; if (entryTime && exitTime) { const holdMs = exitTime - entryTime; const holdMins = Math.floor(holdMs / 60000); const holdSecs = Math.floor((holdMs % 60000) / 1000); document.getElementById('td-hold-time').textContent = `${holdMins}m ${holdSecs}s`; } else { document.getElementById('td-hold-time').textContent = '--'; } const pnl = parseFloat(trade.pnl) || 0; document.getElementById('td-pnl').textContent = formatCurrency(pnl); document.getElementById('td-pnl').style.color = pnl >= 0 ? 'var(--green)' : 'var(--red)'; const acct = accounts.find(a => a.id === trade.accountId); document.getElementById('td-account').textContent = streamerAccountName(acct?.name || trade.account || '--'); document.getElementById('td-commission').textContent = trade.commission ? formatCurrency(trade.commission) : '--'; // Tags renderTradeTags(noteData.tags || []); // Setup dropdown const tdSetup = document.getElementById('td-setup-select'); tdSetup.innerHTML = '' + playbook.map(s => ``).join(''); tdSetup.value = noteData.setupId || ''; // Notes document.getElementById('td-notes').value = noteData.notes || ''; document.getElementById('trade-detail-modal').classList.add('active'); } function closeTradeDetail() { document.getElementById('trade-detail-modal').classList.remove('active'); currentTradeForDetail = null; } function renderTradeTags(tags) { const container = document.getElementById('td-tags-container'); container.innerHTML = tags.map(tag => ` ${tag} × `).join(''); } function addTradeTag() { const input = document.getElementById('td-new-tag'); const tag = input.value.trim(); if (!tag || !currentTradeForDetail) return; const noteData = tradeNotes[currentTradeForDetail.id] || { notes: '', tags: [] }; if (!noteData.tags.includes(tag)) { noteData.tags.push(tag); tradeNotes[currentTradeForDetail.id] = noteData; renderTradeTags(noteData.tags); } input.value = ''; } function addQuickTag(tag) { if (!currentTradeForDetail) return; const noteData = tradeNotes[currentTradeForDetail.id] || { notes: '', tags: [] }; if (!noteData.tags.includes(tag)) { noteData.tags.push(tag); tradeNotes[currentTradeForDetail.id] = noteData; renderTradeTags(noteData.tags); } } function removeTradeTag(tag) { if (!currentTradeForDetail) return; const noteData = tradeNotes[currentTradeForDetail.id] || { notes: '', tags: [] }; noteData.tags = noteData.tags.filter(t => t !== tag); tradeNotes[currentTradeForDetail.id] = noteData; renderTradeTags(noteData.tags); } async function saveTradeDetails() { if (!currentTradeForDetail) return; const notes = document.getElementById('td-notes').value; const setupId = document.getElementById('td-setup-select')?.value || null; const noteData = tradeNotes[currentTradeForDetail.id] || { notes: '', tags: [] }; noteData.notes = notes; noteData.setupId = setupId; tradeNotes[currentTradeForDetail.id] = noteData; // Save to Firebase await db.collection('users').doc(currentUser.uid).collection('tradeNotes').doc(currentTradeForDetail.id).set(noteData); closeTradeDetail(); renderTradesTable(); } function isAutoSyncSource(source) { return source && (source.includes('rithmic') || source.includes('tradovate')); } async function deleteTrade() { if (!currentTradeForDetail) return; if (!confirm('Delete this trade? This cannot be undone.')) return; const t = currentTradeForDetail; try { const batch = db.batch(); // Purge from both trades and staging batch.delete(db.collection('users').doc(currentUser.uid).collection('trades').doc(t.id)); batch.delete(db.collection('users').doc(currentUser.uid).collection('tradingStage').doc(t.id)); await batch.commit(); trades = trades.filter(tr => tr.id !== t.id); closeTradeDetail(); renderAll(); showToast('Trade deleted', 'success'); } catch (err) { console.error('[DeleteTrade] FAILED:', err); alert('Failed to delete trade: ' + err.message); } } // ===================================================== // CALENDAR CLICK-THROUGH // ===================================================== function onCalendarDayClick(dateKey) { // Navigate to journal and set date navigateTo('journal'); document.getElementById('journal-date').value = dateKey; // Trigger date change to load any existing entry const event = new Event('change'); document.getElementById('journal-date').dispatchEvent(event); } // ===================================================== // WEEKLY REVIEW FUNCTIONS // ===================================================== function openWeeklyReview() { // Set default week ending to this Thursday const today = new Date(); const daysUntilThursday = (4 - today.getDay() + 7) % 7 || 7; const thursday = new Date(today); thursday.setDate(today.getDate() + daysUntilThursday); document.getElementById('wr-week-ending').value = `${thursday.getFullYear()}-${String(thursday.getMonth() + 1).padStart(2, '0')}-${String(thursday.getDate()).padStart(2, '0')}`; document.getElementById('wr-worked-well').value = ''; document.getElementById('wr-didnt-work').value = ''; document.getElementById('wr-lessons').value = ''; document.getElementById('wr-next-week').value = ''; document.getElementById('wr-discipline-rating').value = 5; document.getElementById('wr-discipline-value').textContent = '5'; document.getElementById('weekly-review-modal').classList.add('active'); renderWeeklyPerformanceSummary(); } function closeWeeklyReview() { document.getElementById('weekly-review-modal').classList.remove('active'); if (wrEquityChart) { wrEquityChart.destroy(); wrEquityChart = null; } } async function saveWeeklyReview() { const weekEnding = document.getElementById('wr-week-ending').value; const review = { workedWell: document.getElementById('wr-worked-well').value, didntWork: document.getElementById('wr-didnt-work').value, lessons: document.getElementById('wr-lessons').value, nextWeek: document.getElementById('wr-next-week').value, rating: parseInt(document.getElementById('wr-discipline-rating').value), createdAt: new Date().toISOString() }; await db.collection('users').doc(currentUser.uid).collection('weeklyReviews').doc(weekEnding).set(review); weeklyReviews[weekEnding] = review; closeWeeklyReview(); renderPastReviews(); renderCalendar(); renderWeekSummary(); checkSaturdayReminder(); showNotification('Weekly review saved!', 'success'); } function renderPastReviews() { const container = document.getElementById('past-reviews-container'); const reviews = Object.entries(weeklyReviews).sort((a, b) => b[0].localeCompare(a[0])); if (reviews.length === 0) { container.innerHTML = '
No weekly reviews yet. Click "+ New Review" to start.
'; return; } container.innerHTML = reviews.slice(0, 10).map(([date, review]) => `
Week Ending: ${formatDate(date)}
Discipline: ${review.rating}/10
${review.workedWell ? `
✓ What Worked:
${review.workedWell.substring(0, 150)}${review.workedWell.length > 150 ? '...' : ''}
` : ''} ${review.didntWork ? `
✗ Didn't Work:
${review.didntWork.substring(0, 150)}${review.didntWork.length > 150 ? '...' : ''}
` : ''} ${review.lessons ? `
📚 Lessons:
${review.lessons.substring(0, 150)}${review.lessons.length > 150 ? '...' : ''}
` : ''} ${review.nextWeek ? `
🎯 Next Week Focus:
${review.nextWeek.substring(0, 150)}${review.nextWeek.length > 150 ? '...' : ''}
` : ''}
`).join(''); } async function deleteWeeklyReview(date) { if (!confirm('Delete this weekly review?')) return; await db.collection('users').doc(currentUser.uid).collection('weeklyReviews').doc(date).delete(); delete weeklyReviews[date]; renderPastReviews(); renderCalendar(); renderWeekSummary(); } // Open weekly review with a specific week-ending date pre-selected function openWeeklyReviewForDate(weekEndingDate) { document.getElementById('wr-week-ending').value = weekEndingDate; document.getElementById('wr-worked-well').value = ''; document.getElementById('wr-didnt-work').value = ''; document.getElementById('wr-lessons').value = ''; document.getElementById('wr-next-week').value = ''; document.getElementById('wr-discipline-rating').value = 5; document.getElementById('wr-discipline-value').textContent = '5'; document.getElementById('weekly-review-modal').classList.add('active'); renderWeeklyPerformanceSummary(); } // Show a completed weekly review in a modal function showWeeklyReviewDetail(weekEndingDate) { const review = weeklyReviews[weekEndingDate]; if (!review) { openWeeklyReviewForDate(weekEndingDate); return; } // Pre-fill the modal with existing data for viewing/editing document.getElementById('wr-week-ending').value = weekEndingDate; document.getElementById('wr-worked-well').value = review.workedWell || ''; document.getElementById('wr-didnt-work').value = review.didntWork || ''; document.getElementById('wr-lessons').value = review.lessons || ''; document.getElementById('wr-next-week').value = review.nextWeek || ''; document.getElementById('wr-discipline-rating').value = review.rating || 5; document.getElementById('wr-discipline-value').textContent = review.rating || 5; document.getElementById('weekly-review-modal').classList.add('active'); renderWeeklyPerformanceSummary(); } // Saturday reminder functions function checkSaturdayReminder() { const today = new Date(); if (today.getDay() !== 6) { // 6 = Saturday document.getElementById('saturday-reminder').style.display = 'none'; return; } // Calculate this week's Thursday (week ending date) const thu = new Date(today); thu.setDate(today.getDate() - 2); // Saturday - 2 = Thursday const weekEndingKey = `${thu.getFullYear()}-${String(thu.getMonth() + 1).padStart(2, '0')}-${String(thu.getDate()).padStart(2, '0')}`; // If already reviewed this week, hide if (weeklyReviews[weekEndingKey]) { document.getElementById('saturday-reminder').style.display = 'none'; return; } // Check if dismissed this week const dismissedKey = localStorage.getItem('saturdayReminderDismissed'); if (dismissedKey === weekEndingKey) { document.getElementById('saturday-reminder').style.display = 'none'; return; } document.getElementById('saturday-reminder').style.display = 'flex'; } function openWeeklyReviewFromReminder() { const today = new Date(); const thu = new Date(today); thu.setDate(today.getDate() - 2); const weekEndingKey = `${thu.getFullYear()}-${String(thu.getMonth() + 1).padStart(2, '0')}-${String(thu.getDate()).padStart(2, '0')}`; openWeeklyReviewForDate(weekEndingKey); } async function dismissSaturdayReminder() { const today = new Date(); const thu = new Date(today); thu.setDate(today.getDate() - 2); const weekEndingKey = `${thu.getFullYear()}-${String(thu.getMonth() + 1).padStart(2, '0')}-${String(thu.getDate()).padStart(2, '0')}`; localStorage.setItem('saturdayReminderDismissed', weekEndingKey); document.getElementById('saturday-reminder').style.display = 'none'; // Also save to Firestore for cross-device persistence try { await db.collection('users').doc(currentUser.uid).collection('settings').doc('reminders').set({ saturdayDismissed: weekEndingKey }, { merge: true }); } catch (e) { console.error('Error saving reminder dismissal:', e); } } // Weekly Review Performance Summary let wrEquityChart = null; function renderWeeklyPerformanceSummary() { const weekEndingStr = document.getElementById('wr-week-ending').value; const summaryEl = document.getElementById('wr-performance-summary'); if (!weekEndingStr) { summaryEl.style.display = 'none'; return; } // Calculate Mon-Fri from week ending (Thursday) const weekEnd = new Date(weekEndingStr + 'T00:00:00'); const monday = new Date(weekEnd); monday.setDate(weekEnd.getDate() - 3); // Thu - 3 = Mon const friday = new Date(weekEnd); friday.setDate(weekEnd.getDate() + 1); // Thu + 1 = Fri const monStr = monday.toISOString().split('T')[0]; const friStr = friday.toISOString().split('T')[0]; // Get trades respecting global filters but overriding dates const globalPropFirmFilter = document.getElementById('global-prop-firm-filter')?.value || ''; const globalInstrumentFilter = document.getElementById('global-instrument-filter')?.value || ''; const archivedAccountIds = new Set(accounts.filter(a => a.archived).map(a => a.id)); const accountById = new Map(accounts.map(a => [a.id, a])); const weekTrades = trades.filter(t => { let tradeDate = t.date || ''; if (!tradeDate && t.exitTime && typeof t.exitTime === 'string') { const raw = t.exitTime.split('T')[0]; if (raw.length === 8 && !raw.includes('-')) { tradeDate = raw.substring(0, 4) + '-' + raw.substring(4, 6) + '-' + raw.substring(6, 8); } else { tradeDate = raw; } } if (!tradeDate || tradeDate < monStr || tradeDate > friStr) return false; if (!includeArchivedInMetrics && archivedAccountIds.has(t.accountId)) return false; if (globalInstrumentFilter) { const baseSymbol = (t.symbol || '').replace(/[FGHJKMNQUVXZ]\d{1,2}$/i, ''); if (baseSymbol !== globalInstrumentFilter) return false; } if (selectedAllFirmsAccounts.length > 0) { if (!selectedAllFirmsAccounts.includes(t.accountId)) return false; } else if (globalPropFirmFilter) { const account = accounts.find(a => a.id === t.accountId); if (!account || account.propFirm !== globalPropFirmFilter) return false; } else if (selectedAccountIds.length > 0) { if (selectedAccountIds[0] === 'none') return false; if (!selectedAccountIds.includes(t.accountId)) return false; } // Per-account earliestTradeDate filter — routed through getAccountTrades const acc = accountById.get(t.accountId); if (acc && getAccountTrades(acc, [t]).length === 0) return false; return true; }); if (weekTrades.length === 0) { summaryEl.style.display = 'none'; return; } summaryEl.style.display = 'block'; // --- Compute stats --- const netPnl = weekTrades.reduce((s, t) => s + getNetPnl(t), 0); const wins = weekTrades.filter(t => getNetPnl(t) > 0); const losses = weekTrades.filter(t => getNetPnl(t) < 0); const winRate = weekTrades.length > 0 ? (wins.length / weekTrades.length * 100) : 0; const grossWins = wins.reduce((s, t) => s + getNetPnl(t), 0); const grossLosses = Math.abs(losses.reduce((s, t) => s + getNetPnl(t), 0)); const profitFactor = grossLosses > 0 ? (grossWins / grossLosses) : grossWins > 0 ? Infinity : 0; // Daily P&L const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri']; const dailyPnl = {}; for (let i = 0; i < 5; i++) { const d = new Date(monday); d.setDate(monday.getDate() + i); const key = d.toISOString().split('T')[0]; dailyPnl[key] = { day: dayNames[i], pnl: 0, trades: 0 }; } weekTrades.forEach(t => { let tradeDate = t.date || ''; if (!tradeDate && t.exitTime && typeof t.exitTime === 'string') { const raw = t.exitTime.split('T')[0]; if (raw.length === 8 && !raw.includes('-')) { tradeDate = raw.substring(0, 4) + '-' + raw.substring(4, 6) + '-' + raw.substring(6, 8); } else { tradeDate = raw; } } if (dailyPnl[tradeDate]) { dailyPnl[tradeDate].pnl += getNetPnl(t); dailyPnl[tradeDate].trades++; } }); const dailyArr = Object.values(dailyPnl); const tradingDays = dailyArr.filter(d => d.trades > 0).length; const bestDay = dailyArr.reduce((best, d) => d.pnl > best.pnl ? d : best, { pnl: -Infinity, day: '-' }); const worstDay = dailyArr.reduce((worst, d) => d.pnl < worst.pnl ? d : worst, { pnl: Infinity, day: '-' }); // --- Stats Bar --- const statsBar = document.getElementById('wr-stats-bar'); const fmtPnl = (v) => `${v >= 0 ? '+' : ''}${formatCurrency(Math.abs(v))}`; const pnlColor = (v) => v >= 0 ? 'var(--green)' : 'var(--red)'; const statBox = (label, value, color) => `
${label}
${value}
`; statsBar.innerHTML = [ statBox('Net P&L', fmtPnl(netPnl), pnlColor(netPnl)), statBox('Trades', weekTrades.length), statBox('Win Rate', winRate.toFixed(0) + '%', winRate >= 50 ? 'var(--green)' : 'var(--red)'), statBox('Profit Factor', profitFactor === Infinity ? '∞' : profitFactor.toFixed(2), profitFactor >= 1 ? 'var(--green)' : 'var(--red)'), statBox('Trading Days', tradingDays), statBox('Best Day', bestDay.day !== '-' ? `${bestDay.day} ${fmtPnl(bestDay.pnl)}` : '-', 'var(--green)'), statBox('Worst Day', worstDay.day !== '-' ? `${worstDay.day} ${fmtPnl(worstDay.pnl)}` : '-', 'var(--red)'), statBox('Avg/Trade', fmtPnl(netPnl / weekTrades.length), pnlColor(netPnl)) ].join(''); // --- Daily Breakdown --- const dailyContainer = document.getElementById('wr-daily-breakdown'); dailyContainer.innerHTML = dailyArr.map(d => { const pct = d.trades > 0 ? Math.min(Math.abs(d.pnl) / (Math.max(...dailyArr.map(x => Math.abs(x.pnl))) || 1) * 100, 100) : 0; return `
${d.day}
${d.trades > 0 ? `
` : ''}
${d.trades > 0 ? fmtPnl(d.pnl) : '—'}
${d.trades > 0 ? d.trades + ' trade' + (d.trades !== 1 ? 's' : '') : ''}
`; }).join(''); // --- Account Breakdown --- const acctMap = {}; weekTrades.forEach(t => { if (!acctMap[t.accountId]) acctMap[t.accountId] = { pnl: 0, trades: 0 }; acctMap[t.accountId].pnl += getNetPnl(t); acctMap[t.accountId].trades++; }); const acctEntries = Object.entries(acctMap); const acctWrapper = document.getElementById('wr-account-breakdown-wrapper'); const acctContainer = document.getElementById('wr-account-breakdown'); if (acctEntries.length > 1) { acctWrapper.style.display = 'block'; acctContainer.innerHTML = acctEntries.map(([id, data]) => { const acc = accounts.find(a => a.id === id); const name = acc ? (acc.name || acc.accountNumber || id) : id; return `
${name}
${data.trades} trade${data.trades !== 1 ? 's' : ''} ${fmtPnl(data.pnl)}
`; }).join(''); } else { acctWrapper.style.display = 'none'; } // --- Equity Curve Chart --- const canvas = document.getElementById('wr-equity-chart'); if (wrEquityChart) { wrEquityChart.destroy(); wrEquityChart = null; } // Build cumulative P&L per trade, sorted by exit time const sorted = [...weekTrades].sort((a, b) => { const da = a.exitTime || a.date || ''; const db = b.exitTime || b.date || ''; return da.localeCompare(db); }); let cumPnl = 0; const labels = []; const dataPoints = []; const colors = []; sorted.forEach((t, i) => { cumPnl += getNetPnl(t); labels.push(i + 1); dataPoints.push(cumPnl); colors.push(cumPnl >= 0 ? 'rgba(0,212,170,0.8)' : 'rgba(255,107,107,0.8)'); }); wrEquityChart = new Chart(canvas, { type: 'line', data: { labels: labels, datasets: [{ data: dataPoints, borderColor: netPnl >= 0 ? 'rgba(0,212,170,1)' : 'rgba(255,107,107,1)', backgroundColor: netPnl >= 0 ? 'rgba(0,212,170,0.08)' : 'rgba(255,107,107,0.08)', fill: true, tension: 0.3, pointRadius: sorted.length <= 20 ? 3 : 0, pointBackgroundColor: colors, borderWidth: 2 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: { callbacks: { label: ctx => formatCurrency(ctx.parsed.y) } }}, scales: { x: { display: true, title: { display: true, text: 'Trade #', font: { size: 10 }, color: 'var(--text-muted)' }, ticks: { font: { size: 9 } }, grid: { display: false } }, y: { display: true, ticks: { font: { size: 9 }, callback: v => formatCurrency(v, 0) }, grid: { color: 'rgba(255,255,255,0.05)' } } } } }); } // Journal mini-calendar on Daily Journal page let journalMiniCalMonth = new Date(); function shiftJournalMiniCal(delta) { journalMiniCalMonth.setMonth(journalMiniCalMonth.getMonth() + delta); renderJournalMiniCal(); } function renderJournalMiniCal() { const year = journalMiniCalMonth.getFullYear(); const month = journalMiniCalMonth.getMonth(); const label = document.getElementById('journal-mini-cal-month'); const container = document.getElementById('journal-mini-cal'); if (!label || !container) return; label.textContent = journalMiniCalMonth.toLocaleDateString('en-US', { month: 'long', year: 'numeric' }); const firstDay = new Date(year, month, 1).getDay(); const daysInMonth = new Date(year, month + 1, 0).getDate(); const selectedDate = document.getElementById('journal-date').value; let html = ''; // Empty cells for padding for (let i = 0; i < firstDay; i++) { html += ''; } for (let d = 1; d <= daysInMonth; d++) { const dk = `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`; const hasEntry = journal[dk] && journal[dk].entry; const isSelected = dk === selectedDate; html += `
${d}
`; } container.innerHTML = html; } function selectJournalDate(dateKey) { document.getElementById('journal-date').value = dateKey; // Trigger the existing change handler document.getElementById('journal-date').dispatchEvent(new Event('change')); renderJournalMiniCal(); } // ===================================================== // DAY VIEW FUNCTIONS // ===================================================== let dayViewPage = 0; const DAY_VIEW_PER_PAGE = 10; let dayViewCalMonth = new Date(); function getDayViewData() { const filteredTrades = getMetricsEligibleTrades(); const dayMap = {}; filteredTrades.forEach(t => { const dk = t.date || getTradingSessionDate(t.exitTime) || getDateKey(t.exitTime); if (!dk) return; if (!dayMap[dk]) dayMap[dk] = []; dayMap[dk].push(t); }); // Sort days descending return Object.entries(dayMap) .sort((a, b) => b[0].localeCompare(a[0])) .map(([dateKey, dayTrades]) => { const sorted = [...dayTrades].sort((a, b) => new Date(a.exitTime) - new Date(b.exitTime)); let grossWin = 0, grossLoss = 0, winners = 0, losers = 0, totalQty = 0, totalComm = 0; sorted.forEach(t => { const net = getNetPnl(t); const gross = parseFloat(t.pnl) || net; const comm = parseFloat(t.commission) || 0; totalComm += comm; totalQty += parseInt(t.qty) || 1; if (net >= 0) { winners++; grossWin += gross; } else { losers++; grossLoss += Math.abs(gross); } }); const netPnl = sorted.reduce((s, t) => s + getNetPnl(t), 0); const grossPnl = grossWin - grossLoss; const winRate = sorted.length > 0 ? Math.round((winners / sorted.length) * 100) : 0; const avgWin = winners > 0 ? grossWin / winners : 0; const avgLoss = losers > 0 ? grossLoss / losers : 0; const pf = avgLoss > 0 ? +(grossWin / grossLoss).toFixed(2) : winners > 0 ? 999 : 0; return { dateKey, trades: sorted, netPnl, grossPnl, winners, losers, winRate, totalQty, totalComm, pf, avgWin }; }); } function renderDayView() { const container = document.getElementById('day-view-list'); const pagEl = document.getElementById('day-view-pagination'); const countEl = document.getElementById('day-view-count'); if (!container) return; const days = getDayViewData(); const totalPages = Math.max(1, Math.ceil(days.length / DAY_VIEW_PER_PAGE)); if (dayViewPage >= totalPages) dayViewPage = totalPages - 1; const start = dayViewPage * DAY_VIEW_PER_PAGE; const pageDays = days.slice(start, start + DAY_VIEW_PER_PAGE); countEl.textContent = `${days.length} trading day${days.length !== 1 ? 's' : ''}`; if (days.length === 0) { container.innerHTML = '
No trading days found. Import trades to see your daily performance.
'; pagEl.innerHTML = ''; return; } container.innerHTML = pageDays.map(d => { const dt = new Date(d.dateKey + 'T12:00:00'); const dayLabel = dt.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }); const pnlColor = d.netPnl >= 0 ? 'var(--green)' : 'var(--red)'; const grossColor = d.grossPnl >= 0 ? 'var(--green)' : 'var(--red)'; const pnlArrow = d.netPnl >= 0 ? '▲' : '▼'; const journalForDay = journal[d.dateKey]; const dayShots = getJournalScreenshots(journalForDay, d.dateKey); const hasJournal = journalForDay && (journalForDay.entry || dayShots.length); const avgWinVal = d.avgWin || 0; return `
${dayLabel} ${d.trades.length} trade${d.trades.length !== 1 ? 's' : ''} ${pnlArrow}${d.netPnl >= 0 ? '+' : ''}${formatCurrency(d.netPnl)}
${d.trades.length > 0 ? `` : ''}
Gross P&L
${formatCurrency(d.grossPnl)}
Net P&L
${formatCurrency(d.netPnl)}
W / L
${d.winners} / ${d.losers}
Win Rate
${d.winRate}%
Volume
${d.totalQty}
Commissions
-${formatCurrency(d.totalComm)}
Profit Factor
${d.pf === 999 ? '∞' : d.pf}
Avg Win
${formatCurrency(avgWinVal)}
${renderDayTradeTable(d.trades)}
`; }).join(''); // Render charts for all visible days pageDays.forEach(d => renderDayViewChart(d.dateKey, d.trades)); // Pagination pagEl.innerHTML = renderDayViewPagination(totalPages); renderDayViewPnlCal(); } function renderDayTradeTable(dayTrades) { if (!dayTrades || dayTrades.length === 0) return '
No trades
'; return `${dayTrades.map(t => { const net = getNetPnl(t); const pnlC = net >= 0 ? 'var(--green)' : 'var(--red)'; const side = (t.side || '').toLowerCase(); const sideClass = side === 'long' || side === 'buy' ? 'side-long' : 'side-short'; const sideLabel = side === 'long' || side === 'buy' ? 'Long' : 'Short'; const entry = parseFloat(t.entryPrice) || 0; const exit = parseFloat(t.exitPrice) || 0; // Format time from exitTime let timeStr = ''; try { const et = new Date(t.exitTime); if (!isNaN(et.getTime())) timeStr = et.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true, timeZone: getUserTimeZone() }); } catch(e) {} // Duration let durStr = ''; try { const entryD = new Date(t.entryTime); const exitD = new Date(t.exitTime); if (!isNaN(entryD.getTime()) && !isNaN(exitD.getTime())) { const diffMs = exitD - entryD; const mins = Math.floor(diffMs / 60000); const secs = Math.floor((diffMs % 60000) / 1000); durStr = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`; } } catch(e) {} return ``; }).join('')}
TimeSymbolSideQtyEntryExitP&LDuration
${timeStr} ${t.symbol || ''} ${sideLabel} ${t.qty || t.quantity || 1} ${entry ? entry.toFixed(2) : '-'} ${exit ? exit.toFixed(2) : '-'} ${net >= 0 ? '+' : ''}${formatCurrency(net)} ${durStr}
`; } function toggleDayTrades(el) { const card = el.closest('.dv-card'); card.classList.toggle('trades-open'); } function renderDayViewPagination(totalPages) { if (totalPages <= 1) return ''; const cur = dayViewPage; let html = '
'; // Prev button html += ``; if (totalPages <= 7) { // Show all pages for (let i = 0; i < totalPages; i++) { html += ``; } } else { // First page html += ``; if (cur > 2) html += '...'; // Window around current const lo = Math.max(1, cur - 1); const hi = Math.min(totalPages - 2, cur + 1); for (let i = lo; i <= hi; i++) { html += ``; } if (cur < totalPages - 3) html += '...'; // Last page html += ``; } // Next button html += ``; html += '
'; return html; } function renderDayViewChart(dateKey, dayTrades) { const canvasId = `dv-chart-${dateKey}`; const ctx = document.getElementById(canvasId); if (!ctx || dayTrades.length === 0) return; // Destroy if exists if (ctx._dvChart) { ctx._dvChart.destroy(); } const sorted = [...dayTrades].sort((a, b) => new Date(a.exitTime) - new Date(b.exitTime)); let cum = 0; const data = [0]; sorted.forEach(t => { cum += getNetPnl(t); data.push(cum); }); const color = themePnlColor(cum); const bg = themePnlBg(cum, 0.1); ctx._dvChart = new Chart(ctx, { type: 'line', data: { labels: data.map((_, i) => i === 0 ? 'Start' : `T${i}`), datasets: [{ data, borderColor: color, backgroundColor: bg, fill: true, tension: 0.3, pointRadius: 3, pointBackgroundColor: color, borderWidth: 2 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: { callbacks: { label: c => formatCurrency(c.raw) } } }, scales: { x: { display: false }, y: { grid: { color: '#2a2f3a' }, ticks: { color: '#9ca3af', font: { size: 9 }, callback: v => formatCurrency(v, 0) } } } } }); } let _aiModalDateKey = null; function openAIInsightsModal(dateKey) { _aiModalDateKey = dateKey; const modal = document.getElementById('ai-insights-modal'); const body = document.getElementById('ai-modal-body'); const dateEl = document.getElementById('ai-modal-date'); const statsEl = document.getElementById('ai-modal-header-stats'); const allDays = getDayViewData(); const dayData = allDays.find(dd => dd.dateKey === dateKey); // Format date const d = new Date(dateKey + 'T12:00:00'); const dayNames = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday']; const monthNames = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; dateEl.textContent = `${dayNames[d.getDay()]}, ${monthNames[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`; // Header stats if (dayData && statsEl) { const pc = dayData.netPnl >= 0 ? 'var(--green)' : 'var(--red)'; statsEl.innerHTML = `
Net P&L
${dayData.netPnl >= 0 ? '+' : ''}${formatCurrency(dayData.netPnl)}
Trades
${dayData.trades.length}
`; } // Check cache const cached = dayInsightsCache[dateKey]; if (cached && cached.insights && cached.insights.length > 0 && cached.insights[0].title !== 'Day Summary') { body.innerHTML = renderAIInsightCards(cached.insights); } else { // Generate client-side insights immediately, then optionally enhance with AI if (dayData) { const ruleInsights = generateRuleBasedInsights(dayData); body.innerHTML = renderAIInsightCards(ruleInsights); // Cache rule-based insights const cacheData = { insights: ruleInsights, generatedAt: new Date().toISOString() }; dayInsightsCache[dateKey] = cacheData; if (currentUser) { db.collection('users').doc(currentUser.uid).collection('aiInsights').doc(dateKey).set(cacheData).catch(() => {}); } } else { body.innerHTML = '
No trade data found for this day.
'; } } modal.classList.add('active'); } function generateRuleBasedInsights(dayData) { const insights = []; const { trades, netPnl, grossPnl, winners, losers, winRate, totalQty, totalComm, pf } = dayData; if (!trades || trades.length === 0) return [{ icon: '📊', title: 'No Trades', description: 'No trades recorded for this day.', sentiment: 'neutral' }]; // Compute additional metrics const holdTimes = []; const pnls = []; let maxDrawdown = 0, peakPnl = 0, cumPnl = 0; const sorted = [...trades].sort((a, b) => new Date(a.exitTime) - new Date(b.exitTime)); sorted.forEach(t => { const net = getNetPnl(t); pnls.push(net); cumPnl += net; if (cumPnl > peakPnl) peakPnl = cumPnl; const dd = peakPnl - cumPnl; if (dd > maxDrawdown) maxDrawdown = dd; try { const entry = new Date(t.entryTime); const exit = new Date(t.exitTime); if (!isNaN(entry.getTime()) && !isNaN(exit.getTime())) { holdTimes.push((exit - entry) / 60000); // minutes } } catch(e) {} }); const avgHoldMin = holdTimes.length > 0 ? holdTimes.reduce((a, b) => a + b, 0) / holdTimes.length : 0; const maxTradePnl = Math.max(...pnls); const minTradePnl = Math.min(...pnls); const totalAbsPnl = pnls.reduce((s, p) => s + Math.abs(p), 0); const profitConcentration = totalAbsPnl > 0 ? (maxTradePnl / totalAbsPnl) * 100 : 0; // Build equity curve to detect giveback let running = 0, highWaterMark = 0, gaveBack = false; sorted.forEach(t => { running += getNetPnl(t); if (running > highWaterMark) highWaterMark = running; }); const finalPnl = running; if (highWaterMark > 0 && finalPnl < highWaterMark * 0.4 && highWaterMark > 50) gaveBack = true; // Detect losing streaks and revenge trading let maxLosingStreak = 0, curLosingStreak = 0; let revengeDetected = false; for (let i = 0; i < sorted.length; i++) { if (getNetPnl(sorted[i]) < 0) { curLosingStreak++; if (curLosingStreak > maxLosingStreak) maxLosingStreak = curLosingStreak; // Check if next trade came very quickly after loss (< 1 min) if (i < sorted.length - 1) { try { const exitI = new Date(sorted[i].exitTime); const entryNext = new Date(sorted[i + 1].entryTime); if (!isNaN(exitI.getTime()) && !isNaN(entryNext.getTime()) && (entryNext - exitI) < 60000) { revengeDetected = true; } } catch(e) {} } } else { curLosingStreak = 0; } } // Detect time-in-drawdown let ticksNegative = 0; let rCum = 0; sorted.forEach(t => { rCum += getNetPnl(t); if (rCum < 0) ticksNegative++; }); const pctInDrawdown = sorted.length > 0 ? (ticksNegative / sorted.length) * 100 : 0; // Late session trades (after 3:00 PM ET = 15:00) let lateTrades = 0; sorted.forEach(t => { try { const et = new Date(t.exitTime); if (!isNaN(et.getTime()) && getDateInTZ(et).hours >= 15) lateTrades++; } catch(e) {} }); // ── POSITIVE insights (green) ── if (winRate >= 70) { insights.push({ icon: '✅', title: 'Strong Win Rate', description: `${winRate}% win rate today — well above average. Your entries were disciplined and precise.`, sentiment: 'positive' }); } if (pf >= 2.0 && pf < 999) { insights.push({ icon: '✅', title: 'Strong Profit Factor', description: `Profit factor of ${pf} means your winners significantly outweighed your losers. Great risk-reward execution.`, sentiment: 'positive' }); } if (maxLosingStreak <= 1 && losers > 0 && winners > losers) { insights.push({ icon: '✅', title: 'Good Risk Management', description: `No consecutive losses and controlled drawdown. You managed risk well throughout the session.`, sentiment: 'positive' }); } // Clean trend day: equity curve smooth upward, no major drawdown if (netPnl > 0 && maxDrawdown < netPnl * 0.2 && trades.length >= 3) { insights.push({ icon: '✅', title: 'Clean Trend Day', description: `Smooth equity curve with minimal pullback. You rode the trend effectively without giving back profits.`, sentiment: 'positive' }); } if (netPnl > 0 && pnls.every(p => Math.abs(p) < netPnl * 0.6) && trades.length >= 3) { insights.push({ icon: '✅', title: 'Consistent Execution', description: `P&L was evenly distributed across trades — no single trade dominated the day. Consistent and repeatable.`, sentiment: 'positive' }); } // ── WARNING insights (amber) ── if (trades.length >= 15) { insights.push({ icon: '⚠️', title: 'Overtrading', description: `${trades.length} trades is unusually high. More trades doesn't always mean more profit — consider quality over quantity.`, sentiment: 'warning' }); } if (avgHoldMin > 0 && avgHoldMin < 2) { insights.push({ icon: '⚠️', title: 'Short Hold Times', description: `Average hold time was ${avgHoldMin < 1 ? 'under 1 minute' : avgHoldMin.toFixed(1) + ' minutes'}. Quick scalps can indicate chasing or not letting winners run.`, sentiment: 'warning' }); } if (profitConcentration > 60 && trades.length >= 3) { insights.push({ icon: '⚠️', title: 'Profit Concentration', description: `${Math.round(profitConcentration)}% of your P&L came from a single trade. Your edge may be less repeatable than it appears.`, sentiment: 'warning' }); } if (lateTrades > 0 && lateTrades >= trades.length * 0.3) { insights.push({ icon: '⚠️', title: 'Late Session Trading', description: `${lateTrades} of ${trades.length} trades were after 3:00 PM. Late session often has lower liquidity and wider spreads.`, sentiment: 'warning' }); } // ── NEGATIVE insights (red) ── if (pctInDrawdown >= 70 && netPnl < 0) { insights.push({ icon: '🔴', title: 'Deep in Drawdown', description: `You spent ${Math.round(pctInDrawdown)}% of the session in negative territory. Consider stepping away earlier on tough days.`, sentiment: 'negative' }); } if (maxLosingStreak >= 3 && trades.length > maxLosingStreak + 1) { insights.push({ icon: '🔴', title: 'Tilt Session', description: `${maxLosingStreak} consecutive losses detected. After 2-3 losses in a row, stepping away helps prevent emotional trading.`, sentiment: 'negative' }); } if (gaveBack) { const gaveBackPct = Math.round(((highWaterMark - finalPnl) / highWaterMark) * 100); insights.push({ icon: '🔴', title: 'Giving Away Profits', description: `You were up ${formatCurrency(highWaterMark)} but gave back ${gaveBackPct}%. Consider using a trailing stop on your daily P&L.`, sentiment: 'negative' }); } if (revengeDetected && netPnl < 0) { insights.push({ icon: '🔴', title: 'Revenge Trading', description: `Quick re-entries after losses detected. Revenge trading amplifies drawdowns — wait for clean setups.`, sentiment: 'negative' }); } // Ensure at least one insight if (insights.length === 0) { if (netPnl >= 0) { insights.push({ icon: '✅', title: 'Profitable Day', description: `Net P&L of ${formatCurrency(netPnl)} with ${trades.length} trades. Solid session.`, sentiment: 'positive' }); } else { insights.push({ icon: '📊', title: 'Day Summary', description: `${trades.length} trades with a net P&L of ${formatCurrency(netPnl)}. Review your entries and exits for improvement opportunities.`, sentiment: 'neutral' }); } } return insights; } function closeAIInsightsModal() { document.getElementById('ai-insights-modal').classList.remove('active'); _aiModalDateKey = null; } function renderAIInsightCards(insights) { if (!insights || insights.length === 0) return '
No insights available.
'; return `
${insights.map(i => { const sentimentClass = i.sentiment === 'positive' ? 'sentiment-positive' : i.sentiment === 'warning' ? 'sentiment-warning' : i.sentiment === 'negative' ? 'sentiment-negative' : ''; return `
${i.icon || ''} ${i.title || ''}
${i.description || ''}
`; }).join('')}
`; } async function fetchAIInsights(dateKey) { if (!currentUser) return; const allDays = getDayViewData(); const dayData = allDays.find(dd => dd.dateKey === dateKey); if (!dayData) return; // Build properly structured tradeData for the Cloud Function let grossWin = 0, grossLoss = 0, winCount = 0, lossCount = 0; let largestWin = 0, largestLoss = 0; const tradeList = dayData.trades.map(t => { const net = getNetPnl(t); const gross = parseFloat(t.pnl) || net; if (net >= 0) { winCount++; grossWin += gross; if (gross > largestWin) largestWin = gross; } else { lossCount++; grossLoss += Math.abs(gross); if (Math.abs(gross) > largestLoss) largestLoss = Math.abs(gross); } return { symbol: t.symbol, side: t.side, qty: t.quantity || t.qty, pnl: net, entryTime: t.entryTime, exitTime: t.exitTime }; }); const tradeData = { totalTrades: dayData.trades.length, winningTrades: winCount, losingTrades: lossCount, totalPnL: dayData.netPnl, averageWin: winCount > 0 ? grossWin / winCount : 0, averageLoss: lossCount > 0 ? grossLoss / lossCount : 0, largestWin: largestWin, largestLoss: largestLoss, trades: tradeList }; const question = `Analyze this single trading day and respond with ONLY this JSON structure (no markdown, no code blocks, just raw JSON): {"insights":[{"icon":"emoji","title":"short title","description":"one sentence","sentiment":"positive|warning|negative"}]} Return 3-5 insights from these categories (only include relevant ones): Positive: Strong Win Rate, Consistent Execution, Good Risk Management, Strong Profit Factor, Clean Trend Day Warning: Overtrading, Short Hold Times, Profit Concentration, Late Session Trading Negative: Deep in Drawdown, Tilt Session, Giving Away Profits, Revenge Trading Day: ${dateKey}, ${dayData.trades.length} trades, Net P&L: ${formatCurrency(dayData.netPnl)}, Win Rate: ${dayData.winRate}%, PF: ${dayData.pf === 999 ? 'Infinity' : dayData.pf} Trades: ${JSON.stringify(tradeList)}`; try { const response = await fetch('https://us-central1-trade-journal-fc3ba.cloudfunctions.net/aiTradingCoach', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ question, tradeData, userId: currentUser.uid, mode: 'coach' }) }); if (!response.ok) throw new Error('AI service unavailable'); const result = await response.json(); let insights = []; if (result.insights && Array.isArray(result.insights)) { insights = result.insights; } else { const text = (result.narrative || '').replace(/<[^>]+>/g, ' ').trim(); try { const jsonMatch = text.match(/\{[\s\S]*"insights"[\s\S]*\}/); if (jsonMatch) insights = JSON.parse(jsonMatch[0]).insights || []; } catch (e) {} } if (insights.length > 0) { // Ensure sentiment on all insights insights = insights.map(i => ({ ...i, sentiment: i.sentiment || (i.icon === '✅' ? 'positive' : i.icon === '⚠️' ? 'warning' : i.icon === '🔴' ? 'negative' : 'neutral') })); const cacheData = { insights, generatedAt: new Date().toISOString(), source: 'ai' }; dayInsightsCache[dateKey] = cacheData; await db.collection('users').doc(currentUser.uid).collection('aiInsights').doc(dateKey).set(cacheData); if (_aiModalDateKey === dateKey) { document.getElementById('ai-modal-body').innerHTML = renderAIInsightCards(insights); } } } catch (err) { console.error('AI insights enhancement error for', dateKey, err); // Rule-based insights already showing — no action needed } } async function regenerateAIInsights() { if (!_aiModalDateKey || !currentUser) return; const dateKey = _aiModalDateKey; // Clear cache delete dayInsightsCache[dateKey]; try { await db.collection('users').doc(currentUser.uid).collection('aiInsights').doc(dateKey).delete(); } catch(e) {} // Regenerate rule-based immediately const allDays = getDayViewData(); const dayData = allDays.find(dd => dd.dateKey === dateKey); if (dayData) { const ruleInsights = generateRuleBasedInsights(dayData); document.getElementById('ai-modal-body').innerHTML = renderAIInsightCards(ruleInsights); const cacheData = { insights: ruleInsights, generatedAt: new Date().toISOString() }; dayInsightsCache[dateKey] = cacheData; db.collection('users').doc(currentUser.uid).collection('aiInsights').doc(dateKey).set(cacheData).catch(() => {}); } // Then try AI enhancement fetchAIInsights(dateKey); } function dayViewGoPage(p) { dayViewPage = p; renderDayView(); // Scroll to top of day view document.getElementById('day-view-list')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); } // P&L Mini Calendar for Day View function shiftDayViewCal(delta) { dayViewCalMonth.setMonth(dayViewCalMonth.getMonth() + delta); renderDayViewPnlCal(); } function renderDayViewPnlCal() { const label = document.getElementById('dv-cal-month'); const grid = document.getElementById('dv-cal-grid'); if (!label || !grid) return; const year = dayViewCalMonth.getFullYear(); const month = dayViewCalMonth.getMonth(); label.textContent = dayViewCalMonth.toLocaleDateString('en-US', { month: 'long', year: 'numeric' }); // Build daily P&L map from filtered trades const filteredTrades = getMetricsEligibleTrades(); const dailyPnl = {}; filteredTrades.forEach(t => { const dk = t.date || getTradingSessionDate(t.exitTime) || getDateKey(t.exitTime); if (dk) dailyPnl[dk] = (dailyPnl[dk] || 0) + getNetPnl(t); }); const firstDay = new Date(year, month, 1).getDay(); const daysInMonth = new Date(year, month + 1, 0).getDate(); let html = ''; for (let i = 0; i < firstDay; i++) { html += ''; } for (let d = 1; d <= daysInMonth; d++) { const dk = `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`; const pnl = dailyPnl[dk]; const cls = pnl !== undefined ? (pnl >= 0 ? 'positive' : 'negative') : ''; html += `
${d}
`; } grid.innerHTML = html; } // ===================================================== // SETTINGS NAVIGATION // ===================================================== // Restore the last-visited route from sessionStorage after auth + data load function restoreSavedRoute() { try { const savedRoute = sessionStorage.getItem('pl_current_route'); if (!savedRoute || savedRoute === 'dashboard') return; // dashboard is already the default active page if (savedRoute === 'settings') { const savedSection = sessionStorage.getItem('pl_settings_section') || 'profile'; currentSettingsSection = savedSection; showPage('settings'); } else { const btn = document.querySelector(`.nav-item[data-page="${savedRoute}"]`); if (btn) btn.click(); } } catch(e) {} } // Navigate to a page by clicking its sidebar nav button function showPage(page) { if (typeof labActive !== 'undefined' && labActive && !window._labInternalNav) { endLabTraining(); return; } closeProfilePopup(); // Hide setup widget; re-evaluate on dashboard var sw = document.getElementById('setup-float-widget'); if (sw) sw.style.display = 'none'; if (page === 'dashboard') updateSetupWidget(); // Close sidebar on mobile if (window.innerWidth <= 768) { document.querySelector('.sidebar')?.classList.remove('open'); document.getElementById('sidebar-overlay')?.classList.remove('active'); } // Reset scroll position const mainEl = document.querySelector('.main'); if (mainEl) mainEl.scrollTop = 0; window.scrollTo(0, 0); // Settings has no sidebar nav item — navigate directly if (page === 'settings') { document.querySelectorAll('.nav-item').forEach(i => i.classList.remove('active')); document.querySelectorAll('.page').forEach(p => p.classList.remove('active')); document.getElementById('page-settings').classList.add('active'); const fw = document.getElementById('sticky-filter-wrapper'); if (fw) fw.style.display = 'none'; const smBar = document.getElementById('streamer-mode-bar'); if (smBar) smBar.style.display = 'flex'; switchSettingsSection(currentSettingsSection); stopCalendarAutoRefresh(); try { sessionStorage.setItem('pl_current_route', 'settings'); sessionStorage.setItem('pl_settings_section', currentSettingsSection); } catch(e) {} return; } const btn = document.querySelector(`.nav-item[data-page="${page}"]`); if (btn) btn.click(); } const navigateTo = showPage; let currentSettingsSection = 'profile'; let currentAccountsTab = 'accounts'; let importContentMoved = false; // Move import page content into wizard modal containers (called once on first visit) function moveImportContentToSettings() { if (importContentMoved) return; const csvContent = document.getElementById('import-content-csv'); const rithmicContent = document.getElementById('import-content-rithmic'); const tradovateContent = document.getElementById('import-content-tradovate'); const csvTarget = document.getElementById('wizard-content-csv'); const rithmicTarget = document.getElementById('wizard-content-rithmic'); const tradovateTarget = document.getElementById('wizard-content-tradovate'); if (csvContent && csvTarget) { csvTarget.appendChild(csvContent); csvContent.style.display = 'block'; } if (rithmicContent && rithmicTarget) { rithmicTarget.appendChild(rithmicContent); rithmicContent.style.display = 'block'; } if (tradovateContent && tradovateTarget) { tradovateTarget.appendChild(tradovateContent); tradovateContent.style.display = 'block'; } importContentMoved = true; } function switchAccountsTab(tab) { currentAccountsTab = tab; // Legacy tab-based navigation now opens wizard modal if (tab === 'rithmic' || tab === 'tradovate' || tab === 'csv') { openAddConnectionModal(); showConnWizardStep(2, tab); } } function switchSettingsSection(section) { // Redirect removed sections if (section === 'confluence' || section === 'scorecard-rules' || section === 'checklist') section = 'playbook'; if (section === 'danger') section = 'accounts'; if (section === 'theme') section = 'profile'; currentSettingsSection = section; try { sessionStorage.setItem('pl_settings_section', section); } catch(e) {} document.querySelectorAll('.settings-panel').forEach(p => p.classList.remove('active')); document.getElementById('settings-panel-' + section)?.classList.add('active'); document.querySelectorAll('.settings-nav-item').forEach(i => i.classList.remove('active')); document.querySelector(`.settings-nav-item[data-section="${section}"]`)?.classList.add('active'); // Trigger section-specific rendering if (section === 'accounts') { renderConnectionsPage(); } if (section === 'commissions') { renderFirmCommissions(); renderCommissionsList(); } if (section === 'playbook') { // Auto-expand first setup and checklist on load if (!expandedSetupId && playbook.length > 0) expandedSetupId = playbook[0].id; if (!expandedChecklistId && checklistTemplates && checklistTemplates.length > 0) expandedChecklistId = checklistTemplates[0].id; renderPlaybookSetups(); renderChecklistTemplatesList(); } if (section === 'profile') populateProfileFields(); if (section === 'subscription') renderSubscriptionInfo(); if (section === 'timezone') { const sel = document.getElementById('timezone-select'); if (sel) sel.value = _userTimezone || ''; updateTimezoneDisplay(); } if (section === 'currency') { const csel = document.getElementById('currency-select'); if (csel) csel.value = _userCurrency || 'USD'; updateCurrencyDisplay(); } } // ── Timezone Preference ── async function loadTimezonePreference() { try { const uid = firebase.auth().currentUser?.uid; if (!uid) return; const doc = await db.collection('users').doc(uid).collection('settings').doc('preferences').get(); if (doc.exists && doc.data().timezone) { _userTimezone = doc.data().timezone; } // Update the dropdown if it exists const sel = document.getElementById('timezone-select'); if (sel) sel.value = _userTimezone || ''; updateTimezoneDisplay(); } catch (e) { console.error('[Timezone] Load error:', e); } } async function saveTimezonePreference(tz) { _userTimezone = tz || null; updateTimezoneDisplay(); try { const uid = firebase.auth().currentUser?.uid; if (!uid) return; await db.collection('users').doc(uid).collection('settings').doc('preferences').set( { timezone: tz || null }, { merge: true } ); showToast('Timezone updated', 'success'); // Re-render views that show times if (typeof renderAll === 'function') renderAll(); } catch (e) { console.error('[Timezone] Save error:', e); showToast('Failed to save timezone', 'error'); } } function updateTimezoneDisplay() { const el = document.getElementById('timezone-current-display'); if (!el) return; const tz = getUserTimeZone(); const now = new Date(); const sample = now.toLocaleString('en-US', { timeZone: tz, hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: true, timeZoneName: 'short' }); el.textContent = `Current time in ${tz}: ${sample}`; } // ── Currency Preference ── async function loadCurrencyPreference() { try { const uid = firebase.auth().currentUser?.uid; if (!uid) return; const doc = await db.collection('users').doc(uid).collection('settings').doc('preferences').get(); if (doc.exists && doc.data().currency) { _userCurrency = doc.data().currency; } const sel = document.getElementById('currency-select'); if (sel) sel.value = _userCurrency; updateCurrencyDisplay(); } catch (e) { console.error('[Currency] Load error:', e); } } async function saveCurrencyPreference(currency) { _userCurrency = currency || 'USD'; updateCurrencyDisplay(); try { const uid = firebase.auth().currentUser?.uid; if (!uid) return; await db.collection('users').doc(uid).collection('settings').doc('preferences').set( { currency: currency || 'USD' }, { merge: true } ); showToast('Currency updated to ' + currency, 'success'); if (typeof renderAll === 'function') renderAll(); } catch (e) { console.error('[Currency] Save error:', e); showToast('Failed to save currency', 'error'); } } function updateCurrencyDisplay() { const el = document.getElementById('currency-rate-display'); if (!el) return; if (_userCurrency === 'USD') { el.textContent = ''; return; } if (_exchangeRates && _exchangeRates[_userCurrency]) { el.textContent = `1 USD = ${_exchangeRates[_userCurrency].toFixed(4)} ${_userCurrency} (live rate)`; } else { el.textContent = 'Exchange rate unavailable — displaying in USD'; } } // renderScorecardRulesPreview removed — thresholds now shown in setup modal function populateProfileFields() { if (currentUser) { document.getElementById('settings-display-name').value = currentUser.displayName || ''; document.getElementById('settings-email').value = currentUser.email || ''; updateAllAvatars(); } } function renderSubscriptionInfo() { if (!currentUser) return; // Show skeletons while loading const skeletonBar = ''; const skeletonSm = ''; const planEl = document.getElementById('subscription-plan'); const priceEl = document.getElementById('subscription-price'); const statusEl = document.getElementById('subscription-status'); if (planEl && !planEl.dataset.loaded) planEl.innerHTML = skeletonBar; if (priceEl && !priceEl.dataset.loaded) priceEl.innerHTML = skeletonSm; if (statusEl && !statusEl.dataset.loaded) { statusEl.innerHTML = skeletonSm; statusEl.style.color = 'var(--text-muted)'; } // Fetch from Stripe (authoritative), fall back to Firestore cache refreshSubscriptionFromStripe().then(stripeWorked => { if (!stripeWorked) { db.collection('users').doc(currentUser.uid).get().then(doc => { if (doc.exists && doc.data().subscription) { updateSubscriptionDisplay(doc.data().subscription); } }).catch(() => {}); } }); } async function refreshSubscriptionFromStripe() { try { const user = firebase.auth().currentUser; if (!user) return false; const token = await user.getIdToken(); const res = await fetch('https://us-central1-trade-journal-fc3ba.cloudfunctions.net/refreshSubscription', { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' } }); if (!res.ok) return false; const data = await res.json(); if (data.success && data.subscription) { updateSubscriptionDisplay(data.subscription); return true; } return false; } catch (e) { console.log('[Subscription] Stripe refresh skipped:', e.message); return false; } } async function saveDisplayName() { const name = document.getElementById('settings-display-name').value.trim(); if (!name) { showNotification('Please enter a display name', 'error'); return; } try { await currentUser.updateProfile({ displayName: name }); document.getElementById('user-display-name').textContent = name; updateAllAvatars(); showNotification('Profile updated', 'success'); } catch (e) { showNotification('Failed to update profile: ' + e.message, 'error'); } } // ===== Profile Photo ===== let _avatarFileToUpload = null; function getInitial() { const name = currentUser?.displayName || currentUser?.email || '?'; return name.charAt(0).toUpperCase(); } function updateAllAvatars() { const photoURL = currentUser?.photoURL; const initial = getInitial(); const avatarEls = [ document.getElementById('sidebar-avatar'), document.querySelector('#profile-popup .user-avatar') ]; avatarEls.forEach(el => { if (!el) return; if (photoURL) { el.innerHTML = ``; } else { el.textContent = initial; el.style.color = 'var(--cyan)'; el.style.fontWeight = '600'; } }); // Settings page large preview const preview = document.getElementById('settings-avatar-preview'); if (preview) { if (photoURL) { preview.innerHTML = ``; } else { preview.innerHTML = ''; preview.textContent = initial; } } // Remove button visibility const removeBtn = document.getElementById('remove-avatar-btn'); if (removeBtn) removeBtn.style.display = photoURL ? 'inline-block' : 'none'; } function handleAvatarFileSelect(e) { const file = e.target.files[0]; e.target.value = ''; if (!file) return; const validTypes = ['image/jpeg', 'image/png', 'image/webp']; if (!validTypes.includes(file.type)) { showNotification('Please select a JPG, PNG, or WebP image', 'error'); return; } if (file.size > 5 * 1024 * 1024) { showNotification('Image must be under 5 MB', 'error'); return; } _avatarFileToUpload = file; const reader = new FileReader(); reader.onload = (ev) => { document.getElementById('avatar-crop-preview').src = ev.target.result; document.getElementById('avatar-crop-modal').style.display = 'flex'; }; reader.readAsDataURL(file); } function closeAvatarCropModal() { document.getElementById('avatar-crop-modal').style.display = 'none'; _avatarFileToUpload = null; } async function uploadProfilePhoto() { if (!_avatarFileToUpload || !currentUser) return; const btn = document.getElementById('avatar-upload-btn'); btn.disabled = true; btn.textContent = 'Uploading...'; try { const ext = _avatarFileToUpload.type === 'image/png' ? 'png' : _avatarFileToUpload.type === 'image/webp' ? 'webp' : 'jpg'; const ref = storage.ref(`users/${currentUser.uid}/profile/avatar.${ext}`); await ref.put(_avatarFileToUpload, { contentType: _avatarFileToUpload.type }); const downloadURL = await ref.getDownloadURL(); await currentUser.updateProfile({ photoURL: downloadURL }); await db.collection('users').doc(currentUser.uid).set({ photoURL: downloadURL }, { merge: true }); updateAllAvatars(); closeAvatarCropModal(); showNotification('Profile photo updated', 'success'); } catch (e) { console.error('Avatar upload error:', e); showNotification('Failed to upload photo: ' + e.message, 'error'); } finally { btn.disabled = false; btn.textContent = 'Upload'; } } async function removeProfilePhoto() { if (!currentUser) return; if (!confirm('Remove your profile photo?')) return; try { // Try to delete from storage (may fail if file doesn't exist, that's ok) try { const exts = ['jpg', 'png', 'webp']; for (const ext of exts) { try { await storage.ref(`users/${currentUser.uid}/profile/avatar.${ext}`).delete(); } catch(e) {} } } catch(e) {} await currentUser.updateProfile({ photoURL: '' }); await db.collection('users').doc(currentUser.uid).set({ photoURL: null }, { merge: true }); updateAllAvatars(); showNotification('Profile photo removed', 'success'); } catch (e) { showNotification('Failed to remove photo: ' + e.message, 'error'); } } async function sendPasswordReset() { try { await firebase.auth().sendPasswordResetEmail(currentUser.email); showNotification('Password reset email sent to ' + currentUser.email, 'success'); } catch (e) { showNotification('Failed to send reset email: ' + e.message, 'error'); } } // ===================================================== // PLAYBOOK FUNCTIONS // ===================================================== // ===================================================== // EVALUATION ACCOUNT FUNCTIONS // ===================================================== // Evaluation Pricing Data (typical full prices - users often get discounts) // Prices are approximate and subject to change - shown as reference const EVAL_PRICING = { apex: { 25000: { fullPrice: 147, typicalSale: 35, activationFee: 85 }, 50000: { fullPrice: 167, typicalSale: 45, activationFee: 85 }, 75000: { fullPrice: 187, typicalSale: 55, activationFee: 85 }, 100000: { fullPrice: 207, typicalSale: 65, activationFee: 85 }, 150000: { fullPrice: 297, typicalSale: 85, activationFee: 85 }, 250000: { fullPrice: 517, typicalSale: 125, activationFee: 85 }, 300000: { fullPrice: 657, typicalSale: 150, activationFee: 85 } }, topstep: { 50000: { fullPrice: 165, typicalSale: 49, activationFee: 149 }, 100000: { fullPrice: 325, typicalSale: 99, activationFee: 149 }, 150000: { fullPrice: 375, typicalSale: 149, activationFee: 149 } }, myfundedfutures: { 50000: { fullPrice: 150, typicalSale: 50, activationFee: 0 }, 100000: { fullPrice: 250, typicalSale: 100, activationFee: 0 }, 150000: { fullPrice: 350, typicalSale: 150, activationFee: 0 } }, tradeify: { 50000: { fullPrice: 150, typicalSale: 50, activationFee: 0 }, 100000: { fullPrice: 250, typicalSale: 100, activationFee: 0 }, 150000: { fullPrice: 350, typicalSale: 150, activationFee: 0 } }, bulenox: { 10000: { fullPrice: 85, typicalSale: 25, activationFee: 98 }, 25000: { fullPrice: 145, typicalSale: 45, activationFee: 98 }, 50000: { fullPrice: 175, typicalSale: 55, activationFee: 98 }, 100000: { fullPrice: 275, typicalSale: 85, activationFee: 98 }, 150000: { fullPrice: 350, typicalSale: 105, activationFee: 98 }, 250000: { fullPrice: 535, typicalSale: 165, activationFee: 98 } }, fundednext: { 6000: { fullPrice: 59, typicalSale: 39, activationFee: 0 }, 15000: { fullPrice: 99, typicalSale: 59, activationFee: 0 }, 25000: { fullPrice: 199, typicalSale: 99, activationFee: 0 }, 50000: { fullPrice: 299, typicalSale: 149, activationFee: 0 }, 100000: { fullPrice: 499, typicalSale: 249, activationFee: 0 }, 200000: { fullPrice: 699, typicalSale: 349, activationFee: 0 } }, lucid: { 25000: { fullPrice: 150, typicalSale: 50, activationFee: 0 }, 50000: { fullPrice: 200, typicalSale: 75, activationFee: 0 }, 100000: { fullPrice: 300, typicalSale: 125, activationFee: 0 }, 150000: { fullPrice: 400, typicalSale: 175, activationFee: 0 } }, takeprofittrader: { 25000: { fullPrice: 150, typicalSale: 80, activationFee: 130 }, 50000: { fullPrice: 200, typicalSale: 100, activationFee: 130 }, 75000: { fullPrice: 250, typicalSale: 125, activationFee: 130 }, 100000: { fullPrice: 300, typicalSale: 150, activationFee: 130 }, 150000: { fullPrice: 350, typicalSale: 175, activationFee: 130 } }, fundedfuturesnetwork: { 25000: { fullPrice: 149, typicalSale: 59, activationFee: 0 }, 50000: { fullPrice: 199, typicalSale: 79, activationFee: 0 }, 100000: { fullPrice: 349, typicalSale: 139, activationFee: 0 }, 150000: { fullPrice: 449, typicalSale: 179, activationFee: 0 }, 250000: { fullPrice: 549, typicalSale: 219, activationFee: 0 } }, earn2trade: { 25000: { fullPrice: 150, typicalSale: 99, activationFee: 0 }, 50000: { fullPrice: 200, typicalSale: 149, activationFee: 0 }, 100000: { fullPrice: 350, typicalSale: 249, activationFee: 0 }, 150000: { fullPrice: 450, typicalSale: 349, activationFee: 0 }, 200000: { fullPrice: 550, typicalSale: 449, activationFee: 0 } }, elitetraderfunding: { 10000: { fullPrice: 80, typicalSale: 30, activationFee: 75 }, 25000: { fullPrice: 145, typicalSale: 45, activationFee: 75 }, 50000: { fullPrice: 205, typicalSale: 65, activationFee: 75 }, 100000: { fullPrice: 315, typicalSale: 95, activationFee: 75 }, 150000: { fullPrice: 430, typicalSale: 130, activationFee: 75 }, 200000: { fullPrice: 540, typicalSale: 160, activationFee: 75 }, 250000: { fullPrice: 650, typicalSale: 195, activationFee: 75 }, 300000: { fullPrice: 765, typicalSale: 230, activationFee: 75 } }, legendstrading: { 25000: { fullPrice: 155, typicalSale: 55, activationFee: 0 }, 50000: { fullPrice: 230, typicalSale: 80, activationFee: 0 }, 100000: { fullPrice: 340, typicalSale: 120, activationFee: 0 }, 150000: { fullPrice: 455, typicalSale: 160, activationFee: 0 } } }; // Get suggested eval price function getEvalPricingSuggestion(propFirm, accountSize) { const firmPricing = EVAL_PRICING[propFirm]; if (!firmPricing) return null; // Find exact match or closest size const sizes = Object.keys(firmPricing).map(Number).sort((a, b) => a - b); let matchedSize = sizes[0]; for (const size of sizes) { if (size <= accountSize) matchedSize = size; } return firmPricing[matchedSize] || null; } // Evaluation accounts - derived from main accounts array function getEvalAccounts() { // Check multiple ways an account could be marked as evaluation return accounts.filter(a => a.isEvaluation === true || a.isEvaluation === 'true' || a.stage === 'evaluation' || a.accountType === 'evaluation' || (a.profitTarget && !a.payoutHistory?.length) // Has profit target but no payouts = likely eval ); } // For backwards compatibility let evalAccounts = []; function refreshEvalAccounts() { evalAccounts = getEvalAccounts(); console.log('Eval accounts found:', evalAccounts.length, evalAccounts.map(a => ({name: a.name, isEval: a.isEvaluation, stage: a.stage, status: a.status}))); } function openAddFundedAccountFromPage() { // Redirect to the Settings add account modal with Funded pre-selected showPage('settings'); // Reset and open the add account modal document.getElementById('account-prop-firm').value = ''; document.getElementById('account-connection-type').value = ''; document.getElementById('account-name').value = ''; document.getElementById('account-balance').value = ''; document.getElementById('account-balance').style.display = 'block'; const balanceCustom = document.getElementById('account-balance-custom'); if (balanceCustom) { balanceCustom.value = ''; balanceCustom.style.display = 'none'; } document.getElementById('account-buffer').value = ''; const bufferHint = document.getElementById('buffer-hint'); if (bufferHint) bufferHint.textContent = 'Select account type to see buffer hints'; // Pre-set stage to funded document.getElementById('account-stage').value = 'funded'; // Open the modal document.getElementById('add-account-modal').classList.add('active'); // Trigger form update for funded stage updateAddAccountForm(); } function openAddEvalAccountModal() { // Redirect to the Settings add account modal with Evaluation pre-selected showPage('settings'); // Reset and open the add account modal document.getElementById('account-prop-firm').value = ''; document.getElementById('account-connection-type').value = ''; document.getElementById('account-name').value = ''; document.getElementById('account-balance').value = ''; document.getElementById('account-balance').style.display = 'block'; const balanceCustom = document.getElementById('account-balance-custom'); if (balanceCustom) { balanceCustom.value = ''; balanceCustom.style.display = 'none'; } document.getElementById('account-buffer').value = ''; const bufferHint = document.getElementById('buffer-hint'); if (bufferHint) bufferHint.textContent = 'Select account type to see buffer hints'; // Pre-set stage to evaluation document.getElementById('account-stage').value = 'evaluation'; // Open the modal document.getElementById('add-account-modal').classList.add('active'); // Trigger form update for evaluation stage updateAddAccountForm(); } function closeAddEvalModal() { document.getElementById('add-eval-modal').classList.remove('active'); } function updateEvalRulesFromFirm() { const firm = document.getElementById('eval-prop-firm').value; const size = parseFloat(document.getElementById('eval-account-size').value) || 50000; updateEvalRulesDefaults(firm, size); } function updateEvalRulesFromSize() { const firm = document.getElementById('eval-prop-firm').value; const size = parseFloat(document.getElementById('eval-account-size').value) || 50000; updateEvalRulesDefaults(firm, size); } function updateEvalRulesDefaults(firm, size) { if (!firm) return; // EVAL_RULES_DEFAULTS removed — Firestore is now source of truth console.warn('[Firestore Gap] No eval rules found for firm:', firm, 'size:', size, 'plan:', '', '— add evalRulesByPlan to Firestore propFirmConfig'); return; } let _saveEvalSubmitting = false; async function saveEvalAccount() { if (_saveEvalSubmitting) return; const propFirm = document.getElementById('eval-prop-firm').value; const connectionType = document.getElementById('eval-connection-type').value; const accountName = document.getElementById('eval-account-name').value.trim(); const accountSize = parseFloat(document.getElementById('eval-account-size').value); const cost = parseFloat(document.getElementById('eval-cost').value); const purchaseDate = document.getElementById('eval-purchase-date').value; const profitTarget = parseFloat(document.getElementById('eval-profit-target').value); const maxDrawdown = parseFloat(document.getElementById('eval-max-drawdown').value); const drawdownType = document.getElementById('eval-drawdown-type').value; const dailyLoss = parseFloat(document.getElementById('eval-daily-loss').value) || 0; const minDays = parseInt(document.getElementById('eval-min-days').value) || 0; const timeLimit = parseInt(document.getElementById('eval-time-limit').value) || 0; const consistencyRule = document.getElementById('eval-consistency-rule').value; // Validation if (!propFirm) { alert('Please select a prop firm'); return; } if (!connectionType) { alert('Please select a connection type'); return; } if (!accountName) { alert('Please enter an account name'); return; } if (!accountSize || accountSize <= 0) { alert('Please enter a valid account size'); return; } if (!cost || cost <= 0) { alert('Please enter the evaluation cost'); return; } if (!profitTarget || profitTarget <= 0) { alert('Please enter a profit target'); return; } if (!maxDrawdown || maxDrawdown <= 0) { alert('Please enter max drawdown'); return; } _saveEvalSubmitting = true; const evalAccount = { propFirm, connectionType, name: accountName, accountSize, accountType: 'evaluation', status: 'active', // active, passed, failed, expired cost, purchaseDate, rules: { profitTarget, maxDrawdown, drawdownType, dailyLossLimit: dailyLoss, minDays, timeLimit, consistencyRule }, progress: { currentPnL: 0, peakPnL: 0, drawdownUsed: 0, tradingDays: 0, largestProfitDay: 0, startDate: null }, createdAt: new Date().toISOString() }; try { // Deterministic doc ID prevents duplicates const sanitizedName = accountName.replace(/[^a-zA-Z0-9_-]/g, '_').substring(0, 60); const evalDocId = `${propFirm}_${sanitizedName}`; console.log('[AddEvalAccount] Deterministic ID:', evalDocId); // Check if already exists const existing = accounts.some(a => a.id === evalDocId); if (existing) { showNotification('An evaluation account with this name already exists for this firm', 'error'); return; } await db.collection('users').doc(currentUser.uid).collection('accounts').doc(evalDocId).set(evalAccount); // Do NOT push to local arrays — onSnapshot listener will update them closeAddEvalModal(); updateFilterDropdowns(); showNotification('Evaluation account added!', 'success'); } catch (error) { console.error('Error saving eval account:', error); showNotification('Error saving evaluation', 'error'); } finally { _saveEvalSubmitting = false; } } // Automatically check and update evaluation status based on rules async function checkEvalAutoStatus() { refreshEvalAccounts(); const activeEvals = evalAccounts.filter(e => e.status === 'active' || !e.status); for (const evalAcc of activeEvals) { // Get rules (support both old and new structure) const rules = evalAcc.rules || evalAcc; const profitTarget = rules.profitTarget || evalAcc.profitTarget || null; if (!profitTarget) { console.warn(`[AutoPass] No profitTarget configured for eval account ${evalAcc.name} — skipping auto-pass check`); continue; } const maxDrawdown = rules.maxDrawdown || evalAcc.maxDrawdown || null; if (!maxDrawdown) { console.warn(`[AutoFail] No maxDrawdown configured for eval account ${evalAcc.name} — skipping auto-fail check`); continue; } const minDays = rules.minDays || evalAcc.minDays || 0; const drawdownType = rules.drawdownType || evalAcc.drawdownType || null; if (!drawdownType) { console.warn(`[AutoFail] No drawdownType configured for eval account ${evalAcc.name} — skipping auto-fail check`); continue; } // Calculate progress from trades const evalTrades = trades.filter(t => t.accountId === evalAcc.id); if (evalTrades.length === 0) continue; const currentPnL = evalTrades.reduce((sum, t) => sum + getNetPnl(t), 0); // Sort trades chronologically const sortedTrades = [...evalTrades].sort((a, b) => new Date(a.entryTime || a.date) - new Date(b.entryTime || b.date) ); // Check drawdown based on drawdown type from propFirmConfig plan let maxDrawdownHit = false; const ddType = (drawdownType || 'EOD').toLowerCase(); if (ddType === 'eod') { // EOD trailing: peak only updates at end of each trading day, not after every trade const dailyPnLMap = {}; sortedTrades.forEach(t => { const dateKey = t.date || getTradingSessionDate(t.exitTime) || getDateKey(t.exitTime || t.entryTime || t.date); if (dateKey) dailyPnLMap[dateKey] = (dailyPnLMap[dateKey] || 0) + getNetPnl(t); }); let eodRunningPnL = 0, eodPeak = 0; Object.keys(dailyPnLMap).sort().forEach(day => { eodRunningPnL += dailyPnLMap[day]; if (eodRunningPnL > eodPeak) eodPeak = eodRunningPnL; if (eodPeak - eodRunningPnL >= maxDrawdown) maxDrawdownHit = true; }); } else if (ddType === 'static') { // Static: total P&L from starting balance cannot drop below -maxDrawdown let runningPnL = 0; sortedTrades.forEach(t => { runningPnL += getNetPnl(t); if (runningPnL <= -maxDrawdown) maxDrawdownHit = true; }); } else { // Intraday trailing (default): peak updates after every individual trade let runningPnL = 0, peakPnL = 0; sortedTrades.forEach(t => { runningPnL += getNetPnl(t); if (runningPnL > peakPnL) peakPnL = runningPnL; if (peakPnL - runningPnL >= maxDrawdown) maxDrawdownHit = true; }); } // Trading days count const uniqueDays = new Set(sortedTrades.map(t => t.date || getTradingSessionDate(t.exitTime) || getDateKey(t.exitTime || t.entryTime || t.date) ).filter(d => d)).size; // Determine new status let newStatus = null; let statusReason = ''; // CHECK FOR FAIL CONDITIONS (daily loss limit breach never triggers auto-fail) if (maxDrawdownHit) { newStatus = 'failed'; statusReason = `Max drawdown of $${maxDrawdown} exceeded`; } // CHECK FOR PASS CONDITIONS (only if not failed) else if (currentPnL >= profitTarget) { if (minDays === 0 || uniqueDays >= minDays) { newStatus = 'passed'; statusReason = `Profit target of $${profitTarget} reached with ${uniqueDays} trading days`; } } // Update status if changed if (newStatus && newStatus !== evalAcc.status) { const completedAt = new Date().toISOString(); // Update in database try { await db.collection('users').doc(currentUser.uid).collection('accounts').doc(evalAcc.id).update({ status: newStatus, completedAt: completedAt, statusReason: statusReason }); // Update local arrays evalAcc.status = newStatus; evalAcc.completedAt = completedAt; evalAcc.statusReason = statusReason; const mainAcc = accounts.find(a => a.id === evalAcc.id); if (mainAcc) { mainAcc.status = newStatus; mainAcc.completedAt = completedAt; mainAcc.statusReason = statusReason; } // Show notification if (newStatus === 'passed') { showNotification(`🎉 ${evalAcc.name} AUTO-PASSED! ${statusReason}`, 'success'); } else { showNotification(`❌ ${evalAcc.name} AUTO-FAILED: ${statusReason}`, 'error'); } // Refresh account list renderAccountsList(); } catch (error) { console.error('Error auto-updating eval status:', error); } } } } function showArchiveMetricsModal(evalId) { // Remove any existing modal const existing = document.getElementById('archive-metrics-modal'); if (existing) existing.remove(); const overlay = document.createElement('div'); overlay.id = 'archive-metrics-modal'; overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);backdrop-filter:blur(6px);z-index:9999;display:flex;align-items:center;justify-content:center;'; overlay.innerHTML = `

Keep trades in metrics?

Should trades from this evaluation be included in your performance reports and analytics?

`; overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); document.body.appendChild(overlay); } async function archiveEvalAccount(evalId) { if (!confirm('Archive this evaluation? It will be moved to the archived section.')) return; showArchiveMetricsModal(evalId); } async function completeArchiveEval(evalId, keepMetrics) { const modal = document.getElementById('archive-metrics-modal'); if (modal) modal.remove(); const evalAcc = evalAccounts.find(e => e.id === evalId); const mainAcc = accounts.find(a => a.id === evalId); const archivedAt = new Date().toISOString(); if (evalAcc) { evalAcc.archived = true; evalAcc.archivedAt = archivedAt; evalAcc.excludeFromMetrics = !keepMetrics; } if (mainAcc) { mainAcc.archived = true; mainAcc.archivedAt = archivedAt; mainAcc.excludeFromMetrics = !keepMetrics; } try { await db.collection('users').doc(currentUser.uid).collection('accounts').doc(evalId).update({ archived: true, archivedAt: archivedAt, excludeFromMetrics: !keepMetrics }); updateFilterDropdowns(); renderAll(); const metricsMsg = keepMetrics ? 'Trades will remain in your performance metrics.' : 'Trades excluded from performance metrics.'; showNotification(`Evaluation archived. ${metricsMsg}`, 'success'); } catch (error) { console.error('Error archiving eval:', error); showNotification('Error archiving evaluation', 'error'); } } async function unarchiveEvalAccount(evalId) { const mainAcc = accounts.find(a => a.id === evalId); if (mainAcc) { mainAcc.archived = false; delete mainAcc.archivedAt; delete mainAcc.excludeFromMetrics; } // Also update evalAccounts array const evalAcc = evalAccounts.find(e => e.id === evalId); if (evalAcc) { evalAcc.archived = false; delete evalAcc.archivedAt; delete evalAcc.excludeFromMetrics; } try { await db.collection('users').doc(currentUser.uid).collection('accounts').doc(evalId).update({ archived: false, archivedAt: firebase.firestore.FieldValue.delete(), excludeFromMetrics: firebase.firestore.FieldValue.delete() }); refreshEvalAccounts(); // Refresh the eval accounts array updateFilterDropdowns(); renderAll(); // Refresh all reports (calendar, reports page, etc.) showNotification('Evaluation restored. Trades now included in all reports.', 'success'); } catch (error) { console.error('Error restoring eval:', error); showNotification('Error restoring evaluation', 'error'); } } async function markEvalPassed(evalId) { if (!confirm('Mark this evaluation as PASSED? This will move it to history.')) return; // Find in both arrays refreshEvalAccounts(); const evalAcc = evalAccounts.find(e => e.id === evalId); const mainAcc = accounts.find(a => a.id === evalId); if (evalAcc || mainAcc) { const completedAt = new Date().toISOString(); // Update local arrays if (evalAcc) { evalAcc.status = 'passed'; evalAcc.completedAt = completedAt; } if (mainAcc) { mainAcc.status = 'passed'; mainAcc.completedAt = completedAt; } await db.collection('users').doc(currentUser.uid).collection('accounts').doc(evalId).update({ status: 'passed', completedAt: completedAt }); renderAccountsList(); showNotification('🎉 Congratulations! Evaluation marked as passed!', 'success'); } } async function markEvalFailed(evalId) { if (!confirm('Mark this evaluation as FAILED? This will move it to history.')) return; // Find in both arrays refreshEvalAccounts(); const evalAcc = evalAccounts.find(e => e.id === evalId); const mainAcc = accounts.find(a => a.id === evalId); if (evalAcc || mainAcc) { const completedAt = new Date().toISOString(); // Update local arrays if (evalAcc) { evalAcc.status = 'failed'; evalAcc.completedAt = completedAt; } if (mainAcc) { mainAcc.status = 'failed'; mainAcc.completedAt = completedAt; } await db.collection('users').doc(currentUser.uid).collection('accounts').doc(evalId).update({ status: 'failed', completedAt: completedAt }); renderAccountsList(); showNotification('Evaluation marked as failed', 'error'); } } async function deleteEvalAccount(evalId) { if (!confirm('Delete this evaluation account? All associated trades will also be deleted.')) return; try { // Delete trades and staging docs for this account const tradesSnapshot = await db.collection('users').doc(currentUser.uid).collection('trades').where('accountId', '==', evalId).get(); const stageSnapshot = await db.collection('users').doc(currentUser.uid).collection('tradingStage').where('accountId', '==', evalId).get(); const batch = db.batch(); tradesSnapshot.docs.forEach(doc => batch.delete(doc.ref)); stageSnapshot.docs.forEach(doc => batch.delete(doc.ref)); await batch.commit(); // Delete the account await db.collection('users').doc(currentUser.uid).collection('accounts').doc(evalId).delete(); // Remove from local arrays trades = trades.filter(t => t.accountId !== evalId); evalAccounts = evalAccounts.filter(e => e.id !== evalId); accounts = accounts.filter(a => a.id !== evalId); updateFilterDropdowns(); showNotification('Evaluation account deleted', 'success'); } catch (error) { console.error('Error deleting eval account:', error); showNotification('Error deleting account', 'error'); } } function editEvalAccount(evalId) { editAccount(evalId); } // Load eval accounts on auth async function loadEvalAccounts() { refreshEvalAccounts(); // Use the same robust filter } // ===================================================== // FUNDED ACCOUNTS PAGE FUNCTIONS // ===================================================== function getFundedAccounts() { const globalPropFirmFilter = document.getElementById('global-prop-firm-filter')?.value || ''; return accounts.filter(a => { if (!a.propFirm) return false; if (!includeArchivedInMetrics && a.archived) return false; const stage = (a.stage || '').toLowerCase(); if (!(stage === 'funded' || stage === 'pa' || stage === 'live' || stage === 'sim')) return false; // Respect global account selection if (selectedAllFirmsAccounts.length > 0) { if (!selectedAllFirmsAccounts.includes(a.id)) return false; } // Respect global prop firm filter else if (globalPropFirmFilter) { if (a.propFirm !== globalPropFirmFilter) return false; } return true; }); } async function promptUpdateMLL(accountId, currentMLL) { // Find the account first to get the name const account = accounts.find(a => a.id === accountId); if (!account) return; const accountName = account.name || accountId; const newMLL = prompt( 'Enter current MLL from Rithmic/Tradovate:\n\n' + '(Rithmic: "Auto Liquidate Threshold Value")\n' + '(Tradovate: "Trailing Max Drawdown")\n\n' + 'Current value: ' + formatCurrency(currentMLL), currentMLL.toFixed(2) ); if (newMLL === null) return; const mllValue = parseFloat(newMLL.replace(/[,$]/g, '')); if (isNaN(mllValue) || mllValue <= 0) { alert('Invalid MLL value'); return; } // Update the account in local array const accountIndex = accounts.findIndex(a => a.id === accountId); if (accountIndex === -1) return; const priorMLL = accounts[accountIndex].currentMLL || currentMLL; accounts[accountIndex].currentMLL = mllValue; // Save to Firestore try { await db.collection('users').doc(currentUser.uid).collection('accounts').doc(accountId).update({ currentMLL: mllValue }); } catch (error) { console.error('Error saving MLL:', error); alert('Error saving MLL: ' + error.message); return; } // Re-render renderAll(); // Show confirmation popup alert( 'MLL Updated Successfully!\n\n' + 'Account: ' + accountName + '\n' + 'Prior MLL: ' + formatCurrency(priorMLL) + '\n' + 'New MLL: ' + formatCurrency(mllValue) ); } async function resyncMLL(accountId) { const account = accounts.find(a => a.id === accountId); if (!account) return; if (!account.currentMLL) { alert('MLL is already using the calculated value (no manual override set).'); return; } if (!confirm('Reset MLL for "' + (account.name || accountId) + '" back to the calculated value?\n\nThis will remove your manual override.')) return; // Clear the override const accountIndex = accounts.findIndex(a => a.id === accountId); if (accountIndex === -1) return; delete accounts[accountIndex].currentMLL; try { await db.collection('users').doc(currentUser.uid).collection('accounts').doc(accountId).update({ currentMLL: firebase.firestore.FieldValue.delete() }); } catch (error) { console.error('Error resetting MLL:', error); alert('Error resetting MLL: ' + error.message); return; } // Re-render renderAll(); alert('MLL reset to calculated value for "' + (account.name || accountId) + '"'); } // Apex PA tier lookup — funded PA accounts use tiered max_contracts + DLL based on profit. // Tiers are stored at propFirmConfigs.apex.pa_scaling_tiers[sizeKey] and updated daily at market close. // Returns the matching tier object { tier, profit_min, profit_max, max_contracts, dll } or null if no tier data. function getApexPATier(accountSize, currentProfit, scalingTiers) { const sizeKey = String(accountSize); const tiers = scalingTiers?.[sizeKey]; if (!tiers || !Array.isArray(tiers) || tiers.length === 0) return null; const profit = Math.max(0, currentProfit); // floor at 0 — tier 1 applies when at or below starting balance // Walk tiers high→low so an exact-boundary profit resolves to the higher tier. const match = tiers.slice().reverse().find(t => profit >= t.profit_min && (t.profit_max === null || t.profit_max === undefined || profit <= t.profit_max) ); return match || tiers[0]; // fallback to tier 1 if no match (shouldn't happen with sane ranges) } // Contract scaling check - global so Dashboard + Funded page can both use it function checkContractScaling(accountId, maxAllowed) { const accountTrades = trades.filter(t => t.accountId === accountId && t.entryTime && t.exitTime); if (accountTrades.length === 0) return { maxUsed: 0, maxUsedRaw: 0, violations: [], compliant: true }; function isMicroContract(trade) { const fields = [trade.symbol, trade.instrument, trade.ticker, trade.contract, trade.product]; for (const field of fields) { if (!field) continue; const s = String(field).toUpperCase().trim(); if (/^MNQ|^MES|^M2K|^MYM|^MCL|^MGC|^MSF|^M6E|^M6A|^M6B|^MBT|^MET|MICRO/.test(s)) return true; } return false; } const timePoints = new Set(); accountTrades.forEach(t => { timePoints.add(new Date(t.entryTime).getTime()); timePoints.add(new Date(t.exitTime).getTime()); }); let maxUsed = 0, maxUsedRaw = 0, violations = []; Array.from(timePoints).sort((a,b) => a-b).forEach(time => { let openPos = 0, openPosRaw = 0, openTrades = []; accountTrades.forEach(t => { const et = new Date(t.entryTime).getTime(), xt = new Date(t.exitTime).getTime(); if (et <= time && time < xt) { const qty = Math.abs(parseInt(t.quantity) || parseInt(t.contracts) || 1); const micro = isMicroContract(t); openPos += micro ? qty / 10 : qty; openPosRaw += qty; } }); if (openPos > maxUsed) { maxUsed = openPos; maxUsedRaw = openPosRaw; } if (openPos > maxAllowed) { const dateStr = new Date(time).toISOString().split('T')[0]; const existing = violations.find(v => v.date === dateStr); if (!existing) violations.push({ date: dateStr, maxPosition: openPos, maxPositionRaw: openPosRaw }); else if (openPos > existing.maxPosition) { existing.maxPosition = openPos; existing.maxPositionRaw = openPosRaw; } } }); return { maxUsed: Math.round(maxUsed * 100) / 100, maxUsedRaw, violations, compliant: violations.length === 0 }; } // Show account details - navigate to reports filtered to this account window.showAccountDetails = function(accountId) { const account = accounts.find(a => a.id === accountId); if (!account) { showNotification('Account not found', 'error'); return; } // Set the filter to this account const accountFilter = document.getElementById('filter-account'); if (accountFilter) { accountFilter.value = accountId; } // Navigate to reports page document.querySelector('[data-page="reports"]')?.click(); showNotification(`Viewing ${account.name}`, 'info'); }; function openPlaybook() { // Navigate to Settings > Playbook showPage('settings'); switchSettingsSection('playbook'); } function closePlaybook() { // No-op — playbook is now inline in Settings } let expandedSetupId = null; function toggleSetupExpand(id) { if (expandedSetupId === id) { expandedSetupId = null; } else { expandedSetupId = id; } renderPlaybookSetups(); } function renderPlaybookSetups() { const container = document.getElementById('playbook-setups'); if (!container) return; // Migrate conditions from string to array on load playbook.forEach(setup => { if (typeof setup.conditions === 'string' && setup.conditions.trim()) { setup.conditions = setup.conditions.split('\n').filter(l => l.trim()).map((text, i) => ({ id: 'cond-' + Date.now() + i, text: text.trim() })); } if (!Array.isArray(setup.conditions)) setup.conditions = []; }); if (playbook.length === 0) { container.innerHTML = '
No setups yet. Click "+ Add Setup" to create your first playbook entry.
'; return; } container.innerHTML = playbook.map(setup => { const confItems = setup.confluenceItems || []; const confCount = confItems.length; const confBadge = confCount > 0 ? ` (${confCount} item${confCount !== 1 ? 's' : ''})` : ''; const isExpanded = expandedSetupId === setup.id; const chevron = isExpanded ? '▲' : '▼'; const chevronLabel = isExpanded ? 'Collapse' : 'View'; // Build expanded detail HTML let expandedHtml = ''; if (isExpanded) { // Left column content const sectionLabel = (label) => `
${label}
`; const desc = setup.description ? `
${sectionLabel('Description')}
${setup.description}
` : ''; const entry = setup.entryRules ? `
${sectionLabel('Entry Rules')}
${setup.entryRules}
` : ''; const exit = setup.exitRules ? `
${sectionLabel('Exit Rules')}
${setup.exitRules}
` : ''; const conditions = (setup.conditions || []); const condHtml = conditions.length > 0 ? `
${sectionLabel('Conditions')}
${conditions.map(c => `${c.text}`).join('')}
` : ''; const hasLeftContent = desc || entry || exit || condHtml; const leftCol = hasLeftContent ? `${desc}${entry}${exit}${condHtml}` : '
No details added yet.
'; // Right column: confluence items grouped by section let rightCol = ''; if (confItems.length > 0) { const sections = [...new Set(confItems.map(i => i.section))]; const sectionColors = ['var(--yellow)', 'var(--cyan)', 'var(--orange)', 'var(--purple)', 'var(--green)', 'var(--blue)']; rightCol = sections.map((section, si) => { const color = sectionColors[si % sectionColors.length]; const items = confItems.filter(i => i.section === section); return `
${section}
${items.map(item => `
${item.text} ${item.points} pt${item.points > 1 ? 's' : ''}
`).join('')}
`; }).join(''); // Threshold bar const total = confItems.reduce((sum, item) => sum + item.points, 0); if (total > 0) { const t = { noTrade: Math.floor(total * 0.33), aggressive: Math.floor(total * 0.58), moderate: Math.floor(total * 0.83), conservative: total }; rightCol += `
Grade Thresholds (${total} pts total)
F – No Trade: 0–${t.noTrade} C – Aggressive: ${t.noTrade + 1}–${t.aggressive} B – Moderate: ${t.aggressive + 1}–${t.moderate} A – Conservative: ${t.moderate + 1}–${total}
`; } } else { rightCol = '
No confluence items. Click Edit to add them.
'; } expandedHtml = `
${leftCol}
${rightCol}
`; } return `

${setup.name}${confBadge}${setup.description ? ` — ${setup.description.length > 60 ? setup.description.substring(0, 60) + '...' : setup.description}` : ''}

${isExpanded ? `
${expandedHtml}
` : ''}
`; }).join(''); } function addSetupConditionInput(text) { const list = document.getElementById('setup-conditions-list'); const div = document.createElement('div'); div.style.cssText = 'display: flex; gap: 8px; margin-bottom: 6px; align-items: center;'; div.innerHTML = ` `; list.appendChild(div); } function addNewSetup() { editingSetupId = null; editingConfluenceItems = []; editingSetupImages = []; document.getElementById('setup-modal-title').textContent = 'Add Setup'; document.getElementById('setup-name').value = ''; document.getElementById('setup-description').value = ''; document.getElementById('setup-entry-rules').value = ''; document.getElementById('setup-exit-rules').value = ''; document.getElementById('setup-conditions-list').innerHTML = ''; renderSetupConfluenceEditor(); renderSetupReferenceImages(); document.getElementById('setup-conf-add-row').style.display = 'none'; document.getElementById('setup-modal').classList.add('active'); } function editSetup(id) { const setup = playbook.find(s => s.id === id); if (!setup) return; editingSetupId = id; editingConfluenceItems = [...(setup.confluenceItems || [])].map(i => ({...i})); editingSetupImages = (setup.referenceImages || []).map(img => ({ ...img })); document.getElementById('setup-modal-title').textContent = 'Edit Setup'; document.getElementById('setup-name').value = setup.name || ''; document.getElementById('setup-description').value = setup.description || ''; document.getElementById('setup-entry-rules').value = setup.entryRules || ''; document.getElementById('setup-exit-rules').value = setup.exitRules || ''; // Populate conditions list const list = document.getElementById('setup-conditions-list'); list.innerHTML = ''; (setup.conditions || []).forEach(c => addSetupConditionInput(c.text)); renderSetupConfluenceEditor(); renderSetupReferenceImages(); document.getElementById('setup-conf-add-row').style.display = 'none'; document.getElementById('setup-modal').classList.add('active'); } function closeSetupModal() { document.getElementById('setup-modal').classList.remove('active'); editingSetupId = null; editingSetupImages = []; } async function saveSetup() { // Collect conditions from list inputs const condInputs = document.querySelectorAll('#setup-conditions-list .setup-cond-input'); const conditions = []; condInputs.forEach((inp, i) => { const text = inp.value.trim(); if (text) conditions.push({ id: 'cond-' + Date.now() + i, text }); }); const setup = { name: document.getElementById('setup-name').value.trim(), description: document.getElementById('setup-description').value, entryRules: document.getElementById('setup-entry-rules').value, exitRules: document.getElementById('setup-exit-rules').value, conditions: conditions, confluenceItems: editingConfluenceItems }; if (!setup.name) { alert('Please enter a setup name'); return; } if (editingSetupId) { setup.id = editingSetupId; const idx = playbook.findIndex(s => s.id === editingSetupId); if (idx >= 0) playbook[idx] = setup; } else { setup.id = 'setup-' + Date.now(); playbook.push(setup); } // Disable save button during upload const saveBtn = document.querySelector('#setup-modal .modal-footer .btn-primary'); if (saveBtn) { saveBtn.disabled = true; saveBtn.textContent = 'Saving...'; } try { // Find images that were removed (existed before but not in current array) const oldSetup = playbook.find(s => s.id === setup.id); const oldImages = oldSetup?.referenceImages || []; const currentPaths = new Set(editingSetupImages.filter(i => i.storagePath).map(i => i.storagePath)); const removedImages = oldImages.filter(img => img.storagePath && !currentPaths.has(img.storagePath)); // Delete removed images from Storage for (const img of removedImages) { try { await storage.ref(img.storagePath).delete(); } catch (e) { /* ignore if already gone */ } } // Upload new images (ones with a file property) const basePath = `users/${currentUser.uid}/setupImages/${setup.id}`; for (const img of editingSetupImages) { if (img.file) { const fileName = `${Date.now()}-${img.file.name}`; const ref = storage.ref(`${basePath}/${fileName}`); await ref.put(img.file); img.url = await ref.getDownloadURL(); img.storagePath = `${basePath}/${fileName}`; delete img.file; delete img.preview; } } // Filter out any images that failed upload (undefined url would break Firestore) setup.referenceImages = editingSetupImages .filter(img => img.url) .map(({ url, storagePath }) => ({ url, storagePath })); await db.collection('users').doc(currentUser.uid).collection('playbook').doc(setup.id).set(setup); // Update in-memory playbook const memIdx = playbook.findIndex(s => s.id === setup.id); if (memIdx >= 0) playbook[memIdx] = setup; closeSetupModal(); renderPlaybookSetups(); } catch (error) { console.error('[saveSetup] Error:', error); showNotification('Error saving setup: ' + (error.message || 'Please try again.'), 'error'); } finally { if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = 'Save Setup'; } } } async function deleteSetup(id) { if (!confirm('Delete this setup?')) return; // Delete reference images from Storage const setup = playbook.find(s => s.id === id); if (setup?.referenceImages) { for (const img of setup.referenceImages) { if (img.storagePath) { try { await storage.ref(img.storagePath).delete(); } catch (e) { /* ignore */ } } } } await db.collection('users').doc(currentUser.uid).collection('playbook').doc(id).delete(); playbook = playbook.filter(s => s.id !== id); renderPlaybookSetups(); } // ===================================================== // SETUP REFERENCE IMAGES // ===================================================== function handleSetupImageSelect(event) { const files = Array.from(event.target.files); const maxImages = 5; const maxSize = 10 * 1024 * 1024; // 10MB const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']; for (const file of files) { if (editingSetupImages.length >= maxImages) { alert(`Maximum ${maxImages} images allowed.`); break; } if (!allowedTypes.includes(file.type)) { alert(`${file.name}: Unsupported format. Use JPG, PNG, WebP, or GIF.`); continue; } if (file.size > maxSize) { alert(`${file.name}: File too large (max 10MB).`); continue; } editingSetupImages.push({ file, preview: URL.createObjectURL(file) }); } event.target.value = ''; renderSetupReferenceImages(); } function removeSetupReferenceImage(idx) { const img = editingSetupImages[idx]; if (img?.preview) URL.revokeObjectURL(img.preview); editingSetupImages.splice(idx, 1); renderSetupReferenceImages(); } function renderSetupReferenceImages() { const container = document.getElementById('setup-ref-images'); const addBtn = document.getElementById('setup-add-image-btn'); if (!container) return; if (editingSetupImages.length >= 5 && addBtn) addBtn.style.display = 'none'; else if (addBtn) addBtn.style.display = ''; if (editingSetupImages.length === 0) { container.innerHTML = ''; return; } container.innerHTML = editingSetupImages.map((img, idx) => { const src = img.preview || img.url; return `
`; }).join(''); } // ===================================================== // SETUP CONFLUENCE EDITOR HELPERS // ===================================================== function renderSetupConfluenceEditor() { const container = document.getElementById('setup-confluence-list'); if (!container) return; if (editingConfluenceItems.length === 0) { container.innerHTML = '
No confluence items yet. Click "+ Add Item" above to get started.
'; updateSetupThresholdPreview(); populateSetupConfSectionDropdown(); return; } const sections = [...new Set(editingConfluenceItems.map(i => i.section))]; container.innerHTML = sections.map(section => `
${section}
${editingConfluenceItems.filter(i => i.section === section).map(item => `
${item.text} ${item.points} pt${item.points > 1 ? 's' : ''}
`).join('')}
`).join(''); updateSetupThresholdPreview(); populateSetupConfSectionDropdown(); } function populateSetupConfSectionDropdown() { const select = document.getElementById('setup-conf-section'); if (!select) return; const existingSections = [...new Set(editingConfluenceItems.map(i => i.section))]; select.innerHTML = existingSections.map(s => `` ).join('') + ''; } window.toggleSetupConfSectionInput = function() { const select = document.getElementById('setup-conf-section'); const customInput = document.getElementById('setup-conf-custom-section'); if (select.value === '__new__') { customInput.style.display = 'block'; customInput.focus(); } else { customInput.style.display = 'none'; } }; window.addSetupConfluenceItem = function() { let section = document.getElementById('setup-conf-section').value; const text = document.getElementById('setup-conf-text').value.trim(); const points = parseInt(document.getElementById('setup-conf-points').value) || 1; if (section === '__new__') { section = document.getElementById('setup-conf-custom-section').value.trim(); if (!section) { alert('Please enter a section name'); document.getElementById('setup-conf-custom-section').focus(); return; } } if (!text) return; editingConfluenceItems.push({ id: 'conf-' + Date.now(), section: section, text: text, points: points }); document.getElementById('setup-conf-text').value = ''; document.getElementById('setup-conf-custom-section').value = ''; document.getElementById('setup-conf-custom-section').style.display = 'none'; renderSetupConfluenceEditor(); }; window.editSetupConfluenceItem = function(id) { const item = editingConfluenceItems.find(i => i.id === id); if (!item) return; const newText = prompt('Edit item text:', item.text); if (newText !== null && newText.trim()) { item.text = newText.trim(); const newPoints = prompt('Edit points (1-5):', item.points); if (newPoints !== null) { item.points = Math.max(1, Math.min(5, parseInt(newPoints) || 1)); } renderSetupConfluenceEditor(); } }; window.deleteSetupConfluenceItem = function(id) { if (!confirm('Delete this confluence item?')) return; editingConfluenceItems = editingConfluenceItems.filter(i => i.id !== id); renderSetupConfluenceEditor(); }; window.editSetupConfSectionName = function(oldName) { const newName = prompt('Rename section:', oldName); if (newName !== null && newName.trim() && newName.trim() !== oldName) { editingConfluenceItems.forEach(item => { if (item.section === oldName) item.section = newName.trim(); }); renderSetupConfluenceEditor(); } }; function updateSetupThresholdPreview() { const previewContainer = document.getElementById('setup-threshold-preview'); const valuesContainer = document.getElementById('setup-threshold-values'); if (!previewContainer || !valuesContainer) return; const total = editingConfluenceItems.reduce((sum, item) => sum + item.points, 0); if (total === 0) { previewContainer.style.display = 'none'; return; } previewContainer.style.display = 'block'; const t = { noTrade: Math.floor(total * 0.33), aggressive: Math.floor(total * 0.58), moderate: Math.floor(total * 0.83), conservative: total }; valuesContainer.innerHTML = ` F – No Trade: 0-${t.noTrade} C – Aggressive: ${t.noTrade + 1}-${t.aggressive} B – Moderate: ${t.aggressive + 1}-${t.moderate} A – Conservative: ${t.moderate + 1}-${total} (Total: ${total} pts) `; } // ===================================================== // SEARCH FUNCTIONS // ===================================================== // ===================================================== // DATA EXPORT FUNCTIONS // ===================================================== function exportData() { const data = { exportDate: new Date().toISOString(), trades: trades, journal: journal, scorecards: scorecards, tradeNotes: tradeNotes, weeklyReviews: weeklyReviews, playbook: playbook, accounts: accounts }; const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `trading-journal-backup-${getTodayKey()}.json`; a.click(); URL.revokeObjectURL(url); } function exportTradesToCSV() { const headers = ['Date', 'Symbol', 'Side', 'Qty', 'Entry Price', 'Exit Price', 'Entry Time', 'Exit Time', 'P&L', 'Account', 'Tags', 'Notes']; const rows = trades.map(t => { const noteData = tradeNotes[t.id] || {}; return [ getDateKey(t.exitTime), t.symbol || '', t.side || '', t.qty || 1, t.entryPrice || '', t.exitPrice || '', t.entryTime || '', t.exitTime || '', t.pnl || 0, t.account || '', (noteData.tags || []).join('; '), (noteData.notes || '').replace(/"/g, '""') ].map(v => `"${v}"`).join(','); }); const csv = [headers.join(','), ...rows].join('\n'); const blob = new Blob([csv], { type: 'text/csv' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `trades-export-${getTodayKey()}.csv`; a.click(); URL.revokeObjectURL(url); } // ===================================================== // THEME TOGGLE // ===================================================== let currentTheme = 'dark'; // Track current theme: 'dark', 'light', 'colorblind' // Theme-aware chart colors — reads CSS variables so charts respect colorblind mode function themeGreen() { return getComputedStyle(document.body).getPropertyValue('--green').trim() || '#10b981'; } function themeRed() { return getComputedStyle(document.body).getPropertyValue('--red').trim() || '#ef4444'; } function themeGreenBg(alpha) { const g = themeGreen(); // Convert hex to rgba const r = parseInt(g.slice(1,3),16), gv = parseInt(g.slice(3,5),16), b = parseInt(g.slice(5,7),16); return `rgba(${r},${gv},${b},${alpha || 0.15})`; } function themeRedBg(alpha) { const rd = themeRed(); const r = parseInt(rd.slice(1,3),16), gv = parseInt(rd.slice(3,5),16), b = parseInt(rd.slice(5,7),16); return `rgba(${r},${gv},${b},${alpha || 0.15})`; } function themePnlColor(val) { return val >= 0 ? themeGreen() : themeRed(); } function themePnlBg(val, alpha) { return val >= 0 ? themeGreenBg(alpha) : themeRedBg(alpha); } function toggleTheme() { // Cycle: dark -> light -> colorblind -> dark const order = ['dark', 'light', 'colorblind']; const next = order[(order.indexOf(currentTheme) + 1) % order.length]; setTheme(next); } function setTheme(theme) { currentTheme = theme; isDarkTheme = theme !== 'light'; document.body.classList.remove('light-theme', 'colorblind-theme'); if (theme === 'light') document.body.classList.add('light-theme'); if (theme === 'colorblind') document.body.classList.add('colorblind-theme'); localStorage.setItem('theme', theme); updateThemeOptions(); // Re-render charts so they pick up new theme colors if (typeof renderAll === 'function') { try { renderAll(); } catch(e) {} } } function updateThemeOptions() { // Settings page cards const darkOption = document.getElementById('theme-dark-option'); const lightOption = document.getElementById('theme-light-option'); const cbOption = document.getElementById('theme-colorblind-option'); if (darkOption && lightOption) { darkOption.style.borderColor = currentTheme === 'dark' ? 'var(--cyan)' : 'transparent'; lightOption.style.borderColor = currentTheme === 'light' ? 'var(--cyan)' : 'transparent'; if (cbOption) cbOption.style.borderColor = currentTheme === 'colorblind' ? 'var(--cyan)' : 'transparent'; } // Sync popup theme buttons const popupDark = document.getElementById('popup-theme-dark'); const popupLight = document.getElementById('popup-theme-light'); const popupCb = document.getElementById('popup-theme-colorblind'); if (popupDark) popupDark.classList.toggle('active', currentTheme === 'dark'); if (popupLight) popupLight.classList.toggle('active', currentTheme === 'light'); if (popupCb) popupCb.classList.toggle('active', currentTheme === 'colorblind'); } // ===================================================== // PROFILE POPUP // ===================================================== function toggleProfilePopup(event) { event.stopPropagation(); const popup = document.getElementById('profile-popup'); popup.classList.toggle('active'); } function closeProfilePopup() { document.getElementById('profile-popup')?.classList.remove('active'); } document.addEventListener('click', function(e) { const popup = document.getElementById('profile-popup'); const profileArea = document.getElementById('sidebar-profile-area'); if (popup && popup.classList.contains('active') && !popup.contains(e.target) && !profileArea.contains(e.target)) { closeProfilePopup(); } }); // Close connection status popover on outside click document.addEventListener('click', function(e) { const popover = document.getElementById('conn-status-popover'); const widget = document.getElementById('conn-status-widget'); if (popover && popover.style.display === 'block' && widget && !widget.contains(e.target)) { popover.style.display = 'none'; } }); // Distribution chart mode state window._distMode = 'count'; // 'count', 'dollar', 'percent' function setDistMode(mode) { window._distMode = mode; document.querySelectorAll('.dist-mode-btn').forEach(btn => { if (btn.dataset.mode === mode) { btn.style.background = 'var(--cyan)'; btn.style.color = 'var(--bg-primary)'; btn.style.fontWeight = '600'; } else { btn.style.background = 'transparent'; btn.style.color = 'var(--text-muted)'; btn.style.fontWeight = '400'; } }); renderDistributionChart(); } function renderDistributionChart() { const filteredTrades = window._distFilteredTrades; const chartColors = window._distChartColors; if (!filteredTrades || !chartColors) return; if (window.rptPointsDistChart) window.rptPointsDistChart.destroy(); const mode = window._distMode || 'count'; const zoom = parseInt(document.getElementById('dist-zoom-slider')?.value || '3'); const zoomLabel = document.getElementById('dist-zoom-label'); const zoomNames = { 1: 'XFine', 2: 'Fine', 3: 'Med', 4: 'Wide', 5: 'XWide' }; if (zoomLabel) zoomLabel.textContent = zoomNames[zoom] || 'Med'; // X-axis is ALWAYS points — collect points + pnl per trade const tradeData = []; filteredTrades.forEach(t => { const entry = parseFloat(t.entryPrice); const exit = parseFloat(t.exitPrice); if (isNaN(entry) || isNaN(exit)) return; const pnl = getNetPnl(t); if (pnl === 0) return; const pts = Math.abs(t.side === 'Long' ? exit - entry : entry - exit); tradeData.push({ pts, pnl, win: pnl > 0 }); }); if (tradeData.length === 0) return; // Zoom controls point bucket size: 1, 2, 5, 10, 25 const ptSizes = [1, 2, 5, 10, 25]; const bucketSize = ptSizes[zoom - 1]; const maxPts = Math.max(...tradeData.map(t => t.pts)); const maxBuckets = 30; const numBuckets = Math.min(maxBuckets, Math.ceil(maxPts / bucketSize) + 1); // Build buckets const buckets = []; for (let i = 0; i < numBuckets; i++) { const min = i * bucketSize; const isLast = i === numBuckets - 1; const label = isLast ? min + '+' : min + '-' + (min + bucketSize - (bucketSize >= 1 ? 1 : 0)); buckets.push({ label, min, wins: [], losses: [] }); } // Sort trades into buckets tradeData.forEach(t => { let idx = Math.min(Math.floor(t.pts / bucketSize), buckets.length - 1); if (idx < 0) idx = 0; if (t.win) buckets[idx].wins.push(t); else buckets[idx].losses.push(t); }); // Trim trailing empty buckets let lastNonZero = buckets.length - 1; while (lastNonZero > 2 && buckets[lastNonZero].wins.length === 0 && buckets[lastNonZero].losses.length === 0) lastNonZero--; const trimmed = buckets.slice(0, lastNonZero + 1); // Calculate Y values based on mode const totalTrades = tradeData.length; let winData, lossData, yLabel, tooltipFmt; if (mode === 'count') { winData = trimmed.map(b => b.wins.length); lossData = trimmed.map(b => b.losses.length); yLabel = 'Trade Count'; tooltipFmt = (dataset, val, bucket) => dataset + ': ' + val + ' trades'; } else if (mode === 'dollar') { winData = trimmed.map(b => b.wins.reduce((s, t) => s + t.pnl, 0)); lossData = trimmed.map(b => b.losses.reduce((s, t) => s + t.pnl, 0)); yLabel = 'P&L ($)'; tooltipFmt = (dataset, val, bucket) => dataset + ': $' + val.toFixed(2); } else if (mode === 'percent') { winData = trimmed.map(b => { const total = b.wins.length + b.losses.length; return total > 0 ? (b.wins.length / total * 100) : 0; }); lossData = trimmed.map(b => { const total = b.wins.length + b.losses.length; return total > 0 ? (b.losses.length / total * 100) : 0; }); yLabel = 'Win Rate %'; tooltipFmt = (dataset, val, bucket) => dataset + ': ' + val.toFixed(1) + '% (' + (dataset === 'Wins' ? bucket.wins.length : bucket.losses.length) + ')'; } const labels = trimmed.map(b => b.label); const pointsDistCtx = document.getElementById('rpt-points-dist-chart'); if (pointsDistCtx) { window.rptPointsDistChart = new Chart(pointsDistCtx, { type: 'bar', data: { labels: labels, datasets: [ { label: 'Wins', data: winData, backgroundColor: chartColors.green, borderRadius: 3 }, { label: 'Losses', data: lossData, backgroundColor: chartColors.red, borderRadius: 3 } ] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: true, labels: { color: chartColors.textColor, boxWidth: 12, font: { size: 11 } } }, tooltip: { callbacks: { title: ctx => ctx[0].label + ' points', label: ctx => { const bIdx = ctx.dataIndex; const b = trimmed[bIdx]; const totalInBucket = b ? b.wins.length + b.losses.length : 0; return tooltipFmt(ctx.dataset.label, ctx.parsed.y, b) + ' (' + totalInBucket + ' trades)'; } } } }, scales: { x: { stacked: true, grid: { display: false }, ticks: { color: chartColors.textColor, maxRotation: labels.length > 15 ? 45 : 0, font: { size: labels.length > 20 ? 9 : 11 } }, title: { display: true, text: 'Points Range', color: chartColors.textColor, font: { size: 11 } } }, y: { stacked: mode !== 'dollar', grid: { color: chartColors.gridColor }, ticks: { color: chartColors.textColor, callback: function(val) { if (mode === 'dollar') return formatCurrency(val, 0); if (mode === 'percent') return val.toFixed(0) + '%'; return val; } }, title: { display: true, text: yLabel, color: chartColors.textColor, font: { size: 11 } } } } } }); } } // ==================== LABBUILDER ENGINE ==================== let _lbSavedMetrics = []; function lbInit() { const allTrades = getFilteredTrades(); // Populate instrument dropdown const instruments = [...new Set(allTrades.map(t => (t.symbol || '').replace(/[A-Z]\d{2}$/, '').replace(/\d{2}$/, '')).filter(Boolean))].sort(); const instSelect = document.getElementById('lb-filter-instrument'); if (instSelect) { const cur = instSelect.value; instSelect.innerHTML = '' + instruments.map(i => ``).join(''); instSelect.value = cur || 'all'; } // Populate prop firm dropdown from accounts (propFirm lives on accounts, not trades) const accountFirmMap = {}; accounts.forEach(a => { if (a.propFirm) accountFirmMap[a.id] = a.propFirm; }); const firms = [...new Set(Object.values(accountFirmMap).filter(Boolean))].sort(); const firmSelect = document.getElementById('lb-filter-propfirm'); if (firmSelect) { const cur = firmSelect.value; const firmNames = { apex: 'Apex', topstep: 'TopStep', myfundedfutures: 'MFF', tradeify: 'Tradeify', takeprofittrader: 'TPT', bulenox: 'Bulenox', lucid: 'Lucid', elitetraderfunding: 'ETF', earn2trade: 'E2T', legendstrading: 'Legends', fundedfuturesnetwork: 'FFN', fundednext: 'FundedNext', daytraders: 'DayTraders' }; firmSelect.innerHTML = '' + firms.map(f => ``).join(''); firmSelect.value = cur || 'all'; } try { _lbSavedMetrics = JSON.parse(localStorage.getItem('lb_saved_metrics') || '[]'); } catch(e) { _lbSavedMetrics = []; } lbRenderSaved(); lbRun(); } // Toggle scale grouping options visibility document.addEventListener('change', (e) => { if (e.target?.id === 'lb-scale-group') { const opts = document.getElementById('lb-scale-options'); if (opts) opts.style.display = e.target.checked ? 'block' : 'none'; } }); /** * Group scaled trades: merge fills within the proximity window (same symbol, same side) * into single "position" trades with weighted avg entry/exit, summed quantity and P&L. */ function lbGroupScaledTrades(trades, windowMinutes) { if (!trades.length) return trades; // Sort by entry time const sorted = [...trades].sort((a, b) => new Date(a.entryTime || a.date) - new Date(b.entryTime || b.date) ); const grouped = []; let current = null; for (const t of sorted) { const sym = (t.symbol || '').replace(/[FGHJKMNQUVXZ]\d{1,2}$/i, ''); const side = t.side; const entryMs = new Date(t.entryTime || t.date).getTime(); const exitMs = new Date(t.exitTime || t.date).getTime(); const qty = parseInt(t.quantity || t.qty || 1); const entry = parseFloat(t.entryPrice) || 0; const exit = parseFloat(t.exitPrice) || 0; const pnl = getNetPnl(t); const comm = parseFloat(t.commission) || 0; const fees = parseFloat(t.fees) || 0; if (current && current._sym === sym && current._side === side && current.accountId === t.accountId && entryMs - current._lastEntryMs <= windowMinutes * 60000) { // Merge into current group const prevQty = current._totalQty; const newQty = prevQty + qty; current.entryPrice = ((current._totalQty * parseFloat(current.entryPrice)) + (qty * entry)) / newQty; current._totalQty = newQty; current.quantity = newQty; current.qty = newQty; current._lastEntryMs = entryMs; // Use the latest exit if (exitMs > new Date(current.exitTime || current.date).getTime()) { current.exitTime = t.exitTime; current.exitPrice = exit; } // Weighted avg exit across all fills current.exitPrice = ((prevQty * parseFloat(current._avgExit)) + (qty * exit)) / newQty; current._avgExit = current.exitPrice; // Sum P&L and commissions current._netPnl = (current._netPnl || 0) + pnl; current.pnl = current._netPnl; current.netPnl = current._netPnl; current.commission = (parseFloat(current.commission) || 0) + comm; current.fees = (parseFloat(current.fees) || 0) + fees; current._scaleCount++; } else { // Start new group if (current) grouped.push(current); current = { ...t }; current._sym = sym; current._side = side; current._lastEntryMs = entryMs; current._totalQty = qty; current._avgExit = exit; current._netPnl = pnl; current._scaleCount = 1; current.quantity = qty; current.qty = qty; } } if (current) grouped.push(current); return grouped; } function lbUpdateGroupOptions() { const group = document.getElementById('lb-group-primary').value; const bucketGroup = document.getElementById('lb-bucket-size-group'); const bucketInput = document.getElementById('lb-bucket-size'); const bucketLabel = bucketGroup?.querySelector('label'); const show = ['pointsBucket', 'dollarBucket', 'duration', 'contractsBucket'].includes(group); bucketGroup.style.display = show ? 'block' : 'none'; if (group === 'dollarBucket') { bucketInput.value = bucketInput.value || '100'; if (bucketLabel) bucketLabel.textContent = 'Bucket Size ($)'; } else if (group === 'contractsBucket') { bucketInput.value = bucketInput.value || '1'; if (bucketLabel) bucketLabel.textContent = 'Bucket Size (contracts)'; } else if (group === 'duration') { bucketInput.value = '1'; if (bucketLabel) bucketLabel.textContent = 'Interval (minutes)'; } else if (group === 'pointsBucket') { bucketInput.value = bucketInput.value || '5'; if (bucketLabel) bucketLabel.textContent = 'Bucket Size (points)'; } else { bucketInput.value = bucketInput.value || '5'; if (bucketLabel) bucketLabel.textContent = 'Bucket Size'; } } function lbGetGroupKey(t, groupBy, bucketSize) { const pnl = getNetPnl(t); const entry = parseFloat(t.entryPrice) || 0; const exit = parseFloat(t.exitPrice) || 0; const pts = entry && exit ? Math.abs(t.side === 'Long' ? exit - entry : entry - exit) : 0; switch(groupBy) { case 'dayOfWeek': { const dtz = getDateInTZ(t.date || t.entryTime); return { key: dtz.dayOfWeek, label: ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][dtz.dayOfWeek] || '?' }; } case 'hour': { let h = getDateInTZ(t.entryTime || t.date).hours; return { key: h, label: (h % 12 || 12) + ':00 ' + (h >= 12 ? 'PM' : 'AM') }; } case 'instrument': { const sym = (t.symbol || '').replace(/[A-Z]\d{2}$/, '').replace(/\d{2}$/, ''); return { key: sym, label: sym || '?' }; } case 'side': return { key: t.side || '?', label: t.side || '?' }; case 'account': { const acc = accounts.find(a => a.id === t.accountId); return { key: t.accountId, label: acc?.name || t.accountId || '?' }; } case 'propFirm': { const names = { apex:'Apex', topstep:'TopStep', myfundedfutures:'MFF', tradeify:'Tradeify', takeprofittrader:'TPT', bulenox:'Bulenox', lucid:'Lucid', elitetraderfunding:'ETF', daytraders:'DayTraders' }; const acc2 = accounts.find(a => a.id === t.accountId); const pf = acc2?.propFirm || '?'; return { key: pf, label: names[pf] || pf }; } case 'pointsBucket': { const b = Math.floor(pts / bucketSize) * bucketSize; return { key: b, label: b + '-' + (b + bucketSize - 1) + ' pts' }; } case 'dollarBucket': { const b = Math.floor(Math.abs(pnl) / bucketSize) * bucketSize; return { key: b, label: getCurrencySymbol() + Math.round(convertFromUSD(b)) + '-' + getCurrencySymbol() + Math.round(convertFromUSD(b + bucketSize)) }; } case 'duration': { const mins = Math.max(0, (new Date(t.exitTime) - new Date(t.entryTime)) / 60000); const b = Math.floor(mins / bucketSize) * bucketSize; return { key: b, label: b + '-' + (b + bucketSize) + ' min' }; } case 'contractsBucket': { const qty = parseInt(t.quantity || t.qty || 1); const b = Math.floor(qty / bucketSize) * bucketSize; return { key: b, label: b + '-' + (b + bucketSize - 1) + ' cts' }; } case 'weekNumber': { const dtz = getDateInTZ(t.date || t.entryTime); const d2 = new Date(dtz.year, dtz.month, dtz.day); const wk = Math.ceil(((d2 - new Date(dtz.year, 0, 1)) / 86400000 + new Date(dtz.year, 0, 1).getDay() + 1) / 7); return { key: wk, label: 'Wk ' + wk }; } case 'month': { const dtz = getDateInTZ(t.date || t.entryTime); return { key: dtz.year * 100 + dtz.month, label: ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'][dtz.month] + ' ' + dtz.year }; } case 'quarter': { const dtz = getDateInTZ(t.date || t.entryTime); const q = Math.floor(dtz.month / 3) + 1; return { key: dtz.year * 10 + q, label: 'Q' + q + ' ' + dtz.year }; } case 'tradeNumberInDay': { return { key: t._tradeNumInDay || 1, label: 'Trade #' + (t._tradeNumInDay || 1) }; } default: return { key: 'all', label: 'All Trades' }; } } function lbCalcMetric(trades, metricId) { if (!trades || trades.length === 0) return 0; const wins = trades.filter(t => getNetPnl(t) > 0); const losses = trades.filter(t => getNetPnl(t) < 0); const pnls = trades.map(t => getNetPnl(t)); const totalPnl = pnls.reduce((s, v) => s + v, 0); const winPnls = wins.map(t => getNetPnl(t)); const lossPnls = losses.map(t => getNetPnl(t)); switch(metricId) { case 'winRate': return wins.length / trades.length * 100; case 'avgPnl': return totalPnl / trades.length; case 'totalPnl': return totalPnl; case 'tradeCount': return trades.length; case 'profitFactor': { const gw = winPnls.reduce((s,v) => s+v, 0); const gl = Math.abs(lossPnls.reduce((s,v) => s+v, 0)); return gl > 0 ? gw / gl : gw > 0 ? 99.99 : 0; } case 'avgPoints': { let tot = 0, cnt = 0; trades.forEach(t => { const e = parseFloat(t.entryPrice), x = parseFloat(t.exitPrice); if (!isNaN(e) && !isNaN(x)) { tot += Math.abs(t.side === 'Long' ? x - e : e - x); cnt++; } }); return cnt > 0 ? tot / cnt : 0; } case 'maxWin': return winPnls.length > 0 ? Math.max(...winPnls) : 0; case 'maxLoss': return lossPnls.length > 0 ? Math.min(...lossPnls) : 0; case 'avgWin': return winPnls.length > 0 ? winPnls.reduce((s,v)=>s+v,0) / winPnls.length : 0; case 'avgLoss': return lossPnls.length > 0 ? lossPnls.reduce((s,v)=>s+v,0) / lossPnls.length : 0; case 'winStreak': { let max = 0, cur = 0; trades.sort((a,b) => new Date(a.entryTime||a.date) - new Date(b.entryTime||b.date)); trades.forEach(t => { if (getNetPnl(t) > 0) { cur++; max = Math.max(max, cur); } else cur = 0; }); return max; } case 'expectancy': { const wr = wins.length / trades.length; const avgW = winPnls.length > 0 ? winPnls.reduce((s,v)=>s+v,0)/winPnls.length : 0; const avgL = lossPnls.length > 0 ? Math.abs(lossPnls.reduce((s,v)=>s+v,0)/lossPnls.length) : 0; return (wr * avgW) - ((1-wr) * avgL); } case 'avgDuration': { let tot = 0, cnt = 0; trades.forEach(t => { const d = (new Date(t.exitTime) - new Date(t.entryTime)) / 60000; if (d > 0 && d < 1440) { tot += d; cnt++; } }); return cnt > 0 ? tot / cnt : 0; } case 'riskReward': { const avgW = winPnls.length > 0 ? winPnls.reduce((s,v)=>s+v,0)/winPnls.length : 0; const avgL = lossPnls.length > 0 ? Math.abs(lossPnls.reduce((s,v)=>s+v,0)/lossPnls.length) : 1; return avgL > 0 ? avgW / avgL : 0; } default: return 0; } } function lbFormatMetric(value, metricId) { if (value === Infinity) { if (metricId === 'profitFactor' || metricId === 'riskReward') return '∞'; if (metricId === 'winRate') return '100.0%'; return '∞'; } switch(metricId) { case 'winRate': return value.toFixed(1) + '%'; case 'avgPnl': case 'totalPnl': case 'maxWin': case 'maxLoss': case 'avgWin': case 'avgLoss': case 'expectancy': return formatCurrency(value); case 'tradeCount': case 'winStreak': return Math.round(value).toString(); case 'profitFactor': case 'riskReward': return value.toFixed(2); case 'avgPoints': return value.toFixed(2) + ' pts'; case 'avgDuration': return value.toFixed(1) + ' min'; default: return value.toFixed(2); } } // Position fixed tooltips in LabBuilder steps on hover document.querySelectorAll('.lb-step .info-tooltip').forEach(tip => { tip.addEventListener('mouseenter', function() { const icon = this.querySelector('.tooltip-icon'); const content = this.querySelector('.tooltip-content'); if (!icon || !content) return; const rect = icon.getBoundingClientRect(); content.style.top = (rect.top - content.offsetHeight - 10) + 'px'; content.style.left = Math.max(8, rect.left + rect.width / 2 - content.offsetWidth / 2) + 'px'; }); }); function lbRun() { let trades = getFilteredTrades(); // Apply filters const side = document.getElementById('lb-filter-side')?.value || 'all'; const result = document.getElementById('lb-filter-result')?.value || 'all'; const instrument = document.getElementById('lb-filter-instrument')?.value || 'all'; const session = document.getElementById('lb-filter-session')?.value || 'all'; const propFirmFilter = document.getElementById('lb-filter-propfirm')?.value || 'all'; const minPts = parseFloat(document.getElementById('lb-filter-min-pts')?.value) || 0; const maxPts = parseFloat(document.getElementById('lb-filter-max-pts')?.value) || 0; const minPnl = parseFloat(document.getElementById('lb-filter-min-pnl')?.value) || 0; const maxPnl = parseFloat(document.getElementById('lb-filter-max-pnl')?.value) || 0; const minQty = parseFloat(document.getElementById('lb-filter-min-qty')?.value) || 0; const maxQty = parseFloat(document.getElementById('lb-filter-max-qty')?.value) || 0; if (side !== 'all') trades = trades.filter(t => t.side === side); if (result === 'wins') trades = trades.filter(t => getNetPnl(t) > 0); else if (result === 'losses') trades = trades.filter(t => getNetPnl(t) < 0); if (instrument !== 'all') trades = trades.filter(t => (t.symbol||'').replace(/[A-Z]\d{2}$/,'').replace(/\d{2}$/,'') === instrument); if (propFirmFilter !== 'all') trades = trades.filter(t => { const acc = accounts.find(a => a.id === t.accountId); return acc?.propFirm === propFirmFilter; }); if (session !== 'all') { trades = trades.filter(t => { const h = new Date(t.entryTime || t.date).getHours(); const m = new Date(t.entryTime || t.date).getMinutes(); if (session === 'pre') return h < 9 || (h === 9 && m < 30); if (session === 'am') return (h === 9 && m >= 30) || (h >= 10 && h < 12); if (session === 'pm') return h >= 12 && h < 16; if (session === 'post') return h >= 16; return true; }); } if (minPts > 0 || maxPts > 0) { trades = trades.filter(t => { const e = parseFloat(t.entryPrice), x = parseFloat(t.exitPrice); if (isNaN(e) || isNaN(x)) return false; const pts = Math.abs(t.side === 'Long' ? x - e : e - x); return (minPts <= 0 || pts >= minPts) && (maxPts <= 0 || pts <= maxPts); }); } if (minPnl !== 0 || maxPnl !== 0) { trades = trades.filter(t => { const p = getNetPnl(t); return (minPnl === 0 || p >= minPnl) && (maxPnl === 0 || p <= maxPnl); }); } if (minQty > 0 || maxQty > 0) { trades = trades.filter(t => { const q = parseInt(t.quantity || t.qty || 1); return (minQty <= 0 || q >= minQty) && (maxQty <= 0 || q <= maxQty); }); } // Scale grouping: merge fills within proximity window into single position trades const scaleEnabled = document.getElementById('lb-scale-group')?.checked || false; if (scaleEnabled) { const scaleWindow = parseFloat(document.getElementById('lb-scale-window')?.value) || 3; trades = lbGroupScaledTrades(trades, scaleWindow); } // Annotate trade number in day const byDay = {}; trades.sort((a,b) => new Date(a.entryTime||a.date) - new Date(b.entryTime||b.date)); trades.forEach(t => { const d = (t.date || t.entryTime || '').slice(0, 10); if (!byDay[d]) byDay[d] = 0; byDay[d]++; t._tradeNumInDay = byDay[d]; }); // Update summary stats lbUpdateSummary(trades); const countEl = document.getElementById('lb-trade-count'); if (countEl) countEl.textContent = trades.length + ' trades'; const groupBy = document.getElementById('lb-group-primary')?.value || 'none'; const groupBy2 = document.getElementById('lb-group-secondary')?.value || 'none'; const bucketSize = parseFloat(document.getElementById('lb-bucket-size')?.value) || 5; const metricId = document.getElementById('lb-metric')?.value || 'winRate'; const metric2Id = document.getElementById('lb-metric2')?.value || 'none'; const vizType = document.getElementById('lb-viz')?.value || 'bar'; const colorScheme = document.getElementById('lb-color')?.value || 'cyan'; const sortBy = document.getElementById('lb-sort')?.value || 'natural'; const limitGroups = parseInt(document.getElementById('lb-limit')?.value) || 0; const showBenchmark = document.getElementById('lb-show-benchmark')?.checked || false; const showDataTable = document.getElementById('lb-show-data-table')?.checked || false; const metricNames = { winRate:'Win Rate', avgPnl:'Avg P&L', totalPnl:'Total P&L', tradeCount:'Trade Count', profitFactor:'Profit Factor', avgPoints:'Avg Points', maxWin:'Largest Win', maxLoss:'Largest Loss', avgWin:'Avg Win', avgLoss:'Avg Loss', winStreak:'Best Streak', expectancy:'Expectancy', avgDuration:'Avg Duration', riskReward:'Risk:Reward' }; const groupNames = { none:'Overall', dayOfWeek:'Day of Week', hour:'Hour', instrument:'Instrument', side:'Side', account:'Account', propFirm:'Prop Firm', pointsBucket:'Points Range', dollarBucket:'$ Range', duration:'Duration', contractsBucket:'Contracts', weekNumber:'Week', month:'Month', quarter:'Quarter', tradeNumberInDay:'Trade # in Day' }; // Heatmap mode (2D grouping) if (vizType === 'heatmap' && groupBy !== 'none' && groupBy2 !== 'none') { lbRenderHeatmap(trades, groupBy, groupBy2, bucketSize, metricId, metricNames, groupNames); return; } if (vizType === 'heatmap') { const hc = document.getElementById('lb-heatmap-container'); if (hc) { hc.style.display = 'block'; hc.innerHTML = '

Select both Primary and Secondary group to render a heatmap.

'; } const rc = document.getElementById('lb-results-card'); if (rc) rc.style.display = 'block'; return; } // Build groups const groups = {}; if (groupBy === 'none') { groups['All Trades'] = { key: 0, label: 'All Trades', trades: trades }; } else { trades.forEach(t => { const g = lbGetGroupKey(t, groupBy, bucketSize); if (!groups[g.label]) groups[g.label] = { key: g.key, label: g.label, trades: [] }; groups[g.label].trades.push(t); }); } const isCumulative = document.getElementById('lb-cumulative')?.checked || false; let results = Object.values(groups).map(g => ({ key: g.key, label: g.label, value: lbCalcMetric(g.trades, metricId), value2: metric2Id !== 'none' ? lbCalcMetric(g.trades, metric2Id) : null, count: g.trades.length, trades: g.trades })); if (sortBy === 'natural') results.sort((a,b) => a.key - b.key || a.label.localeCompare(b.label)); else if (sortBy === 'metric-desc') results.sort((a,b) => b.value - a.value); else if (sortBy === 'metric-asc') results.sort((a,b) => a.value - b.value); else if (sortBy === 'count-desc') results.sort((a,b) => b.count - a.count); // Cumulative mode: each bucket includes all trades from that bucket and above // Bottom-up: lowest range contains ALL trades, highest contains only its own // Only applies to numeric bucket groups (points, dollar, duration, contracts) if (isCumulative && ['pointsBucket', 'dollarBucket', 'duration', 'contractsBucket'].includes(groupBy)) { // Sort by key ascending for display, then accumulate from the top down results.sort((a,b) => a.key - b.key); let cumTrades = []; // Walk from highest bucket to lowest, accumulating trades for (let i = results.length - 1; i >= 0; i--) { cumTrades = results[i].trades.concat(cumTrades); results[i].value = lbCalcMetric(cumTrades, metricId); results[i].value2 = metric2Id !== 'none' ? lbCalcMetric(cumTrades, metric2Id) : null; results[i].count = cumTrades.length; results[i].label = '≥ ' + results[i].label.replace(/[-–]\d+$/, '').trim(); } } if (limitGroups > 0) results = results.slice(0, limitGroups); window._lbResults = results; window._lbMetricId = metricId; window._lbMetric2Id = metric2Id; // Update filter badge + groupby hint lbUpdateFilterBadge(); const hintEl = document.getElementById('lb-groupby-hint'); if (hintEl) hintEl.style.display = groupBy === 'none' ? 'block' : 'none'; const resultsCard = document.getElementById('lb-results-card'); if (resultsCard) resultsCard.style.display = results.length > 0 ? 'block' : 'none'; const titleEl = document.getElementById('lb-results-title'); if (titleEl) titleEl.textContent = (metricNames[metricId] || metricId) + (groupBy !== 'none' ? ' by ' + (groupNames[groupBy] || groupBy) : ''); const heatmapContainer = document.getElementById('lb-heatmap-container'); if (heatmapContainer) heatmapContainer.style.display = 'none'; // Colors function getColors(count) { const c = colorScheme; if (c === 'cyan') return new Array(count).fill('rgba(0, 212, 170, 0.7)'); if (c === 'purple') return new Array(count).fill('rgba(139, 92, 246, 0.7)'); if (c === 'winloss') return results.map(r => r.value >= 0 ? 'rgba(34, 197, 94, 0.7)' : 'rgba(239, 68, 68, 0.7)'); if (c === 'gradient') return results.map((r,i) => { const p = count > 1 ? i/(count-1) : 0; return `rgba(${Math.round(34+p*205)}, ${Math.round(197-p*129)}, 68, 0.7)`; }); if (c === 'rainbow') return results.map((r,i) => `hsla(${(i/count)*300}, 70%, 55%, 0.7)`); return new Array(count).fill('rgba(0, 212, 170, 0.7)'); } const colors = getColors(results.length); // Render table or chart const chartContainer = document.getElementById('lb-chart-container'); const tableContainer = document.getElementById('lb-table-container'); if (vizType === 'table') { chartContainer.style.display = 'none'; tableContainer.style.display = 'block'; lbRenderTable(results, metricId, metric2Id, metricNames, tableContainer); } else { chartContainer.style.display = 'block'; tableContainer.style.display = showDataTable ? 'block' : 'none'; if (showDataTable) lbRenderTable(results, metricId, metric2Id, metricNames, tableContainer); if (window._lbChart) { try { window._lbChart.destroy(); } catch(e) {} window._lbChart = null; } const ctx = document.getElementById('lb-chart'); if (!ctx) return; // Also check for existing Chart.js instance on the canvas const existingChart = Chart.getChart(ctx); if (existingChart) { try { existingChart.destroy(); } catch(e) {} } const labels = results.map(r => r.label); const data = results.map(r => (metricId === 'profitFactor' && r.value >= 99.99) ? null : r.value); const chartType = vizType === 'horizontalBar' ? 'bar' : vizType; const isH = vizType === 'horizontalBar'; const isDonut = vizType === 'doughnut'; const datasets = [{ label: metricNames[metricId] || metricId, data, backgroundColor: colors, borderColor: colors.map(c => c.replace('0.7','1')), borderWidth: 1, borderRadius: 3, maxBarThickness: results.length <= 2 ? 200 : undefined }]; if (metric2Id !== 'none' && (vizType === 'bar' || vizType === 'line')) { datasets.push({ label: metricNames[metric2Id] || metric2Id, data: results.map(r => (metric2Id === 'profitFactor' && r.value2 >= 99.99) ? null : r.value2), backgroundColor: 'rgba(139, 92, 246, 0.5)', borderColor: 'rgba(139, 92, 246, 0.8)', borderWidth: 1, borderRadius: 3, yAxisID: 'y2' }); } // Benchmark annotation const plugins = []; if (showBenchmark && !isDonut && results.length > 1) { const avg = data.filter(v => v !== null).reduce((s,v)=>s+v,0) / data.filter(v => v !== null).length; plugins.push({ id: 'benchmarkLine', afterDraw(chart) { const { ctx: c, chartArea: { left, right }, scales: { y } } = chart; const yPos = y.getPixelForValue(avg); c.save(); c.strokeStyle = 'rgba(245, 158, 11, 0.8)'; c.lineWidth = 2; c.setLineDash([6, 4]); c.beginPath(); c.moveTo(left, yPos); c.lineTo(right, yPos); c.stroke(); c.fillStyle = 'rgba(245, 158, 11, 0.9)'; c.font = '10px sans-serif'; c.fillText('avg: ' + lbFormatMetric(avg, metricId), right - 80, yPos - 5); c.restore(); } }); } const options = { responsive: true, maintainAspectRatio: false, indexAxis: isH ? 'y' : 'x', plugins: { legend: { display: datasets.length > 1 || isDonut, labels: { color: '#94a3b8', boxWidth: 12, font: { size: 11 } } }, tooltip: { callbacks: { label: ctx2 => { const mid = ctx2.datasetIndex === 0 ? metricId : metric2Id; return (ctx2.dataset.label||'') + ': ' + lbFormatMetric(ctx2.parsed[isH?'x':'y'] ?? ctx2.parsed, mid) + ' (' + results[ctx2.dataIndex]?.count + ' trades)'; } } } } }; if (!isDonut) { options.scales = { x: { grid: { display: false }, ticks: { color: '#94a3b8', maxRotation: labels.length > 12 ? 45 : 0, font: { size: labels.length > 15 ? 9 : 11 } } }, y: { grid: { color: 'rgba(148, 163, 184, 0.1)' }, ticks: { color: '#94a3b8' } } }; if (datasets.length > 1 && metric2Id !== 'none') options.scales.y2 = { position: 'right', grid: { display: false }, ticks: { color: '#8b5cf6' } }; } window._lbChart = new Chart(ctx, { type: chartType, data: { labels, datasets }, options, plugins }); } } function lbUpdateSummary(trades) { const wins = trades.filter(t => getNetPnl(t) > 0); const losses = trades.filter(t => getNetPnl(t) < 0); const totalPnl = trades.reduce((s,t) => s + getNetPnl(t), 0); const grossWin = wins.reduce((s,t) => s + getNetPnl(t), 0); const grossLoss = Math.abs(losses.reduce((s,t) => s + getNetPnl(t), 0)); const el = (id, val) => { const e = document.getElementById(id); if (e) e.textContent = val; }; const elC = (id, color) => { const e = document.getElementById(id); if (e) e.style.color = color; }; el('lb-stat-count', trades.length); el('lb-stat-winrate', trades.length > 0 ? (wins.length/trades.length*100).toFixed(1) + '%' : '0%'); el('lb-stat-pnl', formatCurrency(totalPnl)); elC('lb-stat-pnl', totalPnl >= 0 ? 'var(--green)' : 'var(--red)'); el('lb-stat-avgpnl', trades.length > 0 ? formatCurrency(totalPnl/trades.length) : formatCurrency(0)); elC('lb-stat-avgpnl', totalPnl >= 0 ? 'var(--green)' : 'var(--red)'); el('lb-stat-pf', grossLoss > 0 ? (grossWin/grossLoss).toFixed(2) : grossWin > 0 ? '∞' : '0'); } function lbRenderTable(results, metricId, metric2Id, metricNames, container) { let html = ''; html += ''; html += ''; if (metric2Id !== 'none') html += ''; html += ''; results.forEach(r => { html += ''; html += ''; html += ''; if (metric2Id !== 'none') html += ''; html += ''; }); html += '
Group' + (metricNames[metricId]||metricId) + '' + (metricNames[metric2Id]||metric2Id) + 'Trades
' + r.label + '' + lbFormatMetric(r.value, metricId) + '' + lbFormatMetric(r.value2, metric2Id) + '' + r.count + '
'; container.innerHTML = html; } function lbRenderHeatmap(trades, groupBy1, groupBy2, bucketSize, metricId, metricNames, groupNames) { const resultsCard = document.getElementById('lb-results-card'); const chartContainer = document.getElementById('lb-chart-container'); const heatmapContainer = document.getElementById('lb-heatmap-container'); const tableContainer = document.getElementById('lb-table-container'); if (resultsCard) resultsCard.style.display = 'block'; if (chartContainer) chartContainer.style.display = 'none'; if (heatmapContainer) heatmapContainer.style.display = 'block'; if (tableContainer) tableContainer.style.display = 'none'; // Destroy existing chart to prevent canvas reuse error if (window._lbChart) { try { window._lbChart.destroy(); } catch(e) {} window._lbChart = null; } const titleEl = document.getElementById('lb-results-title'); if (titleEl) titleEl.textContent = (metricNames[metricId]||metricId) + ': ' + (groupNames[groupBy1]||groupBy1) + ' × ' + (groupNames[groupBy2]||groupBy2); // Build 2D groups const matrix = {}; const rowKeys = new Set(); const colKeys = new Set(); trades.forEach(t => { const r = lbGetGroupKey(t, groupBy1, bucketSize); const c = lbGetGroupKey(t, groupBy2, bucketSize); const rk = r.label, ck = c.label; rowKeys.add(rk); colKeys.add(ck); if (!matrix[rk]) matrix[rk] = {}; if (!matrix[rk][ck]) matrix[rk][ck] = []; matrix[rk][ck].push(t); }); // Numeric-aware sort: extract leading number for bucket groups, fall back to string sort const numSort = (a, b) => { const na = parseFloat(a), nb = parseFloat(b); if (!isNaN(na) && !isNaN(nb)) return na - nb; return a.localeCompare(b); }; const rows = [...rowKeys].sort(numSort); const cols = [...colKeys].sort(numSort); // Calculate metric values and find min/max const values = {}; let minVal = Infinity, maxVal = -Infinity; rows.forEach(r => { values[r] = {}; cols.forEach(c => { const trds = matrix[r]?.[c] || []; const v = trds.length > 0 ? lbCalcMetric(trds, metricId) : null; values[r][c] = v; if (v !== null) { minVal = Math.min(minVal, v); maxVal = Math.max(maxVal, v); } }); }); // Render heatmap table let html = ''; html += ''; cols.forEach(c => { html += ''; }); html += ''; rows.forEach(r => { html += ''; cols.forEach(c => { const v = values[r][c]; const count = (matrix[r]?.[c] || []).length; if (v === null) { html += ''; } else { const range = maxVal - minVal || 1; const pct = (v - minVal) / range; const r2 = Math.round(239 * (1-pct) + 34 * pct); const g2 = Math.round(68 * (1-pct) + 197 * pct); const bg = `rgba(${r2}, ${g2}, 68, 0.25)`; html += ``; } }); html += ''; }); html += '
' + c + '
' + r + '${lbFormatMetric(v, metricId)}
'; heatmapContainer.innerHTML = html; } function lbClearFilters() { ['lb-filter-side','lb-filter-result','lb-filter-instrument','lb-filter-session','lb-filter-propfirm'].forEach(id => { const el = document.getElementById(id); if (el) el.value = 'all'; }); ['lb-filter-min-pts','lb-filter-max-pts','lb-filter-min-pnl','lb-filter-max-pnl','lb-filter-min-qty','lb-filter-max-qty'].forEach(id => { const el = document.getElementById(id); if (el) el.value = ''; }); const sg = document.getElementById('lb-scale-group'); if (sg) { sg.checked = false; const so = document.getElementById('lb-scale-options'); if (so) so.style.display = 'none'; } const sw = document.getElementById('lb-scale-window'); if (sw) sw.value = '3'; lbRun(); } function lbGetActiveFilterCount() { let count = 0; if (document.getElementById('lb-filter-side')?.value !== 'all') count++; if (document.getElementById('lb-filter-result')?.value !== 'all') count++; if (document.getElementById('lb-filter-instrument')?.value !== 'all') count++; if (document.getElementById('lb-filter-session')?.value !== 'all') count++; if (document.getElementById('lb-filter-propfirm')?.value !== 'all') count++; if (document.getElementById('lb-filter-min-pts')?.value) count++; if (document.getElementById('lb-filter-max-pts')?.value) count++; if (document.getElementById('lb-filter-min-pnl')?.value) count++; if (document.getElementById('lb-filter-max-pnl')?.value) count++; if (document.getElementById('lb-filter-min-qty')?.value) count++; if (document.getElementById('lb-filter-max-qty')?.value) count++; if (document.getElementById('lb-scale-group')?.checked) count++; return count; } function lbUpdateFilterBadge() { const header = document.getElementById('lb-filter-header'); if (!header) return; const count = lbGetActiveFilterCount(); header.textContent = count > 0 ? `① Filter (${count} active)` : '① Filter'; } function lbReset() { ['lb-filter-side','lb-filter-result','lb-filter-instrument','lb-filter-session','lb-filter-propfirm'].forEach(id => { const el = document.getElementById(id); if (el) el.value = 'all'; }); ['lb-filter-min-pts','lb-filter-max-pts','lb-filter-min-pnl','lb-filter-max-pnl','lb-filter-min-qty','lb-filter-max-qty'].forEach(id => { const el = document.getElementById(id); if (el) el.value = ''; }); const g = (id,v) => { const el = document.getElementById(id); if (el) el.value = v; }; g('lb-group-primary','none'); g('lb-group-secondary','none'); g('lb-metric','winRate'); g('lb-metric2','none'); g('lb-viz','bar'); g('lb-color','cyan'); g('lb-sort','natural'); g('lb-limit','0'); g('lb-bucket-size','5'); document.getElementById('lb-bucket-size-group').style.display = 'none'; const bench = document.getElementById('lb-show-benchmark'); if (bench) bench.checked = false; const dt = document.getElementById('lb-show-data-table'); if (dt) dt.checked = false; const cum = document.getElementById('lb-cumulative'); if (cum) cum.checked = false; const sg = document.getElementById('lb-scale-group'); if (sg) { sg.checked = false; const so = document.getElementById('lb-scale-options'); if (so) so.style.display = 'none'; } const sw = document.getElementById('lb-scale-window'); if (sw) sw.value = '3'; lbRun(); } function lbTemplate(id) { lbReset(); const g = (sel, val) => { const el = document.getElementById(sel); if (el) el.value = val; }; const chk = (sel, val) => { const el = document.getElementById(sel); if (el) el.checked = val; }; switch(id) { case 'winRateByHour': g('lb-group-primary','hour'); g('lb-metric','winRate'); g('lb-metric2','tradeCount'); g('lb-viz','bar'); g('lb-color','gradient'); chk('lb-show-benchmark',true); break; case 'pnlByDay': g('lb-group-primary','dayOfWeek'); g('lb-metric','totalPnl'); g('lb-viz','bar'); g('lb-color','winloss'); break; case 'winRateByPoints': g('lb-group-primary','pointsBucket'); g('lb-bucket-size','2'); g('lb-metric','winRate'); g('lb-metric2','tradeCount'); g('lb-viz','bar'); g('lb-color','gradient'); chk('lb-show-benchmark',true); document.getElementById('lb-bucket-size-group').style.display = 'block'; break; case 'countByInstrument': g('lb-group-primary','instrument'); g('lb-metric','tradeCount'); g('lb-viz','doughnut'); g('lb-color','rainbow'); break; case 'pfBySession': g('lb-group-primary','hour'); g('lb-metric','profitFactor'); g('lb-metric2','tradeCount'); g('lb-viz','bar'); g('lb-color','cyan'); chk('lb-show-benchmark',true); break; case 'avgPnlByDollar': g('lb-group-primary','dollarBucket'); g('lb-bucket-size','100'); g('lb-metric','avgPnl'); g('lb-viz','bar'); g('lb-color','winloss'); document.getElementById('lb-bucket-size-group').style.display = 'block'; break; case 'longVsShort': g('lb-group-primary','side'); g('lb-metric','winRate'); g('lb-metric2','totalPnl'); g('lb-viz','bar'); g('lb-color','cyan'); chk('lb-show-data-table',true); break; case 'monthlyWinRate': g('lb-group-primary','month'); g('lb-metric','winRate'); g('lb-metric2','tradeCount'); g('lb-viz','line'); g('lb-color','cyan'); chk('lb-show-benchmark',true); break; case 'heatmapHourDay': g('lb-group-primary','hour'); g('lb-group-secondary','dayOfWeek'); g('lb-metric','winRate'); g('lb-viz','heatmap'); break; case 'tradeNumberAnalysis': g('lb-group-primary','tradeNumberInDay'); g('lb-metric','winRate'); g('lb-metric2','avgPnl'); g('lb-viz','bar'); g('lb-color','gradient'); g('lb-limit','10'); chk('lb-show-benchmark',true); break; case 'recoveryAfterLoss': g('lb-group-primary','tradeNumberInDay'); g('lb-filter-result','losses'); g('lb-metric','avgPnl'); g('lb-viz','bar'); g('lb-color','winloss'); g('lb-limit','10'); break; case 'durationAnalysis': g('lb-group-primary','duration'); g('lb-bucket-size','5'); g('lb-metric','winRate'); g('lb-metric2','tradeCount'); g('lb-viz','bar'); g('lb-color','gradient'); chk('lb-show-benchmark',true); document.getElementById('lb-bucket-size-group').style.display = 'block'; break; case 'instrumentByDay': g('lb-group-primary','instrument'); g('lb-group-secondary','dayOfWeek'); g('lb-metric','totalPnl'); g('lb-viz','heatmap'); break; case 'quarterlyTrend': g('lb-group-primary','quarter'); g('lb-metric','totalPnl'); g('lb-metric2','winRate'); g('lb-viz','bar'); g('lb-color','winloss'); break; } lbRun(); } function lbSaveMetric() { const config = { name: prompt('Name this metric:'), side: document.getElementById('lb-filter-side').value, result: document.getElementById('lb-filter-result').value, instrument: document.getElementById('lb-filter-instrument').value, session: document.getElementById('lb-filter-session').value, propfirm: document.getElementById('lb-filter-propfirm')?.value || 'all', group: document.getElementById('lb-group-primary').value, group2: document.getElementById('lb-group-secondary')?.value || 'none', bucketSize: document.getElementById('lb-bucket-size').value, metric: document.getElementById('lb-metric').value, metric2: document.getElementById('lb-metric2').value, viz: document.getElementById('lb-viz').value, color: document.getElementById('lb-color').value, sort: document.getElementById('lb-sort').value, limit: document.getElementById('lb-limit').value, benchmark: document.getElementById('lb-show-benchmark')?.checked || false, dataTable: document.getElementById('lb-show-data-table')?.checked || false, cumulative: document.getElementById('lb-cumulative')?.checked || false, scaleGroup: document.getElementById('lb-scale-group')?.checked || false, scaleWindow: document.getElementById('lb-scale-window')?.value || '3' }; if (!config.name) return; _lbSavedMetrics.push(config); try { localStorage.setItem('lb_saved_metrics', JSON.stringify(_lbSavedMetrics)); } catch(e) {} lbRenderSaved(); } function lbLoadMetric(idx) { const c = _lbSavedMetrics[idx]; if (!c) return; const g = (sel, val) => { const el = document.getElementById(sel); if (el) el.value = val || ''; }; const chk = (sel, val) => { const el = document.getElementById(sel); if (el) el.checked = !!val; }; g('lb-filter-side',c.side); g('lb-filter-result',c.result); g('lb-filter-instrument',c.instrument); g('lb-filter-session',c.session); g('lb-filter-propfirm',c.propfirm); g('lb-group-primary',c.group); g('lb-group-secondary',c.group2); g('lb-bucket-size',c.bucketSize); g('lb-metric',c.metric); g('lb-metric2',c.metric2); g('lb-viz',c.viz); g('lb-color',c.color); g('lb-sort',c.sort); g('lb-limit',c.limit); chk('lb-show-benchmark',c.benchmark); chk('lb-show-data-table',c.dataTable); chk('lb-cumulative',c.cumulative); chk('lb-scale-group',c.scaleGroup); g('lb-scale-window',c.scaleWindow || '3'); const scaleOpts = document.getElementById('lb-scale-options'); if (scaleOpts) scaleOpts.style.display = c.scaleGroup ? 'block' : 'none'; lbUpdateGroupOptions(); lbRun(); } function lbDeleteMetric(idx) { _lbSavedMetrics.splice(idx, 1); try { localStorage.setItem('lb_saved_metrics', JSON.stringify(_lbSavedMetrics)); } catch(e) {} lbRenderSaved(); } function lbRenderSaved() { const container = document.getElementById('lb-saved-metrics'); const list = document.getElementById('lb-saved-list'); if (!container || !list) return; if (_lbSavedMetrics.length === 0) { container.style.display = 'none'; return; } container.style.display = 'block'; list.innerHTML = _lbSavedMetrics.map((m, i) => `
${m.name}
`).join(''); } function lbExportCSV() { const results = window._lbResults; const metricId = window._lbMetricId; const metric2Id = window._lbMetric2Id; if (!results || results.length === 0) return; const metricNames = { winRate:'Win Rate',avgPnl:'Avg P&L',totalPnl:'Total P&L',tradeCount:'Trade Count',profitFactor:'Profit Factor',avgPoints:'Avg Points',maxWin:'Largest Win',maxLoss:'Largest Loss',avgWin:'Avg Win',avgLoss:'Avg Loss',winStreak:'Best Streak',expectancy:'Expectancy',avgDuration:'Avg Duration',riskReward:'Risk:Reward' }; let csv = 'Group,' + (metricNames[metricId]||metricId); if (metric2Id !== 'none') csv += ',' + (metricNames[metric2Id]||metric2Id); csv += ',Trade Count\n'; results.forEach(r => { csv += '"' + r.label + '",' + r.value.toFixed(4); if (metric2Id !== 'none') csv += ',' + (r.value2||0).toFixed(4); csv += ',' + r.count + '\n'; }); const blob = new Blob([csv], { type: 'text/csv' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'LabBuilder_' + new Date().toISOString().slice(0,10) + '.csv'; a.click(); } // ==================== END LABBUILDER ==================== function renderReports() { // Populate setup filter dropdown const rptSetupFilter = document.getElementById('reports-setup-filter'); if (rptSetupFilter && rptSetupFilter.options.length <= 1) { playbook.forEach(s => { const opt = document.createElement('option'); opt.value = s.id; opt.textContent = s.name; rptSetupFilter.appendChild(opt); }); } let filteredTrades = getMetricsEligibleTrades(); // Apply setup filter const rptSetupVal = rptSetupFilter?.value || ''; if (rptSetupVal) { filteredTrades = filteredTrades.filter(t => { const noteData = tradeNotes[t.id] || {}; return noteData.setupId === rptSetupVal; }); } // Basic stats const winners = filteredTrades.filter(t => getNetPnl(t) > 0); const losers = filteredTrades.filter(t => getNetPnl(t) < 0); const breakeven = filteredTrades.filter(t => getNetPnl(t) === 0); const totalPnl = filteredTrades.reduce((sum, t) => sum + getNetPnl(t), 0); const grossProfit = winners.reduce((sum, t) => sum + getNetPnl(t), 0); const grossLoss = Math.abs(losers.reduce((sum, t) => sum + getNetPnl(t), 0)); const profitFactor = grossLoss > 0 ? grossProfit / grossLoss : grossProfit > 0 ? Infinity : 0; const avgWin = winners.length > 0 ? grossProfit / winners.length : 0; const avgLoss = losers.length > 0 ? grossLoss / losers.length : 0; const avgTrade = filteredTrades.length > 0 ? totalPnl / filteredTrades.length : 0; const largestWin = winners.length > 0 ? Math.max(...winners.map(t => getNetPnl(t))) : 0; const largestLoss = losers.length > 0 ? Math.min(...losers.map(t => getNetPnl(t))) : 0; const winRate = filteredTrades.length > 0 ? (winners.length / filteredTrades.length * 100) : 0; const lossRate = filteredTrades.length > 0 ? (losers.length / filteredTrades.length) : 0; const expectancy = (winRate/100 * avgWin) - (lossRate * avgLoss); // Daily stats const dailyPnl = {}; const sortedByTime = [...filteredTrades].sort((a, b) => new Date(a.exitTime) - new Date(b.exitTime)); filteredTrades.forEach(t => { const date = getDateKey(t.exitTime); dailyPnl[date] = (dailyPnl[date] || 0) + getNetPnl(t); }); const dailyValues = Object.values(dailyPnl); const bestDay = dailyValues.length > 0 ? Math.max(...dailyValues) : 0; const worstDay = dailyValues.length > 0 ? Math.min(...dailyValues) : 0; // Max drawdown let peak = 0, maxDrawdown = 0, cumulative = 0; sortedByTime.forEach(t => { cumulative += getNetPnl(t); if (cumulative > peak) peak = cumulative; const dd = peak - cumulative; if (dd > maxDrawdown) maxDrawdown = dd; }); // Consecutive wins/losses let maxWins = 0, maxLosses = 0, currentWins = 0, currentLosses = 0; sortedByTime.forEach(t => { if (getNetPnl(t) > 0) { currentWins++; currentLosses = 0; maxWins = Math.max(maxWins, currentWins); } else if (getNetPnl(t) < 0) { currentLosses++; currentWins = 0; maxLosses = Math.max(maxLosses, currentLosses); } }); // Risk metrics const tradingDays = Object.keys(dailyPnl).length; const avgDailyPnl = tradingDays > 0 ? totalPnl / tradingDays : 0; const dailyStdDev = tradingDays > 1 ? Math.sqrt(dailyValues.reduce((sum, v) => sum + Math.pow(v - avgDailyPnl, 2), 0) / (tradingDays - 1)) : 0; const sharpe = dailyStdDev > 0 ? (avgDailyPnl / dailyStdDev) * Math.sqrt(252) : 0; const negativeDays = dailyValues.filter(v => v < 0); const downStdDev = negativeDays.length > 1 ? Math.sqrt(negativeDays.reduce((sum, v) => sum + Math.pow(v, 2), 0) / negativeDays.length) : 0; const sortino = downStdDev > 0 ? (avgDailyPnl / downStdDev) * Math.sqrt(252) : 0; const calmar = maxDrawdown > 0 ? (totalPnl / maxDrawdown) : 0; const kelly = avgLoss > 0 ? ((winRate/100) - ((1 - winRate/100) / (avgWin / avgLoss))) * 100 : 0; // Update DOM - Key Metrics const setEl = (id, val, className) => { const el = document.getElementById(id); if (el) { el.textContent = val; if (className !== undefined) el.className = className; } }; setEl('rpt-total-pnl', formatCurrency(totalPnl), totalPnl >= 0 ? 'positive' : 'negative'); setEl('rpt-win-rate', winRate.toFixed(1) + '%'); setEl('rpt-pf', profitFactor === Infinity ? '∞' : profitFactor.toFixed(2)); setEl('rpt-total-trades', filteredTrades.length); setEl('rpt-avg-trade', formatCurrency(avgTrade), avgTrade >= 0 ? 'positive' : 'negative'); setEl('rpt-max-dd', formatCurrency(-maxDrawdown), 'negative'); // Stats cards setEl('rpt-best-trade', formatCurrency(largestWin)); setEl('rpt-worst-trade', formatCurrency(largestLoss)); setEl('rpt-avg-win', formatCurrency(avgWin)); setEl('rpt-avg-loss', formatCurrency(-avgLoss)); setEl('rpt-best-day', formatCurrency(bestDay)); setEl('rpt-worst-day', formatCurrency(worstDay)); setEl('rpt-win-count', winners.length); setEl('rpt-loss-count', losers.length); setEl('rpt-be-count', breakeven.length); setEl('rpt-max-win-streak', maxWins); setEl('rpt-max-loss-streak', maxLosses); // Risk metrics setEl('rpt-sharpe', sharpe.toFixed(2)); setEl('rpt-sortino', sortino.toFixed(2)); setEl('rpt-calmar', calmar.toFixed(2)); setEl('rpt-kelly', kelly.toFixed(1) + '%'); setEl('rpt-expectancy', formatCurrency(expectancy), expectancy >= 0 ? 'positive' : 'negative'); // ============ CHARTS ============ // Destroy existing charts ['rptEquityChart', 'rptWinLossChart', 'rptMonthlyChart', 'rptDowChart', 'rptPnlDistChart', 'rptAccountChart', 'rptHoldTimeChart', 'rptContractsChart', 'rptHalfHourChart', 'rptPointsChart', 'rptDrawdownChart', 'rptFirstHourChart', 'rptTagsChart', 'rptSetupChart', 'rptTrendChart', 'rpt2CumDailyChart', 'rpt2DailyBarChart', 'rpt2RadarChart', 'rpt2PayoutChart', 'rpt2CumTradeChart', 'rpt2DrawdownChart'].forEach(name => { if (window[name]) { window[name].destroy(); window[name] = null; } }); const chartColors = { green: themeGreen(), greenBar: themeGreenBg(0.7), red: themeRed(), redBar: themeRedBg(0.7), cyan: '#06b6d4', purple: 'rgba(139,92,246,0.7)', gray: '#6b7280', gridColor: 'rgba(255,255,255,0.04)', textColor: '#9ca3af' }; // 1. EQUITY CURVE const equityCtx = document.getElementById('rpt-equity-chart'); if (equityCtx && sortedByTime.length > 0) { let equity = 0; const equityData = sortedByTime.map((t, i) => { equity += getNetPnl(t); return { x: i, y: equity, date: new Date(t.exitTime).toLocaleDateString() }; }); window.rptEquityChart = new Chart(equityCtx, { type: 'line', data: { labels: equityData.map(d => d.date), datasets: [{ label: 'Equity', data: equityData.map(d => d.y), borderColor: chartColors.cyan, borderWidth: 1.5, backgroundColor: 'rgba(6, 182, 212, 0.06)', fill: true, tension: 0.3, pointRadius: 0, pointHitRadius: 10 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { display: false }, y: { grid: { color: chartColors.gridColor }, ticks: { color: chartColors.textColor, callback: v => formatCurrency(v, 0) } } } } }); } // 2. WIN/LOSS DONUT const winLossCtx = document.getElementById('rpt-winloss-chart'); if (winLossCtx) { window.rptWinLossChart = new Chart(winLossCtx, { type: 'doughnut', data: { labels: ['Wins', 'Losses', 'Break Even'], datasets: [{ data: [winners.length, losers.length, breakeven.length], backgroundColor: [chartColors.greenBar, chartColors.redBar, chartColors.gray], borderWidth: 0 }] }, options: { responsive: true, maintainAspectRatio: false, cutout: '65%', plugins: { legend: { display: false }, tooltip: { callbacks: { label: ctx => `${ctx.label}: ${ctx.raw} (${(ctx.raw / filteredTrades.length * 100).toFixed(1)}%)` } } } } }); } // 3. MONTHLY PERFORMANCE BAR CHART const monthlyPnl = {}; filteredTrades.forEach(t => { const date = new Date(t.exitTime); const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; monthlyPnl[monthKey] = (monthlyPnl[monthKey] || 0) + getNetPnl(t); }); const monthlyCtx = document.getElementById('rpt-monthly-chart'); if (monthlyCtx && Object.keys(monthlyPnl).length > 0) { const sortedMonths = Object.keys(monthlyPnl).sort(); const monthLabels = sortedMonths.map(k => { const [y, m] = k.split('-'); return new Date(y, m - 1).toLocaleDateString('en-US', { month: 'short', year: '2-digit' }); }); const monthData = sortedMonths.map(k => monthlyPnl[k]); window.rptMonthlyChart = new Chart(monthlyCtx, { type: 'bar', data: { labels: monthLabels, datasets: [{ label: 'Monthly P&L', data: monthData, backgroundColor: monthData.map(v => v >= 0 ? chartColors.greenBar : chartColors.redBar) }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { grid: { display: false }, ticks: { color: chartColors.textColor } }, y: { grid: { color: chartColors.gridColor }, ticks: { color: chartColors.textColor, callback: v => formatCurrency(v, 0) } } } } }); } // 4. DAY OF WEEK BAR CHART const dowData = { 0: [], 1: [], 2: [], 3: [], 4: [], 5: [], 6: [] }; filteredTrades.forEach(t => { const d = new Date(t.exitTime); const dow = d.getDay(); if (dowData[dow]) { dowData[dow].push(getNetPnl(t)); } }); const dowCtx = document.getElementById('rpt-dow-chart'); if (dowCtx) { const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; const dowTotals = [1, 2, 3, 4, 5].map(d => dowData[d].reduce((a, b) => a + b, 0)); // Mon-Fri only window.rptDowChart = new Chart(dowCtx, { type: 'bar', data: { labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'], datasets: [{ label: 'P&L', data: dowTotals, backgroundColor: dowTotals.map(v => v >= 0 ? chartColors.greenBar : chartColors.redBar) }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { grid: { display: false }, ticks: { color: chartColors.textColor } }, y: { grid: { color: chartColors.gridColor }, ticks: { color: chartColors.textColor, callback: v => formatCurrency(v, 0) } } } } }); } // 5. TIME OF DAY HEATMAP const timeHeatmap = document.getElementById('rpt-time-heatmap'); if (timeHeatmap) { const hourlyPnl = {}; filteredTrades.forEach(t => { try { const hour = getDateInTZ(t.entryTime).hours; if (hour >= 8 && hour <= 15) { hourlyPnl[hour] = (hourlyPnl[hour] || 0) + getNetPnl(t); } } catch (e) {} }); // Find max for scaling const maxHourPnl = Math.max(...Object.values(hourlyPnl).map(Math.abs), 1); let heatmapHtml = ''; for (let h = 8; h <= 15; h++) { const pnl = hourlyPnl[h] || 0; const intensity = Math.min(Math.abs(pnl) / maxHourPnl, 1); let bgColor; if (pnl > 0) { bgColor = `rgba(0, 212, 170, ${0.12 + intensity * 0.5})`; } else if (pnl < 0) { bgColor = `rgba(239, 68, 68, ${0.12 + intensity * 0.5})`; } else { bgColor = 'var(--bg-card-inner)'; } heatmapHtml += `
${h}:00
${formatCurrency(pnl)}
`; } timeHeatmap.innerHTML = heatmapHtml; } // 6. P&L DISTRIBUTION HISTOGRAM const pnlDistCtx = document.getElementById('rpt-pnl-dist-chart'); if (pnlDistCtx && filteredTrades.length > 0) { const pnlValues = filteredTrades.map(t => getNetPnl(t)); const minPnl = Math.min(...pnlValues); const maxPnl = Math.max(...pnlValues); const range = maxPnl - minPnl || 1; const bucketSize = range / 10; const buckets = {}; pnlValues.forEach(pnl => { const bucket = Math.floor((pnl - minPnl) / bucketSize); const bucketKey = Math.round(minPnl + bucket * bucketSize); buckets[bucketKey] = (buckets[bucketKey] || 0) + 1; }); const sortedBuckets = Object.keys(buckets).map(Number).sort((a, b) => a - b); window.rptPnlDistChart = new Chart(pnlDistCtx, { type: 'bar', data: { labels: sortedBuckets.map(k => formatCurrency(k)), datasets: [{ label: 'Trades', data: sortedBuckets.map(k => buckets[k]), backgroundColor: sortedBuckets.map(k => k >= 0 ? chartColors.greenBar : chartColors.redBar) }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { grid: { display: false }, ticks: { color: chartColors.textColor, maxRotation: 45 } }, y: { grid: { color: chartColors.gridColor }, ticks: { color: chartColors.textColor } } } } }); } // 7. ACCOUNT PERFORMANCE CHART const accountPnl = {}; filteredTrades.forEach(t => { const accId = t.accountId || 'Unknown'; accountPnl[accId] = (accountPnl[accId] || 0) + getNetPnl(t); }); const accountCtx = document.getElementById('rpt-account-chart'); if (accountCtx && Object.keys(accountPnl).length > 0) { const accLabels = Object.keys(accountPnl).map(id => { const acc = accounts.find(a => a.id === id); return acc ? streamerAccountName(acc.name) : id; }); const accData = Object.values(accountPnl); window.rptAccountChart = new Chart(accountCtx, { type: 'bar', data: { labels: accLabels, datasets: [{ label: 'P&L', data: accData, backgroundColor: accData.map(v => v >= 0 ? 'rgba(6,182,212,0.6)' : chartColors.redBar) }] }, options: { responsive: true, maintainAspectRatio: false, indexAxis: 'y', plugins: { legend: { display: false } }, scales: { x: { grid: { color: chartColors.gridColor }, ticks: { color: chartColors.textColor, callback: v => formatCurrency(v, 0) } }, y: { grid: { display: false }, ticks: { color: chartColors.textColor } } } } }); } // 8. TRADE STREAK VISUALIZATION const streakViz = document.getElementById('rpt-streak-viz'); if (streakViz) { const last50 = sortedByTime.slice(-50); streakViz.innerHTML = last50.map(t => { const pnl = getNetPnl(t); let color = chartColors.gray; if (pnl > 0) color = chartColors.green; else if (pnl < 0) color = chartColors.red; return `
`; }).join(''); } // 9. HOLD TIME CHART const holdTimeData = { quick: { count: 0, wins: 0 }, medium: { count: 0, wins: 0 }, long: { count: 0, wins: 0 } }; filteredTrades.forEach(t => { try { const duration = (new Date(t.exitTime) - new Date(t.entryTime)) / (1000 * 60); const pnl = getNetPnl(t); if (duration < 1) { holdTimeData.quick.count++; if (pnl > 0) holdTimeData.quick.wins++; } else if (duration <= 5) { holdTimeData.medium.count++; if (pnl > 0) holdTimeData.medium.wins++; } else { holdTimeData.long.count++; if (pnl > 0) holdTimeData.long.wins++; } } catch (e) {} }); const holdTimeCtx = document.getElementById('rpt-holdtime-chart'); if (holdTimeCtx) { const holdLabels = ['< 1 min', '1-5 min', '> 5 min']; const holdWinRates = [ holdTimeData.quick.count > 0 ? (holdTimeData.quick.wins / holdTimeData.quick.count * 100) : 0, holdTimeData.medium.count > 0 ? (holdTimeData.medium.wins / holdTimeData.medium.count * 100) : 0, holdTimeData.long.count > 0 ? (holdTimeData.long.wins / holdTimeData.long.count * 100) : 0 ]; window.rptHoldTimeChart = new Chart(holdTimeCtx, { type: 'bar', data: { labels: holdLabels, datasets: [{ label: 'Win Rate %', data: holdWinRates, backgroundColor: holdWinRates.map(v => v >= 50 ? chartColors.greenBar : chartColors.redBar) }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { grid: { display: false }, ticks: { color: chartColors.textColor } }, y: { min: 0, max: 100, grid: { color: chartColors.gridColor }, ticks: { color: chartColors.textColor, callback: v => v + '%' } } } } }); } // 10. CONTRACT SIZE WIN RATE CHART const contractData = { 1: { count: 0, wins: 0 }, 2: { count: 0, wins: 0 }, 3: { count: 0, wins: 0 }, '4+': { count: 0, wins: 0 } }; filteredTrades.forEach(t => { const qty = parseInt(t.qty) || 1; const pnl = getNetPnl(t); const key = qty >= 4 ? '4+' : qty; contractData[key].count++; if (pnl > 0) contractData[key].wins++; }); const contractsCtx = document.getElementById('rpt-contracts-chart'); if (contractsCtx) { const contractLabels = ['1 ct', '2 ct', '3 ct', '4+ ct']; const contractWinRates = ['1', '2', '3', '4+'].map(k => contractData[k].count > 0 ? (contractData[k].wins / contractData[k].count * 100) : 0 ); window.rptContractsChart = new Chart(contractsCtx, { type: 'bar', data: { labels: contractLabels, datasets: [{ label: 'Win Rate %', data: contractWinRates, backgroundColor: contractWinRates.map(v => v >= 50 ? chartColors.purple : chartColors.redBar) }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { grid: { display: false }, ticks: { color: chartColors.textColor } }, y: { min: 0, max: 100, grid: { color: chartColors.gridColor }, ticks: { color: chartColors.textColor, callback: v => v + '%' } } } } }); } // 11. HALF HOUR PERFORMANCE CHART const halfHourData = {}; filteredTrades.forEach(t => { try { const hour = getDateInTZ(t.entryTime).hours; const half = new Date(t.entryTime).getMinutes() < 30 ? '00' : '30'; const key = `${hour}:${half}`; if (!halfHourData[key]) halfHourData[key] = { pnl: 0, count: 0 }; halfHourData[key].pnl += getNetPnl(t); halfHourData[key].count++; } catch (e) {} }); const halfHourCtx = document.getElementById('rpt-halfhour-chart'); if (halfHourCtx && Object.keys(halfHourData).length > 0) { const sortedHours = Object.keys(halfHourData).sort((a, b) => { const [ah, am] = a.split(':').map(Number); const [bh, bm] = b.split(':').map(Number); return (ah * 60 + am) - (bh * 60 + bm); }); const halfHourPnl = sortedHours.map(k => halfHourData[k].pnl); window.rptHalfHourChart = new Chart(halfHourCtx, { type: 'bar', data: { labels: sortedHours, datasets: [{ label: 'P&L', data: halfHourPnl, backgroundColor: halfHourPnl.map(v => v >= 0 ? chartColors.greenBar : chartColors.redBar) }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { grid: { display: false }, ticks: { color: chartColors.textColor, maxRotation: 45 } }, y: { grid: { color: chartColors.gridColor }, ticks: { color: chartColors.textColor, callback: v => formatCurrency(v, 0) } } } } }); } // 12. POINTS ANALYSIS CHART let avgPtsWon = 0, avgPtsLost = 0, maxPtsWon = 0, maxPtsLost = 0; let ptsWonCount = 0, ptsLostCount = 0; filteredTrades.forEach(t => { if (!t.entryPrice || !t.exitPrice) return; const entry = parseFloat(t.entryPrice); const exit = parseFloat(t.exitPrice); if (isNaN(entry) || isNaN(exit)) return; let pts = t.side === 'Long' ? exit - entry : entry - exit; const pnl = getNetPnl(t); if (pnl > 0) { avgPtsWon += pts; ptsWonCount++; maxPtsWon = Math.max(maxPtsWon, pts); } else if (pnl < 0) { avgPtsLost += Math.abs(pts); ptsLostCount++; maxPtsLost = Math.max(maxPtsLost, Math.abs(pts)); } }); avgPtsWon = ptsWonCount > 0 ? avgPtsWon / ptsWonCount : 0; avgPtsLost = ptsLostCount > 0 ? avgPtsLost / ptsLostCount : 0; const pointsCtx = document.getElementById('rpt-points-chart'); if (pointsCtx) { window.rptPointsChart = new Chart(pointsCtx, { type: 'bar', data: { labels: ['Avg Pts Won', 'Avg Pts Lost', 'Max Pts Won', 'Max Pts Lost'], datasets: [{ data: [avgPtsWon, avgPtsLost, maxPtsWon, maxPtsLost], backgroundColor: [chartColors.greenBar, chartColors.redBar, chartColors.greenBar, chartColors.redBar] }] }, options: { responsive: true, maintainAspectRatio: false, indexAxis: 'y', plugins: { legend: { display: false } }, scales: { x: { grid: { color: chartColors.gridColor }, ticks: { color: chartColors.textColor, callback: v => v.toFixed(1) + ' pts' } }, y: { grid: { display: false }, ticks: { color: chartColors.textColor } } } } }); } // 12b. TRADE DISTRIBUTION CHART (Win/Loss by points/$/% with zoom) if (window.rptPointsDistChart) window.rptPointsDistChart.destroy(); // Store filtered trades for re-render window._distFilteredTrades = filteredTrades; window._distChartColors = chartColors; renderDistributionChart(); // 13. DRAWDOWN ANALYSIS CHART let peakEquity = 0, currentDD = 0, daysSincePeak = 0; const ddData = []; let equityVal = 0; sortedByTime.forEach((t, i) => { equityVal += getNetPnl(t); if (equityVal > peakEquity) { peakEquity = equityVal; } ddData.push(peakEquity - equityVal); }); currentDD = peakEquity - equityVal; const avgDD = ddData.length > 0 ? ddData.reduce((a, b) => a + b, 0) / ddData.length : 0; const recoveryFactor = maxDrawdown > 0 ? totalPnl / maxDrawdown : 0; const drawdownCtx = document.getElementById('rpt-drawdown-chart'); if (drawdownCtx) { window.rptDrawdownChart = new Chart(drawdownCtx, { type: 'bar', data: { labels: ['Max DD', 'Avg DD', 'Current DD', 'Recovery Factor'], datasets: [{ data: [maxDrawdown, avgDD, currentDD, recoveryFactor * 100], backgroundColor: [chartColors.redBar, chartColors.redBar, chartColors.redBar, 'rgba(6,182,212,0.6)'] }] }, options: { responsive: true, maintainAspectRatio: false, indexAxis: 'y', plugins: { legend: { display: false } }, scales: { x: { grid: { color: chartColors.gridColor }, ticks: { color: chartColors.textColor } }, y: { grid: { display: false }, ticks: { color: chartColors.textColor } } } } }); } // 14. DOW x TRADE NUMBER MATRIX const dowTradeNumData = {}; const dowDailyCount = {}; const daysOfWeek = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; sortedByTime.forEach(t => { const date = getDateKey(t.entryTime); const dow = getDateInTZ(t.entryTime).dayOfWeek; dowDailyCount[date] = (dowDailyCount[date] || 0) + 1; const tradeNum = Math.min(dowDailyCount[date], 4); if (!dowTradeNumData[dow]) dowTradeNumData[dow] = { 1: [], 2: [], 3: [], 4: [] }; dowTradeNumData[dow][tradeNum].push(getNetPnl(t)); }); const dowTradeNumGrid = document.getElementById('rpt-dow-tradenum-grid'); if (dowTradeNumGrid) { let gridHtml = '
T1
T2
T3
T4+
'; [1, 2, 3, 4, 5].forEach(dow => { gridHtml += `
${daysOfWeek[dow]}
`; [1, 2, 3, 4].forEach(tn => { const trades = dowTradeNumData[dow]?.[tn] || []; const avgPnl = trades.length > 0 ? trades.reduce((a, b) => a + b, 0) / trades.length : 0; const wr = trades.length > 0 ? (trades.filter(p => p > 0).length / trades.length * 100) : 0; const intensity = Math.min(Math.abs(avgPnl) / 100, 1); let bg = 'var(--bg-card-inner)'; if (avgPnl > 0) bg = `rgba(0, 212, 170, ${0.12 + intensity * 0.5})`; else if (avgPnl < 0) bg = `rgba(239, 68, 68, ${0.12 + intensity * 0.5})`; gridHtml += `
${formatCurrency(avgPnl)}
${trades.length}t
`; }); }); dowTradeNumGrid.innerHTML = gridHtml; } // 15. FIRST HOUR VS REST OF DAY const firstHourTrades = []; const restOfDayTrades = []; filteredTrades.forEach(t => { try { const hour = getDateInTZ(t.entryTime).hours; const min = new Date(t.entryTime).getMinutes(); const timeVal = hour + min / 60; if (timeVal >= 8.5 && timeVal < 9.5) { firstHourTrades.push(t); } else { restOfDayTrades.push(t); } } catch (e) {} }); const fhPnl = firstHourTrades.reduce((sum, t) => sum + getNetPnl(t), 0); const fhWr = firstHourTrades.length > 0 ? (firstHourTrades.filter(t => getNetPnl(t) > 0).length / firstHourTrades.length * 100) : 0; const rodPnl = restOfDayTrades.reduce((sum, t) => sum + getNetPnl(t), 0); const rodWr = restOfDayTrades.length > 0 ? (restOfDayTrades.filter(t => getNetPnl(t) > 0).length / restOfDayTrades.length * 100) : 0; const firstHourCtx = document.getElementById('rpt-firsthour-chart'); if (firstHourCtx) { window.rptFirstHourChart = new Chart(firstHourCtx, { type: 'bar', data: { labels: ['First Hour (8:30-9:30)', 'Rest of Day'], datasets: [ { label: 'P&L', data: [fhPnl, rodPnl], backgroundColor: [fhPnl >= 0 ? chartColors.greenBar : chartColors.redBar, rodPnl >= 0 ? chartColors.greenBar : chartColors.redBar], yAxisID: 'y' }, { label: 'Win Rate %', data: [fhWr, rodWr], backgroundColor: ['rgba(6,182,212,0.6)', chartColors.purple], yAxisID: 'y1' } ] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: true, position: 'top', labels: { color: chartColors.textColor, boxWidth: 12 } } }, scales: { x: { grid: { display: false }, ticks: { color: chartColors.textColor } }, y: { position: 'left', grid: { color: chartColors.gridColor }, ticks: { color: chartColors.textColor, callback: v => formatCurrency(v, 0) } }, y1: { position: 'right', min: 0, max: 100, grid: { display: false }, ticks: { color: chartColors.textColor, callback: v => v + '%' } } } } }); } // 16. TAGS PERFORMANCE CHART const tagsPnl = {}; filteredTrades.forEach(t => { if (t.tags && Array.isArray(t.tags)) { t.tags.forEach(tag => { if (!tagsPnl[tag]) tagsPnl[tag] = { pnl: 0, count: 0 }; tagsPnl[tag].pnl += getNetPnl(t); tagsPnl[tag].count++; }); } }); const tagsCtx = document.getElementById('rpt-tags-chart'); if (tagsCtx && Object.keys(tagsPnl).length > 0) { const sortedTags = Object.entries(tagsPnl).sort((a, b) => b[1].pnl - a[1].pnl); const tagLabels = sortedTags.map(([tag]) => tag); const tagData = sortedTags.map(([, data]) => data.pnl); window.rptTagsChart = new Chart(tagsCtx, { type: 'bar', data: { labels: tagLabels, datasets: [{ label: 'P&L', data: tagData, backgroundColor: tagData.map(v => v >= 0 ? chartColors.greenBar : chartColors.redBar) }] }, options: { responsive: true, maintainAspectRatio: false, indexAxis: 'y', plugins: { legend: { display: false } }, scales: { x: { grid: { color: chartColors.gridColor }, ticks: { color: chartColors.textColor, callback: v => formatCurrency(v, 0) } }, y: { grid: { display: false }, ticks: { color: chartColors.textColor } } } } }); } // P&L BY SETUP CHART const setupPnl = {}; filteredTrades.forEach(t => { const noteData = tradeNotes[t.id] || {}; const sid = noteData.setupId; if (sid) { if (!setupPnl[sid]) setupPnl[sid] = { pnl: 0, name: '' }; setupPnl[sid].pnl += getNetPnl(t); setupPnl[sid].name = playbook.find(s => s.id === sid)?.name || sid; } }); const setupCtx = document.getElementById('rpt-setup-chart'); if (setupCtx && Object.keys(setupPnl).length > 0) { const sortedSetups = Object.values(setupPnl).sort((a, b) => b.pnl - a.pnl); const setupLabels = sortedSetups.map(s => s.name); const setupData = sortedSetups.map(s => s.pnl); window.rptSetupChart = new Chart(setupCtx, { type: 'bar', data: { labels: setupLabels, datasets: [{ label: 'P&L', data: setupData, backgroundColor: setupData.map(v => v >= 0 ? chartColors.greenBar : chartColors.redBar) }] }, options: { responsive: true, maintainAspectRatio: false, indexAxis: 'y', plugins: { legend: { display: false } }, scales: { x: { grid: { color: chartColors.gridColor }, ticks: { color: chartColors.textColor, callback: v => formatCurrency(v, 0) } }, y: { grid: { display: false }, ticks: { color: chartColors.textColor } } } } }); } // 17. WIN RATE / AVG WIN / AVG LOSS TREND const trendData = {}; filteredTrades.forEach(t => { const date = getDateKey(t.exitTime); if (!trendData[date]) trendData[date] = { wins: 0, losses: 0, winSum: 0, lossSum: 0 }; const pnl = getNetPnl(t); if (pnl > 0) { trendData[date].wins++; trendData[date].winSum += pnl; } else if (pnl < 0) { trendData[date].losses++; trendData[date].lossSum += Math.abs(pnl); } }); const trendCtx = document.getElementById('rpt-trend-chart'); if (trendCtx && Object.keys(trendData).length > 0) { const sortedDates = Object.keys(trendData).sort(); const winRates = sortedDates.map(d => { const total = trendData[d].wins + trendData[d].losses; return total > 0 ? (trendData[d].wins / total * 100) : 0; }); const avgWins = sortedDates.map(d => trendData[d].wins > 0 ? trendData[d].winSum / trendData[d].wins : 0); const avgLosses = sortedDates.map(d => trendData[d].losses > 0 ? trendData[d].lossSum / trendData[d].losses : 0); window.rptTrendChart = new Chart(trendCtx, { type: 'line', data: { labels: sortedDates.map(d => new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })), datasets: [ { label: 'Win Rate %', data: winRates, borderColor: chartColors.cyan, borderWidth: 1.5, backgroundColor: 'transparent', yAxisID: 'y', tension: 0.3, pointRadius: 2 }, { label: 'Avg Win', data: avgWins, borderColor: chartColors.green, borderWidth: 1.5, backgroundColor: 'transparent', yAxisID: 'y1', tension: 0.3, pointRadius: 2 }, { label: 'Avg Loss', data: avgLosses, borderColor: chartColors.red, borderWidth: 1.5, backgroundColor: 'transparent', yAxisID: 'y1', tension: 0.3, pointRadius: 2 } ] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: true, position: 'top', labels: { color: chartColors.textColor, boxWidth: 12 } } }, scales: { x: { grid: { display: false }, ticks: { color: chartColors.textColor, maxTicksLimit: 10 } }, y: { position: 'left', min: 0, max: 100, grid: { color: chartColors.gridColor }, ticks: { color: chartColors.textColor, callback: v => v + '%' } }, y1: { position: 'right', grid: { display: false }, ticks: { color: chartColors.textColor, callback: v => formatCurrency(v, 0) } } } } }); } // ============ ANALYTICS TILES (moved from Dashboard) ============ // --- Cumulative P&L (Daily) --- { const cumCtx = document.getElementById('rpt2-cum-daily-chart'); if (cumCtx) { const cdDailyPnl = {}; filteredTrades.forEach(t => { const dk = getDateKey(t.exitTime); cdDailyPnl[dk] = (cdDailyPnl[dk] || 0) + getNetPnl(t); }); const cdSorted = Object.keys(cdDailyPnl).sort(); let cdCum = 0; const cdData = cdSorted.map(dk => { cdCum += cdDailyPnl[dk]; return { x: new Date(dk), y: cdCum }; }); const cdTotal = cdData.length > 0 ? cdData[cdData.length - 1].y : 0; const cdColor = cdTotal >= 0 ? themeGreen() : themeRed(); const cdBg = cdTotal >= 0 ? themeGreenBg(0.06) : themeRedBg(0.06); setEl('rpt2-cum-daily-total', formatCurrency(cdTotal)); const cdTotalEl = document.getElementById('rpt2-cum-daily-total'); if (cdTotalEl) cdTotalEl.style.color = cdTotal >= 0 ? 'var(--green)' : 'var(--red)'; if (cdData.length > 0) { window.rpt2CumDailyChart = new Chart(cumCtx, { type: 'line', data: { datasets: [{ label: 'Cumulative P&L (Daily)', data: cdData, borderColor: cdColor, backgroundColor: cdBg, borderWidth: 1.5, fill: true, tension: 0.2, pointRadius: 2, pointBackgroundColor: cdColor }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: { callbacks: { label: c => 'Net: ' + formatCurrency(c.raw.y) } } }, interaction: { intersect: false, mode: 'index' }, scales: { x: { type: 'time', time: { unit: 'day' }, grid: { color: chartColors.gridColor }, ticks: { color: chartColors.textColor } }, y: { grid: { color: chartColors.gridColor }, ticks: { color: chartColors.textColor, callback: v => formatCurrency(v, 0) } } } } }); } } } // --- Daily P&L + Cumulative --- { const dbCtx = document.getElementById('rpt2-daily-bar-chart'); if (dbCtx) { const dbDailyPnl = {}; filteredTrades.forEach(t => { const d = t.date || getTradingSessionDate(t.exitTime) || getDateKey(t.exitTime); dbDailyPnl[d] = (dbDailyPnl[d] || 0) + getNetPnl(t); }); const dbSorted = Object.keys(dbDailyPnl).sort(); const dbLabels = dbSorted.map(d => formatDate(d)); const dbData = dbSorted.map(d => dbDailyPnl[d]); const dbColors = dbData.map(v => v >= 0 ? 'rgba(0,212,170,0.7)' : 'rgba(239,68,68,0.7)'); let dbCum = 0; const dbCumData = dbData.map(v => { dbCum += v; return dbCum; }); const dbTotalEl = document.getElementById('rpt2-daily-total'); if (dbTotalEl && dbCumData.length > 0) dbTotalEl.textContent = formatCurrency(dbCumData[dbCumData.length - 1]); if (dbData.length > 0) { window.rpt2DailyBarChart = new Chart(dbCtx, { type: 'bar', data: { labels: dbLabels, datasets: [{ type: 'bar', label: 'Daily P&L', data: dbData, backgroundColor: dbColors, order: 2 }, { type: 'line', label: 'Cumulative', data: dbCumData, borderColor: 'rgba(59,130,246,0.6)', borderWidth: 1.5, backgroundColor: 'transparent', tension: 0.3, pointRadius: 1, order: 1 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: true, labels: { color: chartColors.textColor } } }, scales: { x: { grid: { color: chartColors.gridColor }, ticks: { color: chartColors.textColor, maxTicksLimit: 10 } }, y: { grid: { color: chartColors.gridColor }, ticks: { color: chartColors.textColor, callback: v => formatCurrency(v, 0) } } } } }); } } } // --- Lab Score Radar --- { const radarCtx = document.getElementById('rpt2-radar-chart'); if (radarCtx) { const avgRatio = avgLoss > 0 ? avgWin / avgLoss : (avgWin > 0 ? Infinity : 0); const winDays = dailyValues.filter(v => v > 0).length; const dayWinRate = tradingDays > 0 ? (winDays / tradingDays * 100) : 0; const lsWinPct = Math.min(100, winRate); const lsPfScore = Math.min(100, profitFactor * 20); const lsRecovery = maxDrawdown > 0 ? Math.min(100, (totalPnl / maxDrawdown) * 30) : 50; const lsAvgWL = avgRatio === Infinity ? 100 : Math.min(100, avgRatio * 50); const lsConsistency = dayWinRate; // Max Drawdown axis: lower DD% = better score (inverse). Cap at 10% as "worst" const ddPct = totalPnl !== 0 ? (maxDrawdown / Math.abs(totalPnl)) * 100 : 0; const lsMaxDD = Math.max(0, Math.min(100, 100 - ddPct * 10)); const lsScore = Math.min(100, Math.max(0, lsWinPct * 0.25 + lsPfScore * 0.25 + lsRecovery * 0.15 + lsAvgWL * 0.2 + lsConsistency * 0.15)).toFixed(1); setEl('rpt2-perf-score', lsScore); const scoreEl = document.getElementById('rpt2-perf-score'); const scoreNum = parseFloat(lsScore); if (scoreEl) scoreEl.style.color = scoreNum >= 70 ? '#00d4aa' : scoreNum >= 50 ? '#f59e0b' : themeRed(); // Update gradient bar indicator const indicator = document.getElementById('labscore-indicator'); if (indicator) { indicator.style.left = lsScore + '%'; // Color indicator border based on score indicator.style.borderColor = scoreNum >= 70 ? '#00d4aa' : scoreNum >= 50 ? '#f59e0b' : themeRed(); indicator.style.boxShadow = scoreNum >= 70 ? '0 0 6px rgba(0,212,170,0.3)' : scoreNum >= 50 ? '0 0 6px rgba(245,158,11,0.3)' : '0 0 6px rgba(239,68,68,0.3)'; } window.rpt2RadarChart = new Chart(radarCtx, { type: 'radar', data: { labels: ['Win %', 'Profit Factor', 'Recovery', 'Avg W/L', 'Consistency', 'Max DD'], datasets: [{ data: [lsWinPct, lsPfScore, lsRecovery, lsAvgWL, lsConsistency, lsMaxDD], backgroundColor: 'rgba(0,212,170,0.15)', borderColor: '#00d4aa', borderWidth: 2, pointBackgroundColor: '#00d4aa', pointBorderColor: 'var(--bg-card)', pointBorderWidth: 2, pointRadius: 4, pointHoverRadius: 6 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { r: { beginAtZero: true, max: 100, ticks: { display: false, stepSize: 20 }, grid: { color: 'rgba(255,255,255,0.06)', lineWidth: 1 }, angleLines: { color: 'rgba(255,255,255,0.06)', lineWidth: 1 }, pointLabels: { color: '#6b7280', font: { size: 10, weight: '500' } } } } } }); } } // --- Payouts --- { const payCtx = document.getElementById('rpt2-payout-chart'); if (payCtx) { const payGlobalFirm = document.getElementById('global-prop-firm-filter')?.value || ''; const payArchivedIds = new Set(accounts.filter(a => a.archived).map(a => a.id)); const withdrawals = payouts.filter(p => { if (p.type !== 'withdrawal') return false; const acc = accounts.find(a => a.id === p.accountId); if (acc) { if (!includeArchivedInMetrics && payArchivedIds.has(acc.id)) return false; if (globalStageFilter === 'funded' && (acc.stage === 'evaluation' || acc.isEvaluation)) return false; if (globalStageFilter === 'evaluation' && acc.stage !== 'evaluation' && !acc.isEvaluation) return false; if (payGlobalFirm && acc.propFirm !== payGlobalFirm) return false; if (selectedAllFirmsAccounts.length > 0 && !selectedAllFirmsAccounts.includes(acc.id)) return false; } return true; }).sort((a, b) => new Date(a.date) - new Date(b.date)); if (withdrawals.length === 0) { setEl('rpt2-payout-total', '$0.00'); window.rpt2PayoutChart = new Chart(payCtx, { type: 'bar', data: { labels: ['No payouts yet'], datasets: [{ label: 'Payouts', data: [0], backgroundColor: 'rgba(0,212,170,0.12)', borderColor: '#00d4aa', borderWidth: 1 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, grid: { color: chartColors.gridColor }, ticks: { color: chartColors.textColor } }, x: { grid: { display: false }, ticks: { color: chartColors.textColor } } } } }); } else { let payCum = 0; const payLabels = [], payAmounts = [], payCumData = []; withdrawals.forEach(w => { payLabels.push(new Date(w.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })); payAmounts.push(w.amount); payCum += w.amount; payCumData.push(payCum); }); setEl('rpt2-payout-total', formatCurrency(payCum)); window.rpt2PayoutChart = new Chart(payCtx, { type: 'bar', data: { labels: payLabels, datasets: [{ type: 'line', label: 'Cumulative', data: payCumData, borderColor: '#00d4aa', borderWidth: 1.5, backgroundColor: 'rgba(0,212,170,0.06)', fill: true, tension: 0.3, pointRadius: 3, pointBackgroundColor: '#00d4aa', yAxisID: 'y' }, { type: 'bar', label: 'Payout', data: payAmounts, backgroundColor: 'rgba(59,130,246,0.45)', borderColor: 'rgba(59,130,246,0.6)', borderWidth: 1, yAxisID: 'y' }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: true, position: 'top', labels: { color: chartColors.textColor, boxWidth: 12 } } }, scales: { y: { beginAtZero: true, grid: { color: chartColors.gridColor }, ticks: { color: chartColors.textColor, callback: v => formatCurrency(v, 0) } }, x: { grid: { display: false }, ticks: { color: chartColors.textColor } } } } }); } } } // --- P&L Per Trade --- { const ptCtx = document.getElementById('rpt2-cum-trade-chart'); if (ptCtx) { let ptCum = 0; const ptData = sortedByTime.map(t => { ptCum += getNetPnl(t); return { x: new Date(t.exitTime), y: ptCum }; }); const ptFinal = ptData.length > 0 ? ptData[ptData.length - 1].y : 0; const ptColor = ptFinal >= 0 ? themeGreen() : themeRed(); const ptBg = ptFinal >= 0 ? themeGreenBg(0.06) : themeRedBg(0.06); setEl('rpt2-cum-trade-total', formatCurrency(ptFinal)); const ptTotalEl = document.getElementById('rpt2-cum-trade-total'); if (ptTotalEl) ptTotalEl.style.color = ptFinal >= 0 ? 'var(--green)' : 'var(--red)'; if (ptData.length > 0) { window.rpt2CumTradeChart = new Chart(ptCtx, { type: 'line', data: { datasets: [{ label: 'Cumulative P&L', data: ptData, borderColor: ptColor, backgroundColor: ptBg, fill: true, tension: 0.3, pointRadius: 0 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { type: 'time', time: { unit: 'day' }, grid: { color: chartColors.gridColor }, ticks: { color: chartColors.textColor } }, y: { grid: { color: chartColors.gridColor }, ticks: { color: chartColors.textColor, callback: v => formatCurrency(v, 0) } } } } }); } } } // --- Max Drawdown --- { const ddCtx = document.getElementById('rpt2-drawdown-chart'); if (ddCtx) { let ddPeak = 0, ddCum = 0, ddMaxVal = 0; const ddData = sortedByTime.map((t, i) => { ddCum += getNetPnl(t); if (ddCum > ddPeak) ddPeak = ddCum; const dd = ddPeak - ddCum; if (dd > ddMaxVal) ddMaxVal = dd; return { x: i + 1, y: -dd }; }); setEl('rpt2-max-dd', '-' + formatCurrency(ddMaxVal)); if (ddData.length > 0) { window.rpt2DrawdownChart = new Chart(ddCtx, { type: 'line', data: { datasets: [{ label: 'Drawdown', data: ddData, borderColor: themeRed(), borderWidth: 1.5, backgroundColor: themeRedBg(0.06), fill: true, tension: 0.3, pointRadius: 0 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { type: 'linear', title: { display: true, text: 'Trade #', color: chartColors.textColor }, grid: { color: chartColors.gridColor }, ticks: { color: chartColors.textColor } }, y: { grid: { color: chartColors.gridColor }, ticks: { color: chartColors.textColor, callback: v => formatCurrency(v, 0) } } } } }); } } } // --- Eval Pass Rate --- { const globalFirm = document.getElementById('global-prop-firm-filter')?.value || ''; const archivedIds = new Set(accounts.filter(a => a.archived).map(a => a.id)); const evalAccs = accounts.filter(a => { if (a.stage !== 'evaluation' && !a.isEvaluation) return false; if (globalStageFilter === 'funded') return false; // Stage filter: hide evals when "Funded" selected if (!includeArchivedInMetrics && archivedIds.has(a.id)) return false; if (globalFirm && a.propFirm !== globalFirm) return false; if (selectedAllFirmsAccounts.length > 0 && !selectedAllFirmsAccounts.includes(a.id)) return false; return true; }); const evalPassed = evalAccs.filter(a => a.status === 'passed').length; const evalFailed = evalAccs.filter(a => a.status === 'failed').length; const evalActive = evalAccs.filter(a => !a.status || a.status === 'active').length; const evalTotal = evalAccs.length; const evalCompleted = evalPassed + evalFailed; const evalPassPct = evalCompleted > 0 ? (evalPassed / evalCompleted * 100) : 0; const evalRingColor = evalPassPct >= 70 ? 'var(--green)' : evalPassPct >= 40 ? 'var(--yellow)' : evalTotal === 0 ? 'var(--text-muted)' : 'var(--red)'; const circumEval = 2 * Math.PI * 34; setEl('rpt-eval-pass-pct', evalCompleted > 0 ? evalPassPct.toFixed(0) + '%' : '-'); const evalPctEl = document.getElementById('rpt-eval-pass-pct'); if (evalPctEl) evalPctEl.style.color = evalRingColor; setEl('rpt-eval-pass-count', `${evalPassed} / ${evalCompleted} Passed`); setEl('rpt-eval-passed', evalPassed); setEl('rpt-eval-failed', evalFailed); setEl('rpt-eval-active', evalActive); const evalRing = document.getElementById('rpt-eval-ring'); if (evalRing) { evalRing.style.stroke = evalRingColor; evalRing.setAttribute('stroke-dashoffset', circumEval * (1 - evalPassPct / 100)); } const evalRingLbl = document.getElementById('rpt-eval-ring-label'); if (evalRingLbl) { evalRingLbl.textContent = evalCompleted > 0 ? evalPassPct.toFixed(0) + '%' : '-'; evalRingLbl.style.color = evalRingColor; } } // --- Funded Payout Rate --- { const globalFirm = document.getElementById('global-prop-firm-filter')?.value || ''; const archivedIds = new Set(accounts.filter(a => a.archived).map(a => a.id)); const fundedAccs = accounts.filter(a => { if (a.stage === 'evaluation' || a.isEvaluation) return false; if (globalStageFilter === 'evaluation') return false; // Stage filter: hide funded when "Eval" selected if (!a.propFirm) return false; if (!includeArchivedInMetrics && archivedIds.has(a.id)) return false; if (globalFirm && a.propFirm !== globalFirm) return false; if (selectedAllFirmsAccounts.length > 0 && !selectedAllFirmsAccounts.includes(a.id)) return false; return true; }); const fundedTotal = fundedAccs.length; const accPayoutCounts = fundedAccs.map(a => { const count = (payouts || []).filter(p => p.accountId === a.id).length; return { name: a.name || 'Unnamed', count }; }); const paidAccounts = accPayoutCounts.filter(a => a.count > 0).length; const payoutRatePct = fundedTotal > 0 ? (paidAccounts / fundedTotal * 100) : 0; const totalPayoutsAll = accPayoutCounts.reduce((s, a) => s + a.count, 0); const avgPayouts = fundedTotal > 0 ? (totalPayoutsAll / fundedTotal) : 0; const payRingColor = payoutRatePct >= 50 ? 'var(--green)' : payoutRatePct > 0 ? 'var(--yellow)' : fundedTotal === 0 ? 'var(--text-muted)' : 'var(--red)'; const circumPay = 2 * Math.PI * 34; setEl('rpt-payout-rate-pct', fundedTotal > 0 ? payoutRatePct.toFixed(0) + '%' : '-'); const payPctEl = document.getElementById('rpt-payout-rate-pct'); if (payPctEl) payPctEl.style.color = payRingColor; setEl('rpt-payout-rate-count', `${paidAccounts} / ${fundedTotal} Paid Out`); setEl('rpt-payout-rate-avg', `Avg: ${avgPayouts.toFixed(1)} payouts per account`); const payRing = document.getElementById('rpt-payout-ring'); if (payRing) { payRing.style.stroke = payRingColor; payRing.setAttribute('stroke-dashoffset', circumPay * (1 - payoutRatePct / 100)); } const payRingLbl = document.getElementById('rpt-payout-ring-label'); if (payRingLbl) { payRingLbl.textContent = fundedTotal > 0 ? payoutRatePct.toFixed(0) + '%' : '-'; payRingLbl.style.color = payRingColor; } const listEl = document.getElementById('rpt-payout-rate-list'); if (listEl) { const sorted = [...accPayoutCounts].sort((a, b) => b.count - a.count); listEl.innerHTML = sorted.map(a => `
${a.name} ${a.count} payout${a.count !== 1 ? 's' : ''}
` ).join(''); } } } // ============ KEY STATS TAB ============ let currentReportsTab = 'charts'; function switchReportsTab(tab) { currentReportsTab = tab; document.getElementById('rpt-tab-charts').classList.toggle('active', tab === 'charts'); document.getElementById('rpt-tab-keystats').classList.toggle('active', tab === 'keystats'); document.getElementById('rpt-panel-charts').style.display = tab === 'charts' ? '' : 'none'; document.getElementById('rpt-panel-keystats').style.display = tab === 'keystats' ? '' : 'none'; if (tab === 'keystats') renderKeyStats(); } function renderKeyStats() { const filteredTrades = getMetricsEligibleTrades(); const sortedByTime = [...filteredTrades].sort((a, b) => new Date(a.exitTime) - new Date(b.exitTime)); // Basic stats const winners = filteredTrades.filter(t => getNetPnl(t) > 0); const losers = filteredTrades.filter(t => getNetPnl(t) < 0); const breakeven = filteredTrades.filter(t => getNetPnl(t) === 0); const totalPnl = filteredTrades.reduce((sum, t) => sum + getNetPnl(t), 0); const grossProfit = winners.reduce((sum, t) => sum + getNetPnl(t), 0); const grossLoss = Math.abs(losers.reduce((sum, t) => sum + getNetPnl(t), 0)); const profitFactor = grossLoss > 0 ? grossProfit / grossLoss : grossProfit > 0 ? Infinity : 0; const avgWin = winners.length > 0 ? grossProfit / winners.length : 0; const avgLoss = losers.length > 0 ? grossLoss / losers.length : 0; const avgTrade = filteredTrades.length > 0 ? totalPnl / filteredTrades.length : 0; const largestWin = winners.length > 0 ? Math.max(...winners.map(t => getNetPnl(t))) : 0; const largestLoss = losers.length > 0 ? Math.min(...losers.map(t => getNetPnl(t))) : 0; const winRate = filteredTrades.length > 0 ? (winners.length / filteredTrades.length * 100) : 0; const lossRate = filteredTrades.length > 0 ? (losers.length / filteredTrades.length) : 0; const expectancy = (winRate / 100 * avgWin) - (lossRate * avgLoss); // Daily aggregation const dailyPnl = {}; filteredTrades.forEach(t => { const date = getDateKey(t.exitTime); dailyPnl[date] = (dailyPnl[date] || 0) + getNetPnl(t); }); const dailyKeys = Object.keys(dailyPnl); const dailyValues = Object.values(dailyPnl); const tradingDays = dailyKeys.length; const winningDays = dailyValues.filter(v => v > 0).length; const losingDays = dailyValues.filter(v => v < 0).length; const bestDay = dailyValues.length > 0 ? Math.max(...dailyValues) : 0; const worstDay = dailyValues.length > 0 ? Math.min(...dailyValues) : 0; const avgDailyPnl = tradingDays > 0 ? totalPnl / tradingDays : 0; // Monthly aggregation const monthlyPnl = {}; filteredTrades.forEach(t => { const d = new Date(t.exitTime); if (isNaN(d.getTime())) return; const key = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0'); monthlyPnl[key] = (monthlyPnl[key] || 0) + getNetPnl(t); }); const monthKeys = Object.keys(monthlyPnl).sort(); const monthValues = Object.values(monthlyPnl); let bestMonthKey = '', worstMonthKey = '', bestMonthVal = 0, worstMonthVal = 0; if (monthKeys.length > 0) { monthKeys.forEach(k => { if (monthlyPnl[k] > bestMonthVal || !bestMonthKey) { bestMonthVal = monthlyPnl[k]; bestMonthKey = k; } if (monthlyPnl[k] < worstMonthVal || !worstMonthKey) { worstMonthVal = monthlyPnl[k]; worstMonthKey = k; } }); } const avgPerMonth = monthKeys.length > 0 ? totalPnl / monthKeys.length : 0; function formatMonthKey(k) { if (!k) return ''; const [y, m] = k.split('-'); const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; return months[parseInt(m) - 1] + ' ' + y; } // Max drawdown let peak = 0, maxDrawdownDollar = 0, maxDrawdownPct = 0, cumulative = 0; sortedByTime.forEach(t => { cumulative += getNetPnl(t); if (cumulative > peak) peak = cumulative; const dd = peak - cumulative; if (dd > maxDrawdownDollar) { maxDrawdownDollar = dd; maxDrawdownPct = peak > 0 ? (dd / peak * 100) : 0; } }); // Consecutive streaks let maxWins = 0, maxLosses = 0, currentWins = 0, currentLosses = 0; sortedByTime.forEach(t => { if (getNetPnl(t) > 0) { currentWins++; currentLosses = 0; maxWins = Math.max(maxWins, currentWins); } else if (getNetPnl(t) < 0) { currentLosses++; currentWins = 0; maxLosses = Math.max(maxLosses, currentLosses); } }); // Hold time let totalHoldAll = 0, countHoldAll = 0, totalHoldWin = 0, countHoldWin = 0, totalHoldLoss = 0, countHoldLoss = 0; filteredTrades.forEach(t => { if (t.entryTime && t.exitTime) { const entry = new Date(t.entryTime); const exit = new Date(t.exitTime); if (!isNaN(entry.getTime()) && !isNaN(exit.getTime())) { const secs = (exit - entry) / 1000; if (secs > 0 && secs < 86400) { totalHoldAll += secs; countHoldAll++; if (getNetPnl(t) > 0) { totalHoldWin += secs; countHoldWin++; } else if (getNetPnl(t) < 0) { totalHoldLoss += secs; countHoldLoss++; } } } } }); function formatHoldTime(secs) { if (!secs || secs <= 0) return '-'; if (secs >= 3600) return Math.floor(secs / 3600) + 'h ' + Math.floor((secs % 3600) / 60) + 'm'; return Math.floor(secs / 60) + 'm ' + Math.floor(secs % 60) + 's'; } const avgHoldAll = countHoldAll > 0 ? totalHoldAll / countHoldAll : 0; const avgHoldWin = countHoldWin > 0 ? totalHoldWin / countHoldWin : 0; const avgHoldLoss = countHoldLoss > 0 ? totalHoldLoss / countHoldLoss : 0; // Volume & sizing let totalVolume = 0, maxPositionSize = 0; filteredTrades.forEach(t => { const qty = parseFloat(t.quantity || t.contracts || 1); totalVolume += qty; if (qty > maxPositionSize) maxPositionSize = qty; }); const avgPositionSize = filteredTrades.length > 0 ? totalVolume / filteredTrades.length : 0; const avgDailyVolume = tradingDays > 0 ? totalVolume / tradingDays : 0; // Average drawdown (average of all drawdown troughs) let drawdownSum = 0, drawdownCount = 0; let ddPeak2 = 0, ddCum2 = 0; sortedByTime.forEach(t => { ddCum2 += getNetPnl(t); if (ddCum2 > ddPeak2) ddPeak2 = ddCum2; const dd = ddPeak2 - ddCum2; if (dd > 0) { drawdownSum += dd; drawdownCount++; } }); const avgDrawdown = drawdownCount > 0 ? drawdownSum / drawdownCount : 0; // Prop firm specific let accountsAtRisk = 0, payoutEligibleCount = 0, totalPayoutsReceived = 0; let minDistToMLL = Infinity, closestMllAccName = '', accountsLost = 0, consistencyMetCount = 0, ruleViolations = 0; let activeAccounts = 0, totalDaysToFirstPayout = 0, firstPayoutCount = 0; let ruleViolationsThisMonth = 0; const nowMonth = new Date().getFullYear() + '-' + String(new Date().getMonth() + 1).padStart(2, '0'); accounts.filter(a => !a.archived && a.propFirm && a.propFirm !== 'personal' && a.propFirm !== 'other').forEach(acc => { const stage = (acc.stage || '').toLowerCase(); if (stage === 'blown' || stage === 'failed') { accountsLost++; return; } activeAccounts++; const firmConfig = propFirmConfigs[acc.propFirm]; if (!firmConfig) return; try { const metrics = calculateAccountMetrics(acc, firmConfig, filteredTrades); if (!metrics.mllSafe) accountsAtRisk++; if (metrics.isEligible) payoutEligibleCount++; totalPayoutsReceived += metrics.payoutCount || 0; if (metrics.distanceToMLL !== undefined && metrics.distanceToMLL < minDistToMLL) { minDistToMLL = metrics.distanceToMLL; closestMllAccName = acc.name || acc.id; } if (metrics.consistencyMet) consistencyMetCount++; else if (metrics.hasConsistencyRule) { ruleViolations++; ruleViolationsThisMonth++; } // Days to first payout if (metrics.payoutCount > 0 && metrics.lastPayoutDate && acc.createdAt) { const created = new Date(acc.createdAt); const firstPayout = new Date(metrics.lastPayoutDate); if (!isNaN(created.getTime()) && !isNaN(firstPayout.getTime())) { const days = Math.round((firstPayout - created) / 86400000); if (days > 0 && days < 365) { totalDaysToFirstPayout += days; firstPayoutCount++; } } } } catch (e) { /* skip account on error */ } }); const payoutEligibilityRate = activeAccounts > 0 ? (payoutEligibleCount / activeAccounts * 100) : 0; const consistencyScore = activeAccounts > 0 ? (consistencyMetCount / activeAccounts * 100) : 0; const avgDaysToFirstPayout = firstPayoutCount > 0 ? Math.round(totalDaysToFirstPayout / firstPayoutCount) : 0; if (minDistToMLL === Infinity) minDistToMLL = 0; // Days below buffer - count daily P&L entries where cumulative dipped into drawdown territory let daysBelowBuffer = 0; const sortedDailyKeys = Object.keys(dailyPnl).sort(); let runningTotal = 0; sortedDailyKeys.forEach(dk => { runningTotal += dailyPnl[dk]; if (runningTotal < 0) daysBelowBuffer++; }); // Helpers function colorFor(val) { return val > 0 ? 'positive' : val < 0 ? 'negative' : 'neutral'; } function statRow(label, value, colorClass, tooltip) { let tooltipHtml = ''; if (tooltip) { tooltipHtml = `${tooltip}`; } return `
${label}${tooltipHtml} ${value}
`; } // Build HTML const html = `
Best Month
${formatCurrency(bestMonthVal)}
${formatMonthKey(bestMonthKey)}
Worst Month
${formatCurrency(worstMonthVal)}
${formatMonthKey(worstMonthKey)}
Avg per Month
${formatCurrency(avgPerMonth)}
${monthKeys.length} month${monthKeys.length !== 1 ? 's' : ''}
Profitability
${statRow('Total P&L', formatCurrency(totalPnl), colorFor(totalPnl), 'Net profit/loss across all filtered trades')} ${statRow('Avg Trade P&L', formatCurrency(avgTrade), colorFor(avgTrade), 'Average net P&L per trade')} ${statRow('Largest Win', formatCurrency(largestWin), 'positive', 'Single best trade')} ${statRow('Largest Loss', formatCurrency(largestLoss), 'negative', 'Single worst trade')} ${statRow('Profit Factor', profitFactor === Infinity ? '∞' : profitFactor.toFixed(2), profitFactor >= 1 ? 'positive' : 'negative', 'Gross profit / gross loss. Above 1.0 = profitable')} ${statRow('Trade Expectancy', formatCurrency(expectancy), colorFor(expectancy), '(Win% × Avg Win) − (Loss% × Avg Loss). Expected value per trade')}
Win / Loss
${statRow('Total Trades', filteredTrades.length, 'neutral')} ${statRow('Winners', winners.length, 'positive')} ${statRow('Losers', losers.length, 'negative')} ${statRow('Breakeven', breakeven.length, 'neutral')} ${statRow('Win Rate', winRate.toFixed(1) + '%', winRate >= 50 ? 'positive' : 'negative', 'Percentage of trades that were profitable')} ${statRow('Max Consecutive Wins', maxWins, 'positive')} ${statRow('Max Consecutive Losses', maxLosses, 'negative')}
Risk Management
${statRow('Max Drawdown ($)', formatCurrency(-maxDrawdownDollar), 'negative', 'Largest peak-to-trough decline in cumulative P&L')} ${statRow('Max Drawdown (%)', maxDrawdownPct.toFixed(1) + '%', 'negative', 'Drawdown as percentage of peak equity')} ${statRow('Avg Drawdown', formatCurrency(-avgDrawdown), avgDrawdown > 0 ? 'negative' : 'neutral', 'Average drawdown amount across all drawdown periods')} ${statRow('Closest to MLL', closestMllAccName ? formatCurrency(minDistToMLL) + ' (' + closestMllAccName + ')' : '-', minDistToMLL > 0 ? 'positive' : 'negative', 'Account closest to Maximum Loss Limit and its distance')} ${statRow('Days Below Buffer', daysBelowBuffer, daysBelowBuffer > 0 ? 'negative' : 'positive', 'Trading days where cumulative P&L was negative (underwater)')} ${statRow('Accounts Lost', accountsLost, accountsLost > 0 ? 'negative' : 'neutral', 'Accounts blown or failed due to drawdown violations')}
Time Analysis
${statRow('Avg Hold Time (All)', formatHoldTime(avgHoldAll), 'neutral', 'Average duration from entry to exit across all trades')} ${statRow('Avg Hold Time (Winners)', formatHoldTime(avgHoldWin), 'positive')} ${statRow('Avg Hold Time (Losers)', formatHoldTime(avgHoldLoss), 'negative')} ${statRow('Trading Days', tradingDays, 'neutral')} ${statRow('Winning Days', winningDays, 'positive')} ${statRow('Losing Days', losingDays, 'negative')} ${statRow('Best Day', formatCurrency(bestDay), 'positive')} ${statRow('Worst Day', formatCurrency(worstDay), 'negative')} ${statRow('Avg Daily P&L', formatCurrency(avgDailyPnl), colorFor(avgDailyPnl))}
Volume & Sizing
${statRow('Avg Daily Volume', avgDailyVolume.toFixed(1), 'neutral', 'Average contracts traded per day')} ${statRow('Total Volume', totalVolume, 'neutral')} ${statRow('Avg Position Size', avgPositionSize.toFixed(1), 'neutral', 'Average contracts per trade')} ${statRow('Max Position Size', maxPositionSize, 'neutral')}
Prop Firm Specific
${statRow('Accounts at Risk', accountsAtRisk, accountsAtRisk > 0 ? 'negative' : 'positive', 'Accounts where current balance is below the Maximum Loss Limit safety zone')} ${statRow('Payout Eligibility Rate', payoutEligibilityRate.toFixed(0) + '%', payoutEligibilityRate >= 50 ? 'positive' : 'neutral', 'Percentage of active accounts currently eligible for payout')} ${statRow('Avg Days to First Payout', avgDaysToFirstPayout > 0 ? avgDaysToFirstPayout + ' days' : '-', 'neutral', 'Average number of days from account creation to first payout across all accounts')} ${statRow('Total Payouts Received', totalPayoutsReceived, totalPayoutsReceived > 0 ? 'positive' : 'neutral', 'Total number of payouts received across all prop firm accounts')} ${statRow('Consistency Score', consistencyScore.toFixed(0) + '%', consistencyScore >= 80 ? 'positive' : consistencyScore >= 50 ? 'neutral' : 'negative', 'Percentage of active accounts meeting their consistency rule')} ${statRow('Rule Violations This Month', ruleViolationsThisMonth, ruleViolationsThisMonth > 0 ? 'negative' : 'positive', 'Accounts currently not meeting their consistency requirement')}
`; document.getElementById('rpt-panel-keystats').innerHTML = html; } // ============ WIDGET CONFIGURATION SYSTEM ============ const defaultWidgetVisibility = { 'key-metrics': true, 'cum-daily-pnl': true, 'daily-pnl-bar': true, 'labscore-radar': true, 'payouts-history': true, 'pnl-per-trade': true, 'max-dd-pertrade': true, 'equity-curve': true, 'winloss-donut': true, 'monthly-chart': true, 'dow-chart': true, 'time-heatmap': true, 'pnl-dist': true, 'account-chart': true, 'best-worst': true, 'trade-streak': true, 'risk-metrics': true, 'hold-time': true, 'contract-size': true, 'halfhour-chart': true, 'points-chart': true, 'drawdown-chart': true, 'dow-tradenum': false, 'first-hour': true, 'tags-chart': true, 'setup-chart': true, 'trend-chart': true, 'eval-pass-rate': true, 'funded-payout-rate': true }; function getWidgetVisibility() { try { const saved = localStorage.getItem('reportWidgetVisibility'); if (saved) { return { ...defaultWidgetVisibility, ...JSON.parse(saved) }; } } catch (e) {} return { ...defaultWidgetVisibility }; } function saveWidgetVisibility(visibility) { try { localStorage.setItem('reportWidgetVisibility', JSON.stringify(visibility)); } catch (e) {} if (typeof currentUser !== 'undefined' && currentUser && typeof db !== 'undefined') { try { db.collection('users').doc(currentUser.uid).collection('settings').doc('reportWidgets').set({ visibility: visibility, updatedAt: new Date().toISOString() }, { merge: true }); } catch (e) {} } } function applyWidgetVisibility() { const visibility = getWidgetVisibility(); document.querySelectorAll('.report-widget').forEach(widget => { const widgetId = widget.dataset.widget; if (widgetId && visibility[widgetId] !== undefined) { widget.style.display = visibility[widgetId] ? '' : 'none'; } }); } window.toggleWidgetConfig = function() { const panel = document.getElementById('widget-config-panel'); if (!panel) return; const isVisible = panel.style.display !== 'none'; panel.style.display = isVisible ? 'none' : 'block'; if (!isVisible) { renderWidgetToggles(); } }; function renderWidgetToggles() { const container = document.getElementById('widget-toggles'); if (!container) return; const visibility = getWidgetVisibility(); const widgets = document.querySelectorAll('.report-widget[data-widget]'); container.innerHTML = Array.from(widgets).map(widget => { const id = widget.dataset.widget; const name = widget.dataset.widgetName || id; const checked = visibility[id] !== false; return ` `; }).join(''); } window.toggleWidget = function(widgetId, visible) { const visibility = getWidgetVisibility(); visibility[widgetId] = visible; saveWidgetVisibility(visibility); applyWidgetVisibility(); }; // ============ WIDGET GRID LAYOUT SYSTEM ============ function getWidgetOrder() { try { const saved = localStorage.getItem('reportWidgetOrder'); if (saved) return JSON.parse(saved); } catch (e) {} return null; } function saveWidgetOrder(order) { try { localStorage.setItem('reportWidgetOrder', JSON.stringify(order)); } catch (e) {} // Persist to Firestore if (typeof currentUser !== 'undefined' && currentUser && typeof db !== 'undefined') { try { db.collection('users').doc(currentUser.uid).collection('settings').doc('reportWidgets').set({ order: order, columns: getWidgetColumns(), updatedAt: new Date().toISOString() }, { merge: true }); } catch (e) {} } } // Load widget settings from Firestore (called after auth) async function loadWidgetSettingsFromFirestore() { if (!currentUser || !db) return; try { const doc = await db.collection('users').doc(currentUser.uid).collection('settings').doc('reportWidgets').get(); if (doc.exists) { const data = doc.data(); if (data.order && !localStorage.getItem('reportWidgetOrder')) { localStorage.setItem('reportWidgetOrder', JSON.stringify(data.order)); } if (data.columns && !localStorage.getItem('reportWidgetColumns')) { localStorage.setItem('reportWidgetColumns', data.columns); } if (data.visibility && !localStorage.getItem('reportWidgetVisibility')) { localStorage.setItem('reportWidgetVisibility', JSON.stringify(data.visibility)); } } } catch (e) {} } function getWidgetColumns() { try { return localStorage.getItem('reportWidgetColumns') || '2'; } catch (e) {} return '2'; } function saveWidgetColumns(cols) { try { localStorage.setItem('reportWidgetColumns', cols); } catch (e) {} if (typeof currentUser !== 'undefined' && currentUser && typeof db !== 'undefined') { try { db.collection('users').doc(currentUser.uid).collection('settings').doc('reportWidgets').set({ columns: cols, updatedAt: new Date().toISOString() }, { merge: true }); } catch (e) {} } } window.setWidgetColumns = function(cols) { const grid = document.getElementById('widget-grid'); if (!grid) return; grid.classList.remove('cols-1', 'cols-2', 'cols-3'); grid.classList.add('cols-' + cols); saveWidgetColumns(cols); }; window.resetWidgetLayout = function() { if (!confirm('Reset widget layout to default?')) return; localStorage.removeItem('reportWidgetOrder'); localStorage.removeItem('reportWidgetColumns'); localStorage.removeItem('reportWidgetVisibility'); // Clear Firestore too if (typeof currentUser !== 'undefined' && currentUser && typeof db !== 'undefined') { try { db.collection('users').doc(currentUser.uid).collection('settings').doc('reportWidgets').delete(); } catch (e) {} } location.reload(); }; function applyWidgetOrder() { const grid = document.getElementById('widget-grid'); if (!grid) return; const order = getWidgetOrder(); if (!order) return; const widgets = Array.from(grid.querySelectorAll('.report-widget[data-widget]')); const widgetMap = {}; widgets.forEach(w => widgetMap[w.dataset.widget] = w); // Reorder based on saved order order.forEach(id => { if (widgetMap[id]) { grid.appendChild(widgetMap[id]); } }); } function initWidgetDragDrop() { const grid = document.getElementById('widget-grid'); if (!grid) return; // Apply saved columns & order const cols = getWidgetColumns(); const select = document.getElementById('widget-columns'); if (select) select.value = cols; setWidgetColumns(cols); applyWidgetOrder(); let draggedWidget = null; let lastTarget = null; let lastInsertPos = null; // 'before' or 'after' let dragOverRAF = null; function saveCurrentOrder() { const newOrder = Array.from(grid.querySelectorAll('.report-widget[data-widget]')).map(w => w.dataset.widget); saveWidgetOrder(newOrder); } function clearIndicators() { grid.querySelectorAll('.drag-insert-before,.drag-insert-after').forEach(w => { w.classList.remove('drag-insert-before', 'drag-insert-after'); }); } function getDropTarget(x, y) { const widgets = Array.from(grid.querySelectorAll('.report-widget:not(.dragging)')); let closest = null; let closestDist = Infinity; widgets.forEach(w => { const rect = w.getBoundingClientRect(); const cx = rect.left + rect.width / 2; const cy = rect.top + rect.height / 2; const dist = Math.hypot(x - cx, y - cy); if (dist < closestDist) { closestDist = dist; closest = w; } }); return closest; } // Add mobile reorder buttons to each widget grid.querySelectorAll('.report-widget').forEach(widget => { if (widget.querySelector('.widget-reorder-btns')) return; const btnContainer = document.createElement('div'); btnContainer.className = 'widget-reorder-btns'; btnContainer.innerHTML = ` `; btnContainer.querySelectorAll('.widget-reorder-btn').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const dir = btn.dataset.dir; const allWidgets = Array.from(grid.querySelectorAll('.report-widget')); const idx = allWidgets.indexOf(widget); if (dir === 'up' && idx > 0) { grid.insertBefore(widget, allWidgets[idx - 1]); } else if (dir === 'down' && idx < allWidgets.length - 1) { grid.insertBefore(allWidgets[idx + 1], widget); } saveCurrentOrder(); }); }); widget.appendChild(btnContainer); // Drag handle: handle + header are draggable const handle = widget.querySelector('.widget-drag-handle'); const header = widget.querySelector('.card-header'); [handle, header].filter(Boolean).forEach(el => { el.style.cursor = 'grab'; el.addEventListener('mousedown', (e) => { if (e.target.closest('.info-tooltip') || e.target.closest('button') || e.target.closest('select') || e.target.closest('input')) return; widget.setAttribute('draggable', 'true'); }); }); widget.addEventListener('dragstart', (e) => { draggedWidget = widget; lastTarget = null; lastInsertPos = null; // Create a lightweight ghost — just the header label, not the full chart const ghost = document.createElement('div'); ghost.style.cssText = 'position:absolute;top:-9999px;left:-9999px;padding:10px 18px;background:var(--bg-card);border:1px solid rgba(255,255,255,0.12);border-radius:10px;color:#f0f2f5;font-size:13px;font-weight:600;white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.5);pointer-events:none;'; ghost.textContent = widget.dataset.widgetName || widget.querySelector('.card-title')?.textContent?.trim() || 'Widget'; document.body.appendChild(ghost); e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, ghost.offsetHeight / 2); requestAnimationFrame(() => { requestAnimationFrame(() => { if (ghost.parentNode) ghost.remove(); }); }); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', widget.dataset.widget || ''); // Lock grid height to prevent reflow, then fade source widget grid.style.minHeight = grid.offsetHeight + 'px'; grid.classList.add('drag-active'); requestAnimationFrame(() => { widget.classList.add('dragging'); }); }); widget.addEventListener('dragend', () => { widget.classList.remove('dragging'); widget.removeAttribute('draggable'); clearIndicators(); // Unlock grid height grid.style.minHeight = ''; grid.classList.remove('drag-active'); if (dragOverRAF) { cancelAnimationFrame(dragOverRAF); dragOverRAF = null; } draggedWidget = null; lastTarget = null; lastInsertPos = null; saveCurrentOrder(); }); }); // Throttled dragover — one RAF per frame, skip if target+position unchanged let pendingXY = null; grid.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; if (!draggedWidget) return; pendingXY = { x: e.clientX, y: e.clientY }; if (!dragOverRAF) { dragOverRAF = requestAnimationFrame(() => { dragOverRAF = null; if (!pendingXY || !draggedWidget) return; const { x, y } = pendingXY; pendingXY = null; const target = getDropTarget(x, y); if (!target || target === draggedWidget) { if (lastTarget) { clearIndicators(); lastTarget = null; lastInsertPos = null; } return; } const rect = target.getBoundingClientRect(); const insertPos = y > rect.top + rect.height / 2 ? 'after' : 'before'; // Skip if nothing changed if (target === lastTarget && insertPos === lastInsertPos) return; lastTarget = target; lastInsertPos = insertPos; // Update indicator — only a class toggle, zero reflow clearIndicators(); target.classList.add(insertPos === 'before' ? 'drag-insert-before' : 'drag-insert-after'); }); } }); grid.addEventListener('drop', (e) => { e.preventDefault(); if (dragOverRAF) { cancelAnimationFrame(dragOverRAF); dragOverRAF = null; } clearIndicators(); if (draggedWidget) { const target = getDropTarget(e.clientX, e.clientY); if (target && target !== draggedWidget) { const rect = target.getBoundingClientRect(); const isAfter = e.clientY > rect.top + rect.height / 2; if (isAfter) { grid.insertBefore(draggedWidget, target.nextSibling); } else { grid.insertBefore(draggedWidget, target); } } } }); grid.addEventListener('dragleave', (e) => { if (!grid.contains(e.relatedTarget)) { clearIndicators(); lastTarget = null; lastInsertPos = null; } }); } // Initialize on page load/navigation const origRenderReports = typeof renderReports !== 'undefined' ? renderReports : null; function renderRptDailyPnlChart() { const ctx = document.getElementById('daily-pnl-chart'); if (!ctx) return; const sortedTrades = [...getMetricsEligibleTrades()].sort((a, b) => new Date(a.exitTime) - new Date(b.exitTime)); const dailyPnl = {}; sortedTrades.forEach(t => { const date = getDateKey(t.exitTime); dailyPnl[date] = (dailyPnl[date] || 0) + getNetPnl(t); }); const sortedDates = Object.keys(dailyPnl).sort(); let cumulative = 0; const data = sortedDates.map(d => { cumulative += dailyPnl[d]; return cumulative; }); if (window.rptDailyPnlChart) { window.rptDailyPnlChart.destroy(); window.rptDailyPnlChart = null; } if (data.length === 0) return; window.rptDailyPnlChart = new Chart(ctx, { type: 'line', data: { labels: sortedDates.map(d => formatDate(d)), datasets: [{ data: data, borderColor: themeGreen(), backgroundColor: themeGreenBg(0.1), fill: true, tension: 0.3, pointRadius: 2 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { grid: { color: '#2a2f3a' }, ticks: { color: '#6b7280', maxTicksLimit: 10 } }, y: { grid: { color: '#2a2f3a' }, ticks: { color: '#6b7280', callback: v => formatCurrency(v, 0) } } } } }); } function renderRptDurationScatterChart() { const ctx = document.getElementById('duration-scatter-chart'); if (!ctx) return; const winnerData = []; const loserData = []; getMetricsEligibleTrades().forEach(t => { try { const entry = new Date(t.entryTime); const exit = new Date(t.exitTime); const durationMin = (exit - entry) / (1000 * 60); const pnl = getNetPnl(t); if (durationMin >= 0 && durationMin < 30) { const point = { x: durationMin, y: pnl }; if (pnl >= 0) winnerData.push(point); else loserData.push(point); } } catch (e) {} }); if (window.rptDurationChart) { window.rptDurationChart.destroy(); window.rptDurationChart = null; } window.rptDurationChart = new Chart(ctx, { type: 'scatter', data: { datasets: [ { label: 'Winners', data: winnerData, backgroundColor: themeGreen(), pointRadius: 6 }, { label: 'Losers', data: loserData, backgroundColor: themeRed(), pointRadius: 6 } ] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: true, position: 'top', labels: { color: '#9ca3af' } } }, scales: { x: { title: { display: true, text: 'Duration', color: '#6b7280' }, grid: { color: '#2a2f3a' }, ticks: { color: '#6b7280', callback: v => v + 'm' } }, y: { grid: { color: '#2a2f3a' }, ticks: { color: '#6b7280', callback: v => formatCurrency(v, 0) } } } } }); } function renderRptDailyBarChart() { const ctx = document.getElementById('rpt-daily-bar-chart'); if (!ctx) return; const dailyPnl = {}; getMetricsEligibleTrades().forEach(t => { const date = getDateKey(t.exitTime); dailyPnl[date] = (dailyPnl[date] || 0) + getNetPnl(t); }); const sortedDates = Object.keys(dailyPnl).sort(); const labels = sortedDates.map(d => formatDate(d)); const data = sortedDates.map(d => dailyPnl[d]); const colors = data.map(v => themePnlColor(v)); let cum = 0; const cumData = data.map(v => { cum += v; return cum; }); if (window.rptDailyBarChart) { window.rptDailyBarChart.destroy(); window.rptDailyBarChart = null; } if (data.length === 0) return; window.rptDailyBarChart = new Chart(ctx, { type: 'bar', data: { labels: labels, datasets: [ { type: 'bar', label: 'Daily P&L', data: data, backgroundColor: colors, order: 2 }, { type: 'line', label: 'Cumulative', data: cumData, borderColor: '#3b82f6', backgroundColor: 'transparent', tension: 0.3, pointRadius: 2, order: 1 } ] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: true, labels: { color: '#9ca3af' } } }, scales: { x: { grid: { color: '#2a2f3a' }, ticks: { color: '#6b7280', maxTicksLimit: 8 } }, y: { grid: { color: '#2a2f3a' }, ticks: { color: '#6b7280', callback: v => formatCurrency(v, 0) } } } } }); } function renderRptTrendChart() { const ctx = document.getElementById('rpt-trend-chart'); if (!ctx) return; const dailyTrades = {}; getMetricsEligibleTrades().forEach(t => { const date = getDateKey(t.exitTime); if (!dailyTrades[date]) dailyTrades[date] = []; dailyTrades[date].push(t); }); const sortedDates = Object.keys(dailyTrades).sort(); const labels = sortedDates.map(d => formatDate(d)); const winRateData = [], avgWinData = [], avgLossData = []; sortedDates.forEach(date => { const dayTrades = dailyTrades[date]; const winners = dayTrades.filter(t => getNetPnl(t) > 0); const losers = dayTrades.filter(t => getNetPnl(t) < 0); const wr = dayTrades.length > 0 ? (winners.length / dayTrades.length * 100) : 0; const avgW = winners.length > 0 ? winners.reduce((s, t) => s + getNetPnl(t), 0) / winners.length : 0; const avgL = losers.length > 0 ? Math.abs(losers.reduce((s, t) => s + getNetPnl(t), 0)) / losers.length : 0; winRateData.push(wr); avgWinData.push(avgW); avgLossData.push(avgL); }); if (window.rptTrendChart) { window.rptTrendChart.destroy(); window.rptTrendChart = null; } if (labels.length === 0) return; window.rptTrendChart = new Chart(ctx, { type: 'line', data: { labels: labels, datasets: [ { label: 'Win %', data: winRateData, borderColor: '#3b82f6', backgroundColor: 'transparent', yAxisID: 'y', tension: 0.3, pointRadius: 2 }, { label: 'Avg Win', data: avgWinData, borderColor: themeGreen(), backgroundColor: 'transparent', yAxisID: 'y1', tension: 0.3, pointRadius: 2 }, { label: 'Avg Loss', data: avgLossData, borderColor: themeRed(), backgroundColor: 'transparent', yAxisID: 'y1', tension: 0.3, pointRadius: 2 } ] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: true, position: 'top', labels: { color: '#9ca3af' } } }, scales: { x: { grid: { color: '#2a2f3a' }, ticks: { color: '#6b7280', maxTicksLimit: 8 } }, y: { type: 'linear', position: 'left', min: 0, max: 100, grid: { color: '#2a2f3a' }, ticks: { color: '#6b7280', callback: v => v + '%' } }, y1: { type: 'linear', position: 'right', grid: { display: false }, ticks: { color: '#6b7280', callback: v => formatCurrency(v, 0) } } } } }); } function renderDailyPnlChart() { const ctx = document.getElementById('daily-pnl-chart'); if (!ctx) return; const dailyPnl = {}; getMetricsEligibleTrades().forEach(t => { const date = getDateKey(t.exitTime); dailyPnl[date] = (dailyPnl[date] || 0) + getNetPnl(t); }); const sortedDates = Object.keys(dailyPnl).sort(); const labels = sortedDates.map(d => formatDate(d)); const data = sortedDates.map(d => dailyPnl[d]); const colors = data.map(v => themePnlColor(v)); if (window.dailyPnlChart) { window.dailyPnlChart.destroy(); window.dailyPnlChart = null; } if (data.length === 0) return; window.dailyPnlChart = new Chart(ctx, { type: 'bar', data: { labels: labels, datasets: [{ label: 'Daily P&L', data: data, backgroundColor: colors }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { grid: { color: '#2a2f3a' }, ticks: { color: '#6b7280', maxTicksLimit: 10 } }, y: { grid: { color: '#2a2f3a' }, ticks: { color: '#6b7280', callback: v => formatCurrency(v, 0) } } } } }); } function renderWinLossDistChart() { const ctx = document.getElementById('winloss-dist-chart'); if (!ctx) return; const trades = getMetricsEligibleTrades(); const winners = trades.filter(t => getNetPnl(t) > 0).length; const losers = trades.filter(t => getNetPnl(t) < 0).length; const breakeven = trades.filter(t => getNetPnl(t) === 0).length; if (window.winLossDistChart) { window.winLossDistChart.destroy(); window.winLossDistChart = null; } window.winLossDistChart = new Chart(ctx, { type: 'doughnut', data: { labels: ['Winners', 'Losers', 'Breakeven'], datasets: [{ data: [winners, losers, breakeven], backgroundColor: [themeGreen(), themeRed(), '#6b7280'] }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'bottom', labels: { color: '#9ca3af' } } } } }); } function renderPnlHistogramChart() { const ctx = document.getElementById('pnl-histogram-chart'); if (!ctx) return; const pnls = getMetricsEligibleTrades().map(t => getNetPnl(t)); if (pnls.length === 0) return; // Create buckets const min = Math.min(...pnls); const max = Math.max(...pnls); const range = max - min; const bucketSize = range / 10 || 1; const buckets = {}; for (let i = 0; i < 10; i++) { const bucketStart = min + (i * bucketSize); const bucketEnd = min + ((i + 1) * bucketSize); const label = `${formatCurrency(bucketStart)}`; buckets[label] = pnls.filter(p => p >= bucketStart && p < bucketEnd).length; } if (window.pnlHistogramChart) { window.pnlHistogramChart.destroy(); window.pnlHistogramChart = null; } window.pnlHistogramChart = new Chart(ctx, { type: 'bar', data: { labels: Object.keys(buckets), datasets: [{ label: 'Frequency', data: Object.values(buckets), backgroundColor: '#3b82f6' }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { grid: { color: '#2a2f3a' }, ticks: { color: '#6b7280', maxRotation: 45 } }, y: { grid: { color: '#2a2f3a' }, ticks: { color: '#6b7280' } } } } }); } function renderAnalytics() { // Calculate completion correlation — split TRADE days by checklist completion using the // checklistCompletions source (shared helper), NOT the legacy sc.checklistComplete flag. const datesWithChecklist = Object.keys(scorecards).filter(date => isChecklistCompleteForDate(date)); const datesWithoutChecklist = Object.keys(scorecards).filter(date => !isChecklistCompleteForDate(date)); // Get daily P&L for dates with scorecards const dailyPnl = {}; trades.forEach(t => { const date = getDateKey(t.exitTime); dailyPnl[date] = (dailyPnl[date] || 0) + getNetPnl(t); }); // Win rate with checklist const tradesWithChecklist = trades.filter(t => datesWithChecklist.includes(getDateKey(t.exitTime))); const tradesWithoutChecklist = trades.filter(t => datesWithoutChecklist.includes(getDateKey(t.exitTime))); const winRateWith = tradesWithChecklist.length > 0 ? ((tradesWithChecklist.filter(t => getNetPnl(t) > 0).length / tradesWithChecklist.length) * 100).toFixed(1) : '--'; const winRateWithout = tradesWithoutChecklist.length > 0 ? ((tradesWithoutChecklist.filter(t => getNetPnl(t) > 0).length / tradesWithoutChecklist.length) * 100).toFixed(1) : '--'; const pnlWith = tradesWithChecklist.reduce((sum, t) => sum + getNetPnl(t), 0); const pnlWithout = tradesWithoutChecklist.reduce((sum, t) => sum + getNetPnl(t), 0); const avgPnlWith = tradesWithChecklist.length > 0 ? pnlWith / tradesWithChecklist.length : 0; const avgPnlWithout = tradesWithoutChecklist.length > 0 ? pnlWithout / tradesWithoutChecklist.length : 0; // Update stats document.getElementById('completion-rate').textContent = Object.keys(scorecards).length > 0 ? ((datesWithChecklist.length / Object.keys(scorecards).length) * 100).toFixed(0) + '%' : '--%'; document.getElementById('winrate-with-checklist').textContent = winRateWith + '%'; document.getElementById('winrate-without-checklist').textContent = winRateWithout + '%'; document.getElementById('pnl-difference').textContent = formatCurrency(avgPnlWith - avgPnlWithout); document.getElementById('pnl-difference').className = `stat-value ${avgPnlWith >= avgPnlWithout ? 'positive' : 'negative'}`; document.getElementById('days-with-checklist').textContent = datesWithChecklist.length; document.getElementById('days-without-checklist').textContent = datesWithoutChecklist.length; document.getElementById('avg-win-with').textContent = formatCurrency(avgPnlWith); document.getElementById('avg-loss-without').textContent = formatCurrency(avgPnlWithout); // Skip tracking renderSkipTracking(); renderCorrelationChart(datesWithChecklist, datesWithoutChecklist, dailyPnl); } function renderSkipTracking() { const container = document.getElementById('skip-tracking-list'); const allItems = [...checklistSettings.riskItems, ...checklistSettings.structureItems]; const skipData = allItems.map(item => ({ id: item.id, text: item.text, count: checklistSettings.skipHistory[item.id] || 0 })).sort((a, b) => b.count - a.count); const maxCount = Math.max(...skipData.map(d => d.count), 1); if (skipData.every(d => d.count === 0)) { container.innerHTML = '
No skip data yet. Complete some checklists to see patterns.
'; return; } container.innerHTML = skipData.filter(d => d.count > 0).map(d => `
${d.text} ${d.count}x
`).join(''); } function renderCorrelationChart(datesWithChecklist, datesWithoutChecklist, dailyPnl) { const ctx = document.getElementById('correlation-chart'); if (!ctx) return; const withData = datesWithChecklist.map(d => dailyPnl[d] || 0); const withoutData = datesWithoutChecklist.map(d => dailyPnl[d] || 0); const avgWith = withData.length > 0 ? withData.reduce((a, b) => a + b, 0) / withData.length : 0; const avgWithout = withoutData.length > 0 ? withoutData.reduce((a, b) => a + b, 0) / withoutData.length : 0; // Properly destroy existing chart if (correlationChart) { correlationChart.destroy(); correlationChart = null; } correlationChart = new Chart(ctx, { type: 'bar', data: { labels: ['With Checklist', 'Without Checklist'], datasets: [{ label: 'Avg Daily P&L', data: [avgWith, avgWithout], backgroundColor: [themePnlColor(avgWith), themePnlColor(avgWithout)], borderRadius: 6 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { y: { grid: { color: '#2a2f3a' }, ticks: { color: '#6b7280', callback: v => formatCurrency(v, 0) } }, x: { grid: { display: false }, ticks: { color: '#6b7280' } } } } }); } function updateFilterDropdowns() { const accountFilterOptions = document.getElementById('account-filter-options'); const importAccountSelect = document.getElementById('import-account'); const clearAccountSelect = document.getElementById('clear-account-select'); const activeAccounts = accounts.filter(a => !a.archived); const archivedAccounts = accounts.filter(a => a.archived); // Helper to get prop firm display name const getPropFirmName = (key) => { const names = { 'apex': 'Apex Trader Funding', 'topstep': 'TopStep', 'myfundedfutures': 'My Funded Futures', 'tradeify': 'Tradeify', 'fundednext': 'FundedNext', 'lucid': 'Lucid Trading', 'takeprofittrader': 'Take Profit Trader', 'fundedfuturesnetwork': 'Funded Futures Network', 'earn2trade': 'Earn2Trade', 'bulenox': 'Bulenox', 'elitetraderfunding': 'Elite Trader Funding', 'personal': 'Personal', 'other': 'Other' }; return names[key] || key || 'Unknown'; }; // Sort function: alphabetically then numerically const sortAccounts = (a, b) => { const nameA = a.name || ''; const nameB = b.name || ''; // Extract numeric parts for smarter sorting (e.g., APEX-3091-804 vs APEX-3091-805) const numA = nameA.match(/\d+/g); const numB = nameB.match(/\d+/g); // If both have same prefix, sort by last number const prefixA = nameA.replace(/\d+/g, ''); const prefixB = nameB.replace(/\d+/g, ''); if (prefixA === prefixB && numA && numB) { // Compare last numeric segment const lastNumA = parseInt(numA[numA.length - 1]) || 0; const lastNumB = parseInt(numB[numB.length - 1]) || 0; return lastNumA - lastNumB; } // Otherwise alphabetical return nameA.localeCompare(nameB); }; // Group accounts by prop firm const groupByPropFirm = (accountList) => { const groups = {}; accountList.forEach(a => { const firm = a.propFirm || guessPropFirmFromName(a.name) || 'other'; if (!groups[firm]) groups[firm] = []; groups[firm].push(a); }); // Sort accounts within each group Object.keys(groups).forEach(firm => { groups[firm].sort(sortAccounts); }); return groups; }; // Build multi-select options HTML with checkboxes const buildMultiSelectOptions = (accountList, showArchived = false) => { const groups = groupByPropFirm(accountList); // Sort prop firm groups alphabetically const sortedFirms = Object.keys(groups).sort((a, b) => { return getPropFirmName(a).localeCompare(getPropFirmName(b)); }); let html = ''; sortedFirms.forEach(firm => { const firmName = getPropFirmName(firm); html += `
${firmName}
`; groups[firm].forEach(a => { const suffix = showArchived ? ' (archived)' : ''; const isFunded = a.stage === 'funded' || a.stage === 'Funded'; const isEval = a.stage === 'evaluation' || a.stage === 'Evaluation' || (!a.stage && a.propFirm !== 'personal'); const tag = isFunded ? '' : isEval ? '' : ''; html += `
`; }); }); return html; }; // Build grouped options HTML for regular selects const buildGroupedOptions = (accountList, showArchived = false) => { const groups = groupByPropFirm(accountList); // Sort prop firm groups alphabetically const sortedFirms = Object.keys(groups).sort((a, b) => { return getPropFirmName(a).localeCompare(getPropFirmName(b)); }); let html = ''; sortedFirms.forEach(firm => { const firmName = getPropFirmName(firm); html += ``; groups[firm].forEach(a => { const suffix = showArchived ? ' (archived)' : ''; const isFunded = a.stage === 'funded' || a.stage === 'Funded'; const tag = isFunded ? ' [FUNDED]' : a.stage === 'evaluation' ? ' [EVAL]' : ''; html += ``; }); html += ''; }); return html; }; // Build multi-select options for filter if (accountFilterOptions) { let multiSelectHtml = buildMultiSelectOptions(activeAccounts); if (archivedAccounts.length > 0) { multiSelectHtml += `
─── Archived ───
`; multiSelectHtml += buildMultiSelectOptions(archivedAccounts, true); } accountFilterOptions.innerHTML = multiSelectHtml; } // Build grouped active options for other selects const activeGroupedOptions = buildGroupedOptions(activeAccounts); // For import, only show active accounts (grouped) if (importAccountSelect) { importAccountSelect.innerHTML = '' + activeGroupedOptions; // Pre-select based on global filter const globalFilter = document.getElementById('global-account-filter'); if (globalFilter && globalFilter.value) { importAccountSelect.value = globalFilter.value; } } // For recalculate, show all accounts including archived const recalcAccountSelect = document.getElementById('recalc-account'); if (recalcAccountSelect) { let recalcOptions = activeGroupedOptions; if (archivedAccounts.length > 0) { recalcOptions += ''; recalcOptions += buildGroupedOptions(archivedAccounts, true); } recalcAccountSelect.innerHTML = '' + recalcOptions; // Pre-select based on global filter const globalFilter = document.getElementById('global-account-filter'); if (globalFilter && globalFilter.value) { recalcAccountSelect.value = globalFilter.value; } } if (clearAccountSelect) { let filterOptions = activeGroupedOptions; if (archivedAccounts.length > 0) { filterOptions += ''; filterOptions += buildGroupedOptions(archivedAccounts, true); } clearAccountSelect.innerHTML = '' + filterOptions; } // Update display text updateAccountFilterDisplay(); } // Multi-select account filter functions let selectedAccountIds = []; // Empty means all accounts window.toggleAccountFilterDropdown = function() { const dropdown = document.getElementById('account-filter-dropdown'); dropdown.style.display = dropdown.style.display === 'none' ? 'block' : 'none'; }; window.updateAccountFilterDisplay = function() { const checkboxes = document.querySelectorAll('#account-filter-options input[type="checkbox"]'); const checked = Array.from(checkboxes).filter(cb => cb.checked); const display = document.getElementById('account-filter-text'); if (!display) return; // Element doesn't exist anymore if (checked.length === 0) { display.textContent = 'No Accounts'; selectedAccountIds = ['none']; // Special flag for no accounts } else if (checked.length === checkboxes.length) { display.textContent = 'All Accounts'; selectedAccountIds = []; // Empty means all } else if (checked.length <= 2) { const names = checked.map(cb => { const acc = accounts.find(a => a.id === cb.value); return acc ? acc.name.split('-').pop() : cb.value; }); display.textContent = names.join(', '); selectedAccountIds = checked.map(cb => cb.value); } else { display.textContent = `${checked.length} accounts selected`; selectedAccountIds = checked.map(cb => cb.value); } }; // Close dropdown when clicking outside document.addEventListener('click', function(e) { const container = document.getElementById('account-filter-container'); const dropdown = document.getElementById('account-filter-dropdown'); if (container && dropdown && !container.contains(e.target)) { dropdown.style.display = 'none'; } }); // Clear trades by account (and optional date) document.getElementById('clear-trades-panel-btn')?.addEventListener('click', async () => { const accountId = document.getElementById('clear-account-select').value; const dateStr = document.getElementById('clear-account-date')?.value; const statusEl = document.getElementById('clear-account-status'); if (!accountId) { if (statusEl) statusEl.innerHTML = '
Please select an account first
'; else alert('Please select an account first'); return; } // Require a date — prevents accidental full-account wipe when the user // forgets to pick one. To clear every trade, pick a specific date and // repeat, or use a dedicated purge flow (not this button). if (!dateStr) { if (statusEl) statusEl.innerHTML = '
Please select a date before clearing trades
'; else alert('Please select a date before clearing trades'); return; } const isAllAccounts = accountId === '__all__'; const account = isAllAccounts ? null : accounts.find(a => a.id === accountId); const accountLabel = isAllAccounts ? 'ALL accounts' : (account?.name || 'this account'); let targetTrades = isAllAccounts ? [...trades] : trades.filter(t => t.accountId === accountId); // Filter by the required date // Use the same date resolution as the P&L Calendar: normalizeDateToString → getTradingSessionDate → getDateKey targetTrades = targetTrades.filter(t => { const td = normalizeDateToString(t.date) || getTradingSessionDate(t.exitTime) || getDateKey(t.exitTime) || ''; return td === dateStr; }); if (targetTrades.length === 0) { const msg = dateStr ? `No trades found for ${accountLabel} on ${dateStr}` : `No trades found for ${accountLabel}`; if (statusEl) statusEl.innerHTML = `
${msg}
`; else alert(msg); return; } // Calculate P&L for confirmation const totalPnl = targetTrades.reduce((sum, t) => sum + getNetPnl(t), 0); let confirmMsg; if (isAllAccounts && !dateStr) { confirmMsg = `Delete ALL ${targetTrades.length} trades across ALL accounts?\n\nTotal P&L: ${formatCurrency(totalPnl)}\n\nThis cannot be undone.`; } else if (dateStr) { confirmMsg = `Delete ${targetTrades.length} trades from ${accountLabel} on ${dateStr}?\n\nTotal P&L: ${formatCurrency(totalPnl)}`; } else { confirmMsg = `Delete ALL ${targetTrades.length} trades from ${accountLabel}?\n\nTotal P&L: ${formatCurrency(totalPnl)}`; } if (!confirm(confirmMsg)) return; // Double confirmation for all accounts if (isAllAccounts && !dateStr) { if (!confirm('FINAL WARNING: All trade data will be permanently deleted. Continue?')) return; } try { if (statusEl) statusEl.innerHTML = '
Deleting trades...
'; // Step 1: Purge staging docs for affected accounts let stageQuery = db.collection('users').doc(currentUser.uid).collection('tradingStage'); if (!isAllAccounts) stageQuery = stageQuery.where('accountId', '==', accountId); const stageSnap = await stageQuery.get(); for (let i = 0; i < stageSnap.docs.length; i += 450) { const batch = db.batch(); stageSnap.docs.slice(i, i + 450).forEach(doc => batch.delete(doc.ref)); await batch.commit(); } // Step 2: Delete trades let cleared = 0; let failed = 0; for (let i = 0; i < targetTrades.length; i += 400) { const chunk = targetTrades.slice(i, i + 400); try { const batch = db.batch(); chunk.forEach(t => { batch.delete(db.collection('users').doc(currentUser.uid).collection('trades').doc(t.id)); }); await batch.commit(); cleared += chunk.length; } catch (batchErr) { console.error('Batch delete error:', batchErr); failed += chunk.length; } } // Remove from local array const deletedIds = new Set(targetTrades.map(t => t.id)); trades = trades.filter(t => !deletedIds.has(t.id)); renderAll(); const successMsg = `${cleared} trades cleared from ${accountLabel}` + (failed > 0 ? ` (${failed} failed)` : '') + ` (P&L: ${formatCurrency(totalPnl)})`; if (statusEl) statusEl.innerHTML = `
${successMsg}
`; showToast(successMsg, 'success'); } catch (error) { console.error('Error deleting trades:', error); if (statusEl) statusEl.innerHTML = `
Error: ${error.message}
`; else alert('Error deleting trades: ' + error.message); } }); // ===================================================== // EVENT HANDLERS // ===================================================== // Navigation document.querySelectorAll('.nav-item').forEach(item => { item.addEventListener('click', () => { document.querySelectorAll('.nav-item').forEach(i => i.classList.remove('active')); document.getElementById('import-btn-top')?.classList.remove('active'); item.classList.add('active'); document.querySelectorAll('.page').forEach(p => p.classList.remove('active')); document.getElementById('page-' + item.dataset.page).classList.add('active'); // Reset scroll position on page navigation const mainEl = document.querySelector('.main'); if (mainEl) mainEl.scrollTop = 0; window.scrollTo(0, 0); // Show global filter bar only on pages that use it const page = item.dataset.page; const filterPages = ['dashboard', 'reports', 'trades', 'labbuilder']; const filterWrapper = document.getElementById('sticky-filter-wrapper'); const streamerBar = document.getElementById('streamer-mode-bar'); const filterVisible = filterPages.includes(page); if (filterWrapper) filterWrapper.style.display = filterVisible ? '' : 'none'; // Show standalone streamer toggle only when filter bar is hidden if (streamerBar) streamerBar.style.display = filterVisible ? 'none' : 'flex'; // Track page/section view try { sessionStorage.setItem('pl_current_route', page); } catch(e) {} trackSectionView(page); // Stop calendar auto-refresh when leaving News page stopCalendarAutoRefresh(); // Trigger page-specific render functions if (page === 'news') onNewsPageVisible(); if (page === 'process') renderProcessPage(); if (page === 'journal') { renderJournalMiniCal(); renderDayView(); document.getElementById('journal-date').dispatchEvent(new Event('change')); } if (page === 'dashboard') { renderDashboard(); initPropComplianceWidget(); checkSaturdayReminder(); updateSetupWidget(); setTimeout(function(){ updateSetupWidget(); }, 1500); setTimeout(function(){ updateSetupWidget(); }, 3000); } if (page === 'payouts') renderPayoutTracker(); if (page === 'share-card') { // Sync active style button to current _labcardStyle document.querySelectorAll('.labcard-style-btn[data-style]').forEach(function(b) { b.classList.toggle('active', b.dataset.style === _labcardStyle); }); // Load logo from Firestore if not already in memory (covers page-load-on-dashboard case) if (!updatedLabcardUserLogoUrl) loadUpdatedLabCardUserLogo(); // Restore type state setLabCardType(_labcardType); if (_labcardType === 'payout') { renderUpdatedLabCardGallery(); } updateUpdatedLabCardLogoPreview(); } if (page === 'roi') renderROITracker(); if (page === 'reports') { renderReports(); setTimeout(initWidgetDragDrop, 100); if (currentReportsTab === 'keystats') renderKeyStats(); } if (page === 'labbuilder') { lbInit(); } if (page === 'affiliate') renderAffiliateSelfView(); if (page === 'affiliate-management') renderAffiliateManagement(); if (page === 'analytics') renderAnalyticsPage(); if (page === 'financial-performance') renderFinancialPerformance(); if (page === 'scraper-tools') renderScraperToolsPage(); if (page === 'admin-dashboard') renderAdminDashboard(); if (page === 'scorecard') { populateSetupSelector(); renderPremarketChecklist(); renderTodaysTrades(); renderRecentScorecards(); // Render discipline tab content if it's visible if (document.getElementById('sc-panel-discipline').style.display !== 'none') { renderProcessPage(); } } if (page === 'settings') { switchSettingsSection(currentSettingsSection); } // Import page removed — now lives in Settings > Accounts if (page === 'trades') { // Render trades table renderTradesTable(); } }); }); // Add Connection button (top of sidebar) — opens Add Connection modal document.getElementById('import-btn-top')?.addEventListener('click', () => { openAddConnectionModal(); }); // Calendar navigation document.getElementById('cal-prev').addEventListener('click', () => { currentMonth.setMonth(currentMonth.getMonth() - 1); renderCalendar(); }); document.getElementById('cal-next').addEventListener('click', () => { currentMonth.setMonth(currentMonth.getMonth() + 1); renderCalendar(); }); // Mobile filter toggle function toggleMobileFilter() { const bar = document.getElementById('filter-bar-content'); const chevron = document.getElementById('mobile-filter-chevron'); if (!bar) return; const isOpen = bar.classList.contains('mobile-open'); bar.classList.toggle('mobile-open', !isOpen); if (chevron) chevron.classList.toggle('open', !isOpen); } function closeMobileFilter() { const bar = document.getElementById('filter-bar-content'); const chevron = document.getElementById('mobile-filter-chevron'); if (bar) bar.classList.remove('mobile-open'); if (chevron) chevron.classList.remove('open'); } function updateMobileFilterBadge() { const badge = document.getElementById('mobile-filter-badge'); if (!badge) return; let count = 0; const pf = document.getElementById('global-prop-firm-filter'); if (pf && pf.value) count++; if (selectedAccountIds && selectedAccountIds.length > 0) count++; if (typeof globalStageFilter !== 'undefined' && globalStageFilter !== 'all') count++; if (document.getElementById('filter-from')?.value) count++; if (document.getElementById('filter-to')?.value) count++; badge.textContent = count > 0 ? count + ' active' : ''; } // Update badge on page load and after filter changes setTimeout(updateMobileFilterBadge, 500); // Date filter change handler — debounced to prevent rapid successive re-renders function onDateFilterChange() { // Update UI indicators immediately (lightweight) renderActiveFilterPills(); updateGlobalFilterIndicator(); updateMobileFilterBadge(); // Debounce the heavy renderAll call if (_renderAllDebounceTimer) clearTimeout(_renderAllDebounceTimer); _renderAllDebounceTimer = setTimeout(() => { _renderAllDebounceTimer = null; renderAll(); }, 300); } // Reset filter button document.getElementById('reset-filter')?.addEventListener('click', () => { // Reset global filters const propFirmFilter = document.getElementById('global-prop-firm-filter'); if (propFirmFilter) propFirmFilter.value = ''; document.getElementById('filter-from').value = ''; document.getElementById('filter-to').value = ''; // Reset stage filter globalStageFilter = 'all'; document.querySelectorAll('.stage-filter-btn').forEach(btn => { btn.classList.toggle('active', btn.dataset.stage === 'all'); }); // Reset internal state selectedAllFirmsAccounts = []; selectedAccountIds = []; // Update filter indicator and pills renderActiveFilterPills(); updateGlobalFilterIndicator(); // Repopulate account dropdown (since firm was reset) populateGlobalAccountFilter(); // Cancel any pending debounced render and render immediately if (_renderAllDebounceTimer) { clearTimeout(_renderAllDebounceTimer); _renderAllDebounceTimer = null; } renderAll(); closeMobileFilter(); updateMobileFilterBadge(); showNotification('Filters reset', 'success'); }); // Reset premarket checklist document.getElementById('reset-premarket-btn').addEventListener('click', () => { if (confirm('Reset all checklist items?')) { checklistChecked = {}; checklistSettings.riskItems.forEach(i => i.checked = false); checklistSettings.structureItems.forEach(i => i.checked = false); const dateKey = document.getElementById('scorecard-date')?.value || getTodayKey(); saveChecklistCompletion(dateKey); saveChecklistSettings(); renderPremarketChecklist(); } }); // Edit item modal document.getElementById('close-edit-modal').addEventListener('click', () => { document.getElementById('edit-item-modal').classList.remove('active'); }); document.getElementById('cancel-edit-btn').addEventListener('click', () => { document.getElementById('edit-item-modal').classList.remove('active'); }); document.getElementById('save-edit-btn').addEventListener('click', () => { const text = document.getElementById('edit-item-text').value.trim(); if (text && editingItemId) { const items = editingItemType === 'risk' ? checklistSettings.riskItems : checklistSettings.structureItems; // Convert to string for comparison (dataset attributes are always strings) const item = items.find(i => String(i.id) === String(editingItemId)); if (item) { item.text = text; saveChecklistSettings(); renderPremarketChecklist(); } } document.getElementById('edit-item-modal').classList.remove('active'); }); // Add Trade - Show inline form document.getElementById('add-trade-btn').addEventListener('click', () => { currentTradeId = null; currentTradeDirection = 'long'; currentScreenshot = null; document.getElementById('trade-notes').value = ''; document.getElementById('screenshot-preview').classList.add('hidden'); document.getElementById('screenshot-placeholder').classList.remove('hidden'); document.getElementById('screenshot-img').src = ''; renderConfluenceChecklist(); updateTradeScore(); document.getElementById('trade-direction-long').classList.add('btn-primary'); document.getElementById('trade-direction-long').classList.remove('btn-secondary'); document.getElementById('trade-direction-short').classList.remove('btn-primary'); document.getElementById('trade-direction-short').classList.add('btn-secondary'); document.getElementById('delete-trade-btn').style.display = 'none'; document.getElementById('trade-entry-title').textContent = 'New Trade'; document.getElementById('trade-entry-card').classList.remove('hidden'); document.getElementById('trade-entry-card').scrollIntoView({ behavior: 'smooth' }); }); document.getElementById('cancel-trade-btn').addEventListener('click', () => { document.getElementById('trade-entry-card').classList.add('hidden'); }); document.getElementById('trade-direction-long').addEventListener('click', () => { currentTradeDirection = 'long'; document.getElementById('trade-direction-long').classList.add('btn-primary'); document.getElementById('trade-direction-long').classList.remove('btn-secondary'); document.getElementById('trade-direction-short').classList.remove('btn-primary'); document.getElementById('trade-direction-short').classList.add('btn-secondary'); }); document.getElementById('trade-direction-short').addEventListener('click', () => { currentTradeDirection = 'short'; document.getElementById('trade-direction-short').classList.add('btn-primary'); document.getElementById('trade-direction-short').classList.remove('btn-secondary'); document.getElementById('trade-direction-long').classList.remove('btn-primary'); document.getElementById('trade-direction-long').classList.add('btn-secondary'); }); // Gray candles removed from UI - grayCandles data preserved on existing trades // Screenshot handling document.getElementById('screenshot-drop-zone').addEventListener('click', (e) => { // Don't trigger file input if clicking on image or buttons if (e.target.id === 'screenshot-img' || e.target.id === 'remove-screenshot-btn' || e.target.id === 'replace-screenshot-btn') { return; } // Only trigger file input if no screenshot is currently showing if (document.getElementById('screenshot-preview').classList.contains('hidden')) { document.getElementById('screenshot-input').click(); } }); document.getElementById('screenshot-drop-zone').addEventListener('dragover', (e) => { e.preventDefault(); e.currentTarget.style.borderColor = 'var(--green)'; }); document.getElementById('screenshot-drop-zone').addEventListener('dragleave', (e) => { e.currentTarget.style.borderColor = 'var(--border-color)'; }); document.getElementById('screenshot-drop-zone').addEventListener('drop', (e) => { e.preventDefault(); e.currentTarget.style.borderColor = 'var(--border-color)'; const file = e.dataTransfer.files[0]; if (file && file.type.startsWith('image/')) { handleScreenshotUpload(file); } }); document.getElementById('screenshot-input').addEventListener('change', (e) => { const file = e.target.files[0]; if (file) handleScreenshotUpload(file); }); document.getElementById('remove-screenshot-btn').addEventListener('click', (e) => { e.stopPropagation(); currentScreenshot = null; document.getElementById('screenshot-preview').classList.add('hidden'); document.getElementById('screenshot-placeholder').classList.remove('hidden'); document.getElementById('screenshot-img').src = ''; }); document.getElementById('replace-screenshot-btn').addEventListener('click', (e) => { e.stopPropagation(); document.getElementById('screenshot-input').click(); }); function handleScreenshotUpload(file) { const reader = new FileReader(); reader.onload = (e) => { // Resize image to reduce storage size const img = new Image(); img.onload = () => { const canvas = document.createElement('canvas'); const maxSize = 800; let width = img.width; let height = img.height; if (width > height && width > maxSize) { height = (height * maxSize) / width; width = maxSize; } else if (height > maxSize) { width = (width * maxSize) / height; height = maxSize; } canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0, width, height); currentScreenshot = canvas.toDataURL('image/jpeg', 0.7); document.getElementById('screenshot-img').src = currentScreenshot; document.getElementById('screenshot-preview').classList.remove('hidden'); document.getElementById('screenshot-placeholder').classList.add('hidden'); }; img.src = e.target.result; }; reader.readAsDataURL(file); } // Manage Confluence Items - open the selected setup's edit modal, or navigate to playbook document.getElementById('manage-confluence-btn').addEventListener('click', () => { const setupSelect = document.getElementById('trade-setup-select'); const setupId = setupSelect?.value; if (setupId) { editSetup(setupId); } else { showPage('settings'); switchSettingsSection('playbook'); } }); // Delete Account confirmation modal — enable button only when "DELETE" is typed document.getElementById('delete-account-confirm-input').addEventListener('input', function() { const btn = document.getElementById('delete-account-confirm-btn'); const isMatch = this.value.trim() === 'DELETE'; btn.disabled = !isMatch; btn.style.opacity = isMatch ? '1' : '0.5'; btn.style.cursor = isMatch ? 'pointer' : 'not-allowed'; }); document.getElementById('delete-account-confirm-btn').addEventListener('click', async () => { if (!pendingDeleteAccountId) return; const idToDelete = pendingDeleteAccountId; closeDeleteAccountModal(); await window.deleteAccount(idToDelete); }); // Delete Connection confirmation modal — enable button only when "DELETE" is typed document.getElementById('delete-connection-input')?.addEventListener('input', function() { const btn = document.getElementById('delete-connection-confirm-btn'); const isMatch = this.value.trim() === 'DELETE'; btn.disabled = !isMatch; btn.style.opacity = isMatch ? '1' : '0.5'; btn.style.cursor = isMatch ? 'pointer' : 'not-allowed'; }); document.getElementById('delete-connection-confirm-btn')?.addEventListener('click', () => { executeDeleteConnection(); }); // Clear Account Trades confirmation modal — enable button only when "CLEAR" is typed document.getElementById('clear-account-trades-input')?.addEventListener('input', function() { const btn = document.getElementById('clear-account-trades-confirm-btn'); const isMatch = this.value.trim() === 'CLEAR'; btn.disabled = !isMatch; btn.style.opacity = isMatch ? '1' : '0.5'; btn.style.cursor = isMatch ? 'pointer' : 'not-allowed'; }); document.getElementById('clear-account-trades-confirm-btn')?.addEventListener('click', async () => { if (!pendingClearAccountId) return; const accountId = pendingClearAccountId; const account = accounts.find(a => a.id === accountId); const accountLabel = account?.name || 'this account'; closeClearAccountTradesModal(); const targetTrades = trades.filter(t => t.accountId === accountId); if (targetTrades.length === 0) { showToast(`No trades found for ${accountLabel}`, 'warning'); return; } try { // Step 1: Purge staging docs for this account const stageSnap = await db.collection('users').doc(currentUser.uid) .collection('tradingStage').where('accountId', '==', accountId).get(); for (let i = 0; i < stageSnap.docs.length; i += 450) { const batch = db.batch(); stageSnap.docs.slice(i, i + 450).forEach(doc => batch.delete(doc.ref)); await batch.commit(); } // Step 2: Delete trades let cleared = 0; for (let i = 0; i < targetTrades.length; i += 400) { const chunk = targetTrades.slice(i, i + 400); const batch = db.batch(); for (const t of chunk) { batch.delete(db.collection('users').doc(currentUser.uid).collection('trades').doc(t.id)); } await batch.commit(); cleared += chunk.length; } const deletedIds = new Set(targetTrades.map(t => t.id)); trades = trades.filter(t => !deletedIds.has(t.id)); renderAll(); renderConnectionsPage(); showToast(`${cleared} trades deleted from ${accountLabel}`, 'success'); } catch (err) { console.error('Clear trades error:', err); showToast(`Failed to clear trades: ${err.message}`, 'error'); } }); // Standalone confluence editor removed — confluence items now managed per-setup function populateSetupSelector() { const sel = document.getElementById('trade-setup-select'); if (!sel) return; sel.innerHTML = '' + playbook.map(s => ``).join(''); } function onTradeSetupChange() { renderConfluenceChecklist(); updateTradeScore(); } function getSelectedSetupConfluenceItems() { const setupSelect = document.getElementById('trade-setup-select'); const setupId = setupSelect?.value; if (!setupId) return []; const setup = playbook.find(s => s.id === setupId); return (setup && Array.isArray(setup.confluenceItems)) ? setup.confluenceItems : []; } function renderConfluenceChecklist() { const container = document.getElementById('confluence-checklist'); const setupSelect = document.getElementById('trade-setup-select'); const setupId = setupSelect?.value; if (!setupId) { container.innerHTML = '
Select a setup to see confluence checklist
'; return; } const setup = playbook.find(s => s.id === setupId); if (!setup) { container.innerHTML = ''; return; } const items = setup.confluenceItems || []; const sections = [...new Set(items.map(i => i.section))]; let html = sections.map(section => { const sectionItems = items.filter(i => i.section === section); return `
${section}
${sectionItems.map(item => `
`).join('')} `; }).join(''); // Append setup conditions if (Array.isArray(setup.conditions) && setup.conditions.length > 0) { html += `
Setup Conditions
`; setup.conditions.forEach(c => { html += `
`; }); html += `
`; } container.innerHTML = html; container.querySelectorAll('.checklist-item').forEach(el => { const checkbox = el.querySelector('input[type="checkbox"]'); el.addEventListener('click', (e) => { if (e.target.tagName === 'INPUT') return; checkbox.checked = !checkbox.checked; el.classList.toggle('checked', checkbox.checked); updateTradeScore(); }); checkbox.addEventListener('change', () => { el.classList.toggle('checked', checkbox.checked); updateTradeScore(); }); }); } function updateTradeScore() { const grayCandles = false; let score = 0; const totalPoints = getTotalConfluencePoints(true); // Score setup's confluence items const setupItems = getSelectedSetupConfluenceItems(); setupItems.forEach(item => { const checkbox = document.getElementById('conf-' + item.id); if (checkbox && checkbox.checked) { score += item.points; } }); // Count setup condition checks const setupSelect = document.getElementById('trade-setup-select'); const setupId = setupSelect?.value; if (setupId) { const setup = playbook.find(s => s.id === setupId); if (setup && Array.isArray(setup.conditions)) { setup.conditions.forEach(c => { const cb = document.getElementById('conf-setup-' + c.id); if (cb && cb.checked) score += 1; }); const rulesCb = document.getElementById('conf-setup-rules-' + setup.id); if (rulesCb && rulesCb.checked) score += 1; } } document.getElementById('trade-score').textContent = score; document.getElementById('trade-total-points').textContent = totalPoints; document.getElementById('trade-score-progress').style.width = totalPoints > 0 ? `${(score / totalPoints) * 100}%` : '0%'; const gradeInfo = getTradeGrade(score, grayCandles); const gradeEl = document.getElementById('trade-grade'); gradeEl.textContent = gradeInfo.grade; gradeEl.className = 'grade-badge ' + gradeInfo.class; } document.getElementById('save-trade-btn').addEventListener('click', async () => { const dateKey = document.getElementById('scorecard-date').value || getTodayKey(); const grayCandles = false; let score = 0; const checks = {}; // Score setup's confluence items const setupItems = getSelectedSetupConfluenceItems(); setupItems.forEach(item => { const checkbox = document.getElementById('conf-' + item.id); if (checkbox && checkbox.checked) { score += item.points; checks[item.id] = true; } }); // Include setup condition checks const setupSelect = document.getElementById('trade-setup-select'); const selectedSetupId = setupSelect?.value || null; if (selectedSetupId) { const setup = playbook.find(s => s.id === selectedSetupId); if (setup && Array.isArray(setup.conditions)) { setup.conditions.forEach(c => { const cb = document.getElementById('conf-setup-' + c.id); if (cb && cb.checked) { score += 1; checks['setup-' + c.id] = true; } }); const rulesCb = document.getElementById('conf-setup-rules-' + setup.id); if (rulesCb && rulesCb.checked) { score += 1; checks['setup-rules-' + setup.id] = true; } } } const trade = { direction: currentTradeDirection, grayCandles: grayCandles, score: score, totalPoints: getTotalConfluencePoints(true), checks: checks, setupId: selectedSetupId, notes: document.getElementById('trade-notes').value, screenshot: currentScreenshot || null, timestamp: new Date().toISOString() }; // Get or create scorecard const scorecard = scorecards[dateKey] || { trades: [] }; if (currentTradeId !== null) { scorecard.trades[currentTradeId] = trade; } else { if (scorecard.trades.length >= 3) { alert('Max 3 trades per day!'); return; } scorecard.trades.push(trade); } await saveScorecard(dateKey, scorecard); document.getElementById('trade-entry-card').classList.add('hidden'); renderTodaysTrades(); renderRecentScorecards(); renderAnalytics(); }); document.getElementById('delete-trade-btn').addEventListener('click', async () => { if (currentTradeId === null) return; if (!confirm('Delete this trade?')) return; const dateKey = document.getElementById('scorecard-date').value || getTodayKey(); const scorecard = scorecards[dateKey]; if (scorecard && scorecard.trades) { scorecard.trades.splice(currentTradeId, 1); await saveScorecard(dateKey, scorecard); } document.getElementById('trade-entry-card').classList.add('hidden'); renderTodaysTrades(); renderRecentScorecards(); }); // Screenshot viewer modal functions window.openScreenshotModal = function(src) { if (!src) return; document.getElementById('screenshot-viewer-img').src = src; document.getElementById('screenshot-viewer-modal').classList.add('active'); document.body.style.overflow = 'hidden'; }; window.closeScreenshotModal = function(event) { if (event && event.target !== event.currentTarget) return; document.getElementById('screenshot-viewer-modal').classList.remove('active'); document.body.style.overflow = ''; }; // Close screenshot modal on Escape key document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && document.getElementById('screenshot-viewer-modal').classList.contains('active')) { closeScreenshotModal(); } if (e.key === 'Escape' && document.getElementById('support-modal').style.display !== 'none') { closeSupportModal(); } }); // Support Modal Functions window.openSupportModal = function() { document.getElementById('support-modal').style.display = 'flex'; document.getElementById('support-form').style.display = 'block'; document.getElementById('support-success').style.display = 'none'; document.getElementById('support-form').reset(); document.body.style.overflow = 'hidden'; }; window.closeSupportModal = function() { document.getElementById('support-modal').style.display = 'none'; document.body.style.overflow = ''; }; window.submitSupportTicket = async function(event) { event.preventDefault(); const submitBtn = document.getElementById('support-submit-btn'); const submitText = document.getElementById('support-submit-text'); const submitLoading = document.getElementById('support-submit-loading'); // Show loading state submitBtn.disabled = true; submitText.style.display = 'none'; submitLoading.style.display = 'inline'; const ticketData = { category: document.getElementById('support-category').value, subject: document.getElementById('support-subject').value, description: document.getElementById('support-description').value, priority: document.querySelector('input[name="support-priority"]:checked')?.value || 'low', userEmail: currentUser?.email || 'Not logged in', userId: currentUser?.uid || null }; try { // Submit via Cloud Function (sends email + saves to Firestore) const response = await fetch('https://us-central1-trade-journal-fc3ba.cloudfunctions.net/submitSupportTicket', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(ticketData) }); const result = await response.json(); if (!response.ok) { throw new Error(result.error || 'Failed to submit ticket'); } // Show success message document.getElementById('support-form').style.display = 'none'; document.getElementById('support-success').style.display = 'block'; showNotification('Support ticket submitted successfully!', 'success'); } catch (error) { console.error('Error submitting support ticket:', error); showNotification('Failed to submit ticket. Please try again.', 'error'); } finally { // Reset button state submitBtn.disabled = false; submitText.style.display = 'inline'; submitLoading.style.display = 'none'; } }; // Edit existing trade window.editTrade = function(dateKey, tradeIdx) { const scorecard = scorecards[dateKey]; if (!scorecard || !scorecard.trades[tradeIdx]) return; const trade = scorecard.trades[tradeIdx]; currentTradeId = tradeIdx; currentTradeDirection = trade.direction; currentScreenshot = trade.screenshot || null; document.getElementById('trade-notes').value = trade.notes || ''; // Load screenshot if exists if (currentScreenshot) { document.getElementById('screenshot-img').src = currentScreenshot; document.getElementById('screenshot-preview').classList.remove('hidden'); document.getElementById('screenshot-placeholder').classList.add('hidden'); } else { document.getElementById('screenshot-preview').classList.add('hidden'); document.getElementById('screenshot-placeholder').classList.remove('hidden'); document.getElementById('screenshot-img').src = ''; } // Set direction buttons if (trade.direction === 'long') { document.getElementById('trade-direction-long').classList.add('btn-primary'); document.getElementById('trade-direction-long').classList.remove('btn-secondary'); document.getElementById('trade-direction-short').classList.remove('btn-primary'); document.getElementById('trade-direction-short').classList.add('btn-secondary'); } else { document.getElementById('trade-direction-short').classList.add('btn-primary'); document.getElementById('trade-direction-short').classList.remove('btn-secondary'); document.getElementById('trade-direction-long').classList.remove('btn-primary'); document.getElementById('trade-direction-long').classList.add('btn-secondary'); } // Restore the saved setup BEFORE rendering the checklist — renderConfluenceChecklist() // reads trade-setup-select.value at call time, so the conf-* checkboxes must be built // for this setup before the checks-restore loop can re-tick them. document.getElementById('trade-setup-select').value = trade.setupId || ''; renderConfluenceChecklist(); // Restore checks from saved trade if (trade.checks) { Object.keys(trade.checks).forEach(key => { const checkbox = document.getElementById('conf-' + key); if (checkbox && trade.checks[key]) { checkbox.checked = true; checkbox.closest('.checklist-item')?.classList.add('checked'); } }); } updateTradeScore(); document.getElementById('delete-trade-btn').style.display = 'block'; document.getElementById('trade-entry-title').textContent = 'Edit Trade #' + (tradeIdx + 1); document.getElementById('scorecard-date').value = dateKey; document.getElementById('trade-entry-card').classList.remove('hidden'); document.getElementById('trade-entry-card').scrollIntoView({ behavior: 'smooth' }); }; // Add Account (button removed from HTML — now uses openAddAccountModal() from Add Connection modal) document.getElementById('add-account-btn')?.addEventListener('click', () => { openAddAccountModal(); }); // Add Commission document.getElementById('add-commission-btn').addEventListener('click', () => { openAddCommissionModal(); }); document.getElementById('close-account-modal').addEventListener('click', () => { document.getElementById('add-account-modal').classList.remove('active'); }); document.getElementById('cancel-account-btn').addEventListener('click', () => { document.getElementById('add-account-modal').classList.remove('active'); }); let _addAccountSubmitting = false; document.getElementById('save-account-btn').addEventListener('click', async () => { console.log('[AddAccount] save-account-btn clicked', Date.now(), new Error().stack); if (_addAccountSubmitting) { console.warn('[AddAccount] BLOCKED — already submitting'); return; } const saveBtn = document.getElementById('save-account-btn'); const propFirm = document.getElementById('account-prop-firm').value; const connectionType = document.getElementById('account-connection-type').value; const name = document.getElementById('account-name').value.trim(); const balanceSelect = document.getElementById('account-balance'); const balanceCustom = document.getElementById('account-balance-custom'); // Use custom input if visible and has value, otherwise use select const startingBalance = (balanceCustom && balanceCustom.style.display !== 'none' && balanceCustom.value) ? parseFloat(balanceCustom.value) || 0 : parseFloat(balanceSelect.value) || 0; const stage = document.getElementById('account-stage').value; // New fields const plan = document.getElementById('account-plan')?.value || ''; // Funded account fields const drawdown = parseFloat(document.getElementById('account-drawdown').value) || 0; const buffer = parseFloat(document.getElementById('account-buffer').value) || 0; const addAcctDll = parseFloat(document.getElementById('account-funded-dll').value) || 0; const profitSplit = document.getElementById('account-profit-split').value || null; // consistency, minWithdrawal, tradingDays: intentionally not read — sourced from // propFirmConfig at runtime, never persisted on the account doc. // Evaluation account fields const profitTarget = parseFloat(document.getElementById('account-profit-target').value) || 0; const evalDrawdown = parseFloat(document.getElementById('account-eval-drawdown').value) || 0; const minDays = parseInt(document.getElementById('account-min-days').value) || 0; const drawdownType = document.getElementById('account-drawdown-type').value || null; const dailyLoss = parseFloat(document.getElementById('account-daily-loss').value) || 0; // Evaluation cost fields (date is required when cost > 0 — validated below) const evalCost = parseFloat(document.getElementById('account-eval-cost')?.value) || 0; const evalPurchaseDate = document.getElementById('account-eval-purchase-date')?.value || ''; // Funded cost fields (date is required when cost > 0 — validated below) const fundedCost = parseFloat(document.getElementById('account-funded-cost')?.value) || 0; const fundedPurchaseDate = document.getElementById('account-funded-purchase-date')?.value || ''; // payoutCaps input values intentionally not read — caps come from propFirmConfig at runtime. if (!propFirm) { alert('Please select a prop firm'); return; } if (!connectionType) { alert('Please select a connection type'); return; } if (!name) { alert('Please enter an account name'); return; } if (startingBalance === null || isNaN(startingBalance) || startingBalance < 0) { alert('Please enter a valid starting balance'); return; } if (stage === 'evaluation' && !drawdownType) { showNotification('Please select a drawdown type for this evaluation account', 'error'); return; } if (!stage) { showNotification('Please select an Account Stage', 'warning'); document.getElementById('account-stage').style.border = '1px solid #ffb400'; return; } if (!startingBalance || startingBalance <= 0) { showNotification('Please select a Starting Balance', 'warning'); var balEl = document.getElementById('account-balance'); if (balEl) balEl.style.border = '1px solid #ffb400'; return; } const planVal = document.getElementById('account-plan')?.value || ''; if (!planVal) { showNotification('Please select a Plan', 'warning'); var planEl = document.getElementById('account-plan'); if (planEl) planEl.style.border = '1px solid #ffb400'; return; } // Cost-without-date validation: if a cost is entered, require a purchase date if (stage === 'evaluation' && evalCost > 0 && !evalPurchaseDate) { showNotification('Please enter a purchase date for this account cost', 'warning'); var evalDateEl = document.getElementById('account-eval-purchase-date'); if (evalDateEl) evalDateEl.style.border = '1px solid #ffb400'; return; } if (stage === 'funded' && fundedCost > 0 && !fundedPurchaseDate) { showNotification('Please enter a purchase date for this account cost', 'warning'); var fundedDateEl = document.getElementById('account-funded-purchase-date'); if (fundedDateEl) fundedDateEl.style.border = '1px solid #ffb400'; return; } // Guard against double-submit _addAccountSubmitting = true; saveBtn.disabled = true; saveBtn.textContent = 'Adding...'; try { // TopStep: funded accounts start at $0, combine size stored separately for rule lookups const isTopstepFunded = propFirmConfigs[propFirm]?.fundedStartsAtZero && stage === 'funded'; // Build account object based on stage const account = { propFirm, connectionType, name, startingBalance: isTopstepFunded ? 0 : startingBalance, stage, // 'funded' or 'evaluation' plan, createdAt: new Date().toISOString() }; // Store combine size for TopStep rule lookups (drawdown, caps, etc.) if (isTopstepFunded) { account.accountSize = startingBalance; } if (stage === 'evaluation') { // Evaluation-specific fields account.isEvaluation = true; account.profitTarget = profitTarget; account.maxDrawdown = evalDrawdown; account.minDays = minDays; account.drawdownType = drawdownType; // consistency sourced from propFirmConfig at runtime — never persist on the account doc account.dailyLossLimit = dailyLoss; account.status = 'active'; // active, passed, failed account.evalCost = evalCost; // Store cost on account too account.evalPurchaseDate = evalPurchaseDate; } else { // Funded-specific fields account.isEvaluation = false; account.drawdown = drawdown; account.buffer = buffer; account.dailyLossLimit = addAcctDll; account.profitSplit = profitSplit; // consistency, minWithdrawal, tradingDays, payoutCaps sourced from propFirmConfig // at runtime — never persist on the account doc. if (fundedCost > 0) { account.cost = fundedCost; account.purchaseDate = fundedPurchaseDate; } } // Deterministic doc ID prevents duplicates on double-click const sanitizedName = name.replace(/[^a-zA-Z0-9_-]/g, '_').substring(0, 60); const accountDocId = `${propFirm}_${sanitizedName}`; console.log('[AddAccount] Deterministic ID:', accountDocId, '| propFirm:', propFirm, '| name:', name); // Check if this exact account already exists locally const alreadyExists = accounts.some(a => a.id === accountDocId); if (alreadyExists) { console.warn('[AddAccount] BLOCKED — already exists locally with id:', accountDocId); showNotification('An account with this name already exists for this firm', 'error'); return; } // Also check Firestore directly (covers stale local state) const existingDoc = await db.collection('users').doc(currentUser.uid).collection('accounts').doc(accountDocId).get(); if (existingDoc.exists) { console.warn('[AddAccount] BLOCKED — already exists in Firestore:', accountDocId); showNotification('An account with this name already exists for this firm', 'error'); return; } console.log('[AddAccount] Writing to Firestore...', accountDocId); const docRef = db.collection('users').doc(currentUser.uid).collection('accounts').doc(accountDocId); await docRef.set(account); console.log('[AddAccount] Firestore write SUCCESS:', accountDocId); const accountId = accountDocId; // Do NOT push to local accounts array — onSnapshot listener will update it // If account has a cost, auto-create linked expense const accountCost = stage === 'evaluation' ? evalCost : fundedCost; const accountPurchaseDate = stage === 'evaluation' ? evalPurchaseDate : fundedPurchaseDate; const expenseType = stage === 'evaluation' ? 'eval_fee' : 'activation_fee'; const expId = (stage === 'evaluation' ? 'exp_eval_' : 'exp_act_') + accountId; if (accountCost > 0) { const firmName = propFirmConfigs[propFirm]?.name || propFirm; const stageLabel = stage === 'evaluation' ? 'Evaluation' : 'Funded'; const existingIdx = expenses.findIndex(e => e.id === expId); const expense = { id: expId, date: accountPurchaseDate, propFirm: propFirm, type: expenseType, accountId: accountId, accountName: name, description: `${firmName} ${(startingBalance/1000).toFixed(0)}K ${stageLabel}`, amount: accountCost, expenseCurrency: 'USD', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; if (existingIdx !== -1) { expenses[existingIdx] = expense; } else { expenses.push(expense); } await saveExpenses(); showNotification(`Account added successfully + ${formatCurrency(accountCost)} expense logged to ROI Tracker`, 'success'); } else { showNotification('Account added successfully', 'success'); } // Clear form document.getElementById('account-prop-firm').value = ''; document.getElementById('account-connection-type').value = ''; document.getElementById('account-name').value = ''; document.getElementById('account-balance').value = ''; document.getElementById('account-buffer').value = ''; document.getElementById('account-stage').value = 'funded'; document.getElementById('account-plan-group').style.display = 'none'; document.getElementById('account-rules-section').style.display = 'none'; document.getElementById('funded-rules-fields').style.display = 'block'; document.getElementById('eval-rules-fields').style.display = 'none'; // Reset standalone cost section to match its default hidden state var _resetCostSec = document.getElementById('account-cost-section'); if (_resetCostSec) _resetCostSec.style.display = 'none'; var _resetFundedCost = document.getElementById('funded-cost-block'); if (_resetFundedCost) _resetFundedCost.style.display = 'block'; var _resetEvalCost = document.getElementById('eval-cost-block'); if (_resetEvalCost) _resetEvalCost.style.display = 'none'; // Clear cost fields if (document.getElementById('account-eval-cost')) { document.getElementById('account-eval-cost').value = ''; } if (document.getElementById('account-eval-purchase-date')) { document.getElementById('account-eval-purchase-date').value = ''; } if (document.getElementById('account-funded-cost')) { document.getElementById('account-funded-cost').value = ''; } if (document.getElementById('account-funded-purchase-date')) { document.getElementById('account-funded-purchase-date').value = ''; } if (document.getElementById('eval-price-suggestion')) { document.getElementById('eval-price-suggestion').style.display = 'none'; } document.getElementById('add-account-modal').classList.remove('active'); await fullDataRefresh(); // Track account added event trackAccountAdded(propFirm, startingBalance, stage); } catch (err) { console.error('Failed to add account:', err); showNotification('Failed to add account: ' + err.message, 'error'); } finally { _addAccountSubmitting = false; saveBtn.disabled = false; saveBtn.textContent = 'Add Account'; } }); window.deleteAccount = async function(id) { if (!id) { console.error('deleteAccount called with empty ID'); showNotification('Could not delete account — missing account ID. Please refresh and try again.', 'error'); return; } const accountToDelete = accounts.find(a => a.id === id); const propFirmDeleted = accountToDelete?.propFirm || 'unknown'; const accountName = accountToDelete?.name || 'Account'; // Optimistic: remove from local state immediately const savedAccounts = [...accounts]; const savedTrades = [...trades]; trades = trades.filter(t => t.accountId !== id); accounts = accounts.filter(a => a.id !== id); // Immediate optimistic refresh renderAll(); if (document.getElementById('unified-accounts-table')) renderConnectionsPage(); try { // Delete all trades for this account in batches of 450 const tradesSnapshot = await db.collection('users').doc(currentUser.uid).collection('trades').where('accountId', '==', id).get(); const tradeDocs = tradesSnapshot.docs; for (let i = 0; i < tradeDocs.length; i += 450) { const batch = db.batch(); tradeDocs.slice(i, i + 450).forEach(doc => batch.delete(doc.ref)); await batch.commit(); } // Purge all staging docs for this account const stageSnapshot = await db.collection('users').doc(currentUser.uid).collection('tradingStage').where('accountId', '==', id).get(); for (let i = 0; i < stageSnapshot.docs.length; i += 450) { const batch = db.batch(); stageSnapshot.docs.slice(i, i + 450).forEach(doc => batch.delete(doc.ref)); await batch.commit(); } // Delete the account document await db.collection('users').doc(currentUser.uid).collection('accounts').doc(id).delete(); trackAccountDeleted(propFirmDeleted); await fullDataRefresh(); showNotification(`"${accountName}" deleted successfully`, 'success'); } catch (error) { console.error('Error deleting account:', error); // Rollback: restore local state on failure accounts = savedAccounts; trades = savedTrades; renderAll(); if (document.getElementById('unified-accounts-table')) renderConnectionsPage(); showNotification('Failed to delete account. Please try again.', 'error'); } }; // Journal - use local date string to avoid timezone issues // Multi-screenshot working set + tunable cap (replaces the legacy single-image vars). const JOURNAL_MAX_SCREENSHOTS = 6; // tunable let journalScreenshots = []; // working set: {url,path} (persisted) | {file,previewUrl} (pending upload) let _journalPendingStorageDeletes = []; // {path,url} of persisted objects to delete after a successful save (orphan fix) let _journalReplaceIdx = null; // index being replaced, or null for add document.getElementById('journal-date').value = getTodayKey(); // Journal image upload — label[for] handles click-to-upload natively // Drag and drop handlers document.getElementById('journal-drop-zone').addEventListener('dragover', (e) => { e.preventDefault(); e.currentTarget.style.borderColor = 'var(--green)'; e.currentTarget.style.background = 'rgba(0, 255, 136, 0.05)'; }); document.getElementById('journal-drop-zone').addEventListener('dragleave', (e) => { e.currentTarget.style.borderColor = 'var(--border-color)'; e.currentTarget.style.background = ''; }); document.getElementById('journal-drop-zone').addEventListener('drop', (e) => { e.preventDefault(); e.currentTarget.style.borderColor = 'var(--border-color)'; e.currentTarget.style.background = ''; _journalReplaceIdx = null; // a drop always adds handleJournalImageFiles(e.dataTransfer.files); }); // Image file(s) selected via input (add mode, or single-file replace when _journalReplaceIdx is set) document.getElementById('journal-image-input').addEventListener('change', (e) => { handleJournalImageFiles(e.target.files); e.target.value = ''; }); // Validate a single journal image file (type + size). function validateJournalFile(file) { if (!file.type.startsWith('image/')) { showNotification('Please select an image file', 'error'); return false; } if (file.size > 5 * 1024 * 1024) { showNotification('Image must be under 5MB', 'error'); return false; } return true; } function readJournalFilePreview(file, cb) { const reader = new FileReader(); reader.onload = (e) => cb(e.target.result); reader.readAsDataURL(file); } // Handle one or more selected/dropped image files (add mode, or replace mode for one slot). function handleJournalImageFiles(fileList) { const files = Array.from(fileList || []).filter(Boolean); if (!files.length) return; // Replace mode — swap exactly one slot with the first chosen file. if (_journalReplaceIdx != null) { const idx = _journalReplaceIdx; _journalReplaceIdx = null; const file = files[0]; if (!validateJournalFile(file)) return; const old = journalScreenshots[idx]; if (old && old.url && !old.file) { _journalPendingStorageDeletes.push({ path: (old.path != null ? old.path : null), url: old.url }); } readJournalFilePreview(file, (previewUrl) => { journalScreenshots[idx] = { file, previewUrl }; renderJournalScreenshotGrid(); }); return; } // Add mode — respect remaining slots up to the cap. const remaining = JOURNAL_MAX_SCREENSHOTS - journalScreenshots.length; if (remaining <= 0) { showNotification(`Maximum ${JOURNAL_MAX_SCREENSHOTS} screenshots per entry`, 'warning'); return; } let toAdd = files; if (files.length > remaining) { showNotification(`Only ${remaining} more screenshot${remaining !== 1 ? 's' : ''} can be added (max ${JOURNAL_MAX_SCREENSHOTS}).`, 'warning'); toAdd = files.slice(0, remaining); } toAdd.forEach(file => { if (!validateJournalFile(file)) return; readJournalFilePreview(file, (previewUrl) => { journalScreenshots.push({ file, previewUrl }); renderJournalScreenshotGrid(); }); }); } // ORPHAN FIX helper: delete a list of Storage objects ({path,url}); prefer path, // fall back to refFromURL for legacy items (path:null). Best-effort, surfaces failures. async function deleteJournalStorageObjects(list) { if (!list || !list.length) return; for (const item of list) { try { let ref = null; if (item && item.path) ref = storage.ref().child(item.path); else if (item && item.url) ref = storage.refFromURL(item.url); if (ref) await ref.delete(); } catch (err) { console.warn('[Journal] storage delete failed', err && (err.code || err.message)); showNotification('A screenshot could not be fully removed from storage.', 'warning', 5000); } } } // Save journal entry with image document.getElementById('save-journal-btn').addEventListener('click', async () => { const dateKey = document.getElementById('journal-date').value; const entry = document.getElementById('journal-entry').value; if (!entry.trim() && journalScreenshots.length === 0) { showNotification('Please add text or an image', 'error'); return; } const saveBtn = document.getElementById('save-journal-btn'); const originalText = saveBtn.textContent; saveBtn.textContent = 'Saving...'; saveBtn.disabled = true; try { // Upload any newly-added (pending) files; keep already-persisted items as-is. const finalShots = []; let uploadIndex = 0; for (const s of journalScreenshots) { if (s.file) { const ext = (s.file.name.split('.').pop() || 'png'); const path = `journal/${currentUser.uid}/${dateKey}_${Date.now()}_${uploadIndex}.${ext}`; uploadIndex++; const ref = storage.ref().child(path); const snapshot = await ref.put(s.file); const url = await snapshot.ref.getDownloadURL(); finalShots.push({ url, path }); } else if (s.url) { finalShots.push({ url: s.url, path: (s.path != null ? s.path : null) }); } } // Canonical shape = screenshots[]. Lazily drop legacy imageUrl on this user's own save. const journalData = { entry: entry || '', updatedAt: new Date().toISOString(), screenshots: finalShots }; const prevEntry = journal[dateKey]; const writeData = { ...journalData }; if (prevEntry && prevEntry.imageUrl !== undefined) { writeData.imageUrl = firebase.firestore.FieldValue.delete(); } const docRef = db.collection('users').doc(currentUser.uid).collection('journal').doc(dateKey); await docRef.set(writeData, { merge: true }); // In-memory cache reflects the NEW shape (no imageUrl). journal[dateKey] = { entry: journalData.entry, updatedAt: journalData.updatedAt, screenshots: finalShots }; // ORPHAN FIX: Firestore no longer references these — now safe to delete removed/superseded objects. await deleteJournalStorageObjects(_journalPendingStorageDeletes); _journalPendingStorageDeletes = []; // Reset form cancelJournalEdit(); renderJournalMiniCal(); renderDayView(); renderCalendar(); showNotification('Journal entry saved', 'success'); } catch (error) { console.error('Error saving journal:', error); showNotification('Failed to save entry', 'error'); } finally { saveBtn.textContent = originalText; saveBtn.disabled = false; } }); document.getElementById('cancel-journal-btn').addEventListener('click', cancelJournalEdit); // Load existing entry when date changes document.getElementById('journal-date').addEventListener('change', () => { const dateKey = document.getElementById('journal-date').value; const existing = journal[dateKey]; const deleteBtn = document.getElementById('delete-journal-btn'); if (existing) { document.getElementById('journal-entry').value = existing.entry || ''; document.getElementById('save-journal-btn').textContent = 'Update Entry'; document.getElementById('cancel-journal-btn').style.display = 'inline-block'; if (deleteBtn) deleteBtn.style.display = 'inline-block'; // Load existing screenshots (handles legacy single imageUrl via normalizer) loadJournalScreenshotsIntoForm(existing, dateKey); } else { document.getElementById('journal-entry').value = ''; document.getElementById('save-journal-btn').textContent = 'Save Entry'; document.getElementById('cancel-journal-btn').style.display = 'none'; if (deleteBtn) deleteBtn.style.display = 'none'; clearJournalImage(); } renderJournalMiniCal(); }); // ============================================ // RECALCULATE TRADES P&L // ============================================ // Futures instrument specifications for P&L recalculation const INSTRUMENT_SPECS = { // CME - Equity Index 'ES': { tickSize: 0.25, tickValue: 12.50, pointValue: 50.00 }, 'NQ': { tickSize: 0.25, tickValue: 5.00, pointValue: 20.00 }, 'RTY': { tickSize: 0.10, tickValue: 5.00, pointValue: 50.00 }, 'MES': { tickSize: 0.25, tickValue: 1.25, pointValue: 5.00 }, 'MNQ': { tickSize: 0.25, tickValue: 0.50, pointValue: 2.00 }, 'M2K': { tickSize: 0.10, tickValue: 1.00, pointValue: 10.00 }, // CBOT - Equity Index 'YM': { tickSize: 1.00, tickValue: 5.00, pointValue: 5.00 }, 'MYM': { tickSize: 1.00, tickValue: 0.50, pointValue: 0.50 }, // COMEX - Metals 'GC': { tickSize: 0.10, tickValue: 10.00, pointValue: 100.00 }, 'MGC': { tickSize: 0.10, tickValue: 1.00, pointValue: 10.00 }, 'SI': { tickSize: 0.005, tickValue: 25.00, pointValue: 5000.00 }, 'SIL': { tickSize: 0.005, tickValue: 5.00, pointValue: 1000.00 }, 'HG': { tickSize: 0.0005, tickValue: 12.50, pointValue: 25000.00 }, 'PL': { tickSize: 0.10, tickValue: 5.00, pointValue: 50.00 }, // NYMEX - Energy 'CL': { tickSize: 0.01, tickValue: 10.00, pointValue: 1000.00 }, 'QM': { tickSize: 0.025, tickValue: 12.50, pointValue: 500.00 }, 'MCL': { tickSize: 0.01, tickValue: 1.00, pointValue: 100.00 }, 'NG': { tickSize: 0.001, tickValue: 10.00, pointValue: 10000.00 }, 'QG': { tickSize: 0.005, tickValue: 12.50, pointValue: 2500.00 }, 'HO': { tickSize: 0.0001, tickValue: 4.20, pointValue: 42000.00 }, 'RB': { tickSize: 0.0001, tickValue: 4.20, pointValue: 42000.00 }, // CME - Currencies '6E': { tickSize: 0.00005, tickValue: 6.25, pointValue: 125000.00 }, '6B': { tickSize: 0.0001, tickValue: 6.25, pointValue: 62500.00 }, '6J': { tickSize: 0.0000005, tickValue: 6.25, pointValue: 12500000.00 }, '6A': { tickSize: 0.00005, tickValue: 5.00, pointValue: 100000.00 }, '6C': { tickSize: 0.00005, tickValue: 5.00, pointValue: 100000.00 }, '6S': { tickSize: 0.00005, tickValue: 6.25, pointValue: 125000.00 }, '6N': { tickSize: 0.00005, tickValue: 5.00, pointValue: 100000.00 }, 'M6E': { tickSize: 0.0001, tickValue: 1.25, pointValue: 12500.00 }, 'M6A': { tickSize: 0.0001, tickValue: 1.00, pointValue: 10000.00 }, // CME - Crypto 'MBT': { tickSize: 5.00, tickValue: 0.50, pointValue: 0.10 }, 'MET': { tickSize: 0.50, tickValue: 0.05, pointValue: 0.10 }, // CME - Livestock 'HE': { tickSize: 0.00025, tickValue: 10.00, pointValue: 40000.00 }, 'LE': { tickSize: 0.00025, tickValue: 10.00, pointValue: 40000.00 }, // CBOT - Grains 'ZC': { tickSize: 0.25, tickValue: 12.50, pointValue: 50.00 }, 'ZW': { tickSize: 0.25, tickValue: 12.50, pointValue: 50.00 }, 'ZS': { tickSize: 0.25, tickValue: 12.50, pointValue: 50.00 }, 'ZM': { tickSize: 0.10, tickValue: 10.00, pointValue: 100.00 }, 'ZL': { tickSize: 0.0001, tickValue: 6.00, pointValue: 60000.00 }, }; function getPointValueForSymbol(symbol) { // Clean symbol (remove contract month like MNQH5 -> MNQ, ESH6 -> ES) const cleanSymbol = symbol.replace(/[FGHJKMNQUVXZ]\d{1,2}$/, '').replace(/[A-Z]\d$/, ''); const spec = INSTRUMENT_SPECS[cleanSymbol]; return spec ? spec.pointValue : 2.00; // Default to MNQ } function populateRecalcDropdown() { const recalcSelect = document.getElementById('recalc-account'); if (!recalcSelect) return; const globalFilter = document.getElementById('global-account-filter'); const selectedAccountId = globalFilter ? globalFilter.value : null; // Build options grouped by prop firm const activeAccounts = accounts.filter(a => !a.archived); const archivedAccounts = accounts.filter(a => a.archived); const getPropFirmName = (key) => { const names = { 'apex': 'Apex Trader Funding', 'topstep': 'TopStep', 'myfundedfutures': 'My Funded Futures', 'tradeify': 'Tradeify', 'fundednext': 'FundedNext', 'lucid': 'Lucid Trading', 'takeprofittrader': 'Take Profit Trader', 'fundedfuturesnetwork': 'Funded Futures Network', 'earn2trade': 'Earn2Trade', 'bulenox': 'Bulenox', 'elitetraderfunding': 'Elite Trader Funding', 'personal': 'Personal', 'other': 'Other' }; return names[key] || key || 'Unknown'; }; const groupByPropFirm = (accountList) => { const groups = {}; accountList.forEach(a => { const firm = a.propFirm || guessPropFirmFromName(a.name) || 'other'; if (!groups[firm]) groups[firm] = []; groups[firm].push(a); }); return groups; }; const buildGroupedOptions = (accountList, showArchived = false) => { const groups = groupByPropFirm(accountList); const sortedFirms = Object.keys(groups).sort((a, b) => getPropFirmName(a).localeCompare(getPropFirmName(b))); let html = ''; sortedFirms.forEach(firm => { const firmName = getPropFirmName(firm); html += ``; groups[firm].forEach(a => { const suffix = showArchived ? ' (archived)' : ''; const isFunded = a.stage === 'funded' || a.stage === 'Funded'; const tag = isFunded ? ' [FUNDED]' : a.stage === 'evaluation' ? ' [EVAL]' : ''; html += ``; }); html += ''; }); return html; }; let options = ''; options += buildGroupedOptions(activeAccounts); if (archivedAccounts.length > 0) { options += ''; options += buildGroupedOptions(archivedAccounts, true); } recalcSelect.innerHTML = options; // Pre-select based on global filter if (selectedAccountId) { recalcSelect.value = selectedAccountId; } } async function recalculateForAccount(account, statusDiv) { const accountId = account.id; const accountTrades = trades.filter(t => t.accountId === accountId); if (accountTrades.length === 0) { return { accountName: account.name, tradesProcessed: 0, updated: 0, commissionsApplied: 0, oldTotal: 0, newTotal: 0, unmatchedSymbols: new Set() }; } const acctConnectionType = account.connectionType || account.connection || ''; let updated = 0; let commissionsApplied = 0; let oldTotal = 0; let newTotal = 0; const unmatchedSymbols = new Set(); console.log(`[Recalculate] Account: ${account.name}, propFirm: ${account.propFirm}, connectionType: ${acctConnectionType}`); // Process in batches of 450 (Firestore limit is 500) const BATCH_SIZE = 450; let pendingUpdates = []; for (const trade of accountTrades) { const symbol = trade.symbol || ''; const qty = parseInt(trade.quantity || trade.qty) || 1; const side = trade.side || 'Long'; const isSyncedTrade = trade.source && ( trade.source.includes('sync') || trade.source.includes('rithmic') ); const cleanSymbol = symbol.replace(/[FGHJKMNQUVXZ]\d{1,2}$/, '').replace(/[A-Z]\d$/, ''); const settingsCommission = getCommissionRate(account.propFirm, acctConnectionType, cleanSymbol); if (settingsCommission === null && cleanSymbol) { unmatchedSymbols.add(cleanSymbol); } let existingCommission = parseFloat(trade.commission) || 0; let newCommission; if (settingsCommission !== null) { newCommission = settingsCommission * 2 * qty; if (Math.abs(newCommission - existingCommission) > 0.001) { commissionsApplied++; } } else { newCommission = existingCommission; } let grossPnl; if (isSyncedTrade) { const storedPnl = parseFloat(trade.pnl) || 0; const storedNetPnl = parseFloat(trade.netPnl) || storedPnl; if (existingCommission > 0 && Math.abs(storedPnl - storedNetPnl) < 0.01) { grossPnl = storedPnl + existingCommission; } else { grossPnl = storedPnl; } } else { const pointValue = getPointValueForSymbol(symbol); const entryPrice = parseFloat(trade.entryPrice) || 0; const exitPrice = parseFloat(trade.exitPrice) || 0; if (side === 'Long') { grossPnl = (exitPrice - entryPrice) * pointValue * qty; } else { grossPnl = (entryPrice - exitPrice) * pointValue * qty; } } // Round all financial values to 2 decimal places grossPnl = Math.round(grossPnl * 100) / 100; newCommission = Math.round(newCommission * 100) / 100; const newNetPnl = Math.round((grossPnl - newCommission) * 100) / 100; oldTotal += getNetPnl(trade); newTotal += newNetPnl; const pnlChanged = Math.abs(grossPnl - (parseFloat(trade.pnl) || 0)) > 0.01; const netPnlChanged = Math.abs(newNetPnl - (parseFloat(trade.netPnl ?? trade.pnl) || 0)) > 0.01; const commissionChanged = Math.abs(newCommission - existingCommission) > 0.001; if (pnlChanged || netPnlChanged || commissionChanged) { const exchangeFees = parseFloat(trade.exchangeFees) || 0; const totalFees = Math.round((newCommission + exchangeFees) * 100) / 100; pendingUpdates.push({ trade, updateData: { pnl: grossPnl, netPnl: newNetPnl, commission: newCommission, fees: totalFees, recalculatedAt: firebase.firestore.FieldValue.serverTimestamp() }, localData: { pnl: grossPnl, netPnl: newNetPnl, commission: newCommission, fees: totalFees } }); updated++; } } // Commit in batches of 450 for (let i = 0; i < pendingUpdates.length; i += BATCH_SIZE) { const batchSlice = pendingUpdates.slice(i, i + BATCH_SIZE); const batch = db.batch(); batchSlice.forEach(({ trade, updateData }) => { const tradeRef = db.collection('users').doc(currentUser.uid) .collection('trades').doc(trade.id); batch.update(tradeRef, updateData); }); await batch.commit(); } // Update local trades array pendingUpdates.forEach(({ trade, localData }) => { trade.pnl = localData.pnl; trade.netPnl = localData.netPnl; trade.commission = localData.commission; trade.fees = localData.fees; }); return { accountName: account.name, tradesProcessed: accountTrades.length, updated, commissionsApplied, oldTotal, newTotal, unmatchedSymbols }; } async function recalculateAllTrades() { // Bypass dropdown — recalculate all non-archived accounts directly const statusDiv = document.getElementById('recalc-status'); const recalcAllBtn = document.getElementById('recalc-all-btn'); const recalcBtn = document.getElementById('recalc-btn'); if (recalcAllBtn) { recalcAllBtn.disabled = true; recalcAllBtn.style.opacity = '0.5'; } if (recalcBtn) { recalcBtn.disabled = true; } const accountIdsWithTrades = new Set(trades.map(t => t.accountId)); const accountsToProcess = accounts.filter(a => !a.archived && accountIdsWithTrades.has(a.id)); if (accountsToProcess.length === 0) { statusDiv.innerHTML = 'No trades found for any account'; if (recalcAllBtn) { recalcAllBtn.disabled = false; recalcAllBtn.style.opacity = '1'; } if (recalcBtn) { recalcBtn.disabled = false; } return; } let totalUpdated = 0, totalProcessed = 0; try { for (let i = 0; i < accountsToProcess.length; i++) { statusDiv.innerHTML = 'Recalculating ' + accountsToProcess[i].name + '... (' + (i + 1) + '/' + accountsToProcess.length + ')'; const result = await recalculateForAccount(accountsToProcess[i], statusDiv); totalUpdated += result.updated; totalProcessed += result.tradesProcessed; } await fullDataRefresh(); statusDiv.innerHTML = 'Recalculated ' + accountsToProcess.length + ' accounts (' + totalUpdated + ' trades updated)'; } catch (err) { statusDiv.innerHTML = 'Error: ' + err.message + ''; } finally { if (recalcAllBtn) { recalcAllBtn.disabled = false; recalcAllBtn.style.opacity = '1'; } if (recalcBtn) { recalcBtn.disabled = false; recalcBtn.textContent = '🔄 Recalculate'; } } } async function recalculateTrades() { const recalcBtn = document.getElementById('recalc-btn'); const accountId = document.getElementById('recalc-account').value; const statusDiv = document.getElementById('recalc-status'); if (!accountId) { statusDiv.innerHTML = 'Please select an account'; return; } const recalcAllBtn = document.getElementById('recalc-all-btn'); if (recalcBtn) { recalcBtn.disabled = true; recalcBtn.textContent = 'Recalculating...'; } if (recalcAllBtn) { recalcAllBtn.disabled = true; recalcAllBtn.style.opacity = '0.5'; } const isAll = accountId === '__all__'; // Build list of accounts to process let accountsToProcess; if (isAll) { // Find all accounts that have trades const accountIdsWithTrades = new Set(trades.map(t => t.accountId)); accountsToProcess = accounts.filter(a => accountIdsWithTrades.has(a.id)); if (accountsToProcess.length === 0) { statusDiv.innerHTML = 'No trades found for any account'; return; } } else { const account = accounts.find(a => a.id === accountId); if (!account) { statusDiv.innerHTML = 'Account not found'; return; } accountsToProcess = [account]; } statusDiv.innerHTML = `Recalculating ${isAll ? accountsToProcess.length + ' accounts' : 'trades'}...`; try { let totalTradesProcessed = 0; let totalUpdated = 0; let totalCommissionsApplied = 0; let totalOld = 0; let totalNew = 0; const allUnmatchedSymbols = new Set(); const perAccountResults = []; for (const account of accountsToProcess) { if (isAll) { statusDiv.innerHTML = `Recalculating ${account.name}... (${perAccountResults.length + 1}/${accountsToProcess.length})`; } const result = await recalculateForAccount(account, statusDiv); perAccountResults.push(result); totalTradesProcessed += result.tradesProcessed; totalUpdated += result.updated; totalCommissionsApplied += result.commissionsApplied; totalOld += result.oldTotal; totalNew += result.newTotal; result.unmatchedSymbols.forEach(s => allUnmatchedSymbols.add(s)); } const diff = totalNew - totalOld; const diffStr = diff >= 0 ? `+${formatCurrency(diff)}` : `-${formatCurrency(Math.abs(diff))}`; const diffColor = diff >= 0 ? 'var(--green)' : 'var(--red)'; let commissionNote = ''; if (totalCommissionsApplied > 0) { commissionNote = `
💡 Applied commission from Settings to ${totalCommissionsApplied} trades
`; } // Per-account breakdown for "All Accounts" mode let accountBreakdown = ''; if (isAll && perAccountResults.length > 1) { const rows = perAccountResults .filter(r => r.tradesProcessed > 0) .map(r => { const d = r.newTotal - r.oldTotal; const ds = d >= 0 ? `+${formatCurrency(d)}` : `-${formatCurrency(Math.abs(d))}`; const dc = d >= 0 ? 'var(--green)' : 'var(--red)'; return `
${r.accountName} ${r.tradesProcessed} trades, ${r.updated} updated, ${ds}
`; }).join(''); accountBreakdown = `
Per-Account Breakdown
${rows}
`; } statusDiv.innerHTML = `
Recalculation Complete${isAll ? ` (${accountsToProcess.length} accounts)` : ''}
Trades processed: ${totalTradesProcessed}
Updated: ${totalUpdated}
Old Total P&L: ${formatCurrency(totalOld)}
New Total P&L: ${formatCurrency(totalNew)}
Difference: ${diffStr}
${commissionNote} ${accountBreakdown} ${allUnmatchedSymbols.size > 0 ? `
⚠️ No commission rate found for: ${[...allUnmatchedSymbols].join(', ')}
Check Settings → Commission & Fees
` : ''}
`; renderAll(); } catch (error) { console.error('Recalculation error:', error); statusDiv.innerHTML = `Error: ${error.message}`; } finally { if (recalcBtn) { recalcBtn.disabled = false; recalcBtn.textContent = '🔄 Recalculate'; } if (recalcAllBtn) { recalcAllBtn.disabled = false; recalcAllBtn.style.opacity = '1'; } } } // CSV Import document.getElementById('drop-zone').addEventListener('click', () => { document.getElementById('csv-input').click(); }); document.getElementById('csv-input').addEventListener('change', handleCSVUpload); document.getElementById('expense-csv-dropzone').addEventListener('dragover', (e) => { e.preventDefault(); e.currentTarget.style.borderColor = 'var(--cyan)'; }); document.getElementById('expense-csv-dropzone').addEventListener('dragleave', (e) => { e.currentTarget.style.borderColor = 'var(--border-color)'; }); document.getElementById('expense-csv-dropzone').addEventListener('drop', (e) => { e.preventDefault(); e.currentTarget.style.borderColor = 'var(--border-color)'; const file = Array.from(e.dataTransfer.files).find(f => f.name.toLowerCase().endsWith('.csv')); if (file) handleExpenseCSVStep1({ files: [file] }); else showToast('Please drop a CSV file', 'warning'); }); document.getElementById('drop-zone').addEventListener('dragover', (e) => { e.preventDefault(); e.currentTarget.style.borderColor = 'var(--green)'; }); document.getElementById('drop-zone').addEventListener('dragleave', (e) => { e.currentTarget.style.borderColor = 'var(--border-color)'; }); document.getElementById('drop-zone').addEventListener('drop', (e) => { e.preventDefault(); e.currentTarget.style.borderColor = 'var(--border-color)'; const files = Array.from(e.dataTransfer.files).filter(f => f.name.endsWith('.csv')); if (files.length >= 1) { if (files.length > 1) showToast('Only one file at a time — importing the first file', 'warning'); processCSV(files[0]); } }); // Format selector help toggle const formatSelect = document.getElementById('import-format'); if (formatSelect) { formatSelect.addEventListener('change', (e) => { const format = e.target.value; document.getElementById('format-help-auto').style.display = format === 'auto' ? 'block' : 'none'; document.getElementById('format-help-rithmic').style.display = format === 'rithmic' ? 'block' : 'none'; const tradovateAutoHelp = document.getElementById('format-help-tradovate_auto'); if (tradovateAutoHelp) tradovateAutoHelp.style.display = format === 'tradovate_auto' ? 'block' : 'none'; const topstepxHelp = document.getElementById('format-help-topstepx'); if (topstepxHelp) topstepxHelp.style.display = format === 'topstepx' ? 'block' : 'none'; const dxfeedHelp = document.getElementById('format-help-dxfeed'); if (dxfeedHelp) dxfeedHelp.style.display = format === 'dxfeed' ? 'block' : 'none'; }); } async function handleCSVUpload(e) { const files = Array.from(e.target.files).filter(f => f.name.endsWith('.csv')); if (files.length >= 1) { if (files.length > 1) showToast('Only one file at a time — importing the first file', 'warning'); processCSV(files[0]); } } // CSV Validation Functions function validateRithmicCSV(text) { const errors = []; const warnings = []; // Check for Completed Orders section if (!text.includes('Completed Orders')) { errors.push('Missing "Completed Orders" section header'); return { valid: false, errors, warnings, headers: [] }; } // Get headers from Completed Orders section const completedIdx = text.indexOf('Completed Orders'); const completedSection = text.substring(completedIdx); const lines = completedSection.split('\n').slice(1); const headerLine = lines[0] || ''; const headers = headerLine.split(',').map(h => h.trim().replace(/"/g, '')); // Required columns - Update Time can be any timezone (CST, EST, etc.) const requiredExact = ['Status', 'Symbol', 'Buy/Sell', 'Qty Filled', 'Avg Fill Price', 'Commission Fill Rate']; // Check for Update Time with any timezone const hasUpdateTime = headers.some(h => h.startsWith('Update Time')); const updateTimeCol = headers.find(h => h.startsWith('Update Time')) || 'Update Time (CST)'; const missing = requiredExact.filter(col => !headers.some(h => h === col)); if (!hasUpdateTime) { missing.push('Update Time'); } if (missing.length > 0) { errors.push(`Missing required columns: ${missing.join(', ')}`); } return { valid: errors.length === 0, errors, warnings, headers, updateTimeCol }; } function validateTradovateCSV(text) { const errors = []; const warnings = []; // Parse first line to get headers const lines = text.split('\n').filter(l => l.trim()); if (lines.length === 0) { errors.push('CSV file is empty'); return { valid: false, errors, warnings, headers: [], format: null }; } const headers = lines[0].split(',').map(h => h.trim().replace(/"/g, '')); // Check for Tradovate Performance format const perfRequired = ['symbol', 'qty', 'buyPrice', 'sellPrice', 'pnl', 'boughtTimestamp', 'soldTimestamp']; const perfMissing = perfRequired.filter(col => !headers.some(h => h.toLowerCase() === col.toLowerCase())); if (perfMissing.length === 0) { return { valid: true, errors, warnings, headers, format: 'performance' }; } // Check for generic trade format const genericRequired = ['Symbol', 'Side', 'Qty', 'Entry Time', 'Exit Time', 'Entry Price', 'Exit Price', 'P&L']; const hasGenericColumns = headers.some(h => h.toLowerCase().includes('entry')) && headers.some(h => h.toLowerCase().includes('exit')) && headers.some(h => h.toLowerCase().includes('pnl') || h.toLowerCase().includes('profit')); if (hasGenericColumns) { return { valid: true, errors, warnings, headers, format: 'generic' }; } // Neither format detected errors.push('Missing required Tradovate columns'); errors.push(`Expected columns: ${perfRequired.join(', ')}`); errors.push(`Found columns: ${headers.slice(0, 8).join(', ')}${headers.length > 8 ? '...' : ''}`); return { valid: false, errors, warnings, headers, format: null }; } function validateTradovateOrdersCSV(text) { const errors = []; const warnings = []; const lines = text.split('\n').filter(l => l.trim()); if (lines.length === 0) { errors.push('CSV file is empty'); return { valid: false, errors, warnings, headers: [] }; } let headerLine = lines[0]; if (headerLine.charCodeAt(0) === 0xFEFF) { headerLine = headerLine.substring(1); } const headers = headerLine.split(',').map(h => h.trim().replace(/"/g, '')); const required = ['B/S', 'Contract', 'Fill Time', 'Status', 'Filled Qty', 'Avg Fill Price']; const missing = required.filter(col => !headers.some(h => h.trim() === col)); if (missing.length > 0) { errors.push(`Missing required columns: ${missing.join(', ')}`); errors.push(`Found columns: ${headers.slice(0, 10).join(', ')}${headers.length > 10 ? '...' : ''}`); return { valid: false, errors, warnings, headers }; } // Count filled orders let filledCount = 0; let totalCount = 0; for (let i = 1; i < lines.length; i++) { const cols = lines[i].split(',').map(c => c.trim().replace(/"/g, '')); const statusIdx = headers.indexOf('Status'); if (statusIdx >= 0) { totalCount++; if (cols[statusIdx] === 'Filled') filledCount++; } } if (filledCount === 0) { errors.push('No filled orders found in CSV'); return { valid: false, errors, warnings, headers }; } if (totalCount > filledCount) { warnings.push(`${totalCount - filledCount} non-filled orders will be skipped`); } return { valid: true, errors, warnings, headers, filledCount }; } function validateTopstepXCSV(text) { const errors = []; const warnings = []; // Parse first line to get headers const lines = text.split('\n').filter(l => l.trim()); if (lines.length === 0) { errors.push('CSV file is empty'); return { valid: false, errors, warnings, headers: [] }; } // Remove BOM if present let headerLine = lines[0]; if (headerLine.charCodeAt(0) === 0xFEFF) { headerLine = headerLine.slice(1); } const headers = headerLine.split(',').map(h => h.trim().replace(/"/g, '')); // Required TopstepX columns const required = ['ContractName', 'EnteredAt', 'ExitedAt', 'EntryPrice', 'ExitPrice', 'PnL', 'Size', 'Type']; const missing = required.filter(col => !headers.some(h => h.toLowerCase() === col.toLowerCase())); if (missing.length > 0) { errors.push(`Missing required columns: ${missing.join(', ')}`); errors.push(`Found columns: ${headers.slice(0, 10).join(', ')}${headers.length > 10 ? '...' : ''}`); return { valid: false, errors, warnings, headers }; } // Check for data rows if (lines.length < 2) { errors.push('No trade data found in CSV'); return { valid: false, errors, warnings, headers }; } return { valid: true, errors, warnings, headers }; } function validateDXFeedCSV(text) { const errors = []; const warnings = []; // Parse first line to get headers const lines = text.split('\n').filter(l => l.trim()); if (lines.length === 0) { errors.push('CSV file is empty'); return { valid: false, errors, warnings, headers: [] }; } // Remove BOM if present let headerLine = lines[0]; if (headerLine.charCodeAt(0) === 0xFEFF) { headerLine = headerLine.slice(1); } const headers = headerLine.split(',').map(h => h.trim().replace(/"/g, '')); // Required DXFeed/Quantower columns (case-insensitive check) // Account | Date/Time | Symbol | Side | Quantity | Price | Gross P/L or Net P/L const hasSymbol = headers.some(h => h.toLowerCase() === 'symbol'); const hasDateTime = headers.some(h => h.toLowerCase() === 'date/time' || h.toLowerCase() === 'datetime'); const hasSide = headers.some(h => h.toLowerCase() === 'side'); const hasQuantity = headers.some(h => h.toLowerCase() === 'quantity' || h.toLowerCase() === 'qty'); const hasPrice = headers.some(h => h.toLowerCase() === 'price'); const hasPnL = headers.some(h => h.toLowerCase().includes('p/l') || h.toLowerCase().includes('pnl') || h.toLowerCase().includes('profit')); const missing = []; if (!hasSymbol) missing.push('Symbol'); if (!hasDateTime) missing.push('Date/Time'); if (!hasSide) missing.push('Side'); if (!hasQuantity) missing.push('Quantity'); if (!hasPrice) missing.push('Price'); if (!hasPnL) missing.push('Gross P/L or Net P/L'); if (missing.length > 0) { errors.push(`Missing required columns: ${missing.join(', ')}`); errors.push(`Found columns: ${headers.slice(0, 12).join(', ')}${headers.length > 12 ? '...' : ''}`); return { valid: false, errors, warnings, headers }; } // Check for data rows if (lines.length < 2) { errors.push('No trade data found in CSV'); return { valid: false, errors, warnings, headers }; } return { valid: true, errors, warnings, headers }; } function processDXFeedCSV(text, account, accountId, status) { try { // Remove BOM if present if (text.charCodeAt(0) === 0xFEFF) { text = text.slice(1); } text = text.replace(/^\xEF\xBB\xBF/, ''); const lines = text.split('\n').filter(l => l.trim()); const headers = lines[0].split(',').map(h => h.trim().replace(/"/g, '')); console.log('DXFeed Headers:', headers); // Map column indices (case-insensitive) const colIndex = {}; headers.forEach((h, i) => { const lower = h.toLowerCase(); colIndex[lower] = i; // Also map common variations if (lower === 'date/time') colIndex['datetime'] = i; if (lower === 'gross p/l') colIndex['pnl'] = i; if (lower === 'net p/l') colIndex['netpnl'] = i; }); console.log('DXFeed Column Index:', colIndex); // Get the P&L column (prefer Gross P/L, fall back to Net P/L) const pnlColIndex = colIndex['gross p/l'] !== undefined ? colIndex['gross p/l'] : (colIndex['net p/l'] !== undefined ? colIndex['net p/l'] : (colIndex['pnl'] !== undefined ? colIndex['pnl'] : null)); const newTrades = []; const skippedRows = []; for (let i = 1; i < lines.length; i++) { const line = lines[i].trim(); if (!line) continue; // Use CSV parser that handles quoted values const cols = parseCSVLine(line); try { // Extract values const symbol = cols[colIndex['symbol']] || ''; const dateTimeStr = cols[colIndex['date/time'] || colIndex['datetime']] || ''; const side = cols[colIndex['side']] || ''; const quantity = parseFloat(cols[colIndex['quantity'] || colIndex['qty']] || 0); const price = parseFloat(cols[colIndex['price']] || 0); const fee = parseFloat(cols[colIndex['fee']] || cols[colIndex['commission']] || 0); // Parse P&L let pnl = 0; if (pnlColIndex !== null) { const pnlStr = cols[pnlColIndex] || '0'; pnl = parseFloat(pnlStr.replace(/[$,()]/g, '').replace(/^\((.+)\)$/, '-$1') || 0); } // Skip if missing essential data if (!symbol || !dateTimeStr || !side || quantity === 0) { skippedRows.push({ row: i + 1, reason: 'Missing essential data' }); continue; } // Parse date/time - Quantower format: "2024-01-15 09:30:45" or similar let tradeDateTime; try { // Try direct parsing tradeDateTime = new Date(dateTimeStr); if (isNaN(tradeDateTime.getTime())) { // Try common formats const match = dateTimeStr.match(/(\d{4})-(\d{2})-(\d{2})\s*(\d{2}):(\d{2}):(\d{2})/); if (match) { const [_, year, month, day, hour, min, sec] = match; tradeDateTime = new Date(`${year}-${month}-${day}T${hour}:${min}:${sec}`); } } } catch (e) { skippedRows.push({ row: i + 1, reason: 'Invalid date format' }); continue; } if (!tradeDateTime || isNaN(tradeDateTime.getTime())) { skippedRows.push({ row: i + 1, reason: 'Could not parse date' }); continue; } const tradeDate = tradeDateTime.toISOString().split('T')[0]; const tradeTime = tradeDateTime.toTimeString().split(' ')[0]; // Extract base symbol (remove month/year codes) const baseSymbol = extractBaseSymbol(symbol); // Determine direction const isBuy = side.toLowerCase().includes('buy') || side.toLowerCase() === 'b'; // Create trade object const trade = { id: `dxfeed_${Date.now()}_${i}`, accountId, date: tradeDate, time: tradeTime, symbol: baseSymbol, side: isBuy ? 'Buy' : 'Sell', quantity: Math.abs(quantity), entryPrice: price, exitPrice: price, pnl: pnl, fees: Math.abs(fee), netPnl: pnl - Math.abs(fee), source: 'dxfeed_import', importedAt: new Date().toISOString() }; newTrades.push(trade); } catch (parseError) { console.error('Error parsing DXFeed row', i, ':', parseError); skippedRows.push({ row: i + 1, reason: parseError.message }); } } if (newTrades.length === 0) { status.innerHTML = `
No valid trades found
${skippedRows.length > 0 ? `Skipped ${skippedRows.length} rows due to parsing errors.` : ''}
`; resetCSVImport(); return; } // Multi-file mode: collect trades and skip confirm UI if (window._csvMultiFileMode) { window._csvMultiFileCollector.push(...newTrades); return; } // Store for confirmation window.pendingImportTrades = newTrades; // Show summary const uniqueDates = [...new Set(newTrades.map(t => t.date))]; const totalPnL = newTrades.reduce((sum, t) => sum + (t.netPnl || 0), 0); const totalFees = newTrades.reduce((sum, t) => sum + (t.fees || 0), 0); let summaryHtml = `
✓ DXFeed CSV Parsed Successfully
Trades found: ${newTrades.length}
Trading days: ${uniqueDates.length}
Total P&L: ${formatCurrency(totalPnL)}
Total Fees: ${formatCurrency(totalFees)}
${skippedRows.length > 0 ? `
⚠️ Skipped ${skippedRows.length} rows
` : ''}
`; status.innerHTML = summaryHtml; } catch (error) { console.error('DXFeed CSV processing error:', error); status.innerHTML = `
Error processing DXFeed CSV
${error.message}
`; resetCSVImport(); } } async function processTopstepXCSV(text, account, accountId, status) { try { // Remove BOM if present (can appear at start of file) if (text.charCodeAt(0) === 0xFEFF) { text = text.slice(1); } // Also handle UTF-8 BOM that might appear as  text = text.replace(/^\xEF\xBB\xBF/, ''); const lines = text.split('\n').filter(l => l.trim()); const headers = lines[0].split(',').map(h => h.trim().replace(/"/g, '').replace(/^\xEF\xBB\xBF/, '')); console.log('TopstepX Headers:', headers); // Map column indices (case-insensitive) const colIndex = {}; headers.forEach((h, i) => { colIndex[h.toLowerCase()] = i; }); console.log('TopstepX Column Index:', colIndex); const newTrades = []; const skippedRows = []; for (let i = 1; i < lines.length; i++) { const line = lines[i].trim(); if (!line) continue; // Split by comma (simple split since TopstepX doesn't use quoted values with commas) const cols = line.split(',').map(c => c.trim()); try { const contractName = cols[colIndex['contractname']] || ''; const enteredAt = cols[colIndex['enteredat']] || ''; const exitedAt = cols[colIndex['exitedat']] || ''; const entryPrice = parseFloat(cols[colIndex['entryprice']]) || 0; const exitPrice = parseFloat(cols[colIndex['exitprice']]) || 0; const fees = parseFloat(cols[colIndex['fees']]) || 0; const pnl = parseFloat(cols[colIndex['pnl']]) || 0; const size = parseInt(cols[colIndex['size']]) || 1; const type = cols[colIndex['type']] || ''; // Long or Short console.log(`Row ${i}: contract=${contractName}, entry=${enteredAt}, type=${type}, pnl=${pnl}`); // Skip if missing critical data if (!contractName || !enteredAt || !exitedAt) { skippedRows.push({ row: i + 1, reason: `Missing data: contract=${contractName}, enteredAt=${enteredAt}, exitedAt=${exitedAt}` }); continue; } // Parse timestamps (format: "01/01/2026 18:25:00 -06:00") const entryTime = parseTopstepXTimestamp(enteredAt); const exitTime = parseTopstepXTimestamp(exitedAt); if (!entryTime || !exitTime) { skippedRows.push({ row: i + 1, reason: `Invalid timestamp: enteredAt=${enteredAt}, exitedAt=${exitedAt}` }); continue; } // Extract base symbol (remove month/year suffix like NQH6 -> NQ) const symbol = extractBaseSymbol(contractName); // Determine side const side = type.toLowerCase() === 'long' ? 'Long' : 'Short'; // Net P&L (TopstepX PnL is gross, subtract fees for net) const netPnl = pnl - fees; // Create trade object const trade = { accountId: accountId, symbol: symbol, side: side, quantity: size, entryPrice: entryPrice, exitPrice: exitPrice, entryTime: entryTime, exitTime: exitTime, pnl: parseFloat(netPnl.toFixed(2)), fees: parseFloat(fees.toFixed(2)), source: 'topstepx', importedAt: new Date().toISOString() }; newTrades.push(trade); } catch (rowError) { console.error(`Row ${i} error:`, rowError); skippedRows.push({ row: i + 1, reason: rowError.message }); } } console.log('TopstepX: Found', newTrades.length, 'trades, skipped', skippedRows.length, 'rows'); if (skippedRows.length > 0) console.log('Skipped rows:', skippedRows); if (newTrades.length === 0) { status.innerHTML = `
No valid trades found
${skippedRows.length > 0 ? `
Skipped ${skippedRows.length} rows
` : ''}
Check browser console for details
`; resetCSVImport(); return; } // Multi-file mode: collect trades and skip confirm UI if (window._csvMultiFileMode) { window._csvMultiFileCollector.push(...newTrades); return; } // Check for duplicates against existing trades const allTopstepSnap = await db.collection('users').doc(currentUser.uid) .collection('trades').where('accountId', '==', accountId).get(); const allExistingTrades = allTopstepSnap.docs.map(d => ({ id: d.id, ...d.data() })); const uniqueTrades = newTrades.filter(newTrade => { return !allExistingTrades.some(existing => { if (existing.symbol !== newTrade.symbol) return false; if ((existing.side || existing.direction) !== (newTrade.side || newTrade.direction)) return false; if ((existing.qty || existing.quantity || 1) !== (newTrade.qty || newTrade.quantity || 1)) return false; if (existing.entryPrice !== newTrade.entryPrice) return false; if (existing.exitPrice !== newTrade.exitPrice) return false; const timeDiff = Math.abs(new Date(existing.entryTime) - new Date(newTrade.entryTime)); return timeDiff < 30000; }); }); const duplicates = newTrades.length - uniqueTrades.length; // Calculate totals for preview const importPnl = uniqueTrades.reduce((sum, t) => sum + getNetPnl(t), 0); // Show confirmation dialog status.innerHTML = `
📋 TopstepX Import Preview
Trades to Import
${uniqueTrades.length}
Total P&L
${formatCurrency(importPnl)}
${duplicates > 0 ? `
⚠ ${duplicates} duplicate trades will be skipped
` : ''} ${skippedRows.length > 0 ? `
${skippedRows.length} rows skipped (invalid data)
` : ''}
Account: ${account.name}
`; // Store trades for confirmation window.pendingTopstepXTrades = uniqueTrades; document.getElementById('confirm-topstepx-import').onclick = async () => { document.getElementById('confirm-topstepx-import').disabled = true; document.getElementById('confirm-topstepx-import').textContent = 'Importing...'; status.innerHTML = '
Saving trades to database...
'; try { // Write to staging table, then sync to trades via Cloud Function const pendingTrades = window.pendingTopstepXTrades; await saveTradesToStaging(pendingTrades, accountId); const importedPnl = pendingTrades.reduce((sum, t) => sum + getNetPnl(t), 0); // Calculate account-specific P&L from reloaded trades const accountTrades = trades.filter(t => t.accountId === accountId); const accountTotal = accountTrades.reduce((sum, t) => sum + getNetPnl(t), 0); status.innerHTML = `
✓ Successfully imported ${window.pendingTopstepXTrades.length} trades
Added P&L: ${formatCurrency(importedPnl)}
Account Total P&L: ${formatCurrency(accountTotal)}
`; window.pendingTopstepXTrades = null; } catch (error) { console.error('TopstepX import error:', error); status.innerHTML = `
Error saving: ${error.message}
`; } resetCSVImport(); }; document.getElementById('cancel-topstepx-import').onclick = () => { status.innerHTML = '
Import cancelled
'; window.pendingTopstepXTrades = null; resetCSVImport(); }; } catch (error) { console.error('TopstepX CSV processing error:', error); status.innerHTML = `
Error processing TopstepX CSV
${error.message}
`; resetCSVImport(); } } function parseTopstepXTimestamp(timestamp) { // Format: "01/01/2026 18:25:00 -06:00" — has timezone offset, convert to UTC ISO 8601. try { const match = timestamp.match(/(\d{2})\/(\d{2})\/(\d{4})\s+(\d{2}):(\d{2}):(\d{2})\s*([+-]\d{2}:\d{2})/); if (!match) return null; const [_, month, day, year, hour, min, sec, offset] = match; // Rebuild as ISO 8601 with offset so Date() parses it correctly const isoWithOffset = `${year}-${month}-${day}T${hour}:${min}:${sec}${offset}`; const d = new Date(isoWithOffset); if (isNaN(d.getTime())) return null; return d.toISOString(); // "2026-01-01T00:25:00.000Z" } catch (e) { return null; } } /** * Normalise a CSV timestamp string to ISO 8601 UTC ("YYYY-MM-DDTHH:MM:SS.000Z"). * @param {string} raw - Raw timestamp string from CSV cell * @param {string} sourceTZ - IANA timezone name for timestamps that have no offset * e.g. "America/Chicago" for CT (CST/CDT auto-handled by Intl) * @returns {string|null} - ISO 8601 UTC string, or null if unparseable */ function csvTimestampToISO(raw, sourceTZ) { if (!raw) return null; const s = String(raw).trim(); // 1. Already has a Z suffix or UTC offset — parse directly if (/Z$/.test(s) || /[+-]\d{2}:\d{2}$/.test(s)) { const d = new Date(s); return isNaN(d.getTime()) ? null : d.toISOString(); } // 2. Looks like ISO-ish without timezone ("2025-03-15T09:30:00" or "2025-03-15 09:30:00") if (/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}$/.test(s)) { if (sourceTZ) { // Use Intl to find the UTC offset for this wall-clock time in the given timezone const naive = s.replace(' ', 'T'); const [year, month, day, hour, min, sec] = naive.split(/[-T:]/).map(Number); // Interpret as local time in sourceTZ by finding offset at that instant // Approximation: try parsing as UTC and compute offset from formatter const utcGuess = new Date(Date.UTC(year, month - 1, day, hour, min, sec)); const fmt = new Intl.DateTimeFormat('en-US', { timeZone: sourceTZ, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }); // Get the formatted local time if utcGuess were the UTC moment const parts = fmt.formatToParts(utcGuess); const get = type => parts.find(p => p.type === type)?.value; const localHour = parseInt(get('hour')); const diffHours = localHour - hour; // Shift UTC guess by -diffHours to land on the right UTC moment const corrected = new Date(utcGuess.getTime() - diffHours * 3600000); return isNaN(corrected.getTime()) ? null : corrected.toISOString(); } // No timezone hint — treat as-is (best effort) const d = new Date(s.replace(' ', 'T')); return isNaN(d.getTime()) ? null : d.toISOString(); } // 3. Locale formats with AM/PM: "3/15/2025 2:30:00 PM" (Tradovate Orders, CT) const ampmMatch = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})\s+(\d{1,2}):(\d{2}):(\d{2})\s*(AM|PM)$/i); if (ampmMatch) { let [, mo, dy, yr, hr, mn, sc, ampm] = ampmMatch; hr = parseInt(hr); if (ampm.toUpperCase() === 'PM' && hr !== 12) hr += 12; if (ampm.toUpperCase() === 'AM' && hr === 12) hr = 0; const naive = `${yr}-${mo.padStart(2,'0')}-${dy.padStart(2,'0')}T${String(hr).padStart(2,'0')}:${mn}:${sc}`; return csvTimestampToISO(naive, sourceTZ); } // 4. Locale format without AM/PM: "03/18/2026 09:30:45" (Rithmic CSV, CST) const mdyMatch = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})\s+(\d{2}):(\d{2}):(\d{2})$/); if (mdyMatch) { const [, mo, dy, yr, hr, mn, sc] = mdyMatch; const naive = `${yr}-${mo.padStart(2,'0')}-${dy.padStart(2,'0')}T${hr}:${mn}:${sc}`; return csvTimestampToISO(naive, sourceTZ); } // 5. Fallback — let Date() try const d = new Date(s); return isNaN(d.getTime()) ? null : d.toISOString(); } // Parse a CSV timestamp string as raw UTC digits — no timezone conversion applied. // Used by Option D offset detection before applying the measured delta. function parseCsvAsRawUTC(s) { if (!s) return NaN; const str = String(s).trim(); // MM/DD/YYYY HH:MM:SS (Tradovate Orders CSV format) const m = str.match(/(\d{1,2})\/(\d{1,2})\/(\d{4})\s+(\d{1,2}):(\d{2}):(\d{2})/); if (m) return Date.UTC(+m[3], +m[1]-1, +m[2], +m[4], +m[5], +m[6]); // YYYY-MM-DDThh:mm:ss or YYYY-MM-DD hh:mm:ss (no suffix) const m2 = str.match(/(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})/); if (m2) return Date.UTC(+m2[1], +m2[2]-1, +m2[3], +m2[4], +m2[5], +m2[6]); return NaN; } // Returns the median of a numeric array. function medianOfArray(arr) { if (!arr.length) return null; const sorted = [...arr].sort((a, b) => a - b); const mid = Math.floor(sorted.length / 2); return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; } function extractBaseSymbol(contractName) { // TopstepX format: NQH6, ESZ5, etc. (Symbol + Month + Year) // Extract base symbol (first 2-3 chars before month code) const match = contractName.match(/^([A-Z]{1,4})[FGHJKMNQUVXZ]\d/); if (match) { return match[1]; } // Fallback: just return first 2-3 letters return contractName.replace(/[FGHJKMNQUVXZ]\d.*$/, '') || contractName; } function parseCSVLine(line) { // Simple CSV parser that handles quoted values const result = []; let current = ''; let inQuotes = false; for (let i = 0; i < line.length; i++) { const char = line[i]; if (char === '"') { inQuotes = !inQuotes; } else if (char === ',' && !inQuotes) { result.push(current.trim()); current = ''; } else { current += char; } } result.push(current.trim()); return result; } function showValidationErrors(status, formatName, validation) { let html = `
❌ ${formatName} CSV Validation Failed
${validation.errors.map(e => `
• ${e}
`).join('')}
`; if (validation.headers.length > 0) { html += `
Columns found in your file:
${validation.headers.join(', ')}
`; } // Add format-specific help html += `
💡 Select the correct format above to see required columns
`; status.innerHTML = html; } function resetCSVImport() { // Clear file input so user can re-select the same file const fileInput = document.getElementById('csv-input'); if (fileInput) fileInput.value = ''; // Clear preview const preview = document.getElementById('csv-preview'); if (preview) preview.style.display = 'none'; // Clear pending trades window.pendingImportTrades = null; window.pendingTradovateImport = null; // If in multi-file mode, signal this file is done if (window._csvMultiFileResolve) { window._csvMultiFileResolve(); window._csvMultiFileResolve = null; } } async function processCSV(file) { const accountId = document.getElementById('import-account').value; const formatSelect = document.getElementById('import-format'); const selectedFormat = formatSelect ? formatSelect.value : 'auto'; if (!accountId) { document.getElementById('import-status').innerHTML = '
Please select an account first
'; resetCSVImport(); return; } const account = accounts.find(a => a.id === accountId); const status = document.getElementById('import-status'); status.innerHTML = '
Reading file...
'; // Read file as text first to detect format const text = await file.text(); // Show preview showCSVPreview(text); // Determine format let format = selectedFormat; if (format === 'auto') { // Auto-detect based on content // Check Tradovate Orders format first (has B/S, Contract, Fill Time, Status, Filled Qty) const firstLineHeaders = text.split('\n')[0]; if (firstLineHeaders.includes('B/S') && firstLineHeaders.includes('Contract') && firstLineHeaders.includes('Fill Time') && firstLineHeaders.includes('Filled Qty')) { format = 'tradovate_orders'; status.innerHTML = '
Detected: Tradovate Orders format. Validating...
'; } else if (text.includes('Completed Orders') || text.includes('Avg Fill Price')) { format = 'rithmic'; status.innerHTML = '
Detected: Rithmic format. Validating...
'; } else if (text.includes('ContractName') && text.includes('EnteredAt') && text.includes('ExitedAt') && text.includes('PnL')) { format = 'topstepx'; status.innerHTML = '
Detected: TopstepX format. Validating...
'; } else if (text.includes('boughtTimestamp') && text.includes('soldTimestamp') && text.includes('buyPrice')) { format = 'tradovate'; status.innerHTML = '
Detected: Tradovate Performance format. Validating...
'; } else if (text.includes('Entry Time') || text.includes('Exit Time') || text.includes('Net P&L')) { format = 'tradovate'; status.innerHTML = '
Detected: Generic trade format. Validating...
'; } else { // Try to parse headers const firstLine = text.split('\n')[0].toLowerCase(); // Check for DXFeed/Quantower format if (firstLine.includes('date/time') && firstLine.includes('gross p/l') && firstLine.includes('side')) { format = 'dxfeed'; status.innerHTML = '
Detected: DXFeed (Quantower) format. Validating...
'; } else if (firstLine.includes('symbol') && (firstLine.includes('pnl') || firstLine.includes('profit'))) { format = 'tradovate'; status.innerHTML = '
Detected: Trade format with P&L. Validating...
'; } else { status.innerHTML = `
❌ Could not auto-detect CSV format
Please select Rithmic, Tradovate, TopstepX, or DXFeed from the dropdown above to see required columns.
`; resetCSVImport(); return; } } } // Validate based on format if (format === 'rithmic') { const validation = validateRithmicCSV(text); if (!validation.valid) { showValidationErrors(status, 'Rithmic', validation); resetCSVImport(); return; } if (validation.warnings.length > 0) { console.log('Rithmic CSV warnings:', validation.warnings); } status.innerHTML = '
✓ Validation passed. Processing Rithmic CSV...
'; if (window._csvMultiFileMode) { await new Promise(resolve => { window._csvMultiFileResolve = resolve; processRithmicCSV(text, account, accountId, status); }); } else { processRithmicCSV(text, account, accountId, status); } } else if (format === 'topstepx') { const validation = validateTopstepXCSV(text); if (!validation.valid) { showValidationErrors(status, 'TopstepX', validation); resetCSVImport(); return; } if (validation.warnings.length > 0) { console.log('TopstepX CSV warnings:', validation.warnings); } status.innerHTML = '
✓ Validation passed. Processing TopstepX CSV...
'; processTopstepXCSV(text, account, accountId, status); } else if (format === 'tradovate_auto' || format === 'tradovate' || format === 'tradovate_orders') { // Auto-detect which Tradovate report type based on headers const headerLine = text.split('\n')[0]; const isOrdersFormat = headerLine.includes('B/S') && headerLine.includes('Contract') && headerLine.includes('Fill Time') && headerLine.includes('Filled Qty'); if (isOrdersFormat) { const validation = validateTradovateOrdersCSV(text); if (!validation.valid) { showValidationErrors(status, 'Tradovate Orders', validation); resetCSVImport(); return; } if (validation.warnings.length > 0) { console.log('Tradovate Orders CSV warnings:', validation.warnings); } status.innerHTML = `
✓ Detected Orders report (${validation.filledCount} filled orders). FIFO matching...
`; if (window._csvMultiFileMode) { await new Promise(resolve => { window._csvMultiFileResolve = resolve; processTradovateOrdersCSV(text, account, accountId, status); }); } else { processTradovateOrdersCSV(text, account, accountId, status); } } else { const validation = validateTradovateCSV(text); if (!validation.valid) { showValidationErrors(status, 'Tradovate', validation); resetCSVImport(); return; } if (validation.warnings.length > 0) { console.log('Tradovate CSV warnings:', validation.warnings); } status.innerHTML = `
✓ Detected Performance report (${validation.format} format). Processing...
`; if (window._csvMultiFileMode) { await new Promise(resolve => { window._csvMultiFileResolve = resolve; processTradovateCSV(file, text, account, accountId, status); }); } else { processTradovateCSV(file, text, account, accountId, status); } } } else if (format === 'dxfeed') { const validation = validateDXFeedCSV(text); if (!validation.valid) { showValidationErrors(status, 'DXFeed (Quantower)', validation); resetCSVImport(); return; } if (validation.warnings.length > 0) { console.log('DXFeed CSV warnings:', validation.warnings); } status.innerHTML = '
✓ Validation passed. Processing DXFeed CSV...
'; processDXFeedCSV(text, account, accountId, status); } } async function processMultipleCSVFiles(files) { const accountId = document.getElementById('import-account').value; if (!accountId) { document.getElementById('import-status').innerHTML = '
Please select an account first
'; resetCSVImport(); return; } const account = accounts.find(a => a.id === accountId); const status = document.getElementById('import-status'); // Initialize multi-file collector window._csvMultiFileCollector = []; window._csvMultiFileMode = true; const errors = []; status.innerHTML = `
Processing ${files.length} files...
`; for (let i = 0; i < files.length; i++) { status.innerHTML = `
Processing file ${i + 1} of ${files.length}: ${files[i].name}...
`; try { await processCSV(files[i]); } catch (err) { errors.push(`${files[i].name}: ${err.message}`); } } // Exit multi-file mode window._csvMultiFileMode = false; const collectedTrades = window._csvMultiFileCollector || []; window._csvMultiFileCollector = null; window._csvMultiFileResolve = null; if (collectedTrades.length === 0) { status.innerHTML = `
No trades found across ${files.length} files
${errors.length > 0 ? `
${errors.join('
')}
` : ''}
`; resetCSVImport(); return; } // Cross-file dedup: remove duplicates within the collected trades const seen = new Set(); const uniqueTrades = []; let crossFileDupes = 0; collectedTrades.forEach(t => { const key = `${t.symbol}|${t.entryTime}|${t.exitTime}|${t.direction || t.side}`; if (!seen.has(key)) { seen.add(key); uniqueTrades.push(t); } else { crossFileDupes++; } }); // Dedup against existing Firestore trades + deleted trade blocklist const allMultiFileTradesSnap = await db.collection('users').doc(currentUser.uid) .collection('trades').where('accountId', '==', accountId).get(); const allAccountTrades = allMultiFileTradesSnap.docs.map(d => ({ id: d.id, ...d.data() })); const newTrades = uniqueTrades.filter(t => { return !allAccountTrades.some(existing => { // If both trades have fill IDs, use exact fill ID match only — no time fuzzy match needed if ((t.buyFillId || t.buyOrderId) && (existing.buyFillId || existing.buyOrderId)) { return (t.buyFillId || t.buyOrderId) === (existing.buyFillId || existing.buyOrderId) && (t.sellFillId || t.sellOrderId) === (existing.sellFillId || existing.sellOrderId); } // Fallback: business-value similarity for legacy trades without fill IDs if (existing.symbol !== t.symbol) return false; if ((existing.side || existing.direction) !== (t.side || t.direction)) return false; if ((existing.qty || existing.quantity || 1) !== (t.qty || t.quantity || 1)) return false; if (existing.entryPrice !== t.entryPrice) return false; if (existing.exitPrice !== t.exitPrice) return false; const timeDiff = Math.abs(new Date(existing.entryTime) - new Date(t.entryTime)); return timeDiff < 30000; }); }); const existingDupes = uniqueTrades.length - newTrades.length; if (newTrades.length === 0) { status.innerHTML = `
All trades already exist
${collectedTrades.length} trades found across ${files.length} files. ${crossFileDupes > 0 ? `${crossFileDupes} duplicates across files. ` : ''} ${existingDupes > 0 ? `${existingDupes} already in database.` : ''}
`; resetCSVImport(); return; } // Show combined confirmation const totalPnl = newTrades.reduce((sum, t) => sum + getNetPnl(t), 0); const uniqueDates = [...new Set(newTrades.map(t => t.date).filter(Boolean))]; const uniqueSymbols = [...new Set(newTrades.map(t => t.symbol).filter(Boolean))]; let statsHtml = `
Trades to Import
${newTrades.length}
Total P&L
${formatCurrency(totalPnl)}
`; let notesHtml = ''; if (crossFileDupes > 0) notesHtml += `
${crossFileDupes} duplicate(s) across files removed
`; if (existingDupes > 0) notesHtml += `
${existingDupes} trade(s) already in database skipped
`; if (errors.length > 0) notesHtml += `
${errors.length} file(s) had errors: ${errors.join(', ')}
`; notesHtml += `
From ${files.length} files | ${uniqueDates.length} trading day(s) | ${uniqueSymbols.join(', ') || 'N/A'}
`; status.innerHTML = `
Multi-File Import Preview
${statsHtml} ${notesHtml}
Account: ${account.name}
`; document.getElementById('confirm-multi-import').onclick = async () => { document.getElementById('confirm-multi-import').disabled = true; document.getElementById('confirm-multi-import').textContent = 'Importing...'; status.innerHTML = '
Saving trades to database...
'; try { // Write to staging table, then sync to trades via Cloud Function await saveTradesToStaging(newTrades, accountId); const accountTrades = trades.filter(t => t.accountId === accountId); const accountTotal = accountTrades.reduce((sum, t) => sum + getNetPnl(t), 0); status.innerHTML = `
Imported ${newTrades.length} executions from ${files.length} files
Added P&L: ${formatCurrency(totalPnl)}
Account Total P&L: ${formatCurrency(accountTotal)}
`; trackTradesImported('csv-multi', newTrades.length); renderAll(); renderConnectionsPage(); } catch (error) { console.error('Multi-file import error:', error); status.innerHTML = `
Error saving: ${error.message}
`; } resetCSVImport(); }; document.getElementById('cancel-multi-import').onclick = () => { status.innerHTML = '
Import cancelled
'; resetCSVImport(); }; } function showCSVPreview(text) { const preview = document.getElementById('import-preview'); const thead = document.getElementById('preview-thead'); const tbody = document.getElementById('preview-tbody'); if (!preview || !thead || !tbody) return; try { const lines = text.split('\n').filter(l => l.trim()); if (lines.length === 0) return; // For Rithmic, find the actual data section let dataStart = 0; if (text.includes('Completed Orders')) { for (let i = 0; i < lines.length; i++) { if (lines[i].includes('Completed Orders')) { dataStart = i + 1; break; } } } const headers = lines[dataStart].split(',').map(h => h.trim().replace(/"/g, '')); const rows = lines.slice(dataStart + 1, dataStart + 6); thead.innerHTML = '' + headers.slice(0, 8).map(h => `${h}`).join('') + ''; tbody.innerHTML = rows.map(row => { const cols = row.split(',').map(c => c.trim().replace(/"/g, '')); return '' + cols.slice(0, 8).map(c => `${c.substring(0, 20)}`).join('') + ''; }).join(''); preview.style.display = 'block'; } catch (e) { console.error('Preview error:', e); } } function processRithmicCSV(text, account, accountId, status) { try { // Find the "Completed Orders" section const completedIdx = text.indexOf('Completed Orders'); if (completedIdx === -1) { status.innerHTML = '
Could not find "Completed Orders" section in CSV file
'; resetCSVImport(); return; } // Get everything after "Completed Orders" line const completedSection = text.substring(completedIdx); const lines = completedSection.split('\n').slice(1); // Skip "Completed Orders" line // Parse the CSV portion Papa.parse(lines.join('\n'), { header: true, skipEmptyLines: true, complete: async (results) => { if (results.data.length === 0) { status.innerHTML = '
No data rows found in Completed Orders section
'; resetCSVImport(); return; } // Log headers for debugging const headers = Object.keys(results.data[0]); console.log('Rithmic CSV headers:', headers); // Detect the Update Time column (could be CST, EST, or other timezone) const timeCol = headers.find(h => h.startsWith('Update Time')) || 'Update Time (CST)'; console.log('Detected time column:', timeCol); // Filter to only filled orders (including partially filled) const fills = results.data.filter(row => { const rowStatus = row.Status || row.status || ''; const avgPrice = parseFloat(row['Avg Fill Price'] || row['AvgFillPrice'] || 0); const qtyFilled = parseInt(row['Qty Filled'] || row['QtyFilled'] || 0); // Include "Filled" and "Partially Filled, Balance Cancelled" orders const isFilled = rowStatus === 'Filled' || rowStatus.includes('Partially Filled'); return isFilled && avgPrice > 0 && qtyFilled > 0; }); if (fills.length === 0) { status.innerHTML = `
No filled orders found
Found ${results.data.length} rows, but none have Status="Filled" with a valid price.
Make sure you're exporting from R|Trader Pro with filled orders.
`; resetCSVImport(); return; } // Group fills and match entries with exits using FIFO const matchedTrades = matchRithmicFills(fills, account, timeCol); if (matchedTrades.length === 0) { status.innerHTML = '
Could not match any trades from fills
'; resetCSVImport(); return; } // Multi-file mode: collect trades and skip confirm UI if (window._csvMultiFileMode) { window._csvMultiFileCollector.push(...matchedTrades); if (window._csvMultiFileResolve) { window._csvMultiFileResolve(); window._csvMultiFileResolve = null; } return; } // Check for existing trades from this account (active ones for display) const existingAccountTrades = trades.filter(t => t.accountId === accountId); const existingPnl = existingAccountTrades.reduce((sum, t) => sum + getNetPnl(t), 0); // Query Firestore for existing trades + deleted trade blocklist const allTradesSnap = await db.collection('users').doc(currentUser.uid) .collection('trades').where('accountId', '==', accountId).get(); const allAccountTrades = allTradesSnap.docs.map(d => ({ id: d.id, ...d.data() })); // Check for duplicates — COUNT-AWARE (multi-contract safe). The matcher expands an // N-lot fill into N byte-identical qty=1 siblings; a boolean "does a match exist" // check would flag all N as one duplicate and silently drop N-1 contracts (and // block re-adding missing contracts on a superset re-import). Instead, count how // many identical siblings are INCOMING vs already STORED and admit only the surplus // (mirrors the proven Tradovate count-based dedup at functions/index.js:5556-5593). const duplicates = []; const newTrades = []; // Per-trade dedup key: exact fill-ID pair when present (uniquely separates distinct // trades and groups true siblings, which share BOTH fill IDs), else a business-value // key. The legacy ±30s fuzzy entry-time window is dropped on purpose — it existed to // absorb CSV-vs-API clock skew, but Rithmic API sync is disabled so CSV is the only // Rithmic source and same-file re-imports match exactly. const rithmicDedupKey = (t) => { const bf = t.buyFillId || t.buyOrderId || ''; const sf = t.sellFillId || t.sellOrderId || ''; if (bf && sf) return 'f|' + bf + '|' + sf; return 'b|' + (t.date || '') + '|' + (t.symbol || '') + '|' + (t.side || t.direction || '') + '|' + (t.qty || t.quantity || 1) + '|' + (t.entryPrice || '') + '|' + (t.exitPrice || '') + '|' + Math.round((parseFloat(t.pnl) || 0) * 100); }; const existingKeyCounts = {}; allAccountTrades.forEach(e => { const k = rithmicDedupKey(e); existingKeyCounts[k] = (existingKeyCounts[k] || 0) + 1; }); const incomingKeyCounts = {}; matchedTrades.forEach(trade => { const k = rithmicDedupKey(trade); const incoming = (incomingKeyCounts[k] = (incomingKeyCounts[k] || 0) + 1); const existing = existingKeyCounts[k] || 0; if (incoming <= existing) { duplicates.push(trade); // this sibling is already stored } else { newTrades.push(trade); // surplus sibling — genuinely new, admit it } }); // If all trades are duplicates, show error with diagnostics if (newTrades.length === 0 && duplicates.length > 0) { const matchingSources = [...new Set(existingAccountTrades.map(t => t.source || 'unknown'))]; const matchingDates = [...new Set(existingAccountTrades.map(t => t.date || 'no-date'))].sort(); console.log(`[CSV Import] All ${duplicates.length} trades are duplicates. Account has ${existingAccountTrades.length} existing trades.`); console.log(`[CSV Import] Existing trade sources:`, matchingSources); console.log(`[CSV Import] Existing trade dates:`, matchingDates); console.log(`[CSV Import] Sample existing trades:`, existingAccountTrades.slice(0, 5).map(t => ({ id: t.id, date: t.date, symbol: t.symbol, side: t.side || t.direction, entryTime: t.entryTime, pnl: t.pnl, netPnl: t.netPnl, source: t.source }))); status.innerHTML = `
⚠️ All ${duplicates.length} trades already exist
This account has ${existingAccountTrades.length} trades in the database (sources: ${matchingSources.join(', ')}).
Duplicate detection is based on: date, symbol, entry time, and direction.
`; resetCSVImport(); return; } // Calculate totals for display (only new trades) const totalPnl = newTrades.reduce((sum, t) => sum + getNetPnl(t), 0); // Show preview before saving let infoHtml = ''; if (existingAccountTrades.length > 0) { infoHtml = `
ℹ️ This account already has ${existingAccountTrades.length} trades (P&L: ${formatCurrency(existingPnl)})
`; } // Show duplicate warning if some were skipped let duplicateHtml = ''; if (duplicates.length > 0) { duplicateHtml = `
⚠️ Skipping ${duplicates.length} duplicate trade${duplicates.length > 1 ? 's' : ''} (already imported to this account)
`; } status.innerHTML = ` ${infoHtml} ${duplicateHtml}
Preview: ${fills.length} fills → ${newTrades.length} new executions${duplicates.length > 0 ? ` (${duplicates.length} duplicates skipped)` : ''}
P&L to add: ${formatCurrency(totalPnl)}
Check browser console (F12) for detailed debug info
`; // Store only NEW trades for confirmation (not duplicates) window.pendingImportTrades = newTrades; document.getElementById('confirm-import-btn').onclick = async () => { // Prevent double-click document.getElementById('confirm-import-btn').disabled = true; document.getElementById('confirm-import-btn').textContent = 'Importing...'; status.innerHTML = '
Saving trades...
'; try { // Write to staging table, then sync to trades via Cloud Function const pendingTrades = window.pendingImportTrades; const selectedAccountId = pendingTrades[0]?.accountId; await saveTradesToStaging(pendingTrades, selectedAccountId); const importedPnl = pendingTrades.reduce((sum, t) => sum + getNetPnl(t), 0); // Calculate account-specific P&L from reloaded trades const accountTrades = trades.filter(t => t.accountId === selectedAccountId); const accountTotal = accountTrades.reduce((sum, t) => sum + getNetPnl(t), 0); status.innerHTML = `
✓ Imported ${window.pendingImportTrades.length} executions
Added P&L: ${formatCurrency(importedPnl)}
Account Total P&L: ${formatCurrency(accountTotal)}
`; // Track CSV import trackTradesImported('csv', window.pendingImportTrades.length); window.pendingImportTrades = null; } catch (error) { console.error('Import error:', error); status.innerHTML = `
Error saving: ${error.message}
`; resetCSVImport(); } }; document.getElementById('cancel-import-btn').onclick = () => { status.innerHTML = '
Import cancelled
'; resetCSVImport(); }; }, error: (error) => { status.innerHTML = `
Parse error: ${error.message}
`; resetCSVImport(); } }); } catch (error) { console.error(error); status.innerHTML = `
Error: ${error.message}
`; resetCSVImport(); } } function matchRithmicFills(fills, account, timeCol = 'Update Time (CST)') { // Group fills by symbol first to handle mixed instruments (MNQ + NQ) const fillsBySymbol = {}; fills.forEach(fill => { const symbol = fill.Symbol || ''; if (!fillsBySymbol[symbol]) fillsBySymbol[symbol] = []; fillsBySymbol[symbol].push(fill); }); console.log('=== Rithmic Import Debug ==='); console.log('Symbols found:', Object.keys(fillsBySymbol).join(', ')); const allMatchedTrades = []; // Process each symbol separately for (const [symbol, symbolFills] of Object.entries(fillsBySymbol)) { console.log(`\n--- Processing ${symbol} ---`); // Sort fills by time (using detected time column) const sortedFills = [...symbolFills].sort((a, b) => new Date(a[timeCol]) - new Date(b[timeCol]) ); // Clean symbol (remove contract month like MNQH5 -> MNQ, ESH6 -> ES) const cleanSymbol = symbol.replace(/[FGHJKMNQUVXZ]\d{1,2}$/, '').replace(/[A-Z]\d$/, ''); // Look up fallback commission from Commission Settings const fallbackCommission = getCommissionRate(account.propFirm, account.connectionType, cleanSymbol); console.log(`Commission Settings fallback: ${account.propFirm} + ${account.connectionType} + ${cleanSymbol} = $${fallbackCommission !== null ? fallbackCommission : 'not set'}/side`); // Futures instrument specifications // Point Value = Tick Value / Tick Size const INSTRUMENT_SPECS = { // CME - Equity Index 'ES': { tickSize: 0.25, tickValue: 12.50, pointValue: 50.00 }, 'NQ': { tickSize: 0.25, tickValue: 5.00, pointValue: 20.00 }, 'RTY': { tickSize: 0.10, tickValue: 5.00, pointValue: 50.00 }, 'MES': { tickSize: 0.25, tickValue: 1.25, pointValue: 5.00 }, 'MNQ': { tickSize: 0.25, tickValue: 0.50, pointValue: 2.00 }, 'M2K': { tickSize: 0.10, tickValue: 1.00, pointValue: 10.00 }, // CBOT - Equity Index 'YM': { tickSize: 1.00, tickValue: 5.00, pointValue: 5.00 }, 'MYM': { tickSize: 1.00, tickValue: 0.50, pointValue: 0.50 }, // COMEX - Metals 'GC': { tickSize: 0.10, tickValue: 10.00, pointValue: 100.00 }, 'MGC': { tickSize: 0.10, tickValue: 1.00, pointValue: 10.00 }, 'SI': { tickSize: 0.005, tickValue: 25.00, pointValue: 5000.00 }, 'SIL': { tickSize: 0.005, tickValue: 5.00, pointValue: 1000.00 }, 'HG': { tickSize: 0.0005, tickValue: 12.50, pointValue: 25000.00 }, 'PL': { tickSize: 0.10, tickValue: 5.00, pointValue: 50.00 }, // NYMEX - Energy 'CL': { tickSize: 0.01, tickValue: 10.00, pointValue: 1000.00 }, 'QM': { tickSize: 0.025, tickValue: 12.50, pointValue: 500.00 }, 'MCL': { tickSize: 0.01, tickValue: 1.00, pointValue: 100.00 }, 'NG': { tickSize: 0.001, tickValue: 10.00, pointValue: 10000.00 }, 'QG': { tickSize: 0.005, tickValue: 12.50, pointValue: 2500.00 }, 'HO': { tickSize: 0.0001, tickValue: 4.20, pointValue: 42000.00 }, 'RB': { tickSize: 0.0001, tickValue: 4.20, pointValue: 42000.00 }, // CME - Currencies '6E': { tickSize: 0.00005, tickValue: 6.25, pointValue: 125000.00 }, '6B': { tickSize: 0.0001, tickValue: 6.25, pointValue: 62500.00 }, '6J': { tickSize: 0.0000005, tickValue: 6.25, pointValue: 12500000.00 }, '6A': { tickSize: 0.00005, tickValue: 5.00, pointValue: 100000.00 }, '6C': { tickSize: 0.00005, tickValue: 5.00, pointValue: 100000.00 }, '6S': { tickSize: 0.00005, tickValue: 6.25, pointValue: 125000.00 }, '6N': { tickSize: 0.00005, tickValue: 5.00, pointValue: 100000.00 }, 'M6E': { tickSize: 0.0001, tickValue: 1.25, pointValue: 12500.00 }, 'M6A': { tickSize: 0.0001, tickValue: 1.00, pointValue: 10000.00 }, // CME - Crypto 'MBT': { tickSize: 5.00, tickValue: 0.50, pointValue: 0.10 }, 'MET': { tickSize: 0.50, tickValue: 0.05, pointValue: 0.10 }, // CME - Livestock 'HE': { tickSize: 0.00025, tickValue: 10.00, pointValue: 40000.00 }, 'LE': { tickSize: 0.00025, tickValue: 10.00, pointValue: 40000.00 }, // CBOT - Grains 'ZC': { tickSize: 0.25, tickValue: 12.50, pointValue: 50.00 }, 'ZW': { tickSize: 0.25, tickValue: 12.50, pointValue: 50.00 }, 'ZS': { tickSize: 0.25, tickValue: 12.50, pointValue: 50.00 }, 'ZM': { tickSize: 0.10, tickValue: 10.00, pointValue: 100.00 }, 'ZL': { tickSize: 0.0001, tickValue: 6.00, pointValue: 60000.00 }, }; // Get point value from specs, default to MNQ ($2) if unknown const spec = INSTRUMENT_SPECS[cleanSymbol]; const pointValue = spec ? spec.pointValue : 2.00; if (!spec) { console.warn('Unknown symbol:', cleanSymbol, '- using default point value $2.00 (MNQ)'); } // Calculate aggregate P&L (mathematically accurate for flat positions) let totalBuyQty = 0; let totalBuyPoints = 0; let totalSellQty = 0; let totalSellPoints = 0; let totalCommission = 0; sortedFills.forEach(fill => { const side = fill['Buy/Sell']; const qty = parseInt(fill['Qty Filled']) || 1; const price = parseFloat(fill['Avg Fill Price']) || 0; // Primary: CSV commission, Fallback: Commission Settings const csvCommission = parseFloat(fill['Commission Fill Rate']) || 0; const perSideRate = csvCommission > 0 ? csvCommission : (fallbackCommission || 0); const commission = perSideRate * qty; if (side === 'B') { totalBuyQty += qty; totalBuyPoints += qty * price; } else { totalSellQty += qty; totalSellPoints += qty * price; } totalCommission += commission; }); const pointsDiff = totalSellPoints - totalBuyPoints; const grossPnl = pointsDiff * pointValue; const netPnl = grossPnl - totalCommission; console.log('Symbol:', symbol, '| Point Value: $' + pointValue); console.log('Total Buys:', totalBuyQty, '| Total Sells:', totalSellQty); console.log('Gross P&L: $' + grossPnl.toFixed(2)); console.log('Commission: $' + totalCommission.toFixed(2)); console.log('Net P&L: $' + netPnl.toFixed(2)); // FIFO matching for this symbol const longQueue = []; const shortQueue = []; const matchedTrades = []; sortedFills.forEach(fill => { const side = fill['Buy/Sell']; const qty = parseInt(fill['Qty Filled']) || 1; const price = parseFloat(fill['Avg Fill Price']) || 0; const time = csvTimestampToISO(fill[timeCol], 'America/Chicago') || fill[timeCol]; // Primary: CSV commission, Fallback: Commission Settings const csvCommission = parseFloat(fill['Commission Fill Rate']) || 0; const commission = csvCommission > 0 ? csvCommission : (fallbackCommission || 0); const fillId = fill['Fill Id'] || fill.fillId || fill.fillID || ''; for (let i = 0; i < qty; i++) { if (side === 'B') { if (shortQueue.length > 0) { const entry = shortQueue.shift(); const pnl = (entry.price - price) * pointValue - entry.commission - commission; matchedTrades.push({ accountId: account.id, symbol: cleanSymbol, side: 'Short', qty: 1, entryTime: entry.time, exitTime: time, entryPrice: entry.price, exitPrice: price, pnl: pnl, commission: entry.commission + commission, date: getTradingSessionDate(time) || getTradingSessionDate(entry.time) || (time || '').substring(0, 10), source: 'csv-import', sellFillId: entry.fillId || '', buyFillId: fillId || '' }); } else { longQueue.push({ price, time, commission, fillId }); } } else { if (longQueue.length > 0) { const entry = longQueue.shift(); const pnl = (price - entry.price) * pointValue - entry.commission - commission; matchedTrades.push({ accountId: account.id, symbol: cleanSymbol, side: 'Long', qty: 1, entryTime: entry.time, exitTime: time, entryPrice: entry.price, exitPrice: price, pnl: pnl, commission: entry.commission + commission, date: getTradingSessionDate(time) || getTradingSessionDate(entry.time) || (time || '').substring(0, 10), source: 'csv-import', buyFillId: entry.fillId || '', sellFillId: fillId || '' }); } else { shortQueue.push({ price, time, commission, fillId }); } } } }); // Adjust for any rounding discrepancy ONLY if positions are flat const matchedTotal = matchedTrades.reduce((sum, t) => sum + getNetPnl(t), 0); console.log('Matched trades total: $' + matchedTotal.toFixed(2)); // Only reconcile if buy qty == sell qty (position is flat) if (matchedTrades.length > 0 && totalBuyQty === totalSellQty && Math.abs(matchedTotal - netPnl) > 0.01) { const diff = netPnl - matchedTotal; console.log('Adjusting last trade by: $' + diff.toFixed(2)); matchedTrades[matchedTrades.length - 1].pnl += diff; } else if (totalBuyQty !== totalSellQty) { console.log('WARNING: Position not flat. Buys:', totalBuyQty, 'Sells:', totalSellQty); console.log('Using matched trades P&L only (no adjustment)'); } allMatchedTrades.push(...matchedTrades); } console.log('\n=== Total across all symbols ==='); const grandTotal = allMatchedTrades.reduce((sum, t) => sum + getNetPnl(t), 0); console.log('Total matched trades:', allMatchedTrades.length); console.log('Grand Total P&L: $' + grandTotal.toFixed(2)); console.log('==========================='); return allMatchedTrades; } function processTradovateOrdersCSV(text, account, accountId, status) { Papa.parse(text, { header: true, skipEmptyLines: true, complete: async (results) => { try { console.log('Tradovate Orders CSV headers:', Object.keys(results.data[0] || {})); console.log('First row:', results.data[0]); if (results.data.length === 0) { status.innerHTML = '
No data found in CSV
'; resetCSVImport(); return; } // Filter to only Filled orders const filledOrders = results.data.filter(row => { const s = (row['Status'] || '').trim(); return s === 'Filled'; }); console.log(`Tradovate Orders: ${results.data.length} total rows, ${filledOrders.length} filled orders`); if (filledOrders.length === 0) { status.innerHTML = '
No filled orders found in CSV. Only orders with Status = "Filled" are imported.
'; resetCSVImport(); return; } // Fetch all account trades once — used for timezone offset detection (Option D) and dedup const allTradesSnap = await db.collection('users').doc(currentUser.uid) .collection('trades').where('accountId', '==', accountId).get(); const allAccountTrades = allTradesSnap.docs.map(d => ({ id: d.id, ...d.data() })); const apiTradesForOffset = allAccountTrades.filter(t => t.source === 'tradovate' || t.source === 'tradovate-auto-sync' ); // Parse fills — store raw UTC digits for offset detection; fillTime applied after const fills = filledOrders.map(row => { const contractRaw = (row['Contract'] || '').trim(); const cleanSymbol = contractRaw.replace(/[FGHJKMNQUVXZ]\d{1,2}$/, ''); const side = (row['B/S'] || '').trim(); const qty = parseInt(row['Filled Qty']) || 0; const price = parseFloat(row['Avg Fill Price']) || 0; const fillTimeRaw = (row['Fill Time'] || '').trim(); const rawUtcMs = parseCsvAsRawUTC(fillTimeRaw); const orderId = row.orderId || row['Order ID'] || row['orderId'] || ''; return { symbol: cleanSymbol, contractRaw, side, qty, price, fillTimeRaw, rawUtcMs, orderId }; }).filter(f => f.symbol && f.qty > 0 && f.price > 0 && f.fillTimeRaw); // Option D: measure timezone offset by matching CSV fill prices to existing API trade prices const tzDeltas = []; for (const fill of fills) { if (isNaN(fill.rawUtcMs)) continue; for (const apiTrade of apiTradesForOffset) { if (fill.symbol !== apiTrade.symbol) continue; const entryMs = new Date(apiTrade.entryTime).getTime(); const exitMs = new Date(apiTrade.exitTime).getTime(); if (!isNaN(entryMs) && fill.price === apiTrade.entryPrice) { const d = entryMs - fill.rawUtcMs; if (Math.abs(d) < 86400000) tzDeltas.push(d); } if (!isNaN(exitMs) && fill.price === apiTrade.exitPrice) { const d = exitMs - fill.rawUtcMs; if (Math.abs(d) < 86400000) tzDeltas.push(d); } } } const tzDeltaMs = medianOfArray(tzDeltas); console.log(`[CSV TZ] ${tzDeltas.length} offset samples. Median delta: ${tzDeltaMs !== null ? (tzDeltaMs / 3600000).toFixed(3) + 'h' : 'none — using stored timezone fallback'}`); // Apply offset to all fill timestamps fills.forEach(fill => { if (tzDeltaMs !== null && !isNaN(fill.rawUtcMs)) { fill.fillTime = new Date(fill.rawUtcMs + tzDeltaMs).toISOString(); } else { fill.fillTime = csvTimestampToISO(fill.fillTimeRaw, getUserTimeZone()) || fill.fillTimeRaw; } }); console.log(`Parsed ${fills.length} valid fills from Orders CSV`); if (fills.length === 0) { status.innerHTML = '
No valid fills could be extracted from the Orders CSV.
'; resetCSVImport(); return; } // Sort fills by time (earliest first for FIFO) fills.sort((a, b) => new Date(a.fillTime) - new Date(b.fillTime)); // Group fills by clean symbol const fillsBySymbol = {}; fills.forEach(f => { if (!fillsBySymbol[f.symbol]) fillsBySymbol[f.symbol] = []; fillsBySymbol[f.symbol].push(f); }); // FIFO matching per symbol const matchedTrades = []; const unmatchedBuys = {}; const unmatchedSells = {}; for (const [symbol, symbolFills] of Object.entries(fillsBySymbol)) { const buyQueue = []; // FIFO queue of {qty: 1, price, fillTime} const sellQueue = []; // Expand multi-qty fills into individual 1-lot entries for (const fill of symbolFills) { const queue = fill.side === 'Buy' ? buyQueue : sellQueue; const oppositeQueue = fill.side === 'Buy' ? sellQueue : buyQueue; // Try to match against opposite queue first (FIFO) let remaining = fill.qty; while (remaining > 0 && oppositeQueue.length > 0) { const opposite = oppositeQueue[0]; const matchQty = Math.min(remaining, opposite.qty); const buyFill = fill.side === 'Buy' ? fill : opposite; const sellFill = fill.side === 'Buy' ? opposite : fill; const buyTime = new Date(buyFill.fillTime); const sellTime = new Date(sellFill.fillTime); const isLong = buyTime < sellTime; const spec = INSTRUMENT_SPECS[symbol]; const pointValue = spec ? spec.pointValue : 1; const priceDiff = sellFill.price - buyFill.price; const grossPnl = priceDiff * pointValue * matchQty; // Commission lookup const commissionRate = getCommissionRate(account.propFirm, account.connectionType, symbol); const totalCommission = commissionRate !== null ? (commissionRate * 2 * matchQty) : 0; const netPnl = grossPnl - totalCommission; matchedTrades.push({ accountId: accountId, symbol: symbol, side: isLong ? 'Long' : 'Short', qty: matchQty, entryTime: isLong ? buyFill.fillTime : sellFill.fillTime, exitTime: isLong ? sellFill.fillTime : buyFill.fillTime, entryPrice: isLong ? buyFill.price : sellFill.price, exitPrice: isLong ? sellFill.price : buyFill.price, pnl: netPnl, commission: totalCommission, buyOrderId: isLong ? (buyFill.orderId || '') : (sellFill.orderId || ''), sellOrderId: isLong ? (sellFill.orderId || '') : (buyFill.orderId || '') }); remaining -= matchQty; opposite.qty -= matchQty; if (opposite.qty <= 0) oppositeQueue.shift(); } // If remaining, add to own queue if (remaining > 0) { queue.push({ qty: remaining, price: fill.price, fillTime: fill.fillTime, orderId: fill.orderId || '' }); } } if (buyQueue.length > 0) unmatchedBuys[symbol] = buyQueue.reduce((s, f) => s + f.qty, 0); if (sellQueue.length > 0) unmatchedSells[symbol] = sellQueue.reduce((s, f) => s + f.qty, 0); } console.log(`FIFO matched ${matchedTrades.length} trades from ${fills.length} fills`); if (Object.keys(unmatchedBuys).length > 0) console.log('Unmatched buys:', unmatchedBuys); if (Object.keys(unmatchedSells).length > 0) console.log('Unmatched sells:', unmatchedSells); if (matchedTrades.length === 0) { status.innerHTML = `
No trades could be matched
${fills.length} fills were parsed but FIFO matching produced no complete trades.
This can happen if only buys or only sells are present for each symbol.
`; resetCSVImport(); return; } const newTrades = matchedTrades; // Multi-file mode: collect trades and skip confirm UI if (window._csvMultiFileMode) { window._csvMultiFileCollector.push(...newTrades); if (window._csvMultiFileResolve) { window._csvMultiFileResolve(); window._csvMultiFileResolve = null; } return; } // Calculate totals const totalPnlRaw = newTrades.reduce((sum, t) => sum + getNetPnl(t), 0); const totalCommission = newTrades.reduce((sum, t) => sum + (t.commission || 0), 0); // Check for existing trades from this account const existingAccountTrades = trades.filter(t => t.accountId === accountId); const existingPnl = existingAccountTrades.reduce((sum, t) => sum + getNetPnl(t), 0); // Duplicate detection const duplicates = []; const uniqueTrades = []; newTrades.forEach(trade => { const isDuplicate = allAccountTrades.some(existing => { // If both trades have fill IDs, use exact fill ID match only — no time fuzzy match needed if ((trade.buyFillId || trade.buyOrderId) && (existing.buyFillId || existing.buyOrderId)) { return (trade.buyFillId || trade.buyOrderId) === (existing.buyFillId || existing.buyOrderId) && (trade.sellFillId || trade.sellOrderId) === (existing.sellFillId || existing.sellOrderId); } // Fallback: business-value similarity for legacy trades without fill IDs if (existing.symbol !== trade.symbol) return false; if ((existing.side || existing.direction) !== (trade.side || trade.direction)) return false; if ((existing.qty || existing.quantity || 1) !== (trade.qty || trade.quantity || 1)) return false; if (existing.entryPrice !== trade.entryPrice) return false; if (existing.exitPrice !== trade.exitPrice) return false; const timeDiff = Math.abs(new Date(existing.entryTime) - new Date(trade.entryTime)); return timeDiff < 30000; }); if (isDuplicate) { duplicates.push(trade); } else { uniqueTrades.push(trade); } }); if (uniqueTrades.length === 0 && duplicates.length > 0) { status.innerHTML = `
⚠️ All ${duplicates.length} trades already exist
This account has ${allAccountTrades.length} trades in the database.
Duplicate detection is based on: symbol, side, qty, entry/exit price, and entry time (±30s).
`; resetCSVImport(); return; } const totalPnl = uniqueTrades.reduce((sum, t) => sum + getNetPnl(t), 0); const uniqueCommission = uniqueTrades.reduce((sum, t) => sum + (t.commission || 0), 0); // Build unmatched warning let unmatchedHtml = ''; const unmatchedSymbols = [...new Set([...Object.keys(unmatchedBuys), ...Object.keys(unmatchedSells)])]; if (unmatchedSymbols.length > 0) { const parts = unmatchedSymbols.map(s => { const b = unmatchedBuys[s] || 0; const sl = unmatchedSells[s] || 0; return `${s}: ${b ? b + ' buy' : ''}${b && sl ? ', ' : ''}${sl ? sl + ' sell' : ''}`; }); unmatchedHtml = `
⚠️ Unmatched fills (open positions or partial data): ${parts.join('; ')}
`; } let infoHtml = ''; if (allAccountTrades.length > 0) { infoHtml = `
ℹ️ This account already has ${allAccountTrades.length} trades (P&L: ${formatCurrency(existingPnl)})
`; } let duplicateHtml = ''; if (duplicates.length > 0) { duplicateHtml = `
⚠️ Skipping ${duplicates.length} duplicate trade${duplicates.length > 1 ? 's' : ''} (already imported)
`; } // Check if any symbols missing from INSTRUMENT_SPECS const uniqueSymbols = [...new Set(uniqueTrades.map(t => t.symbol))]; const missingSpecs = uniqueSymbols.filter(s => !INSTRUMENT_SPECS[s]); let specWarningHtml = ''; if (missingSpecs.length > 0) { specWarningHtml = `
⚠️ Unknown instrument${missingSpecs.length > 1 ? 's' : ''}: ${missingSpecs.join(', ')} — P&L calculated as price difference × qty (pointValue=1). Values may be incorrect.
`; } status.innerHTML = ` ${infoHtml} ${duplicateHtml} ${unmatchedHtml} ${specWarningHtml}
Preview: ${uniqueTrades.length} trades matched via FIFO from ${fills.length} fills${duplicates.length > 0 ? ` (${duplicates.length} duplicates skipped)` : ''}
P&L to add: ${formatCurrency(totalPnl)}${uniqueCommission > 0 ? ` (after ${formatCurrency(uniqueCommission)} commission)` : ''}
Symbols: ${uniqueSymbols.join(', ')} | Check browser console (F12) for FIFO debug info
`; window.pendingOrdersImport = uniqueTrades; document.getElementById('confirm-orders-import-btn').onclick = async () => { document.getElementById('confirm-orders-import-btn').disabled = true; document.getElementById('confirm-orders-import-btn').textContent = 'Importing...'; status.innerHTML = '
Saving trades...
'; try { const pendingTrades = window.pendingOrdersImport; const selectedAccountId = pendingTrades[0]?.accountId; await saveTradesToStaging(pendingTrades, selectedAccountId); const importedPnl = pendingTrades.reduce((sum, t) => sum + getNetPnl(t), 0); const accountTrades = trades.filter(t => t.accountId === selectedAccountId); const accountTotal = accountTrades.reduce((sum, t) => sum + getNetPnl(t), 0); status.innerHTML = `
✓ Imported ${pendingTrades.length} trades from Tradovate Orders (FIFO matched)
Added P&L: ${formatCurrency(importedPnl)}
Account Total P&L: ${formatCurrency(accountTotal)}
`; window.pendingOrdersImport = null; } catch (error) { console.error('Tradovate Orders import error:', error); status.innerHTML = `
Error saving: ${error.message}
`; resetCSVImport(); } }; document.getElementById('cancel-orders-import-btn').onclick = () => { status.innerHTML = '
Import cancelled
'; resetCSVImport(); }; } catch (error) { console.error('Tradovate Orders import error:', error); status.innerHTML = `
Error: ${error.message}
`; resetCSVImport(); } }, error: (error) => { status.innerHTML = `
Parse error: ${error.message}
`; resetCSVImport(); } }); } function processTradovateCSV(file, text, account, accountId, status) { Papa.parse(text, { header: true, skipEmptyLines: true, complete: async (results) => { try { console.log('Tradovate CSV headers:', Object.keys(results.data[0] || {})); console.log('First row:', results.data[0]); if (results.data.length === 0) { status.innerHTML = '
No data found in CSV
'; resetCSVImport(); return; } // Fetch all account trades once — used for timezone offset detection (Option D) and dedup const allTradesSnap = await db.collection('users').doc(currentUser.uid) .collection('trades').where('accountId', '==', accountId).get(); const allAccountTrades = allTradesSnap.docs.map(d => ({ id: d.id, ...d.data() })); const apiTradesForOffset = allAccountTrades.filter(t => t.source === 'tradovate' || t.source === 'tradovate-auto-sync' ); // Detect Tradovate Performance format (has buyPrice/sellPrice/boughtTimestamp/soldTimestamp) const firstRow = results.data[0]; const isTradovatePerformance = firstRow.hasOwnProperty('buyPrice') && firstRow.hasOwnProperty('sellPrice') && firstRow.hasOwnProperty('boughtTimestamp') && firstRow.hasOwnProperty('soldTimestamp'); let newTrades = []; if (isTradovatePerformance) { throw new Error( 'Performance Report CSV is no longer supported.\n\n' + 'Please export using the Orders Report instead:\n' + '1. Open Tradovate → Accounts tab → select your account\n' + '2. Click the gear icon → Orders tab\n' + '3. Select your date range → Go → Download CSV\n\n' + 'The Orders Report has no date limit and covers all historical trades.' ); } else { // File does not match Tradovate Performance Report format // User should import using Orders Report format instead throw new Error( 'Unrecognized Tradovate CSV format.\n\n' + 'Please export using the Orders Report:\n' + '1. Open Tradovate → Accounts tab → select your account\n' + '2. Click the gear icon → Orders tab\n' + '3. Select your date range → Go → Download CSV' ); } if (newTrades.length === 0) { const sampleRow = results.data[0]; status.innerHTML = `
No valid trades could be extracted
Found ${results.data.length} rows but couldn't parse any valid trades.
This might happen if timestamps are missing or in an unexpected format.
Sample row: ${JSON.stringify(sampleRow).substring(0, 200)}...
`; console.log('All rows filtered out. Sample row:', sampleRow); resetCSVImport(); return; } // Multi-file mode: collect trades and skip confirm UI if (window._csvMultiFileMode) { window._csvMultiFileCollector.push(...newTrades); if (window._csvMultiFileResolve) { window._csvMultiFileResolve(); window._csvMultiFileResolve = null; } return; } // Calculate totals for display const totalPnlRaw = newTrades.reduce((sum, t) => sum + getNetPnl(t), 0); const totalCommission = newTrades.reduce((sum, t) => sum + (t.commission || 0), 0); // Check for existing trades from this account (active ones for display) const existingAccountTrades = trades.filter(t => t.accountId === accountId); const existingPnl = existingAccountTrades.reduce((sum, t) => sum + getNetPnl(t), 0); // Check for duplicates - same account, date, symbol, entry time, and direction const duplicates = []; const uniqueTrades = []; newTrades.forEach(trade => { const isDuplicate = allAccountTrades.some(existing => { // If both trades have fill IDs, use exact fill ID match only — no time fuzzy match needed if ((trade.buyFillId || trade.buyOrderId) && (existing.buyFillId || existing.buyOrderId)) { return (trade.buyFillId || trade.buyOrderId) === (existing.buyFillId || existing.buyOrderId) && (trade.sellFillId || trade.sellOrderId) === (existing.sellFillId || existing.sellOrderId); } // Fallback: business-value similarity for legacy trades without fill IDs if (existing.symbol !== trade.symbol) return false; if ((existing.side || existing.direction) !== (trade.side || trade.direction)) return false; if ((existing.qty || existing.quantity || 1) !== (trade.qty || trade.quantity || 1)) return false; if (existing.entryPrice !== trade.entryPrice) return false; if (existing.exitPrice !== trade.exitPrice) return false; const timeDiff = Math.abs(new Date(existing.entryTime) - new Date(trade.entryTime)); return timeDiff < 30000; }); if (isDuplicate) { duplicates.push(trade); } else { uniqueTrades.push(trade); } }); // If all trades are duplicates, show error with diagnostics if (uniqueTrades.length === 0 && duplicates.length > 0) { const matchingSources = [...new Set(existingAccountTrades.map(t => t.source || 'unknown'))]; const matchingDates = [...new Set(existingAccountTrades.map(t => t.date || 'no-date'))].sort(); console.log(`[CSV Import] All ${duplicates.length} Tradovate trades are duplicates. Account has ${existingAccountTrades.length} existing trades.`); console.log(`[CSV Import] Existing trade sources:`, matchingSources); console.log(`[CSV Import] Existing trade dates:`, matchingDates); console.log(`[CSV Import] Sample existing trades:`, existingAccountTrades.slice(0, 5).map(t => ({ id: t.id, date: t.date, symbol: t.symbol, side: t.side || t.direction, entryTime: t.entryTime, pnl: t.pnl, netPnl: t.netPnl, source: t.source }))); status.innerHTML = `
⚠️ All ${duplicates.length} trades already exist
This account has ${existingAccountTrades.length} trades in the database (sources: ${matchingSources.join(', ')}).
Duplicate detection is based on: date, symbol, entry time, and direction.
`; resetCSVImport(); return; } // Recalculate totals for unique trades only const totalPnl = uniqueTrades.reduce((sum, t) => sum + getNetPnl(t), 0); const uniqueCommission = uniqueTrades.reduce((sum, t) => sum + (t.commission || 0), 0); // Show preview before saving (like Rithmic does) let infoHtml = ''; if (existingAccountTrades.length > 0) { infoHtml = `
ℹ️ This account already has ${existingAccountTrades.length} trades (P&L: ${formatCurrency(existingPnl)})
`; } // Show duplicate warning if some were skipped let duplicateHtml = ''; if (duplicates.length > 0) { duplicateHtml = `
⚠️ Skipping ${duplicates.length} duplicate trade${duplicates.length > 1 ? 's' : ''} (already imported to this account)
`; } status.innerHTML = ` ${infoHtml} ${duplicateHtml}
Preview: ${uniqueTrades.length} new executions${duplicates.length > 0 ? ` (${duplicates.length} duplicates skipped)` : ''}
P&L to add: ${formatCurrency(totalPnl)}${uniqueCommission > 0 ? ` (after ${formatCurrency(uniqueCommission)} commission)` : ''}
Check browser console (F12) for detailed debug info
`; // Store only UNIQUE trades for confirmation (not duplicates) window.pendingTradovateImport = uniqueTrades; document.getElementById('confirm-tradovate-import-btn').onclick = async () => { // Prevent double-click document.getElementById('confirm-tradovate-import-btn').disabled = true; document.getElementById('confirm-tradovate-import-btn').textContent = 'Importing...'; status.innerHTML = '
Saving trades...
'; try { // Write to staging table, then sync to trades via Cloud Function const pendingTrades = window.pendingTradovateImport; const selectedAccountId = pendingTrades[0]?.accountId; await saveTradesToStaging(pendingTrades, selectedAccountId); const importedPnl = pendingTrades.reduce((sum, t) => sum + getNetPnl(t), 0); // Calculate account-specific P&L from reloaded trades const accountTrades = trades.filter(t => t.accountId === selectedAccountId); const accountTotal = accountTrades.reduce((sum, t) => sum + getNetPnl(t), 0); status.innerHTML = `
✓ Imported ${window.pendingTradovateImport.length} executions from Tradovate
Added P&L: ${formatCurrency(importedPnl)}
Account Total P&L: ${formatCurrency(accountTotal)}
`; window.pendingTradovateImport = null; } catch (error) { console.error('Tradovate import error:', error); status.innerHTML = `
Error saving: ${error.message}
`; resetCSVImport(); } }; document.getElementById('cancel-tradovate-import-btn').onclick = () => { status.innerHTML = '
Import cancelled
'; resetCSVImport(); }; } catch (error) { console.error('Tradovate import error:', error); // white-space: pre-line makes \n characters in the thrown message render as line breaks status.innerHTML = `
Error: ${error.message}
`; resetCSVImport(); } }, error: (error) => { status.innerHTML = `
Parse error: ${error.message}
`; resetCSVImport(); } }); } // Settings document.getElementById('auto-reset-toggle').addEventListener('change', (e) => { checklistSettings.autoReset = e.target.checked; saveChecklistSettings(); }); document.getElementById('reset-time').addEventListener('change', (e) => { checklistSettings.resetTime = e.target.value; saveChecklistSettings(); }); document.getElementById('export-data-btn').addEventListener('click', () => { const data = { trades, accounts, journal, scorecards, checklistSettings }; const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `trade-journal-backup-${getTodayKey()}.json`; a.click(); }); // (Clear ALL Trades button removed — unified into Clear Trades by Account section) // Scorecard date change document.getElementById('scorecard-date').addEventListener('change', async () => { const newDateKey = document.getElementById('scorecard-date').value; // Load checklist completion for the selected date if (currentUser) { try { const completionDoc = await db.collection('users').doc(currentUser.uid).collection('checklistCompletions').doc(newDateKey).get(); checklistChecked = completionDoc.exists ? completionDoc.data() : {}; } catch (e) { checklistChecked = {}; } } else { checklistChecked = {}; } renderPremarketChecklist(); renderTodaysTrades(); }); // Trade filters document.getElementById('side-filter').addEventListener('change', renderTradesTable); document.getElementById('result-filter').addEventListener('change', renderTradesTable); document.getElementById('setup-filter')?.addEventListener('change', renderTradesTable); // Payout Tracker Event Listeners document.getElementById('payout-week-ending')?.addEventListener('change', calculatePayout); document.getElementById('payout-buffer-only')?.addEventListener('change', calculatePayout); document.getElementById('payout-account')?.addEventListener('change', () => { // Keep payoutSettings.accountId in sync with dropdown selection payoutSettings.accountId = document.getElementById('payout-account')?.value || ''; renderPayoutTracker(); }); document.getElementById('save-payout-settings-btn')?.addEventListener('click', async () => { const _prevSettings = { ...payoutSettings }; // Save all the new prop firm settings payoutSettings.propFirm = document.getElementById('payout-prop-firm')?.value || 'apex'; payoutSettings.accountId = document.getElementById('payout-account')?.value || ''; payoutSettings.plan = document.getElementById('payout-plan')?.value || 'pa'; payoutSettings.accountSize = parseInt(document.getElementById('payout-account-size')?.value) || 50000; payoutSettings.buffer = parseFloat(document.getElementById('payout-buffer').value) || 0; payoutSettings.profitSplit = document.getElementById('payout-profit-split')?.value || '100-0'; // Site A — parseFloat('') = NaN; NaN ?? 0 = NaN (?? doesn't catch NaN) → Firestore rejects. Guard with Number.isFinite, fall back to null (Firestore-accepted; downstream `?? 0` renders as 0). const _mw = parseFloat(document.getElementById('payout-min-withdrawal')?.value); payoutSettings.minWithdrawal = Number.isFinite(_mw) ? _mw : null; // Site B — `|| '30%'` silently fabricated a 30% consistency rule on no-consistency plans (e.g. tradeify/elite) when input was empty. Preserve null instead of writing a wrong default. const _c = document.getElementById('payout-consistency')?.value; payoutSettings.consistency = (_c != null && _c !== '') ? _c : null; payoutSettings.overrideDefaults = document.getElementById('override-defaults')?.checked || false; payoutSettings.payoutCaps = [ parseFloat(document.getElementById('payout-cap-1')?.value) || null, parseFloat(document.getElementById('payout-cap-2')?.value) || null, parseFloat(document.getElementById('payout-cap-3')?.value) || null, parseFloat(document.getElementById('payout-cap-4')?.value) || null, parseFloat(document.getElementById('payout-cap-5')?.value) || null ]; try { await savePayouts(); } catch (saveError) { Object.assign(payoutSettings, _prevSettings); console.error('[Save Payout Settings] Save failed:', saveError); showNotification(`Failed to save settings: ${saveError?.message || saveError}`, 'error'); renderPayoutTracker(); return; } renderPayoutTracker(); showNotification('Payout settings saved!', 'success'); }); document.getElementById('record-withdrawal-btn')?.addEventListener('click', async () => { let amount = parseFloat(document.getElementById('withdrawal-amount')?.value) || 0; if (amount <= 0) { showNotification('Please enter a payout amount', 'error'); return; } // Get account info DIRECTLY from dropdown (not from payoutSettings which may be stale) const accountId = document.getElementById('payout-account')?.value || null; const account = accountId ? accounts.find(a => a.id === accountId) : null; // Check if override is enabled const isOverride = document.getElementById('payout-override-toggle')?.checked || false; // Hard limit: reject any amount above Maximum Available (unless override is active) if (!isOverride && _maxAvailablePayout !== null && amount > _maxAvailablePayout) { showNotification(`Amount exceeds maximum available (${formatCurrency(_maxAvailablePayout)})`, 'error'); return; } // Get profit split - from override dropdown if enabled, otherwise from display let profitSplit = null; if (isOverride) { const splitInput = document.getElementById('profit-split-override-input'); profitSplit = parseInt(splitInput?.value) || null; } else { const splitDisplay = document.getElementById('apex-profit-split')?.textContent || ''; profitSplit = parseInt(splitDisplay) || null; } // Block save if split couldn't be determined if (profitSplit === null || isNaN(profitSplit)) { showNotification('Profit split unknown — account config missing. Check propFirmConfig or enable override.', 'error'); return; } // Calculate amounts - user enters GROSS (what's withdrawn from account) // We calculate NET (what they actually receive after split) const grossAmount = amount; // What's withdrawn from account (entered amount) const netAmount = Math.round((profitSplit < 100 ? amount * (profitSplit / 100) : amount) * 100) / 100; // What they receive // Safety check: Verify eligibility before recording (skip if override is enabled) if (!isOverride && account && account.propFirm) { const accountFirmConfig = propFirmConfigs[account.propFirm]; if (accountFirmConfig) { const metrics = calculateAccountMetrics(account, accountFirmConfig); if (!metrics.isEligible) { showNotification(`Not eligible for payout: ${metrics.eligibilityReason}`, 'error'); return; } } } // Get selected date or use today const dateInput = document.getElementById('withdrawal-date'); const selectedDate = dateInput?.value ? new Date(dateInput.value + 'T12:00:00') : new Date(); const dateStr = selectedDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); const propFirm = document.getElementById('payout-prop-firm')?.value || payoutSettings.propFirm || 'apex'; const firmConfig = propFirmConfigs[propFirm]; const withdrawals = payouts.filter(p => p.type === 'withdrawal'); const payoutNumber = withdrawals.length + 1; const accountName = account?.name || null; const accountSize = parseInt(document.getElementById('payout-account-size')?.value) || payoutSettings.accountSize || 50000; const splitNote = profitSplit < 100 ? ` (${profitSplit}% split)` : ''; const noteText = `${firmConfig?.name || 'Prop Firm'} Payout #${payoutNumber}${splitNote}${isOverride ? ' (Override)' : ''}`; // Show both gross and net in confirmation if split applies let confirmMsg; if (profitSplit < 100) { confirmMsg = isOverride ? `⚠️ OVERRIDE MODE\n\nRecord payout:\n• Withdrawn from Account: ${formatCurrency(grossAmount)}\n• Split: ${profitSplit}/${100-profitSplit}\n• You Receive: ${formatCurrency(netAmount)}\n• Date: ${dateStr}${accountName ? `\n• Account: ${accountName}` : ''}\n\nThis bypasses normal eligibility rules.` : `Record payout:\n• Withdrawn from Account: ${formatCurrency(grossAmount)}\n• Split: ${profitSplit}/${100-profitSplit}\n• You Receive: ${formatCurrency(netAmount)}\n• Date: ${dateStr}${accountName ? `\n• Account: ${accountName}` : ''}`; } else { confirmMsg = isOverride ? `⚠️ OVERRIDE MODE\n\nRecord withdrawal of ${formatCurrency(amount)} on ${dateStr}${accountName ? ` for ${accountName}` : ''}?\n\nThis bypasses normal eligibility rules.` : `Record withdrawal of ${formatCurrency(amount)} on ${dateStr}${accountName ? ` for ${accountName}` : ''}?`; } if (!confirm(confirmMsg)) return; // Compute stats snapshot at payout time const payoutDateKey = getDateKey(selectedDate); const snapAccountTrades = accountId ? trades.filter(t => t.accountId === accountId) : []; // Filter trades up to and including payout date const snapTradesUpToDate = snapAccountTrades.filter(t => { const td = normalizeTradeDate(t); return td && td <= payoutDateKey; }); const snapTotalPnl = snapTradesUpToDate.reduce((s, t) => s + getNetPnl(t), 0); const snapWinners = snapTradesUpToDate.filter(t => getNetPnl(t) > 0); const snapWinRate = snapTradesUpToDate.length > 0 ? Math.round((snapWinners.length / snapTradesUpToDate.length) * 100) : 0; const snapGrossProfit = snapWinners.reduce((s, t) => s + getNetPnl(t), 0); const snapGrossLoss = Math.abs(snapTradesUpToDate.filter(t => getNetPnl(t) < 0).reduce((s, t) => s + getNetPnl(t), 0)); const snapPF = snapGrossLoss > 0 ? (snapGrossProfit / snapGrossLoss).toFixed(2) : (snapGrossProfit > 0 ? '\u221e' : '0.00'); const snapSorted = snapTradesUpToDate.slice().sort((a, b) => new Date(a.exitTime || a.entryTime) - new Date(b.exitTime || b.entryTime)); let snapMaxStreak = 0, snapCurStreak = 0; snapSorted.forEach(t => { if (getNetPnl(t) > 0) { snapCurStreak++; if (snapCurStreak > snapMaxStreak) snapMaxStreak = snapCurStreak; } else { snapCurStreak = 0; } }); // Daily P&L: try exact payout date first, then fall back to most recent trading day let snapDayTrades = snapTradesUpToDate.filter(t => normalizeTradeDate(t) === payoutDateKey); if (snapDayTrades.length === 0 && snapTradesUpToDate.length > 0) { // No trades on payout date — use most recent trading day on or before payout date const tradeDates = [...new Set(snapTradesUpToDate.map(t => normalizeTradeDate(t)).filter(Boolean))].sort(); const closestDate = tradeDates[tradeDates.length - 1]; if (closestDate) snapDayTrades = snapTradesUpToDate.filter(t => normalizeTradeDate(t) === closestDate); } const snapDailyPnl = snapDayTrades.reduce((s, t) => s + getNetPnl(t), 0); const snapTradingDays = new Set(snapTradesUpToDate.map(t => normalizeTradeDate(t)).filter(Boolean)).size; const snapAvgDailyPnl = snapTradingDays > 0 ? snapTotalPnl / snapTradingDays : 0; const snapBalance = account ? (account.currentBalance || (accountSize + snapTotalPnl)) : (accountSize + snapTotalPnl); const snapYtdPayouts = payouts.filter(p => p.type === 'withdrawal' && new Date(p.date).getFullYear() === selectedDate.getFullYear()).reduce((s, p) => s + (p.amount || 0), 0) + netAmount; // Record the NET amount (what trader actually receives) payouts.push({ type: 'withdrawal', amount: netAmount, grossAmount: grossAmount, profitSplit: profitSplit, date: selectedDate.toISOString(), note: noteText, propFirm: propFirm, accountSize: accountSize, accountId: accountId, accountName: accountName, wasOverride: isOverride, statsSnapshot: { totalPnl: snapTotalPnl, dailyPnl: snapDailyPnl, avgDailyPnl: snapAvgDailyPnl, tradingDays: snapTradingDays, winRate: snapWinRate, winStreak: snapMaxStreak, ytdPayouts: snapYtdPayouts, balance: snapBalance, accountSize: accountSize, profitFactor: snapPF } }); try { await savePayouts(); } catch (saveError) { // Roll back the in-memory push so UI doesn't show a phantom payout payouts.pop(); console.error('[Record Withdrawal] Save failed:', saveError); showNotification(`Failed to save payout: ${saveError?.message || saveError}`, 'error'); renderPayoutTracker(); renderDashboard(); renderPropComplianceWidget(); renderROITracker(); return; } // Track payout logged trackPayoutLogged(propFirm, netAmount); // Reset the withdrawal amount input so it recalculates document.getElementById('withdrawal-amount').value = '0.00'; // Reset override toggle if (isOverride) { document.getElementById('payout-override-toggle').checked = false; togglePayoutOverride(); } renderPayoutTracker(); renderDashboard(); renderPropComplianceWidget(); renderROITracker(); if (_labcardType === 'payout') renderUpdatedLabCardGallery(); showNotification(`Withdrawal of ${formatCurrency(netAmount)} recorded for ${dateStr}!`, 'success'); }); document.getElementById('payout-cal-prev')?.addEventListener('click', () => { currentPayoutMonth.setMonth(currentPayoutMonth.getMonth() - 1); renderPayoutCalendar(); }); document.getElementById('payout-cal-next')?.addEventListener('click', () => { currentPayoutMonth.setMonth(currentPayoutMonth.getMonth() + 1); renderPayoutCalendar(); }); // ===================================================== // ROI TRACKER FUNCTIONS // ===================================================== async function saveExpenses() { console.log('saveExpenses called, currentUser:', currentUser?.uid); if (!currentUser) { console.error('No current user!'); throw new Error('Not logged in'); } try { await labSafeFirestoreSet( db.collection('users').doc(currentUser.uid), { expenses: expenses }, { merge: true } ); console.log('Expenses saved to Firestore'); } catch (error) { console.error('Error saving expenses:', error); throw error; } } function openAddExpenseModal() { document.getElementById('expense-modal-title').textContent = '➕ Add Expense'; document.getElementById('expense-edit-id').value = ''; document.getElementById('expense-date').value = new Date().toISOString().split('T')[0]; document.getElementById('expense-prop-firm').value = ''; document.getElementById('expense-type').value = ''; document.getElementById('expense-description').value = ''; document.getElementById('expense-amount').value = ''; document.getElementById('expense-currency').value = _userCurrency || 'USD'; document.getElementById('expense-conversion-note').style.display = 'none'; // Populate accounts dropdown with all accounts initially updateExpenseAccounts(); document.getElementById('expense-account').value = ''; document.getElementById('expense-modal').style.display = 'flex'; } function openEditExpenseModal(expenseId) { const expense = expenses.find(e => e.id === expenseId); if (!expense) return; document.getElementById('expense-modal-title').textContent = '✏️ Edit Expense'; document.getElementById('expense-edit-id').value = expense.id; document.getElementById('expense-date').value = expense.date; document.getElementById('expense-prop-firm').value = expense.propFirm; updateExpenseTypes(); document.getElementById('expense-type').value = expense.type; document.getElementById('expense-description').value = expense.description || ''; document.getElementById('expense-amount').value = expense.expenseOriginalAmount || expense.amount; document.getElementById('expense-currency').value = expense.expenseCurrency || 'USD'; document.getElementById('expense-conversion-note').style.display = 'none'; // Populate accounts dropdown - filter by selected prop firm updateExpenseAccounts(); // If the account was deleted, add it as a placeholder option so the modal still works const accountSelect = document.getElementById('expense-account'); if (expense.accountId && !accounts.some(a => a.id === expense.accountId)) { const orphanOption = document.createElement('option'); orphanOption.value = expense.accountId; orphanOption.textContent = '⚠️ Deleted Account'; accountSelect.appendChild(orphanOption); } accountSelect.value = expense.accountId || ''; document.getElementById('expense-modal').style.display = 'flex'; } function closeExpenseModal() { document.getElementById('expense-modal').style.display = 'none'; } async function updateExpenseConversionNote() { const currency = document.getElementById('expense-currency').value; const rawAmount = parseFloat(document.getElementById('expense-amount').value) || 0; const date = document.getElementById('expense-date').value; const noteEl = document.getElementById('expense-conversion-note'); if (currency === 'USD' || !rawAmount) { noteEl.style.display = 'none'; return; } const today = new Date().toISOString().split('T')[0]; if (date && date !== today) { noteEl.textContent = 'Fetching historical rate...'; noteEl.style.display = 'block'; const rate = await fetchHistoricalRate(date, currency); if (rate) { const usd = (rawAmount / rate).toFixed(2); noteEl.textContent = `≈ $${usd} USD (${date} rate: 1 USD = ${rate.toFixed(4)} ${currency})`; } else { noteEl.textContent = 'Historical rate unavailable — will use current rate'; } } else if (_exchangeRates && _exchangeRates[currency]) { const usd = (rawAmount / _exchangeRates[currency]).toFixed(2); noteEl.textContent = `≈ $${usd} USD (current rate: 1 USD = ${_exchangeRates[currency].toFixed(4)} ${currency})`; noteEl.style.display = 'block'; } else { noteEl.textContent = 'Exchange rates unavailable — will save as USD equivalent'; noteEl.style.display = 'block'; } } function updateExpenseTypes() { const firm = document.getElementById('expense-prop-firm').value; const typeSelect = document.getElementById('expense-type'); const types = propFirmExpenseTypes[firm] || propFirmExpenseTypes.other; typeSelect.innerHTML = ''; types.forEach(type => { typeSelect.innerHTML += ``; }); // Also update accounts dropdown to filter by selected firm updateExpenseAccounts(); } function updateExpenseAccounts() { const selectedFirm = document.getElementById('expense-prop-firm').value; const accountSelect = document.getElementById('expense-account'); const currentValue = accountSelect.value; console.log('[Expense Accounts] Selected firm:', selectedFirm); console.log('[Expense Accounts] All accounts:', accounts.map(a => ({id: a.id, name: a.name, propFirm: a.propFirm}))); accountSelect.innerHTML = ''; // Filter accounts by prop firm (case-insensitive comparison) const filteredAccounts = accounts.filter(acc => { if (!selectedFirm) return true; // Show all if no firm selected const accFirm = (acc.propFirm || '').toLowerCase(); const searchFirm = selectedFirm.toLowerCase(); return accFirm === searchFirm; }); console.log('[Expense Accounts] Filtered accounts:', filteredAccounts.map(a => ({id: a.id, name: a.name, propFirm: a.propFirm}))); filteredAccounts.forEach(acc => { const displayName = acc.name || acc.accountNumber || acc.id; accountSelect.innerHTML += ``; }); // Try to restore previous selection if still valid if (currentValue) { accountSelect.value = currentValue; } } let _expenseSaving = false; async function saveExpense() { if (_expenseSaving) return; _expenseSaving = true; console.log('saveExpense called'); const saveBtn = document.querySelector('#expense-modal .btn-primary'); if (saveBtn) { saveBtn.disabled = true; saveBtn.textContent = 'Saving...'; } try { const editId = document.getElementById('expense-edit-id').value; const date = document.getElementById('expense-date').value; const propFirm = document.getElementById('expense-prop-firm').value; const type = document.getElementById('expense-type').value; const accountId = document.getElementById('expense-account').value; const description = document.getElementById('expense-description').value; const rawAmount = parseFloat(document.getElementById('expense-amount').value) || 0; const expenseCurrency = document.getElementById('expense-currency').value || 'USD'; console.log('Form values:', { date, propFirm, type, accountId, description, rawAmount, expenseCurrency }); if (!date || !type || rawAmount <= 0) { alert('Please fill in date, expense type, and amount'); return; } // Fetch historical rate for display-time conversion (stored but NOT applied to amount) let rateUsed = null; if (expenseCurrency !== 'USD') { const historicalRate = await fetchHistoricalRate(date, expenseCurrency); if (historicalRate) { rateUsed = historicalRate; } else if (_exchangeRates && _exchangeRates[expenseCurrency]) { rateUsed = _exchangeRates[expenseCurrency]; } } const expense = { id: editId || 'exp_' + Date.now(), date: date, propFirm: propFirm, type: type, accountId: accountId, description: description, amount: Math.round(rawAmount * 100) / 100, expenseCurrency: expenseCurrency, ...(expenseCurrency !== 'USD' && { ...(rateUsed && { expenseRateAtDate: rateUsed }) }), createdAt: editId ? (expenses.find(e => e.id === editId)?.createdAt || new Date().toISOString()) : new Date().toISOString(), updatedAt: new Date().toISOString() }; console.log('Expense object:', expense); if (editId) { const index = expenses.findIndex(e => e.id === editId); if (index !== -1) { expenses[index] = expense; } } else { expenses.push(expense); } console.log('Expenses array:', expenses); await saveExpenses(); console.log('Expense saved successfully'); closeExpenseModal(); renderROITracker(); renderExpenseTable(); showToast(editId ? 'Expense updated' : 'Expense added', 'success'); } catch (error) { console.error('Error in saveExpense:', error); alert('Error saving expense: ' + error.message); } finally { _expenseSaving = false; if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = 'Save Expense'; } } } async function deleteExpense(expenseId) { console.log('deleteExpense called with id:', expenseId); if (!confirm('Are you sure you want to delete this expense?')) return; expenses = expenses.filter(e => e.id !== expenseId); console.log('Expenses after delete:', expenses); try { await saveExpenses(); console.log('Delete saved successfully'); renderROITracker(); renderExpenseTable(); showToast('Expense deleted', 'success'); } catch (error) { console.error('Error deleting expense:', error); alert('Error deleting expense: ' + error.message); } } function toggleAllExpenseCheckboxes(checked) { document.querySelectorAll('.expense-row-cb').forEach(cb => { cb.checked = checked; }); updateExpenseSelectionCount(); } function updateExpenseSelectionCount() { const checked = document.querySelectorAll('.expense-row-cb:checked'); const btn = document.getElementById('expense-delete-selected-btn'); const countEl = document.getElementById('expense-selected-count'); if (checked.length > 0) { btn.style.display = 'inline-flex'; countEl.textContent = checked.length; } else { btn.style.display = 'none'; } const allCbs = document.querySelectorAll('.expense-row-cb'); const selectAll = document.getElementById('expense-select-all'); if (selectAll) selectAll.checked = allCbs.length > 0 && checked.length === allCbs.length; } async function deleteSelectedExpenses() { const checked = document.querySelectorAll('.expense-row-cb:checked'); if (checked.length === 0) return; if (!confirm(`Delete ${checked.length} selected expense${checked.length > 1 ? 's' : ''}? This cannot be undone.`)) return; const idsToDelete = new Set(); checked.forEach(cb => idsToDelete.add(cb.dataset.expenseId)); expenses = expenses.filter(e => !idsToDelete.has(e.id)); try { await saveExpenses(); renderROITracker(); renderExpenseTable(); showToast(`${idsToDelete.size} expense${idsToDelete.size > 1 ? 's' : ''} deleted`, 'success'); } catch (error) { console.error('Error deleting selected expenses:', error); alert('Error deleting expenses: ' + error.message); } } function clearAllExpensesConfirm() { document.getElementById('expense-clear-confirm-modal').style.display = 'flex'; } async function clearAllExpenses() { if (expenses.length === 0) { showToast('No expenses to clear', 'info'); return; } expenses = []; try { await saveExpenses(); renderROITracker(); renderExpenseTable(); showToast('All expenses cleared', 'success'); } catch (error) { console.error('Error clearing expenses:', error); alert('Error clearing expenses: ' + error.message); } } function getFilteredExpenses() { // Custom date range takes priority over year/month dropdowns const dateFrom = document.getElementById('roi-date-from')?.value; const dateTo = document.getElementById('roi-date-to')?.value; if (dateFrom || dateTo) { return expenses.filter(e => { const d = e.date.includes('T') ? new Date(e.date) : new Date(e.date + 'T12:00:00'); if (dateFrom && d < new Date(dateFrom + 'T00:00:00')) return false; if (dateTo && d > new Date(dateTo + 'T23:59:59')) return false; return true; }); } const year = document.getElementById('roi-filter-year')?.value; const month = document.getElementById('roi-filter-month')?.value; return expenses.filter(e => { const expDate = e.date.includes('T') ? new Date(e.date) : new Date(e.date + 'T12:00:00'); if (year && expDate.getFullYear() !== parseInt(year)) return false; if (month && (expDate.getMonth() + 1) !== parseInt(month)) return false; return true; }); } function getFilteredPayouts() { // Custom date range takes priority over year/month dropdowns const dateFrom = document.getElementById('roi-date-from')?.value; const dateTo = document.getElementById('roi-date-to')?.value; if (dateFrom || dateTo) { return payouts.filter(p => { if (p.type !== 'withdrawal') return false; const d = p.date.includes('T') ? new Date(p.date) : new Date(p.date + 'T12:00:00'); if (dateFrom && d < new Date(dateFrom + 'T00:00:00')) return false; if (dateTo && d > new Date(dateTo + 'T23:59:59')) return false; return true; }); } const year = document.getElementById('roi-filter-year')?.value; const month = document.getElementById('roi-filter-month')?.value; return payouts.filter(p => { if (p.type !== 'withdrawal') return false; let payDate = p.date.includes('T') ? new Date(p.date) : new Date(p.date + 'T12:00:00'); if (year && payDate.getFullYear() !== parseInt(year)) return false; if (month && (payDate.getMonth() + 1) !== parseInt(month)) return false; return true; }); } // Convert a single expense to the user's display currency. // RULE: If recorded currency matches display currency, return raw amount — no conversion. // If they differ, convert: recorded → USD → display. function getExpenseInDisplayCurrency(exp) { const amt = exp.amount || 0; const recordedCur = exp.expenseCurrency || 'USD'; // Matching currencies — return exact stored value, no conversion if (recordedCur === _userCurrency) return amt; // Different currencies — convert via USD as intermediate let usd; if (recordedCur === 'USD') { usd = amt; } else if (exp.expenseRateAtDate) { usd = amt / exp.expenseRateAtDate; } else if (_exchangeRates && _exchangeRates[recordedCur]) { usd = amt / _exchangeRates[recordedCur]; } else { usd = amt; // fallback: treat as USD } return convertFromUSD(usd); } // Format expense amount for display in the expense table row. // Uses getExpenseInDisplayCurrency so matching currencies show exact values. function formatExpenseAmount(exp) { const displayAmt = getExpenseInDisplayCurrency(exp); return formatDisplayAmount(displayAmt); } // Format a value that is ALREADY in the user's display currency. // Adds currency symbol and formatting only — no exchange rate conversion. function formatDisplayAmount(val, decimals) { const num = parseFloat(val) || 0; const dec = decimals !== undefined ? decimals : (_userCurrency === 'JPY' ? 0 : 2); const sym = getCurrencySymbol(); const formatted = dec === 0 ? Math.abs(Math.round(num)).toLocaleString() : Math.abs(num).toFixed(dec).replace(/\B(?=(\d{3})+(?!\d))/g, ','); return (num >= 0 ? '' : '-') + sym + formatted; } function applyROIPreset(value) { const now = new Date(); const fromEl = document.getElementById('roi-date-from'); const toEl = document.getElementById('roi-date-to'); const customDiv = document.getElementById('roi-custom-range'); const ymDiv = document.getElementById('roi-year-month-filters'); if (value === 'custom') { customDiv.style.display = 'flex'; if (ymDiv) ymDiv.style.display = 'none'; return; } if (!value) { // "Quick Select..." — reset to all customDiv.style.display = 'none'; if (ymDiv) ymDiv.style.display = 'flex'; fromEl.value = ''; toEl.value = ''; document.getElementById('roi-filter-year').value = ''; document.getElementById('roi-filter-month').value = ''; renderROITracker(); return; } let from, to; if (value === 'this_month') { from = new Date(now.getFullYear(), now.getMonth(), 1); to = new Date(now.getFullYear(), now.getMonth() + 1, 0); } else if (value === 'last_month') { from = new Date(now.getFullYear(), now.getMonth() - 1, 1); to = new Date(now.getFullYear(), now.getMonth(), 0); } else if (value === 'this_quarter') { const q = Math.floor(now.getMonth() / 3); from = new Date(now.getFullYear(), q * 3, 1); to = new Date(now.getFullYear(), q * 3 + 3, 0); } else if (value === 'last_quarter') { const q = Math.floor(now.getMonth() / 3) - 1; const yr = q < 0 ? now.getFullYear() - 1 : now.getFullYear(); const qn = q < 0 ? 3 : q; from = new Date(yr, qn * 3, 1); to = new Date(yr, qn * 3 + 3, 0); } else if (value === 'this_year') { from = new Date(now.getFullYear(), 0, 1); to = new Date(now.getFullYear(), 11, 31); } else if (value === 'ytd') { from = new Date(now.getFullYear(), 0, 1); to = now; } fromEl.value = from.toISOString().split('T')[0]; toEl.value = to.toISOString().split('T')[0]; customDiv.style.display = 'flex'; if (ymDiv) ymDiv.style.display = 'none'; renderROITracker(); } function clearROICustomRange() { document.getElementById('roi-date-from').value = ''; document.getElementById('roi-date-to').value = ''; document.getElementById('roi-custom-range').style.display = 'none'; document.getElementById('roi-year-month-filters').style.display = 'flex'; document.getElementById('roi-preset').value = ''; renderROITracker(); } function calculateROIMetrics() { const filteredExpenses = getFilteredExpenses(); const filteredPayouts = getFilteredPayouts(); // All values converted to display currency for aggregation const totalPayouts = filteredPayouts.reduce((sum, p) => sum + convertFromUSD(p.amount || 0), 0); const totalExpenses = filteredExpenses.reduce((sum, e) => sum + getExpenseInDisplayCurrency(e), 0); const netProfit = totalPayouts - totalExpenses; const roi = totalExpenses > 0 ? ((netProfit / totalExpenses) * 100) : 0; const avgPayout = filteredPayouts.length > 0 ? totalPayouts / filteredPayouts.length : 0; return { totalPayouts, totalExpenses, netProfit, roi, avgPayout, payoutCount: filteredPayouts.length }; } function calculatePnLByFirm() { const filteredExpenses = getFilteredExpenses(); const filteredPayouts = getFilteredPayouts(); const firmPnL = {}; // Add expenses filteredExpenses.forEach(e => { const firmKey = e.propFirm || '_general'; if (!firmPnL[firmKey]) { firmPnL[firmKey] = { payouts: 0, expenses: 0 }; } firmPnL[firmKey].expenses += getExpenseInDisplayCurrency(e); }); // Add payouts - try to match to firms filteredPayouts.forEach(p => { // Try to determine firm from payout's propFirm field, then account, then guess from ID let firm = p.propFirm; if (!firm) { const acc = accounts.find(a => a.id === p.accountId); if (acc?.propFirm) { firm = acc.propFirm; } else if (p.accountId) { // Try to guess from account ID prefix const id = p.accountId.toLowerCase(); if (id.includes('apex') || id.includes('pa-')) firm = 'apex'; else if (id.includes('topstep') || id.includes('ts')) firm = 'topstep'; else if (id.includes('mff') || id.includes('myfunded')) firm = 'myfundedfutures'; else if (id.includes('lucid')) firm = 'lucid'; else if (id.includes('tradeify')) firm = 'tradeify'; else if (id.includes('fundednext') || id.includes('fn')) firm = 'fundednext'; } } firm = firm || 'other'; if (!firmPnL[firm]) { firmPnL[firm] = { payouts: 0, expenses: 0 }; } firmPnL[firm].payouts += convertFromUSD(p.amount || 0); }); return firmPnL; } function calculateMonthlyBreakdown() { // ROI Tracker uses only its own year filter — global header filters intentionally excluded const year = document.getElementById('roi-filter-year')?.value; const allYears = !year; const labelYear = allYears ? new Date().getFullYear() : parseInt(year); const months = []; let cumulative = 0; for (let m = 1; m <= 12; m++) { const monthPayouts = payouts.filter(p => { if (p.type !== 'withdrawal') return false; const d = p.date.includes('T') ? new Date(p.date) : new Date(p.date + 'T12:00:00'); if (!allYears && d.getFullYear() !== parseInt(year)) return false; if (d.getMonth() + 1 !== m) return false; return true; }); const monthExpenses = expenses.filter(e => { const d = e.date.includes('T') ? new Date(e.date) : new Date(e.date + 'T12:00:00'); if (!allYears && d.getFullYear() !== parseInt(year)) return false; if (d.getMonth() + 1 !== m) return false; return true; }); const payoutTotal = monthPayouts.reduce((sum, p) => sum + convertFromUSD(p.amount || 0), 0); const expenseTotal = monthExpenses.reduce((sum, e) => sum + getExpenseInDisplayCurrency(e), 0); const net = payoutTotal - expenseTotal; cumulative += net; months.push({ month: m, monthName: new Date(labelYear, m - 1, 1).toLocaleDateString('en-US', { month: 'long' }), payouts: payoutTotal, expenses: expenseTotal, net: net, cumulative: cumulative }); } return months; } function renderROITracker() { // Populate year dropdown dynamically from expense + trade data const yearSelect = document.getElementById('roi-filter-year'); if (yearSelect) { const currentVal = yearSelect.value; const years = new Set(); years.add(new Date().getFullYear()); expenses.forEach(e => { if (e.date) { try { years.add(new Date(e.date.includes('T') ? e.date : e.date + 'T12:00:00').getFullYear()); } catch(x) {} } }); trades.forEach(t => { if (t.date) { try { years.add(new Date(t.date.includes('T') ? t.date : t.date + 'T12:00:00').getFullYear()); } catch(x) {} } }); const sortedYears = [...years].filter(y => !isNaN(y)).sort((a, b) => b - a); yearSelect.innerHTML = '' + sortedYears.map(y => '').join(''); yearSelect.value = currentVal; // Restore previous selection } // Calculate and display metrics const metrics = calculateROIMetrics(); document.getElementById('roi-total-payouts').textContent = formatDisplayAmount(metrics.totalPayouts); document.getElementById('roi-total-expenses').textContent = formatDisplayAmount(metrics.totalExpenses); const netEl = document.getElementById('roi-net-profit'); netEl.textContent = (metrics.netProfit >= 0 ? '+' : '') + formatDisplayAmount(metrics.netProfit); netEl.style.color = metrics.netProfit >= 0 ? 'var(--cyan)' : 'var(--red)'; // Update net profit card background const netCard = document.getElementById('roi-net-card'); if (netCard) { netCard.style.background = metrics.netProfit >= 0 ? 'rgba(0,212,170,0.06)' : 'rgba(239,68,68,0.06)'; const netLabel = netCard.querySelector('div:first-child'); if (netLabel) netLabel.style.color = metrics.netProfit >= 0 ? 'var(--cyan)' : 'var(--red)'; } const roiEl = document.getElementById('roi-percentage'); roiEl.textContent = (metrics.roi >= 0 ? '+' : '') + metrics.roi.toFixed(0) + '%'; roiEl.style.color = metrics.roi >= 0 ? 'var(--cyan)' : 'var(--red)'; document.getElementById('roi-avg-payout').textContent = formatDisplayAmount(metrics.avgPayout); // Render all sections renderExpenseTable(); renderROIWidgets(); initQuickEntry(); if (streamerMode) applySensitiveBlur(); // Lazy backfill historical rates for existing non-USD expenses backfillHistoricalRates(); } let _backfillInProgress = false; async function backfillHistoricalRates() { if (_backfillInProgress) return; const needsBackfill = expenses.filter(e => e.expenseCurrency && e.expenseCurrency !== 'USD' && !e.expenseRateAtDate ); if (needsBackfill.length === 0) return; _backfillInProgress = true; console.log(`[ROI] Backfilling historical rates for ${needsBackfill.length} expenses...`); let updated = 0; for (const exp of needsBackfill) { const rate = await fetchHistoricalRate(exp.date, exp.expenseCurrency); if (rate) { exp.expenseRateAtDate = rate; // Restore original amount if it was previously converted to USD if (exp.expenseOriginalAmount && Math.abs(exp.amount - exp.expenseOriginalAmount) > 0.01) { exp.amount = exp.expenseOriginalAmount; } updated++; } } if (updated > 0) { console.log(`[ROI] Updated ${updated} expenses with historical rates (in-memory only)`); _backfillInProgress = false; renderROITracker(); renderExpenseTable(); } else { _backfillInProgress = false; } } function renderPnLTable() { // Create table structure inside widget if needed const container = document.getElementById('roi-widget-body-pnl_by_firm'); if (container && !document.getElementById('roi-pnl-tbody')) { container.innerHTML = `
FirmPayoutsExpensesNetROIBest Month
TOTAL
`; } const tbody = document.getElementById('roi-pnl-tbody'); const tfoot = document.getElementById('roi-pnl-tfoot'); if (!tbody) return; const firmPnL = calculatePnLByFirm(); const firms = Object.keys(firmPnL); if (firms.length === 0) { tbody.innerHTML = 'No data yet'; if (tfoot) tfoot.style.display = 'none'; return; } let totalPayouts = 0; let totalExpenses = 0; // Calculate best month per firm from payouts const firmBestMonth = {}; const filteredPayouts = getFilteredPayouts(); filteredPayouts.forEach(p => { let firm = p.propFirm; if (!firm) { const acc = accounts.find(a => a.id === p.accountId); if (acc?.propFirm) firm = acc.propFirm; } firm = firm || 'other'; const d = p.date.includes('T') ? new Date(p.date) : new Date(p.date + 'T12:00:00'); const key = d.getFullYear() + '-' + String(d.getMonth()+1).padStart(2,'0'); if (!firmBestMonth[firm]) firmBestMonth[firm] = {}; firmBestMonth[firm][key] = (firmBestMonth[firm][key] || 0) + (p.amount || 0); }); const firmRows = []; firms.sort().forEach(firm => { const data = firmPnL[firm]; const net = data.payouts - data.expenses; const roi = data.expenses > 0 ? ((net / data.expenses) * 100) : (data.payouts > 0 ? 100 : 0); totalPayouts += data.payouts; totalExpenses += data.expenses; // Find best month let bestMonthLabel = '—', bestMonthAmt = 0; if (firmBestMonth[firm]) { Object.entries(firmBestMonth[firm]).forEach(([k, v]) => { if (v > bestMonthAmt) { bestMonthAmt = v; const [y, m] = k.split('-'); bestMonthLabel = new Date(parseInt(y), parseInt(m)-1).toLocaleDateString('en-US', {month:'short', year:'numeric'}); } }); } firmRows.push({ firm, data, net, roi, bestMonthLabel, bestMonthAmt }); }); const bestNet = Math.max(...firmRows.map(r => r.net)); tbody.innerHTML = ''; firmRows.forEach(r => { const isBest = r.net > 0 && r.net === bestNet && firmRows.length > 1; const isNeg = r.net < 0; tbody.innerHTML += ` ${r.firm === '_general' ? 'General' : (propFirmNames[r.firm] || r.firm)}${isBest ? ' TOP' : ''} ${formatDisplayAmount(r.data.payouts)} ${formatDisplayAmount(r.data.expenses)} ${r.net >= 0 ? '+' : ''}${formatDisplayAmount(r.net)} ${r.roi >= 0 ? '+' : ''}${r.roi.toFixed(0)}% ${r.bestMonthLabel}${r.bestMonthAmt > 0 ? ' (' + formatCurrency(r.bestMonthAmt) + ')' : ''} `; }); const totalNet = totalPayouts - totalExpenses; const totalRoi = totalExpenses > 0 ? ((totalNet / totalExpenses) * 100) : 0; document.getElementById('roi-tfoot-payouts').textContent = formatDisplayAmount(totalPayouts); document.getElementById('roi-tfoot-expenses').textContent = formatDisplayAmount(totalExpenses); document.getElementById('roi-tfoot-net').textContent = (totalNet >= 0 ? '+' : '') + formatDisplayAmount(totalNet); document.getElementById('roi-tfoot-net').style.color = totalNet >= 0 ? 'var(--cyan)' : 'var(--red)'; document.getElementById('roi-tfoot-roi').textContent = (totalRoi >= 0 ? '+' : '') + totalRoi.toFixed(0) + '%'; document.getElementById('roi-tfoot-roi').style.color = totalRoi >= 0 ? 'var(--cyan)' : 'var(--red)'; const bmEl = document.getElementById('roi-tfoot-bestmonth'); if (bmEl) bmEl.textContent = '—'; tfoot.style.display = ''; } let expenseCurrentPage = 1; let expensePageSize = 10; let _expenseSearchQuery = ''; function renderExpenseTable() { const tbody = document.getElementById('roi-expense-tbody'); const tfoot = document.getElementById('roi-expense-tfoot'); const paginationEl = document.getElementById('expense-pagination'); const filteredExpenses = getFilteredExpenses(); if (filteredExpenses.length === 0) { const countEl = document.getElementById('roi-expense-count'); if (countEl) countEl.textContent = ''; tbody.innerHTML = 'No expenses yet — type in the row above to start tracking'; if (tfoot) tfoot.style.display = 'none'; const avgWidgetHide1 = document.getElementById('roi-expense-avg-widget'); if (avgWidgetHide1) avgWidgetHide1.style.display = 'none'; if (paginationEl) paginationEl.style.display = 'none'; document.getElementById('expense-delete-selected-btn').style.display = 'none'; const selectAllCb = document.getElementById('expense-select-all'); if (selectAllCb) selectAllCb.checked = false; return; } // Apply text search on top of date/firm filters (search does not affect header KPIs) let displayExpenses = filteredExpenses; if (_expenseSearchQuery) { displayExpenses = filteredExpenses.filter(e => { const firmName = (propFirmNames[e.propFirm] || e.propFirm || 'General').toLowerCase(); const typeLabel = (expenseTypeLabels[e.type] || e.type || '').replace(/^[^\w]*/, '').toLowerCase(); const desc = (e.description || '').toLowerCase(); const dateRaw = e.date.toLowerCase(); const dateFmt = new Date(e.date + 'T12:00:00').toLocaleDateString().toLowerCase(); const amountStr = (e.amount || 0).toString(); return firmName.includes(_expenseSearchQuery) || typeLabel.includes(_expenseSearchQuery) || desc.includes(_expenseSearchQuery) || dateRaw.includes(_expenseSearchQuery) || dateFmt.includes(_expenseSearchQuery) || amountStr.includes(_expenseSearchQuery); }); } // Update count label const countEl = document.getElementById('roi-expense-count'); if (countEl) { if (_expenseSearchQuery && displayExpenses.length !== filteredExpenses.length) { countEl.textContent = displayExpenses.length + ' of ' + filteredExpenses.length + ' expense' + (filteredExpenses.length !== 1 ? 's' : ''); } else { countEl.textContent = filteredExpenses.length > 0 ? filteredExpenses.length + ' expense' + (filteredExpenses.length !== 1 ? 's' : '') : ''; } } if (displayExpenses.length === 0) { tbody.innerHTML = 'No expenses found'; if (tfoot) tfoot.style.display = 'none'; const avgWidgetHide2 = document.getElementById('roi-expense-avg-widget'); if (avgWidgetHide2) avgWidgetHide2.style.display = 'none'; if (paginationEl) paginationEl.style.display = 'none'; return; } // Sort by date descending, then most recently added first within same date displayExpenses.sort((a, b) => { const dateDiff = new Date(b.date) - new Date(a.date); if (dateDiff !== 0) return dateDiff; return new Date(b.createdAt || 0) - new Date(a.createdAt || 0); }); // Grand total of currently displayed (search-filtered) expenses const grandTotal = displayExpenses.reduce((s, e) => s + getExpenseInDisplayCurrency(e), 0); // Pagination math const totalPages = Math.ceil(displayExpenses.length / expensePageSize); if (expenseCurrentPage > totalPages) expenseCurrentPage = totalPages; if (expenseCurrentPage < 1) expenseCurrentPage = 1; const startIdx = (expenseCurrentPage - 1) * expensePageSize; const pageItems = displayExpenses.slice(startIdx, startIdx + expensePageSize); tbody.innerHTML = ''; pageItems.forEach(exp => { const account = accounts.find(a => a.id === exp.accountId); const accountDisplay = account ? (account.accountNumber || account.name || exp.accountId) : (exp.accountId ? '⚠️ Deleted Account' : '-'); const expDate = new Date(exp.date + 'T12:00:00'); tbody.innerHTML += ` ${expDate.toLocaleDateString()} ${exp.propFirm ? (propFirmNames[exp.propFirm] || exp.propFirm) : 'General'} ${(expenseTypeLabels[exp.type] || exp.type).replace(/^[^\w]*/, '')} ${exp.description || ''} ${formatExpenseAmount(exp)} `; }); // Grand total in footer (always visible, always full total) if (tfoot) { tfoot.style.display = ''; const totalEl = document.getElementById('roi-expense-total'); if (totalEl) totalEl.textContent = '-' + formatDisplayAmount(grandTotal); } const avgWidget = document.getElementById('roi-expense-avg-widget'); if (avgWidget) avgWidget.style.display = 'flex'; // AVG per month — months that have any expenses const monthlyData = calculateMonthlyBreakdown(); const monthsWithExpenses = monthlyData.filter(m => m.expenses > 0).length; const avgPerMonth = monthsWithExpenses > 0 ? grandTotal / monthsWithExpenses : 0; // AVG per firm — unique firms in the displayed expenses const firmSet = new Set(displayExpenses.map(e => e.propFirm || '_general')); const uniqueFirmCount = firmSet.size; const avgPerFirm = uniqueFirmCount > 0 ? grandTotal / uniqueFirmCount : 0; // AVG cost per payout — total expenses / total payout count const metrics = calculateROIMetrics(); const avgPerPayout = metrics.payoutCount > 0 ? metrics.totalExpenses / metrics.payoutCount : 0; // Write to DOM const avgMonthEl = document.getElementById('roi-expense-avg-month'); const avgFirmEl = document.getElementById('roi-expense-avg-firm'); const avgPayoutEl = document.getElementById('roi-expense-avg-payout'); if (avgMonthEl) avgMonthEl.textContent = monthsWithExpenses > 0 ? formatDisplayAmount(avgPerMonth) : '—'; if (avgFirmEl) avgFirmEl.textContent = uniqueFirmCount > 0 ? formatDisplayAmount(avgPerFirm) : '—'; if (avgPayoutEl) avgPayoutEl.textContent = metrics.payoutCount > 0 ? formatDisplayAmount(avgPerPayout) : '—'; // Pagination controls if (paginationEl) { if (displayExpenses.length > expensePageSize) { paginationEl.style.display = 'flex'; document.getElementById('expense-page-info').textContent = `Page ${expenseCurrentPage} of ${totalPages}`; document.getElementById('expense-prev-btn').disabled = expenseCurrentPage <= 1; document.getElementById('expense-next-btn').disabled = expenseCurrentPage >= totalPages; } else { paginationEl.style.display = 'none'; } } updateExpenseSelectionCount(); if (streamerMode) applySensitiveBlur(); } function renderMonthlyTable() { const container = document.getElementById('roi-widget-body-monthly'); if (container && !document.getElementById('roi-monthly-tbody')) { container.innerHTML = `
MonthPayoutsExpensesNetCumulative
`; } const tbody = document.getElementById('roi-monthly-tbody'); if (!tbody) return; const months = calculateMonthlyBreakdown(); const activeMonths = months.filter(m => m.payouts > 0 || m.expenses > 0); if (activeMonths.length === 0) { tbody.innerHTML = 'No data for selected period.'; return; } tbody.innerHTML = ''; activeMonths.forEach(m => { const profitable = m.net > 0; const breakeven = m.net === 0 && (m.payouts > 0 || m.expenses > 0); tbody.innerHTML += ` ${m.monthName} ${formatDisplayAmount(m.payouts)} ${formatDisplayAmount(m.expenses)} ${m.net >= 0 ? '+' : ''}${formatDisplayAmount(m.net)} ${m.cumulative >= 0 ? '+' : ''}${formatDisplayAmount(m.cumulative)} ${profitable ? '' : breakeven ? '' : ''} `; }); } function renderROIPayoutChart() { const canvas = document.getElementById('roi-payout-chart'); if (!canvas) return; const ctx = canvas.getContext('2d'); if (roiPayoutChart) { roiPayoutChart.destroy(); } const months = calculateMonthlyBreakdown(); const activeMonths = months.filter(m => m.payouts > 0 || m.expenses > 0); if (activeMonths.length === 0) { // Show empty state ctx.fillStyle = 'var(--text-muted)'; ctx.textAlign = 'center'; ctx.font = '14px system-ui'; ctx.fillText('No payout data to display', canvas.width / 2, canvas.height / 2); return; } roiPayoutChart = new Chart(ctx, { type: 'bar', data: { labels: activeMonths.map(m => m.monthName.substring(0, 3)), datasets: [ { label: 'Payouts', data: activeMonths.map(m => m.payouts), backgroundColor: 'rgba(0, 212, 170, 0.7)', borderColor: 'rgba(0, 212, 170, 1)', borderWidth: 1, borderRadius: 3, order: 2 }, { label: 'Expenses', data: activeMonths.map(m => m.expenses), backgroundColor: 'rgba(239, 68, 68, 0.7)', borderColor: 'rgba(239, 68, 68, 1)', borderWidth: 1, borderRadius: 3, order: 3 }, { label: 'Net Profit', data: activeMonths.map(m => m.net), type: 'line', borderColor: '#06b6d4', backgroundColor: '#06b6d4', borderWidth: 2, pointRadius: 4, pointBackgroundColor: '#06b6d4', pointBorderColor: '#06b6d4', tension: 0.3, order: 1 } ] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'top', labels: { color: '#9ca3af', font: { size: 11 }, usePointStyle: true, padding: 12 } }, tooltip: { backgroundColor: 'rgba(17, 24, 39, 0.95)', titleColor: '#fff', bodyColor: '#9ca3af', borderColor: 'rgba(255,255,255,0.1)', borderWidth: 1, callbacks: { label: function(context) { return context.dataset.label + ': ' + formatDisplayAmount(context.raw); } } } }, scales: { x: { grid: { color: 'rgba(255,255,255,0.04)' }, ticks: { color: '#6b7280', font: { size: 11 } } }, y: { grid: { color: 'rgba(255,255,255,0.04)' }, ticks: { color: '#6b7280', font: { size: 11 }, callback: function(value) { return formatDisplayAmount(value, 0); } } } } } }); } // Cumulative Payout Timeline Chart let cumulativePayoutChart = null; function renderCumulativePayoutTimeline() { const canvas = document.getElementById('roi-cumulative-payout-chart'); if (!canvas) return; const ctx = canvas.getContext('2d'); if (cumulativePayoutChart) { cumulativePayoutChart.destroy(); cumulativePayoutChart = null; } // Get all withdrawals const withdrawals = payouts.filter(p => p.type === 'withdrawal'); if (withdrawals.length === 0) { ctx.fillStyle = '#6b7280'; ctx.textAlign = 'center'; ctx.font = '14px system-ui'; ctx.fillText('No payouts recorded yet', canvas.width / 2, canvas.height / 2); const totalEl = document.getElementById('roi-timeline-total'); if (totalEl) totalEl.textContent = '$0.00'; return; } // Group payouts by prop firm and determine firm for each payout const payoutsByFirm = {}; withdrawals.forEach(p => { let firm = p.propFirm; if (!firm) { const acc = accounts.find(a => a.id === p.accountId); if (acc?.propFirm) { firm = acc.propFirm; } else if (p.accountName) { // Try to match account name const matchedAcc = accounts.find(a => a.name === p.accountName); if (matchedAcc?.propFirm) firm = matchedAcc.propFirm; } } firm = firm || 'other'; if (!payoutsByFirm[firm]) payoutsByFirm[firm] = []; payoutsByFirm[firm].push({ date: p.date.includes('T') ? new Date(p.date) : new Date(p.date + 'T12:00:00'), amount: p.amount || 0, firm: firm }); }); // Sort all payouts by date to get timeline const allPayouts = withdrawals.map(p => { let firm = p.propFirm; if (!firm) { const acc = accounts.find(a => a.id === p.accountId); if (acc?.propFirm) firm = acc.propFirm; } return { date: p.date.includes('T') ? new Date(p.date) : new Date(p.date + 'T12:00:00'), amount: p.amount || 0, firm: firm || 'other' }; }).sort((a, b) => a.date - b.date); // Get unique dates for x-axis labels const uniqueDates = [...new Set(allPayouts.map(p => p.date.toISOString().split('T')[0]))].sort(); const labels = uniqueDates.map(d => { const date = new Date(d + 'T12:00:00'); return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); }); // Calculate cumulative totals for each firm at each date const firms = Object.keys(payoutsByFirm).sort(); const firmColors = { 'apex': { line: '#f59e0b', bg: 'rgba(245, 158, 11, 0.1)' }, 'topstep': { line: '#3b82f6', bg: 'rgba(59, 130, 246, 0.1)' }, 'tradeify': { line: '#8b5cf6', bg: 'rgba(139, 92, 246, 0.1)' }, 'takeprofittrader': { line: '#ec4899', bg: 'rgba(236, 72, 153, 0.1)' }, 'bulenox': { line: '#14b8a6', bg: 'rgba(20, 184, 166, 0.1)' }, 'myfundedfutures': { line: '#06b6d4', bg: 'rgba(6, 182, 212, 0.1)' }, 'earn2trade': { line: '#84cc16', bg: 'rgba(132, 204, 22, 0.1)' }, 'elitetraderfunding': { line: '#ef4444', bg: 'rgba(239, 68, 68, 0.1)' }, 'lucid': { line: '#f97316', bg: 'rgba(249, 115, 22, 0.1)' }, 'fundednext': { line: '#6366f1', bg: 'rgba(99, 102, 241, 0.1)' }, 'other': { line: '#6b7280', bg: 'rgba(107, 114, 128, 0.1)' } }; const datasets = []; // Create dataset for each firm's cumulative payouts firms.forEach(firm => { const firmPayouts = payoutsByFirm[firm].sort((a, b) => a.date - b.date); let cumulative = 0; const cumulativeByDate = {}; firmPayouts.forEach(p => { cumulative += p.amount; const dateKey = p.date.toISOString().split('T')[0]; cumulativeByDate[dateKey] = cumulative; }); // Fill in data points for all dates (carry forward cumulative value) const data = []; let lastValue = 0; uniqueDates.forEach(dateStr => { if (cumulativeByDate[dateStr] !== undefined) { lastValue = cumulativeByDate[dateStr]; } data.push(lastValue); }); const colors = firmColors[firm] || firmColors['other']; datasets.push({ label: propFirmNames[firm] || firm, data: data, borderColor: colors.line, backgroundColor: colors.bg, fill: false, tension: 0.2, pointRadius: 4, pointHoverRadius: 6, borderWidth: 2 }); }); // Add total cumulative line let totalCumulative = 0; const totalData = []; const totalByDate = {}; allPayouts.forEach(p => { totalCumulative += p.amount; const dateKey = p.date.toISOString().split('T')[0]; totalByDate[dateKey] = totalCumulative; }); let lastTotal = 0; uniqueDates.forEach(dateStr => { if (totalByDate[dateStr] !== undefined) { lastTotal = totalByDate[dateStr]; } totalData.push(lastTotal); }); datasets.push({ label: 'Total Net', data: totalData, borderColor: '#00d4aa', backgroundColor: 'rgba(0, 212, 170, 0.1)', fill: true, tension: 0.2, pointRadius: 5, pointHoverRadius: 8, borderWidth: 3, borderDash: [] }); // Update total display const totalEl = document.getElementById('roi-timeline-total'); if (totalEl) totalEl.textContent = formatCurrency(totalCumulative); cumulativePayoutChart = new Chart(ctx, { type: 'line', data: { labels: labels, datasets: datasets }, options: { responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false }, plugins: { legend: { position: 'top', labels: { color: '#9ca3af', font: { size: 11 }, usePointStyle: true, padding: 15 } }, tooltip: { backgroundColor: 'rgba(17, 24, 39, 0.95)', titleColor: '#fff', bodyColor: '#9ca3af', borderColor: 'rgba(255,255,255,0.1)', borderWidth: 1, callbacks: { label: function(context) { return context.dataset.label + ': ' + formatCurrency(context.raw); } } } }, scales: { x: { grid: { color: 'rgba(255,255,255,0.04)' }, ticks: { color: '#6b7280', font: { size: 11 }, maxRotation: 45, minRotation: 0 } }, y: { grid: { color: function(context) { return context.tick.value === 0 ? 'rgba(255,255,255,0.2)' : 'rgba(255,255,255,0.04)'; }, lineWidth: function(context) { return context.tick.value === 0 ? 1.5 : 1; } }, ticks: { color: '#6b7280', font: { size: 11 }, callback: function(value) { return formatCurrency(value, 0); } }, beginAtZero: true } } } }); } // Expense Donut Chart let roiExpenseDonutChart = null; function renderExpenseDonutChart() { const canvas = document.getElementById('roi-expense-donut-chart'); if (!canvas) return; const ctx = canvas.getContext('2d'); if (roiExpenseDonutChart) { roiExpenseDonutChart.destroy(); roiExpenseDonutChart = null; } const filteredExpenses = getFilteredExpenses(); const byType = {}; filteredExpenses.forEach(e => { const t = e.type || 'other'; if (!byType[t]) byType[t] = { amount: 0, count: 0 }; byType[t].amount += getExpenseInDisplayCurrency(e); byType[t].count++; }); const types = Object.keys(byType).sort((a,b) => byType[b].amount - byType[a].amount); const legendEl = document.getElementById('roi-donut-legend'); if (types.length === 0) { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = '#9ca3af'; ctx.textAlign = 'center'; ctx.font = '500 13px -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif'; ctx.fillText('No expenses recorded', canvas.width / 2, canvas.height / 2); if (legendEl) legendEl.innerHTML = ''; return; } const typeColors = { eval_fee: '#f59e0b', activation_fee: '#8b5cf6', reset_fee: '#ef4444', monthly_fee: '#3b82f6', data_fee: '#06b6d4', platform_fee: '#ec4899', vps_fee: '#10b981', education: '#f97316', tax_prep: '#84cc16', monthly_total: '#a78bfa', other: '#6b7280' }; const _fallbackPalette = ['#e11d48','#0ea5e9','#d97706','#7c3aed','#059669','#64748b','#dc2626','#0d9488']; let _fallbackIdx = 0; const _unknownTypeColors = {}; const colors = types.map(t => { if (typeColors[t]) return typeColors[t]; if (!_unknownTypeColors[t]) _unknownTypeColors[t] = _fallbackPalette[_fallbackIdx++ % _fallbackPalette.length]; return _unknownTypeColors[t]; }); const total = types.reduce((s, t) => s + byType[t].amount, 0); roiExpenseDonutChart = new Chart(ctx, { type: 'doughnut', data: { labels: types.map(t => expenseTypeLabels[t] || t), datasets: [{ data: types.map(t => byType[t].amount), backgroundColor: colors, borderColor: 'var(--bg-card)', borderWidth: 2 }] }, options: { responsive: true, maintainAspectRatio: false, cutout: '65%', plugins: { legend: { display: false }, tooltip: { backgroundColor: 'rgba(17,24,39,0.95)', titleColor: '#fff', bodyColor: '#9ca3af', callbacks: { label: function(c) { return c.label + ': ' + formatDisplayAmount(c.raw) + ' (' + ((c.raw/total)*100).toFixed(0) + '%)'; } } } } }, plugins: [{ id: 'centerText', afterDraw: function(chart) { const {ctx: c, width, height} = chart; c.save(); c.textAlign = 'center'; c.textBaseline = 'middle'; c.fillStyle = '#9ca3af'; c.font = '10px system-ui'; c.fillText('Total', width/2, height/2 - 10); c.fillStyle = '#ef4444'; c.font = 'bold 16px JetBrains Mono, monospace'; c.fillText(formatDisplayAmount(total), width/2, height/2 + 8); c.restore(); } }] }); // Render legend if (legendEl) { legendEl.innerHTML = types.map((t, i) => `
${expenseTypeLabels[t] || t}${formatDisplayAmount(byType[t].amount)}(${byType[t].count})
`).join(''); } } // Income Consistency Score function renderIncomeConsistency() { const body = document.getElementById('roi-consistency-body'); if (!body) return; const filteredPayouts = getFilteredPayouts(); if (filteredPayouts.length === 0) { body.innerHTML = '
No payout data yet
'; return; } // Calculate monthly payout data const months = calculateMonthlyBreakdown(); const activeMonths = months.filter(m => m.payouts > 0 || m.expenses > 0); const payoutMonths = months.filter(m => m.payouts > 0); const totalMonths = activeMonths.length || 1; const payoutPct = Math.round((payoutMonths.length / totalMonths) * 100); // Consecutive payout streak (current + best) let currentStreak = 0, bestStreak = 0, tempStreak = 0; months.forEach(m => { if (m.payouts > 0) { tempStreak++; bestStreak = Math.max(bestStreak, tempStreak); } else { tempStreak = 0; } }); // Current streak from end for (let i = months.length - 1; i >= 0; i--) { if (months[i].payouts > 0) currentStreak++; else if (months[i].payouts === 0 && months[i].expenses === 0 && i > activeMonths.length - 1) continue; else break; } // Average days between payouts const sortedDates = filteredPayouts.map(p => p.date.includes('T') ? new Date(p.date) : new Date(p.date + 'T12:00:00')).sort((a,b) => a-b); let avgDaysBetween = 0; if (sortedDates.length > 1) { let totalGap = 0; for (let i = 1; i < sortedDates.length; i++) totalGap += (sortedDates[i] - sortedDates[i-1]) / 86400000; avgDaysBetween = Math.round(totalGap / (sortedDates.length - 1)); } // Score (0-100) let score = 0; score += Math.min(payoutPct, 100) * 0.4; // 40% weight: payout frequency score += Math.min(currentStreak * 10, 30); // 30% weight: current streak (max 3 months) score += Math.min(bestStreak * 5, 15); // 15% weight: best streak score += avgDaysBetween > 0 ? Math.max(0, 15 - (avgDaysBetween - 14) * 0.5) : 0; // 15% weight: frequency score = Math.round(Math.min(score, 100)); const grade = score >= 90 ? 'A' : score >= 80 ? 'B+' : score >= 70 ? 'B' : score >= 60 ? 'C+' : score >= 50 ? 'C' : score >= 40 ? 'D' : 'F'; const gradeColor = score >= 70 ? 'var(--cyan)' : score >= 50 ? 'var(--yellow)' : 'var(--red)'; // Streak dots (last 12 months) — larger squares with month labels const last12 = months.slice(-12); const streakDots = last12.map(m => { const shortMonth = m.monthName.split(' ')[0].substring(0, 3); if (m.payouts > 0) return `
$
`; if (m.expenses > 0) return `
`; return `
`; }).join(''); body.innerHTML = `
${grade}
${score}/100
Current Streak ${currentStreak} mo
Best Streak ${bestStreak} mo
Months with Payouts ${payoutPct}%
Avg Days Between Payouts ${avgDaysBetween > 0 ? avgDaysBetween + 'd' : '—'}
Last 12 Months
${streakDots}
Payout Expenses only Inactive
`; } function exportROIToCSV() { const months = buildMonthlyBreakdownForExport(); const filteredExpenses = getFilteredExpenses(); const filteredPayouts = getFilteredPayouts().slice().sort((a, b) => new Date(a.date) - new Date(b.date)); if (months.length === 0 && filteredExpenses.length === 0 && filteredPayouts.length === 0) { showToast('No data to export', 'info'); return; } function esc(v) { const s = (v == null ? '' : String(v)); return (s.includes(',') || s.includes('"') || s.includes('\n')) ? '"' + s.replace(/"/g, '""') + '"' : s; } let csv = ''; // Section 1: Monthly Summary — range-aware, includes year in month label if (months.length > 0) { csv += 'MONTHLY SUMMARY\n'; csv += 'Month,Payouts,Expenses,Net P&L,Cumulative\n'; months.forEach(m => { csv += [m.monthYear, m.payouts.toFixed(2), m.expenses.toFixed(2), m.net.toFixed(2), m.cumulative.toFixed(2)].map(esc).join(',') + '\n'; }); csv += '\n'; } // Section 2: Payout Detail — uses full filter state (date range, year, month) if (filteredPayouts.length > 0) { csv += 'PAYOUT DETAIL\n'; csv += 'Date,Prop Firm,Account,Net Amount,Gross Amount,Profit Split %,Notes\n'; filteredPayouts.forEach(p => { const payDate = (p.date || '').split('T')[0]; const firmName = p.propFirm ? (propFirmNames[p.propFirm] || p.propFirm) : ''; const acct = p.accountName || accounts.find(a => a.id === p.accountId)?.accountNumber || ''; const gross = p.grossAmount != null ? p.grossAmount.toFixed(2) : p.amount.toFixed(2); const split = p.profitSplit != null ? p.profitSplit : ''; const notes = p.note || p.notes || ''; csv += [payDate, firmName, acct, p.amount.toFixed(2), gross, split, notes].map(esc).join(',') + '\n'; }); csv += '\n'; } // Section 3: Expense Detail — identical format to the expenses table ⬇ Export button if (filteredExpenses.length > 0) { csv += 'EXPENSE DETAIL\n'; csv += 'Date,Prop Firm,Type,Description,Amount,Currency\n'; filteredExpenses.forEach(e => { const firmName = e.propFirm ? (propFirmNames[e.propFirm] || e.propFirm) : ''; const typeLabel = e.type ? (expenseTypeLabels[e.type] || e.type).replace(/^[^\w]*/, '').trim() : ''; csv += [e.date || '', firmName, typeLabel, e.description || '', e.amount != null ? e.amount : '', e.expenseCurrency || _userCurrency || 'USD'].map(esc).join(',') + '\n'; }); } const blob = new Blob([csv], { type: 'text/csv' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `PayoutLab_ROI_${new Date().toISOString().split('T')[0]}.csv`; a.click(); URL.revokeObjectURL(url); } // ===================================================== // EXPENSE CSV IMPORT // ===================================================== let parsedImportRows = []; let expenseCSVRawData = null; // Papa.parse results stashed between steps let expenseCSVColMap = {}; // user-chosen column mapping // ── ROI Widget Dashboard ────────────────────────────── const ROI_WIDGETS = { pnl_by_firm: { id: 'pnl_by_firm', title: '📊 Income by Prop Firm', render: 'renderPnLTable' }, monthly: { id: 'monthly', title: '📅 Monthly Breakdown', render: 'renderMonthlyTable' }, expense_donut: { id: 'expense_donut', title: '🍩 Expense Breakdown', render: 'renderExpenseDonutChart' }, cumulative: { id: 'cumulative', title: '📈 Cumulative P&L', render: 'renderCumulativePayoutTimeline' }, payout_chart: { id: 'payout_chart', title: '💰 Payout History', render: 'renderROIPayoutChart' }, consistency: { id: 'consistency', title: '🔥 Income Consistency', render: 'renderIncomeConsistency' }, }; let _roiActiveWidgets = JSON.parse(localStorage.getItem('roiWidgets') || 'null') || ['pnl_by_firm', 'monthly', 'expense_donut', 'cumulative']; function toggleROIWidgetPicker() { const el = document.getElementById('roi-widget-picker'); el.style.display = el.style.display === 'none' ? 'block' : 'none'; if (el.style.display === 'block') { const optionsEl = document.getElementById('roi-widget-options'); optionsEl.innerHTML = ''; Object.values(ROI_WIDGETS).forEach(w => { const active = _roiActiveWidgets.includes(w.id); optionsEl.innerHTML += ``; }); } } function toggleROIWidget(widgetId) { const idx = _roiActiveWidgets.indexOf(widgetId); if (idx >= 0) { _roiActiveWidgets.splice(idx, 1); } else { _roiActiveWidgets.push(widgetId); } localStorage.setItem('roiWidgets', JSON.stringify(_roiActiveWidgets)); toggleROIWidgetPicker(); // refresh picker buttons renderROIWidgets(); } function renderROIWidgets() { const grid = document.getElementById('roi-widget-grid'); if (!grid) return; grid.innerHTML = ''; _roiActiveWidgets.forEach(widgetId => { const w = ROI_WIDGETS[widgetId]; if (!w) return; // Load saved height or use default const savedHeight = localStorage.getItem('roiWidgetHeight_' + w.id); const card = document.createElement('div'); card.className = 'card roi-resizable-widget'; card.style.cssText = 'overflow: hidden; position: relative; height: ' + (savedHeight || '340') + 'px;'; card.dataset.widgetId = w.id; card.innerHTML = `
${w.title}
`; grid.appendChild(card); }); // Create canvas elements inside chart widgets before rendering const canvasWidgets = { expense_donut: 'roi-expense-donut-chart', cumulative: 'roi-cumulative-payout-chart', payout_chart: 'roi-payout-chart', }; Object.entries(canvasWidgets).forEach(([widgetId, canvasId]) => { const body = document.getElementById('roi-widget-body-' + widgetId); if (body && !document.getElementById(canvasId)) { body.innerHTML = '
'; } }); // Consistency widget uses a div, not canvas const consBody = document.getElementById('roi-widget-body-consistency'); if (consBody && !document.getElementById('roi-consistency-body')) { consBody.innerHTML = '
'; } // Render each widget's content _roiActiveWidgets.forEach(widgetId => { const w = ROI_WIDGETS[widgetId]; if (!w) return; try { if (typeof window[w.render] === 'function') window[w.render](); else if (typeof eval(w.render) === 'function') eval(w.render + '()'); } catch (e) { console.warn('Widget render failed:', w.id, e); } }); // Enable drag-and-drop reordering initROIWidgetDragDrop(); } // ── Inline Row Editing ───────────────────────────── function makeExpenseRowEditable(expId, tr) { const exp = expenses.find(e => e.id === expId); if (!exp) return; // Cancel any other rows currently in edit mode (single-row editing) const editingRows = document.querySelectorAll('#roi-expense-tbody tr[data-editing="true"]'); if (editingRows.length > 0) { renderExpenseTable(); // re-render reverts all edits // Re-find the target row after re-render const newTr = document.querySelector(`#roi-expense-tbody tr[onclick*="'${expId}'"]`); if (newTr) tr = newTr; else return; } tr.setAttribute('data-editing', 'true'); // Build firm options let firmOpts = ''; Object.entries(propFirmNames).sort((a, b) => a[1].localeCompare(b[1])).forEach(([k, v]) => { if (k === 'personal' || k === 'other') return; firmOpts += ''; }); // Build type options const types = propFirmExpenseTypes[exp.propFirm || ''] || propFirmExpenseTypes[''] || propFirmExpenseTypes.other; let typeOpts = ''; types.forEach(t => { typeOpts += ''; }); const inputStyle = 'font-size:13px;padding:4px 6px;width:100%;background:var(--bg-primary);color:var(--text-primary);border:1px solid var(--cyan);border-radius:4px;'; tr.ondblclick = null; tr.onclick = null; tr.onmousedown = null; tr.style.background = 'rgba(0,212,170,0.06)'; tr.innerHTML = ` `; tr.querySelector('#edit-row-amount').focus(); tr.querySelector('#edit-row-amount').addEventListener('keydown', e => { if (e.key === 'Enter') saveEditedRow(expId); if (e.key === 'Escape') renderExpenseTable(); }); } async function saveEditedRow(expId) { const idx = expenses.findIndex(e => e.id === expId); if (idx === -1) return; const date = document.getElementById('edit-row-date')?.value; const propFirm = document.getElementById('edit-row-firm')?.value || ''; const type = document.getElementById('edit-row-type')?.value; const desc = document.getElementById('edit-row-desc')?.value || ''; const amount = parseFloat(document.getElementById('edit-row-amount')?.value) || 0; if (!date || !type || amount <= 0) { showToast('Fill date, type, and amount', 'warning'); return; } const existing = expenses[idx]; const expenseCurrency = existing.expenseCurrency || 'USD'; // Re-fetch historical rate if date changed or rate is missing for non-USD expenses let expenseRateAtDate = existing.expenseRateAtDate; if (expenseCurrency !== 'USD' && (date !== existing.date || !expenseRateAtDate)) { const rate = await fetchHistoricalRate(date, expenseCurrency); if (rate) expenseRateAtDate = rate; } const updates = { date, propFirm, type, description: desc, amount: Math.round(amount * 100) / 100, updatedAt: new Date().toISOString() }; if (expenseCurrency !== 'USD' && expenseRateAtDate) updates.expenseRateAtDate = expenseRateAtDate; expenses[idx] = { ...existing, ...updates }; try { await saveExpenses(); renderROITracker(); renderExpenseTable(); showToast('Expense updated', 'success'); } catch (e) { showToast('Error: ' + e.message, 'error'); } } // ── Widget Drag & Drop ─────────────────────────────── function initROIWidgetDragDrop() { const grid = document.getElementById('roi-widget-grid'); if (!grid) return; grid.querySelectorAll('.roi-resizable-widget').forEach(card => { // Make the header the drag handle, not the whole card const header = card.querySelector('.card-header'); if (header) { header.draggable = true; header.addEventListener('dragstart', e => { e.dataTransfer.setData('text/plain', card.dataset.widgetId); card.style.opacity = '0.5'; }); header.addEventListener('dragend', () => { card.style.opacity = '1'; }); } card.addEventListener('dragover', e => { e.preventDefault(); card.style.borderTop = '2px solid var(--cyan)'; }); card.addEventListener('dragleave', () => { card.style.borderTop = ''; }); card.addEventListener('drop', e => { e.preventDefault(); card.style.borderTop = ''; const fromId = e.dataTransfer.getData('text/plain'); const toId = card.dataset.widgetId; if (fromId && toId && fromId !== toId) { const fromIdx = _roiActiveWidgets.indexOf(fromId); const toIdx = _roiActiveWidgets.indexOf(toId); if (fromIdx >= 0 && toIdx >= 0) { _roiActiveWidgets.splice(fromIdx, 1); _roiActiveWidgets.splice(toIdx, 0, fromId); localStorage.setItem('roiWidgets', JSON.stringify(_roiActiveWidgets)); renderROIWidgets(); } } }); }); // Attach resize handles grid.querySelectorAll('.roi-resize-handle').forEach(handle => { handle.addEventListener('mousedown', startResize); handle.addEventListener('touchstart', startResize, { passive: false }); }); } // ── Widget Resize ──────────────────────────────────── let _resizeTarget = null; let _resizeStartY = 0; let _resizeStartH = 0; function startResize(e) { e.preventDefault(); e.stopPropagation(); const handle = e.currentTarget; const card = handle.closest('.roi-resizable-widget'); if (!card) return; _resizeTarget = card; _resizeStartY = e.clientY || e.touches?.[0]?.clientY || 0; _resizeStartH = card.offsetHeight; card.style.transition = 'none'; document.addEventListener('mousemove', doResize); document.addEventListener('mouseup', stopResize); document.addEventListener('touchmove', doResize, { passive: false }); document.addEventListener('touchend', stopResize); } function doResize(e) { if (!_resizeTarget) return; e.preventDefault(); const y = e.clientY || e.touches?.[0]?.clientY || 0; const newH = Math.max(150, _resizeStartH + (y - _resizeStartY)); _resizeTarget.style.height = newH + 'px'; } function stopResize() { if (_resizeTarget) { const wid = _resizeTarget.dataset.widgetId; if (wid) localStorage.setItem('roiWidgetHeight_' + wid, _resizeTarget.offsetHeight); _resizeTarget.style.transition = ''; _resizeTarget = null; } document.removeEventListener('mousemove', doResize); document.removeEventListener('mouseup', stopResize); document.removeEventListener('touchmove', doResize); document.removeEventListener('touchend', stopResize); } function exportROIData() { exportROIToCSV(); } function toggleROIExportDropdown(e) { e.stopPropagation(); var d = document.getElementById('roi-export-dropdown'); if (d) d.style.display = d.style.display === 'none' ? 'block' : 'none'; setTimeout(function() { document.addEventListener('click', closeROIExportDropdown); }, 0); } function closeROIExportDropdown() { var d = document.getElementById('roi-export-dropdown'); if (d) d.style.display = 'none'; document.removeEventListener('click', closeROIExportDropdown); } // Range-aware monthly breakdown used by PDF and CSV exports. // Groups filtered payouts + expenses by YYYY-MM and returns chronologically sorted rows. function buildMonthlyBreakdownForExport() { const fp = getFilteredPayouts(); const fe = getFilteredExpenses(); const monthMap = {}; fp.forEach(function(p) { if (!p.date) return; const d = p.date.includes('T') ? new Date(p.date) : new Date(p.date + 'T12:00:00'); if (isNaN(d.getTime())) return; const key = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0'); if (!monthMap[key]) monthMap[key] = { payouts: 0, expenses: 0 }; monthMap[key].payouts += convertFromUSD(p.amount || 0); }); fe.forEach(function(e) { if (!e.date) return; const d = e.date.includes('T') ? new Date(e.date) : new Date(e.date + 'T12:00:00'); if (isNaN(d.getTime())) return; const key = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0'); if (!monthMap[key]) monthMap[key] = { payouts: 0, expenses: 0 }; monthMap[key].expenses += getExpenseInDisplayCurrency(e); }); const sorted = Object.keys(monthMap).sort(); let cumulative = 0; return sorted.map(function(key) { const parts = key.split('-'); const monthYear = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, 1).toLocaleDateString('en-US', { month: 'long', year: 'numeric' }); const net = monthMap[key].payouts - monthMap[key].expenses; cumulative += net; return { monthYear: monthYear, payouts: monthMap[key].payouts, expenses: monthMap[key].expenses, net: net, cumulative: cumulative }; }); } function exportROIPDF() { if (!window.jspdf || !window.jspdf.jsPDF) { showToast('PDF library not loaded — refresh the page and try again', 'error'); return; } const { jsPDF } = window.jspdf; const doc = new jsPDF({ unit: 'mm', format: 'a4' }); if (typeof doc.autoTable !== 'function') { showToast('PDF table plugin not loaded — refresh the page and try again', 'error'); return; } // ROI metrics are already in display currency — don't re-convert const fmt = (val) => { const num = parseFloat(val) || 0; const dec = _userCurrency === 'JPY' ? 0 : 2; const sym = getCurrencySymbol(); const abs = dec === 0 ? Math.abs(Math.round(num)).toLocaleString() : Math.abs(num).toFixed(dec).replace(/\B(?=(\d{3})+(?!\d))/g, ','); return (num >= 0 ? '' : '-') + sym + abs; }; const metrics = calculateROIMetrics(); const months = buildMonthlyBreakdownForExport(); const firmPnL = calculatePnLByFirm(); const filteredExpenses = getFilteredExpenses(); const filteredPayouts = getFilteredPayouts(); // Build period label from active filters (used as fallback + filename) const dateFrom = document.getElementById('roi-date-from')?.value; const dateTo = document.getElementById('roi-date-to')?.value; const yearSel = document.getElementById('roi-filter-year')?.value; const monthSel = document.getElementById('roi-filter-month')?.value; let periodLabel; if (dateFrom || dateTo) { periodLabel = (dateFrom || '…') + ' to ' + (dateTo || '…'); } else if (yearSel && monthSel) { const mName = new Date(parseInt(yearSel), parseInt(monthSel) - 1, 1).toLocaleDateString('en-US', { month: 'long' }); periodLabel = mName + ' ' + yearSel; } else if (yearSel) { periodLabel = yearSel; } else if (monthSel) { periodLabel = new Date(2000, parseInt(monthSel) - 1, 1).toLocaleDateString('en-US', { month: 'long' }) + ' (All Years)'; } else { periodLabel = 'All Time'; } // Derive actual date range from filtered data — more accurate than the filter label for tax docs const allDates = [ ...filteredPayouts.map(p => p.date), ...filteredExpenses.map(e => e.date), ].filter(Boolean).sort(); const parseDateSafe = (s) => { const d = s.includes('T') ? new Date(s) : new Date(s + 'T12:00:00'); return isNaN(d.getTime()) ? null : d; }; const earliestDateObj = allDates.length ? parseDateSafe(allDates[0]) : null; const latestDateObj = allDates.length ? parseDateSafe(allDates[allDates.length - 1]) : null; const fmtDateLong = (d) => d.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }); const dateRangeLabel = (earliestDateObj && latestDateObj) ? (fmtDateLong(earliestDateObj) + ' – ' + fmtDateLong(latestDateObj)) : periodLabel; const pageW = doc.internal.pageSize.getWidth(); const pageH = doc.internal.pageSize.getHeight(); const margin = 20; const contentW = pageW - margin * 2; // ── HEADER ───────────────────────────────────────────── doc.setFont('helvetica', 'bold'); doc.setFontSize(22); doc.setTextColor(0, 212, 170); doc.text('PayoutLab', margin, margin + 2); doc.setFont('helvetica', 'normal'); doc.setFontSize(12); doc.setTextColor(110, 110, 120); doc.text('ROI Report', margin, margin + 9); doc.setFontSize(10); doc.setTextColor(50, 50, 60); doc.text('Period: ' + dateRangeLabel, margin, margin + 15); const genAt = new Date().toLocaleString(); doc.setFontSize(9); doc.setTextColor(120, 120, 130); doc.text('Currency: ' + (_userCurrency || 'USD'), pageW - margin, margin + 2, { align: 'right' }); doc.text('Generated: ' + genAt, pageW - margin, margin + 7, { align: 'right' }); doc.setDrawColor(220, 220, 225); doc.setLineWidth(0.3); doc.line(margin, margin + 19, pageW - margin, margin + 19); let cursorY = margin + 25; // ── HERO KPI ROW ─────────────────────────────────────── doc.setFontSize(11); doc.setFont('helvetica', 'bold'); doc.setTextColor(40, 40, 50); doc.text('Key Metrics', margin, cursorY); cursorY += 3; const netPositive = metrics.netProfit >= 0; doc.autoTable({ startY: cursorY, head: [['Total Payouts', 'Total Expenses', 'Net Profit', 'ROI', 'Avg Payout']], body: [[ fmt(metrics.totalPayouts), fmt(metrics.totalExpenses), (netPositive ? '+' : '') + fmt(metrics.netProfit), (metrics.roi >= 0 ? '+' : '') + metrics.roi.toFixed(0) + '%', fmt(metrics.avgPayout), ]], theme: 'grid', margin: { left: margin, right: margin }, styles: { font: 'helvetica', fontSize: 10, halign: 'center', cellPadding: 4 }, headStyles: { fillColor: [240, 242, 247], textColor: [80, 80, 95], fontStyle: 'bold' }, bodyStyles: { fontStyle: 'bold' }, didParseCell: (data) => { if (data.section === 'body' && data.column.index === 2) { data.cell.styles.textColor = netPositive ? [0, 140, 90] : [200, 50, 50]; } if (data.section === 'body' && data.column.index === 3) { data.cell.styles.textColor = metrics.roi >= 0 ? [0, 140, 90] : [200, 50, 50]; } }, }); cursorY = doc.lastAutoTable.finalY + 10; // ── MONTHLY BREAKDOWN ────────────────────────────────── if (months.length > 0) { doc.setFontSize(11); doc.setFont('helvetica', 'bold'); doc.setTextColor(40, 40, 50); doc.text('Monthly Breakdown', margin, cursorY); cursorY += 3; const monthlyTotals = months.reduce((acc, m) => { acc.payouts += m.payouts; acc.expenses += m.expenses; acc.net += m.net; return acc; }, { payouts: 0, expenses: 0, net: 0 }); const finalCumulative = months[months.length - 1].cumulative; doc.autoTable({ startY: cursorY, head: [['Month', 'Payouts', 'Expenses', 'Net P&L', 'Cumulative']], body: months.map(m => [ m.monthYear, fmt(m.payouts), fmt(m.expenses), (m.net >= 0 ? '+' : '') + fmt(m.net), (m.cumulative >= 0 ? '+' : '') + fmt(m.cumulative), ]), foot: [[ 'TOTAL', fmt(monthlyTotals.payouts), fmt(monthlyTotals.expenses), (monthlyTotals.net >= 0 ? '+' : '') + fmt(monthlyTotals.net), (finalCumulative >= 0 ? '+' : '') + fmt(finalCumulative), ]], theme: 'striped', margin: { left: margin, right: margin }, styles: { font: 'helvetica', fontSize: 9, cellPadding: 3 }, headStyles: { fillColor: [0, 212, 170], textColor: [255, 255, 255], fontStyle: 'bold' }, footStyles: { fillColor: [240, 242, 247], textColor: [40, 40, 50], fontStyle: 'bold' }, alternateRowStyles: { fillColor: [248, 249, 252] }, columnStyles: { 0: { halign: 'left' }, 1: { halign: 'right' }, 2: { halign: 'right' }, 3: { halign: 'right' }, 4: { halign: 'right' }, }, }); cursorY = doc.lastAutoTable.finalY + 10; } // ── INCOME BY PROP FIRM ──────────────────────────────── const firmKeys = Object.keys(firmPnL); if (firmKeys.length > 0) { if (cursorY > pageH - 60) { doc.addPage(); cursorY = margin; } doc.setFontSize(11); doc.setFont('helvetica', 'bold'); doc.setTextColor(40, 40, 50); doc.text('Income by Prop Firm', margin, cursorY); cursorY += 3; const firmRows = firmKeys.map(k => { const d = firmPnL[k]; const net = d.payouts - d.expenses; const roi = d.expenses > 0 ? ((net / d.expenses) * 100) : (d.payouts > 0 ? 100 : 0); const name = k === '_general' ? 'General' : (propFirmNames[k] || k); return { name, payouts: d.payouts, expenses: d.expenses, net, roi }; }).sort((a, b) => b.net - a.net); let tP = 0, tE = 0, tN = 0; firmRows.forEach(r => { tP += r.payouts; tE += r.expenses; tN += r.net; }); const tRoi = tE > 0 ? ((tN / tE) * 100) : 0; doc.autoTable({ startY: cursorY, head: [['Firm', 'Total Payouts', 'Total Expenses', 'Net', 'ROI']], body: firmRows.map(r => [ r.name, fmt(r.payouts), fmt(r.expenses), (r.net >= 0 ? '+' : '') + fmt(r.net), (r.roi >= 0 ? '+' : '') + r.roi.toFixed(0) + '%', ]), foot: [[ 'TOTAL', fmt(tP), fmt(tE), (tN >= 0 ? '+' : '') + fmt(tN), (tRoi >= 0 ? '+' : '') + tRoi.toFixed(0) + '%', ]], theme: 'striped', margin: { left: margin, right: margin }, styles: { font: 'helvetica', fontSize: 9, cellPadding: 3 }, headStyles: { fillColor: [0, 212, 170], textColor: [255, 255, 255], fontStyle: 'bold' }, footStyles: { fillColor: [240, 242, 247], textColor: [40, 40, 50], fontStyle: 'bold' }, alternateRowStyles: { fillColor: [248, 249, 252] }, columnStyles: { 0: { halign: 'left' }, 1: { halign: 'right' }, 2: { halign: 'right' }, 3: { halign: 'right' }, 4: { halign: 'right' }, }, }); cursorY = doc.lastAutoTable.finalY + 10; } // ── EXPENSE SUMMARY BY TYPE ──────────────────────────── if (filteredExpenses.length > 0) { if (cursorY > pageH - 60) { doc.addPage(); cursorY = margin; } doc.setFontSize(11); doc.setFont('helvetica', 'bold'); doc.setTextColor(40, 40, 50); doc.text('Expense Summary by Type', margin, cursorY); cursorY += 3; const typeAgg = {}; filteredExpenses.forEach(e => { const k = e.type || 'other'; if (!typeAgg[k]) typeAgg[k] = { count: 0, total: 0 }; typeAgg[k].count += 1; typeAgg[k].total += getExpenseInDisplayCurrency(e); }); const typeRows = Object.entries(typeAgg).map(([k, v]) => ({ label: (expenseTypeLabels[k] || k).replace(/^[^\w]*/, '').trim() || k, count: v.count, total: v.total, })).sort((a, b) => b.total - a.total); const typeTotalCount = typeRows.reduce((s, r) => s + r.count, 0); const typeTotalAmt = typeRows.reduce((s, r) => s + r.total, 0); doc.autoTable({ startY: cursorY, head: [['Category', 'Count', 'Total Amount']], body: typeRows.map(r => [r.label, String(r.count), fmt(r.total)]), foot: [['TOTAL', String(typeTotalCount), fmt(typeTotalAmt)]], theme: 'striped', margin: { left: margin, right: margin }, styles: { font: 'helvetica', fontSize: 9, cellPadding: 3 }, headStyles: { fillColor: [0, 212, 170], textColor: [255, 255, 255], fontStyle: 'bold' }, footStyles: { fillColor: [240, 242, 247], textColor: [40, 40, 50], fontStyle: 'bold' }, alternateRowStyles: { fillColor: [248, 249, 252] }, columnStyles: { 0: { halign: 'left' }, 1: { halign: 'right', cellWidth: 25 }, 2: { halign: 'right', cellWidth: 40 }, }, }); cursorY = doc.lastAutoTable.finalY + 10; } // ── EXPENSE LINE ITEMS ───────────────────────────────── if (filteredExpenses.length > 0) { if (cursorY > pageH - 60) { doc.addPage(); cursorY = margin; } doc.setFontSize(11); doc.setFont('helvetica', 'bold'); doc.setTextColor(40, 40, 50); doc.text('Expense Line Items', margin, cursorY); cursorY += 3; const sortedExp = [...filteredExpenses].sort((a, b) => { const dateDiff = new Date(b.date) - new Date(a.date); if (dateDiff !== 0) return dateDiff; return new Date(b.createdAt || 0) - new Date(a.createdAt || 0); }); const rows = sortedExp.map(e => [ (e.date || '').split('T')[0], e.propFirm ? (propFirmNames[e.propFirm] || e.propFirm) : 'General', e.type ? (expenseTypeLabels[e.type] || e.type).replace(/^[^\w]*/, '').trim() : '', e.description || '', fmt(getExpenseInDisplayCurrency(e)), ]); doc.autoTable({ startY: cursorY, head: [['Date', 'Firm', 'Type', 'Description', 'Amount']], body: rows, theme: 'striped', margin: { left: margin, right: margin }, styles: { font: 'helvetica', fontSize: 8, cellPadding: 2.5, overflow: 'linebreak' }, headStyles: { fillColor: [0, 212, 170], textColor: [255, 255, 255], fontStyle: 'bold' }, alternateRowStyles: { fillColor: [248, 249, 252] }, columnStyles: { 0: { halign: 'left', cellWidth: 22 }, 1: { halign: 'left', cellWidth: 30 }, 2: { halign: 'left', cellWidth: 32 }, 3: { halign: 'left' }, 4: { halign: 'right', cellWidth: 26 }, }, }); cursorY = doc.lastAutoTable.finalY + 4; } // ── FOOTERS ──────────────────────────────────────────── const total = doc.internal.getNumberOfPages(); for (let i = 1; i <= total; i++) { doc.setPage(i); doc.setFont('helvetica', 'normal'); doc.setFontSize(8); doc.setTextColor(140, 140, 150); doc.text('Generated by PayoutLab • app.payoutlab.io', pageW / 2, pageH - 10, { align: 'center' }); doc.text('Page ' + i + ' of ' + total, pageW - margin, pageH - 10, { align: 'right' }); } const safePeriod = periodLabel.replace(/[^\w-]+/g, '_'); doc.save('PayoutLab_ROI_' + safePeriod + '_' + new Date().toISOString().split('T')[0] + '.pdf'); } function downloadExpenseTemplate() { const csv = 'Date,Prop Firm,Expense Type,Amount,Description,Account\n2026-01-15,TopStep,Eval Fee,49.00,50K Evaluation - Black Friday 80% off,APEX-12345\n2026-02-01,,Platform Fee,99.00,NinjaTrader Platform,\n2026-02-15,Apex,Activation Fee,149.00,150K PA activation,\n'; const blob = new Blob([csv], { type: 'text/csv' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'PayoutLab_Expense_Template.csv'; a.click(); URL.revokeObjectURL(url); } // ── Expense Backup Export / Restore ────────────────── function exportExpensesCSV() { if (expenses.length === 0) { showToast('No expenses to export', 'info'); return; } function esc(v) { const s = (v == null ? '' : String(v)); return (s.includes(',') || s.includes('"') || s.includes('\n')) ? '"' + s.replace(/"/g, '""') + '"' : s; } // Standard user-facing format — matches what the CSV importer expects // Columns: Date, Prop Firm, Type, Description, Amount, Currency // Amounts are stored in their recorded currency (expenseCurrency); no conversion applied const header = 'Date,Prop Firm,Type,Description,Amount,Currency'; const rows = expenses.map(e => { const firmName = e.propFirm ? (propFirmNames[e.propFirm] || e.propFirm) : ''; const typeLabel = e.type ? (expenseTypeLabels[e.type] || e.type).replace(/^[^\w]*/, '').trim() : ''; return [ e.date || '', firmName, typeLabel, e.description || '', e.amount != null ? e.amount : '', e.expenseCurrency || _userCurrency || 'USD' ].map(esc).join(','); }); const csv = header + '\n' + rows.join('\n'); const blob = new Blob([csv], { type: 'text/csv' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'PayoutLab_Expenses_' + new Date().toISOString().split('T')[0] + '.csv'; a.click(); URL.revokeObjectURL(url); showToast(`Exported ${expenses.length} expense${expenses.length !== 1 ? 's' : ''}`, 'success'); } async function importExpensesRestore(inputEl) { const file = inputEl.files[0]; if (!file) return; inputEl.value = ''; let text; try { text = await file.text(); } catch (e) { showToast('Could not read file', 'error'); return; } const lines = text.split(/\r?\n/); if (lines.length < 2) { showToast('File appears empty', 'warning'); return; } function parseCSVLine(line) { const result = []; let i = 0, field = ''; while (i <= line.length) { if (i === line.length) { result.push(field); break; } if (line[i] === '"') { i++; while (i < line.length) { if (line[i] === '"' && line[i + 1] === '"') { field += '"'; i += 2; } else if (line[i] === '"') { i++; break; } else { field += line[i++]; } } if (i < line.length && line[i] === ',') i++; } else { while (i < line.length && line[i] !== ',') { field += line[i++]; } if (i < line.length && line[i] === ',') i++; result.push(field); field = ''; continue; } result.push(field); field = ''; } return result; } const headers = parseCSVLine(lines[0]).map(h => h.trim().toLowerCase()); // Detect format: native backup (has 'id' + 'propfirm') vs user-facing (has 'prop firm' + 'amount') const isNativeFormat = headers.includes('id') && headers.includes('propfirm'); const isUserFormat = headers.includes('date') && headers.includes('prop firm') && headers.includes('amount'); if (!isNativeFormat && !isUserFormat) { showToast('Unrecognised format — file must have columns: Date, Prop Firm, Type, Description, Amount', 'warning'); return; } const col = h => headers.indexOf(h); const existingIds = new Set(expenses.map(e => e.id)); const existingKeys = new Set(expenses.map(e => `${e.date}|${e.type}|${e.amount}|${e.expenseCurrency || 'USD'}`)); const validTypes = new Set(Object.keys(expenseTypeLabels)); const imported = [], skipped = [], errors = []; // Column indices — differ by format const iId = isNativeFormat ? col('id') : -1; const iDate = isNativeFormat ? col('date') : col('date'); const iPF = isNativeFormat ? col('propfirm') : col('prop firm'); const iType = isNativeFormat ? col('type') : col('type'); const iDesc = isNativeFormat ? col('description') : col('description'); const iAmt = isNativeFormat ? col('amount') : col('amount'); const iCur = col('currency'); // present in native format and new user-facing exports const iRate = isNativeFormat ? col('expenserateatdate') : -1; const iCreated = isNativeFormat ? col('createdat') : -1; for (let i = 1; i < lines.length; i++) { if (!lines[i].trim()) continue; const c = parseCSVLine(lines[i]); const rowNum = i + 1; const rowId = iId >= 0 ? (c[iId] || '').trim() : ''; const rowDate = iDate >= 0 ? (c[iDate] || '').trim() : ''; const rowDesc = iDesc >= 0 ? (c[iDesc] || '').trim() : ''; const rowAmtRaw = iAmt >= 0 ? (c[iAmt] || '').trim() : ''; const rowRateRaw = iRate >= 0 ? (c[iRate] || '').trim() : ''; const rowCreated = iCreated >= 0 ? (c[iCreated] || '').trim() : ''; // Type: native stores keys directly; user-facing stores display labels — fuzzy match const rowTypeRaw = iType >= 0 ? (c[iType] || '').trim() : ''; let rowType; if (isNativeFormat) { rowType = rowTypeRaw; } else { rowType = fuzzyMatchCategory(rowTypeRaw) || ''; } // Prop firm: native stores keys directly; user-facing stores display names — fuzzy match const rowPFRaw = iPF >= 0 ? (c[iPF] || '').trim() : ''; const rowPF = isNativeFormat ? rowPFRaw : (fuzzyMatchFirm(rowPFRaw) || ''); // Currency: use explicit column if present (native and new user-facing exports include it), // otherwise fall back to user's current display currency (no conversion applied) const rowCurrencyRaw = iCur >= 0 ? (c[iCur] || '').trim().toUpperCase() : ''; const rowCurrency = rowCurrencyRaw || _userCurrency || 'USD'; if (!rowDate) { errors.push(`Row ${rowNum}: missing date`); continue; } const parsedDate = parseExpenseDate(rowDate); if (!parsedDate) { errors.push(`Row ${rowNum}: invalid date "${rowDate}"`); continue; } if (!rowType || !validTypes.has(rowType)) { errors.push(`Row ${rowNum}: unrecognised type "${rowTypeRaw}"`); continue; } const rowAmount = parseFloat(rowAmtRaw.replace(/[$,\s]/g, '')); if (isNaN(rowAmount) || rowAmount <= 0) { errors.push(`Row ${rowNum}: invalid amount "${rowAmtRaw}"`); continue; } const roundedAmt = Math.round(rowAmount * 100) / 100; if (rowId && existingIds.has(rowId)) { skipped.push(`Row ${rowNum}: duplicate id (${rowId})`); continue; } const dedupKey = `${parsedDate}|${rowType}|${roundedAmt}|${rowCurrency}`; if (existingKeys.has(dedupKey)) { skipped.push(`Row ${rowNum}: duplicate (${parsedDate}, ${rowTypeRaw}, ${roundedAmt} ${rowCurrency})`); continue; } const newExp = { id: rowId || ('exp_' + Date.now() + '_' + Math.random().toString(36).substr(2, 6)), date: parsedDate, propFirm: rowPF, type: rowType, description: rowDesc, amount: roundedAmt, expenseCurrency: rowCurrency, createdAt: rowCreated || new Date().toISOString(), updatedAt: new Date().toISOString() }; const parsedRate = rowRateRaw ? parseFloat(rowRateRaw) : NaN; if (!isNaN(parsedRate) && parsedRate > 0) newExp.expenseRateAtDate = parsedRate; imported.push(newExp); existingIds.add(newExp.id); existingKeys.add(dedupKey); } if (imported.length === 0 && errors.length === 0 && skipped.length === 0) { showToast('No data rows found in file', 'warning'); return; } if (imported.length === 0) { showExpenseRestoreResult(0, skipped, errors); return; } imported.forEach(e => expenses.push(e)); try { await saveExpenses(); renderROITracker(); renderExpenseTable(); showExpenseRestoreResult(imported.length, skipped, errors); } catch (err) { imported.forEach(() => expenses.pop()); showToast('Save failed: ' + err.message, 'error'); } } function showExpenseRestoreResult(importedCount, skipped, errors) { if (importedCount > 0 && skipped.length === 0 && errors.length === 0) { showToast(`${importedCount} expense${importedCount !== 1 ? 's' : ''} restored`, 'success'); return; } document.getElementById('expense-restore-imported').textContent = importedCount; document.getElementById('expense-restore-skipped').textContent = skipped.length; document.getElementById('expense-restore-errors').textContent = errors.length; let detail = ''; if (skipped.length > 0) detail += '
Skipped (duplicates):
    ' + skipped.map(s => `
  • ${s}
  • `).join('') + '
'; if (errors.length > 0) detail += '
Errors (invalid rows):
    ' + errors.map(e => `
  • ${e}
  • `).join('') + '
'; document.getElementById('expense-restore-detail').innerHTML = detail; document.getElementById('expense-restore-result-modal').style.display = 'flex'; } // ── Quick Entry Row ────────────────────────────────── function initQuickEntry() { const dateEl = document.getElementById('qe-date'); if (dateEl && !dateEl.value) dateEl.value = new Date().toISOString().split('T')[0]; const firmEl = document.getElementById('qe-firm'); if (firmEl && firmEl.options.length <= 1) { Object.entries(propFirmNames).sort((a, b) => a[1].localeCompare(b[1])).forEach(([key, name]) => { if (key === 'personal' || key === 'other') return; firmEl.innerHTML += ``; }); } updateQETypes(); const amtEl = document.getElementById('qe-amount'); if (amtEl && !amtEl._qeListenerAttached) { amtEl._qeListenerAttached = true; amtEl.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); saveQuickExpense(); } }); } } function updateQETypes() { const firm = document.getElementById('qe-firm')?.value || ''; const typeEl = document.getElementById('qe-type'); if (!typeEl) return; const types = propFirmExpenseTypes[firm] || propFirmExpenseTypes[''] || propFirmExpenseTypes.other; typeEl.innerHTML = ''; types.forEach(t => { typeEl.innerHTML += ``; }); } let _saveQuickExpenseBusy = false; async function saveQuickExpense() { if (_saveQuickExpenseBusy) return; const date = document.getElementById('qe-date')?.value; const propFirm = document.getElementById('qe-firm')?.value || ''; const type = document.getElementById('qe-type')?.value; const desc = document.getElementById('qe-desc')?.value || ''; const amount = parseFloat(document.getElementById('qe-amount')?.value) || 0; if (!date || !type || amount <= 0) { showToast('Enter date, type, and amount', 'warning'); return; } _saveQuickExpenseBusy = true; const expense = { id: 'exp_' + Date.now(), date, propFirm, type, description: desc, amount: Math.round(amount * 100) / 100, expenseCurrency: _userCurrency || 'USD', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; expenses.push(expense); try { await saveExpenses(); renderROITracker(); renderExpenseTable(); showToast('Expense added', 'success'); document.getElementById('qe-firm').value = ''; document.getElementById('qe-type').value = ''; document.getElementById('qe-desc').value = ''; document.getElementById('qe-amount').value = ''; updateQETypes(); document.getElementById('qe-amount').focus(); } catch (e) { expenses.pop(); showToast('Error: ' + e.message, 'error'); } finally { _saveQuickExpenseBusy = false; } } // ── Paste from Spreadsheet ─────────────────────────── function toggleExpensePasteArea() { const area = document.getElementById('expense-paste-area'); area.style.display = area.style.display === 'none' ? 'block' : 'none'; } function _matchFirmKey(text) { if (!text) return ''; const lower = text.toLowerCase().trim(); for (const [key, name] of Object.entries(propFirmNames)) { if (lower === key || lower === name.toLowerCase()) return key; } for (const [key, name] of Object.entries(propFirmNames)) { if (name.toLowerCase().includes(lower) || lower.includes(key)) return key; } return ''; } function _matchTypeKey(text) { if (!text) return 'other'; const lower = text.toLowerCase().trim(); for (const [key, label] of Object.entries(expenseTypeLabels)) { const cleanLabel = label.replace(/^[^\w]*/, '').toLowerCase(); if (lower === key || lower === cleanLabel || lower.includes(cleanLabel) || cleanLabel.includes(lower)) return key; } if (lower.includes('eval')) return 'eval_fee'; if (lower.includes('reset')) return 'reset_fee'; if (lower.includes('activ')) return 'activation_fee'; if (lower.includes('data')) return 'data_fee'; if (lower.includes('platform') || lower.includes('ninja') || lower.includes('quantower')) return 'platform_fee'; if (lower.includes('monthly') || lower.includes('subscri')) return 'monthly_fee'; if (lower.includes('vps') || lower.includes('server')) return 'vps_fee'; return 'other'; } async function importPastedExpenses() { const raw = document.getElementById('expense-paste-input')?.value?.trim(); if (!raw) { showToast('Nothing to import', 'warning'); return; } const lines = raw.split('\n').filter(l => l.trim()); const imported = []; for (const line of lines) { const cols = line.split('\t'); if (cols.length < 3) continue; let date = '', firm = '', type = '', amount = 0, desc = ''; const dateCandidate = cols[0].trim(); if (/^\d{4}-\d{2}-\d{2}$/.test(dateCandidate) || /^\d{1,2}\/\d{1,2}\/\d{2,4}$/.test(dateCandidate)) { date = dateCandidate; if (date.includes('/')) { const parts = date.split('/'); if (parts[2].length === 2) parts[2] = '20' + parts[2]; date = parts[2] + '-' + parts[0].padStart(2, '0') + '-' + parts[1].padStart(2, '0'); } } else { continue; } let amountIdx = -1; for (let i = 1; i < cols.length; i++) { const val = parseFloat(cols[i].replace(/[$,]/g, '')); if (!isNaN(val) && val > 0) { amount = val; amountIdx = i; break; } } if (amount <= 0) continue; const middle = cols.slice(1, amountIdx).map(c => c.trim()).filter(Boolean); if (middle.length >= 2) { firm = _matchFirmKey(middle[0]); type = _matchTypeKey(middle[1]); } else if (middle.length === 1) { const asFirm = _matchFirmKey(middle[0]); if (asFirm) { firm = asFirm; type = 'eval_fee'; } else { type = _matchTypeKey(middle[0]); } } else { type = 'other'; } desc = cols.slice(amountIdx + 1).join(' ').trim(); imported.push({ id: 'exp_' + Date.now() + '_' + imported.length, date, propFirm: firm, type, description: desc, amount: Math.round(amount * 100) / 100, expenseCurrency: _userCurrency || 'USD', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }); } if (imported.length === 0) { showToast('No valid rows found. Use tab-separated: Date, Firm, Type, Amount, Description', 'warning', 5000); return; } expenses.push(...imported); try { await saveExpenses(); renderROITracker(); renderExpenseTable(); document.getElementById('expense-paste-input').value = ''; document.getElementById('expense-paste-area').style.display = 'none'; showToast(imported.length + ' expense' + (imported.length > 1 ? 's' : '') + ' imported', 'success'); } catch (e) { expenses.splice(expenses.length - imported.length, imported.length); showToast('Import failed: ' + e.message, 'error'); } } function openExpenseImportModal() { parsedImportRows = []; expenseCSVRawData = null; expenseCSVColMap = {}; const inp = document.getElementById('expense-csv-input'); if (inp) inp.value = ''; document.getElementById('expense-csv-filename').textContent = ''; document.getElementById('expense-import-step1').style.display = ''; document.getElementById('expense-import-step2').style.display = 'none'; document.getElementById('expense-import-step3').style.display = 'none'; document.getElementById('expense-import-btn').style.display = 'none'; document.getElementById('expense-import-modal').style.display = 'flex'; const importBtn = document.getElementById('expense-import-btn'); if (importBtn) { importBtn.disabled = false; importBtn.textContent = 'Import'; } } function closeExpenseImportModal() { document.getElementById('expense-import-modal').style.display = 'none'; } // ----- Fuzzy matchers ----- function fuzzyMatchCategory(input) { if (!input) return null; const s = input.toLowerCase().trim().replace(/[^a-z0-9 ]/g, '').replace(/\s+/g, ' '); const map = { 'eval fee': 'eval_fee', 'evaluation fee': 'eval_fee', 'evaluation': 'eval_fee', 'eval': 'eval_fee', 'activation fee': 'activation_fee', 'activation': 'activation_fee', 'reset fee': 'reset_fee', 'reset': 'reset_fee', 'monthly fee': 'monthly_fee', 'monthly subscription': 'monthly_fee', 'monthly': 'monthly_fee', 'subscription': 'monthly_fee', 'data fee': 'data_fee', 'data': 'data_fee', 'market data': 'data_fee', 'platform fee': 'platform_fee', 'platform': 'platform_fee', 'software': 'platform_fee', 'vps fee': 'vps_fee', 'vps': 'vps_fee', 'vps infrastructure': 'vps_fee', 'infrastructure': 'vps_fee', 'hosting': 'vps_fee', 'education': 'education', 'education courses': 'education', 'courses': 'education', 'course': 'education', 'training': 'education', 'tax prep': 'tax_prep', 'tax preparation': 'tax_prep', 'tax': 'tax_prep', 'accounting': 'tax_prep', 'monthly total': 'monthly_total', 'other': 'other' }; if (map[s]) return map[s]; for (const [key, val] of Object.entries(map)) { if (s.includes(key) || key.includes(s)) return val; } return null; } function fuzzyMatchFirm(input) { if (!input) return ''; const s = input.toLowerCase().trim(); if (!s) return ''; const map = { 'apex': 'apex', 'apex trader': 'apex', 'apex trader funding': 'apex', 'topstep': 'topstep', 'top step': 'topstep', 'myfundedfutures': 'myfundedfutures', 'my funded futures': 'myfundedfutures', 'mff': 'myfundedfutures', 'mffu': 'myfundedfutures', 'lucid': 'lucid', 'lucid trading': 'lucid', 'tradeify': 'tradeify', 'daytraders': 'daytraders', 'day traders': 'daytraders', 'bulenox': 'bulenox', 'fundednext': 'fundednext', 'funded next': 'fundednext', 'fundedfuturesnetwork': 'fundedfuturesnetwork', 'funded futures network': 'fundedfuturesnetwork', 'ffn': 'fundedfuturesnetwork', 'takeprofittrader': 'takeprofittrader', 'take profit trader': 'takeprofittrader', 'tpt': 'takeprofittrader', 'earn2trade': 'earn2trade', 'elitetraderfunding': 'elitetraderfunding', 'elite trader funding': 'elitetraderfunding', 'legendstrading': 'legendstrading', 'legends trading': 'legendstrading', 'alphafutures': 'alphafutures', 'alpha futures': 'alphafutures', 'tradeday': 'tradeday', 'trade day': 'tradeday', 'thefuturesdesk': 'thefuturesdesk', 'the futures desk': 'thefuturesdesk', 'phidias': 'phidias', 'thetradingpit': 'thetradingpit', 'the trading pit': 'thetradingpit', 'personal': 'personal', 'other': 'other' }; if (map[s]) return map[s]; for (const [key, val] of Object.entries(map)) { if (s.includes(key) || key.includes(s)) return val; } for (const [key, name] of Object.entries(propFirmNames)) { if (name.toLowerCase() === s || name.toLowerCase().includes(s) || s.includes(name.toLowerCase())) return key; } return ''; } function parseExpenseDate(input) { if (!input) return null; const s = input.trim(); if (/^\d{4}-\d{1,2}-\d{1,2}$/.test(s)) { const d = new Date(s + 'T12:00:00'); return isNaN(d.getTime()) ? null : d.toISOString().split('T')[0]; } // DD/MM/YYYY (international) — first component is day if (/^\d{1,2}\/\d{1,2}\/\d{4}$/.test(s)) { const [d, m, y] = s.split('/'); const dt = new Date(`${y}-${m.padStart(2,'0')}-${d.padStart(2,'0')}T12:00:00`); return isNaN(dt.getTime()) ? null : dt.toISOString().split('T')[0]; } // DD-MM-YYYY (international) — first component is day if (/^\d{1,2}-\d{1,2}-\d{4}$/.test(s)) { const [d, m, y] = s.split('-'); const dt = new Date(`${y}-${m.padStart(2,'0')}-${d.padStart(2,'0')}T12:00:00`); return isNaN(dt.getTime()) ? null : dt.toISOString().split('T')[0]; } // Try native Date parse as last resort (e.g., "Jan 15, 2026") const fallback = new Date(s); if (!isNaN(fallback.getTime()) && fallback.getFullYear() > 2000) { return fallback.toISOString().split('T')[0]; } return null; } // ----- Step 1: File Upload ----- function handleExpenseCSVStep1(input) { const file = input.files[0]; if (!file) return; document.getElementById('expense-csv-filename').textContent = file.name; Papa.parse(file, { header: true, skipEmptyLines: true, complete: function(results) { if (!results.meta.fields || results.meta.fields.length === 0) { showNotification('Could not read CSV columns. Check file format.', 'error'); return; } expenseCSVRawData = results; showExpenseColMapping(results.meta.fields, results.data[0] || {}); } }); } // ----- Step 2: Column Mapping ----- function showExpenseColMapping(csvColumns, sampleRow) { document.getElementById('expense-import-step1').style.display = 'none'; document.getElementById('expense-import-step2').style.display = ''; const fields = [ { key: 'date', label: 'Date *', hints: ['date'] }, { key: 'propFirm', label: 'Prop Firm *', hints: ['firm', 'prop', 'broker'] }, { key: 'type', label: 'Expense Type *', hints: ['type', 'cat', 'category'] }, { key: 'amount', label: 'Amount *', hints: ['amount', 'amt', 'cost', 'price', 'total'] }, { key: 'description', label: 'Description', hints: ['desc', 'note', 'memo', 'detail'] }, { key: 'currency', label: 'Currency', hints: ['currency', 'cur', 'ccy'] } ]; const lowerCols = csvColumns.map(c => c.toLowerCase().trim()); const container = document.getElementById('expense-col-mapping'); container.innerHTML = ''; fields.forEach(f => { // Auto-detect best match let bestIdx = -1; for (let i = 0; i < lowerCols.length; i++) { if (f.hints.some(h => lowerCols[i].includes(h))) { bestIdx = i; break; } } const opts = csvColumns.map((col, i) => `` ).join(''); container.innerHTML += `
`; }); // Show sample row const sampleParts = csvColumns.map(c => `${c}: "${sampleRow[c] || ''}"`); document.getElementById('expense-col-sample').textContent = sampleParts.join(' | '); } function expenseImportBackToStep1() { document.getElementById('expense-import-step2').style.display = 'none'; document.getElementById('expense-import-step1').style.display = ''; } function expenseImportBackToStep2() { document.getElementById('expense-import-step3').style.display = 'none'; document.getElementById('expense-import-btn').style.display = 'none'; document.getElementById('expense-import-step2').style.display = ''; } // ----- Step 3: Parse + Preview ----- function expenseImportApplyMapping() { const csvColumns = expenseCSVRawData.meta.fields; const getCol = (key) => { const idx = parseInt(document.getElementById('exp-map-' + key).value); return idx >= 0 ? csvColumns[idx] : null; }; const dateCol = getCol('date'); const firmCol = getCol('propFirm'); const typeCol = getCol('type'); const amtCol = getCol('amount'); const descCol = getCol('description'); const currencyCol = getCol('currency'); // Validate required columns if (!dateCol || !amtCol) { showNotification('Date and Amount columns are required.', 'error'); return; } expenseCSVColMap = { dateCol, firmCol, typeCol, amtCol, descCol, currencyCol }; const rows = []; expenseCSVRawData.data.forEach((row, i) => { const parsed = { rowNum: i + 1, selected: true, status: 'valid', errors: [] }; // Date parsed.date = parseExpenseDate(row[dateCol]); if (!parsed.date) { parsed.status = 'error'; parsed.errors.push('Invalid date'); } // Amount — handle negatives and $ signs let rawAmt = (row[amtCol] || '').toString().replace(/[$,\s]/g, ''); let amt = parseFloat(rawAmt); if (!isNaN(amt) && amt < 0) amt = Math.abs(amt); // treat negatives as positive expense amounts parsed.amount = amt; if (isNaN(parsed.amount) || parsed.amount <= 0) { parsed.status = 'error'; parsed.errors.push('Invalid amount'); } // Expense Type — fuzzy match with fallback const catInput = typeCol ? (row[typeCol] || '').trim() : ''; parsed.type = fuzzyMatchCategory(catInput); parsed.typeRaw = catInput; if (!parsed.type && typeCol) { // Not matched — mark as needing selection, don't error yet parsed.type = ''; parsed.needsTypeSelect = true; } else if (!typeCol) { // No type column mapped — user must pick for each row parsed.type = ''; parsed.needsTypeSelect = true; } // Description parsed.description = descCol ? (row[descCol] || '').trim() : ''; // Prop Firm — fuzzy match with fallback const firmInput = firmCol ? (row[firmCol] || '').trim() : ''; parsed.propFirm = fuzzyMatchFirm(firmInput); parsed.firmRaw = firmInput; if (!parsed.propFirm && firmCol && firmInput) { parsed.needsFirmSelect = true; } else if (!firmCol) { parsed.propFirm = ''; parsed.needsFirmSelect = true; } // Currency — from explicit column if present, else user's display currency (no conversion) const rowCurrencyRaw = currencyCol ? (row[currencyCol] || '').trim().toUpperCase() : ''; parsed.rowCurrency = rowCurrencyRaw || _userCurrency || 'USD'; // Dedup check if (parsed.status === 'valid' && parsed.type && parsed.propFirm) { const key = `${parsed.date}|${parsed.type}|${parsed.amount}|${parsed.propFirm}`; const exists = expenses.some(e => `${e.date}|${e.type}|${e.amount}|${e.propFirm}` === key); if (exists) { parsed.status = 'duplicate'; parsed.errors.push('Duplicate of existing expense'); } } rows.push(parsed); }); parsedImportRows = rows; document.getElementById('expense-import-step2').style.display = 'none'; document.getElementById('expense-import-step3').style.display = ''; renderExpenseImportPreview(); } function buildFirmSelectHtml(rowIdx, currentVal, rawVal) { const firmKeys = Object.keys(propFirmNames).sort((a, b) => (propFirmNames[a] || a).localeCompare(propFirmNames[b] || b)); let opts = ``; firmKeys.forEach(k => { opts += ``; }); opts += ``; return ``; } function buildTypeSelectHtml(rowIdx, currentVal) { const typeKeys = Object.keys(expenseTypeLabels); let opts = ``; typeKeys.forEach(k => { const label = (expenseTypeLabels[k] || k).replace(/^[^\w]*/, '').trim(); opts += ``; }); return ``; } function expenseImportUpdateRow(idx, field, value) { if (idx < 0 || idx >= parsedImportRows.length) return; const row = parsedImportRows[idx]; row[field] = value; if (field === 'propFirm') row.needsFirmSelect = !value; if (field === 'type') row.needsTypeSelect = !value; // Re-validate row.errors = []; row.status = 'valid'; if (!row.date) { row.status = 'error'; row.errors.push('Invalid date'); } if (isNaN(row.amount) || row.amount <= 0) { row.status = 'error'; row.errors.push('Invalid amount'); } if (!row.type) { row.status = 'error'; row.errors.push('Select expense type'); } if (!row.propFirm) { row.status = 'error'; row.errors.push('Select prop firm'); } if (row.status === 'valid') { const key = `${row.date}|${row.type}|${row.amount}|${row.propFirm}`; const exists = expenses.some(e => `${e.date}|${e.type}|${e.amount}|${e.propFirm}` === key); if (exists) { row.status = 'duplicate'; row.errors.push('Duplicate of existing expense'); } } renderExpenseImportPreview(); } function expenseImportToggleRow(idx) { if (idx < 0 || idx >= parsedImportRows.length) return; parsedImportRows[idx].selected = !parsedImportRows[idx].selected; renderExpenseImportPreview(); } function expenseImportToggleAll() { const selectable = parsedImportRows.filter(r => r.status !== 'error'); const allSelected = selectable.every(r => r.selected); selectable.forEach(r => r.selected = !allSelected); renderExpenseImportPreview(); } function renderExpenseImportPreview() { const rows = parsedImportRows; const selected = rows.filter(r => r.selected && r.status === 'valid'); const dupes = rows.filter(r => r.status === 'duplicate'); const errors = rows.filter(r => r.status === 'error'); const needsInput = rows.filter(r => r.status !== 'error' && (r.needsTypeSelect || r.needsFirmSelect)); // If currency column is present, convert rows with a different currency to display currency for the total. // Otherwise sum raw values directly — amounts are assumed to be in the user's current display currency. const hasCurrencyCol = !!expenseCSVColMap.currencyCol; const totalAmt = hasCurrencyCol ? selected.reduce((s, r) => s + getExpenseInDisplayCurrency({ amount: r.amount || 0, expenseCurrency: r.rowCurrency }), 0) : selected.reduce((s, r) => s + (r.amount || 0), 0); // Summary bar const summary = document.getElementById('expense-import-summary'); summary.style.display = 'block'; let summaryHtml = `${selected.length} ready${formatDisplayAmount(totalAmt)}`; if (dupes.length) summaryHtml += ` • ${dupes.length} duplicate${dupes.length > 1 ? 's' : ''}`; if (errors.length) summaryHtml += ` • ${errors.length} error${errors.length > 1 ? 's' : ''}`; if (needsInput.length) summaryHtml += ` • ${needsInput.length} need${needsInput.length === 1 ? 's' : ''} selection`; summary.innerHTML = summaryHtml; // Toggle all button text const selectable = rows.filter(r => r.status !== 'error'); const allSel = selectable.length > 0 && selectable.every(r => r.selected); document.getElementById('expense-toggle-all-btn').textContent = allSel ? 'Deselect All' : 'Select All'; // Build preview table const preview = document.getElementById('expense-import-preview'); let html = ``; rows.forEach((r, idx) => { const isError = r.status === 'error'; const isDupe = r.status === 'duplicate'; const bg = isError ? 'rgba(239,68,68,0.06)' : isDupe ? 'rgba(245,158,11,0.06)' : r.selected ? 'rgba(16,185,129,0.06)' : 'transparent'; const opacity = (!r.selected && !isError) ? 'opacity: 0.45;' : ''; // Checkbox const checkDisabled = isError ? 'disabled' : ''; const checkChecked = r.selected && !isError ? 'checked' : ''; // Status label let statusLabel; if (isError) statusLabel = `Error`; else if (isDupe) statusLabel = `Duplicate`; else if (r.needsTypeSelect || r.needsFirmSelect) statusLabel = `Needs input`; else statusLabel = `Ready`; // Firm cell — dropdown if needs selection or unmatched let firmCell; if (r.needsFirmSelect || !r.propFirm) { firmCell = buildFirmSelectHtml(idx, r.propFirm, r.firmRaw); } else { firmCell = `${propFirmNames[r.propFirm] || r.propFirm}`; } // Type cell — dropdown if needs selection or unmatched let typeCell; if (r.needsTypeSelect || !r.type) { typeCell = buildTypeSelectHtml(idx, r.type); } else { typeCell = (expenseTypeLabels[r.type] || r.type).replace(/^[^\w]*/, '').trim(); } html += ``; if (isError && r.errors.length) { html += ``; } }); html += '
Date Prop Firm Expense Type Amount Description Status
${r.date || ''} ${firmCell} ${typeCell} ${!isNaN(r.amount) ? (hasCurrencyCol ? formatExpenseAmount({ amount: r.amount, expenseCurrency: r.rowCurrency }) : formatDisplayAmount(r.amount)) : ''} ${r.description || '-'} ${statusLabel}
${r.errors.join('; ')}
'; preview.innerHTML = html; // Import button const importBtn = document.getElementById('expense-import-btn'); if (selected.length > 0) { importBtn.style.display = ''; importBtn.textContent = `Import ${selected.length} Expense${selected.length > 1 ? 's' : ''} (${formatDisplayAmount(totalAmt)})`; } else { importBtn.style.display = 'none'; } } async function importExpenses() { const validRows = parsedImportRows.filter(r => r.selected && r.status === 'valid'); if (validRows.length === 0) return; const btn = document.getElementById('expense-import-btn'); btn.disabled = true; btn.textContent = 'Importing...'; const totalAmt = validRows.reduce((s, r) => s + (r.amount || 0), 0); validRows.forEach(r => { expenses.push({ id: 'exp_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9), date: r.date, propFirm: r.propFirm, type: r.type, description: r.description, amount: r.amount, expenseCurrency: r.rowCurrency || _userCurrency || 'USD', accountId: '', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }); }); try { await saveExpenses(); closeExpenseImportModal(); renderROITracker(); showNotification(`${validRows.length} expense${validRows.length > 1 ? 's' : ''} imported — ${formatDisplayAmount(totalAmt)} total`, 'success'); } catch (err) { btn.disabled = false; btn.textContent = 'Import'; showNotification('Import failed: ' + err.message, 'error'); } } // ===================================================== // TAX EXPORT // ===================================================== function openTaxExportModal() { // Respect current ROI year/month filter const filterYear = document.getElementById('roi-filter-year')?.value || ''; const filterMonth = document.getElementById('roi-filter-month')?.value || ''; const year = filterYear || new Date().getFullYear(); if (filterYear && filterMonth) { const mo = String(filterMonth).padStart(2, '0'); const lastDay = new Date(parseInt(year), parseInt(filterMonth), 0).getDate(); document.getElementById('tax-from-date').value = `${year}-${mo}-01`; document.getElementById('tax-to-date').value = `${year}-${mo}-${lastDay}`; } else { document.getElementById('tax-from-date').value = `${year}-01-01`; document.getElementById('tax-to-date').value = `${year}-12-31`; } // Build category checkboxes const container = document.getElementById('tax-category-filters'); container.innerHTML = ''; Object.entries(expenseTypeLabels).forEach(([key, label]) => { const cleanLabel = label.replace(/^[^\w]*/, '').trim(); container.innerHTML += ``; }); updateTaxPreview(); document.getElementById('tax-export-modal').style.display = 'flex'; } function closeTaxExportModal() { document.getElementById('tax-export-modal').style.display = 'none'; } function getSelectedTaxCategories() { return Array.from(document.querySelectorAll('#tax-category-filters input:checked')).map(cb => cb.value); } function getTaxExportData(fromDate, toDate, categoryFilters) { // Tax export uses its own date range from the modal — global header filters intentionally excluded const from = new Date(fromDate + 'T00:00:00'); const to = new Date(toDate + 'T23:59:59'); const filteredExp = expenses.filter(e => { const d = e.date.includes('T') ? new Date(e.date) : new Date(e.date + 'T12:00:00'); if (d < from || d > to) return false; if (!categoryFilters.includes(e.type)) return false; return true; }).sort((a, b) => a.date.localeCompare(b.date)); const filteredPay = payouts.filter(p => { if (p.type !== 'withdrawal') return false; const d = p.date.includes('T') ? new Date(p.date) : new Date(p.date + 'T12:00:00'); if (d < from || d > to) return false; return true; }).sort((a, b) => (a.date || '').localeCompare(b.date || '')); const totalExpenses = filteredExp.reduce((s, e) => s + getExpenseInDisplayCurrency(e), 0); const totalIncome = filteredPay.reduce((s, p) => s + convertFromUSD(p.amount || 0), 0); return { expenses: filteredExp, payouts: filteredPay, summary: { totalExpenses, totalIncome, net: totalIncome - totalExpenses } }; } function updateTaxPreview() { const from = document.getElementById('tax-from-date').value; const to = document.getElementById('tax-to-date').value; if (!from || !to) return; const cats = getSelectedTaxCategories(); const data = getTaxExportData(from, to, cats); document.getElementById('tax-total-expenses').textContent = formatDisplayAmount(data.summary.totalExpenses); document.getElementById('tax-total-income').textContent = formatDisplayAmount(data.summary.totalIncome); const netEl = document.getElementById('tax-net-pnl'); netEl.textContent = formatDisplayAmount(data.summary.net); netEl.style.color = data.summary.net >= 0 ? 'var(--green)' : 'var(--red)'; document.getElementById('tax-expense-count').textContent = data.expenses.length; document.getElementById('tax-payout-count').textContent = data.payouts.length; } function exportTaxCSV() { const from = document.getElementById('tax-from-date').value; const to = document.getElementById('tax-to-date').value; const cats = getSelectedTaxCategories(); const data = getTaxExportData(from, to, cats); let csv = 'PayoutLab Tax Report\n'; csv += `Period: ${from} to ${to}\n`; csv += `Generated: ${new Date().toISOString().split('T')[0]}\n\n`; csv += `Summary\n`; csv += `Total Expenses,${data.summary.totalExpenses.toFixed(2)}\n`; csv += `Total Income,${data.summary.totalIncome.toFixed(2)}\n`; csv += `Net Profit/Loss,${data.summary.net.toFixed(2)}\n\n`; csv += `Expenses\n`; csv += `Date,Description,Category,Amount,Prop Firm\n`; data.expenses.forEach(e => { const catLabel = (expenseTypeLabels[e.type] || e.type).replace(/^[^\w]*/, '').trim(); csv += `${e.date},"${e.description || ''}","${catLabel}",${e.amount.toFixed(2)},"${propFirmNames[e.propFirm] || e.propFirm}"\n`; }); csv += `\nIncome (Payouts)\n`; csv += `Date,Prop Firm,Account,Gross Amount,Profit Split %,Net Amount\n`; data.payouts.forEach(p => { const acc = accounts.find(a => a.id === p.accountId); const firmName = propFirmNames[acc?.propFirm || p.propFirm] || p.propFirm || 'Unknown'; const accName = p.accountName || p.accountId || ''; const gross = p.grossAmount || p.amount || 0; const split = p.profitSplit || 100; csv += `${p.date},"${firmName}","${accName}",${gross.toFixed(2)},${split}%,${(p.amount || 0).toFixed(2)}\n`; }); csv += `\nDisclaimer: This report is for informational purposes only. Consult a qualified tax professional for tax advice.\n`; const blob = new Blob([csv], { type: 'text/csv' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; const year = from.substring(0, 4); a.download = `PayoutLab_Tax_Report_${year}.csv`; a.click(); URL.revokeObjectURL(url); showNotification('Tax report CSV downloaded', 'success'); } function exportTaxPDF() { try { const from = document.getElementById('tax-from-date').value; const to = document.getElementById('tax-to-date').value; if (!from || !to) { showNotification('Please select a date range', 'error'); return; } const cats = getSelectedTaxCategories(); const data = getTaxExportData(from, to, cats); if (typeof window.jspdf === 'undefined') { showNotification('PDF library not loaded. Please refresh and try again.', 'error'); return; } const { jsPDF } = window.jspdf; const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' }); const pageW = doc.internal.pageSize.getWidth(); const margin = 14; const traderName = currentUser?.displayName || currentUser?.email || 'Trader'; const fromLabel = new Date(from + 'T12:00:00').toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }); const toLabel = new Date(to + 'T12:00:00').toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }); const cyanRGB = [0, 180, 150]; const greenRGB = [16, 185, 129]; const redRGB = [220, 50, 50]; let y = margin; // Shared autotable styles const tableTheme = { headStyles: { fillColor: [0, 180, 150], textColor: [255, 255, 255], fontStyle: 'bold', fontSize: 8, cellPadding: 2.5 }, bodyStyles: { fontSize: 7.5, textColor: [40, 40, 40], cellPadding: 2 }, alternateRowStyles: { fillColor: [248, 250, 252] }, styles: { lineColor: [220, 220, 220], lineWidth: 0.2 }, margin: { left: margin, right: margin } }; function sectionHeader(title) { y = doc.lastAutoTable ? doc.lastAutoTable.finalY + 10 : y; doc.setFontSize(11); doc.setFont('helvetica', 'bold'); doc.setTextColor(...cyanRGB); doc.text(title, margin, y); y += 1.5; doc.setDrawColor(...cyanRGB); doc.setLineWidth(0.4); doc.line(margin, y, pageW - margin, y); doc.setLineWidth(0.2); y += 4; } // ========================================== // HEADER // ========================================== doc.setFontSize(22); doc.setFont('helvetica', 'bold'); doc.setTextColor(...cyanRGB); doc.text('PayoutLab', margin, y + 7); doc.setFontSize(11); doc.setFont('helvetica', 'normal'); doc.setTextColor(100, 100, 100); doc.text('P&L Report', margin + 55, y + 7); y += 15; doc.setFontSize(9); doc.setTextColor(80, 80, 80); doc.text(`Prepared for: ${traderName}`, margin, y); y += 5; doc.text(`Period: ${fromLabel} to ${toLabel}`, margin, y); y += 3; doc.setDrawColor(...cyanRGB); doc.setLineWidth(0.8); doc.line(margin, y, pageW - margin, y); doc.setLineWidth(0.2); y += 8; // ========================================== // 1. SUMMARY // ========================================== sectionHeader('Summary'); const netAmt = data.summary.net; const roiPct = data.summary.totalExpenses > 0 ? ((netAmt / data.summary.totalExpenses) * 100).toFixed(1) : 'N/A'; const avgPay = data.payouts.length > 0 ? (data.summary.totalIncome / data.payouts.length).toFixed(2) : '0.00'; doc.autoTable({ startY: y, head: [['Metric', 'Value']], body: [ ['Total Income (Payouts)', formatDisplayAmount(data.summary.totalIncome)], ['Total Expenses', formatDisplayAmount(data.summary.totalExpenses)], ['Net P&L', formatDisplayAmount(netAmt)], ['ROI %', roiPct === 'N/A' ? 'N/A' : roiPct + '%'], ['Avg Payout', formatDisplayAmount(parseFloat(avgPay) || 0)], ['Payout Count', String(data.payouts.length)], ['Expense Count', String(data.expenses.length)] ], ...tableTheme, columnStyles: { 0: { cellWidth: 55, fontStyle: 'bold' }, 1: { cellWidth: 45, halign: 'right' } }, tableWidth: 100, didParseCell: function(hookData) { if (hookData.section === 'body' && hookData.column.index === 1) { const row = hookData.row.index; if (row === 0) hookData.cell.styles.textColor = greenRGB; if (row === 1) hookData.cell.styles.textColor = redRGB; if (row === 2) hookData.cell.styles.textColor = netAmt >= 0 ? greenRGB : redRGB; } } }); // ========================================== // 2. INCOME BY PROP FIRM // ========================================== y = doc.lastAutoTable.finalY + 10; sectionHeader('Income by Prop Firm'); // Build firm-level P&L (payouts + expenses per firm) const firmPnL = {}; data.payouts.forEach(p => { const acc = accounts.find(a => a.id === p.accountId); const firmKey = acc?.propFirm || p.propFirm || 'other'; const firm = propFirmNames[firmKey] || firmKey; if (!firmPnL[firm]) firmPnL[firm] = { payouts: 0, payoutCount: 0, expenses: 0 }; firmPnL[firm].payouts += convertFromUSD(p.amount || 0); firmPnL[firm].payoutCount++; }); data.expenses.forEach(e => { const firm = propFirmNames[e.propFirm] || e.propFirm || 'Other'; if (!firmPnL[firm]) firmPnL[firm] = { payouts: 0, payoutCount: 0, expenses: 0 }; firmPnL[firm].expenses += getExpenseInDisplayCurrency(e); }); const firmRows = Object.entries(firmPnL) .sort((a, b) => (b[1].payouts - b[1].expenses) - (a[1].payouts - a[1].expenses)) .map(([firm, d]) => { const net = d.payouts - d.expenses; const firmRoi = d.expenses > 0 ? ((net / d.expenses) * 100).toFixed(0) + '%' : 'N/A'; return [firm, String(d.payoutCount), formatDisplayAmount(d.payouts), formatDisplayAmount(d.expenses), formatDisplayAmount(net), firmRoi]; }); if (firmRows.length > 0) { doc.autoTable({ startY: y, head: [['Prop Firm', 'Payouts', 'Income', 'Expenses', 'Net Profit', 'ROI %']], body: firmRows, ...tableTheme, columnStyles: { 1: { halign: 'center', cellWidth: 18 }, 2: { halign: 'right', cellWidth: 25 }, 3: { halign: 'right', cellWidth: 25 }, 4: { halign: 'right', cellWidth: 25 }, 5: { halign: 'center', cellWidth: 18 } }, didParseCell: function(hookData) { if (hookData.section === 'body') { if (hookData.column.index === 2) hookData.cell.styles.textColor = greenRGB; if (hookData.column.index === 3) hookData.cell.styles.textColor = redRGB; if (hookData.column.index === 4) { const val = hookData.cell.raw; hookData.cell.styles.textColor = val.startsWith('+') ? greenRGB : redRGB; } } } }); } else { doc.setFontSize(8); doc.setTextColor(120, 120, 120); doc.text('No data for this period.', margin, y); y += 6; } // ========================================== // 3. EXPENSE BREAKDOWN BY TYPE // ========================================== y = doc.lastAutoTable ? doc.lastAutoTable.finalY + 10 : y + 10; sectionHeader('Expense Breakdown by Category'); const typeBreakdown = {}; data.expenses.forEach(e => { const t = (expenseTypeLabels[e.type] || e.type).replace(/^[^\w]*/, '').trim(); if (!typeBreakdown[t]) typeBreakdown[t] = { total: 0, count: 0 }; typeBreakdown[t].total += getExpenseInDisplayCurrency(e); typeBreakdown[t].count++; }); const typeRows = Object.entries(typeBreakdown) .sort((a, b) => b[1].total - a[1].total) .map(([type, d]) => { const pct = data.summary.totalExpenses > 0 ? ((d.total / data.summary.totalExpenses) * 100).toFixed(0) + '%' : '-'; return [type, String(d.count), formatDisplayAmount(d.total), pct]; }); if (typeRows.length > 0) { doc.autoTable({ startY: y, head: [['Category', 'Count', 'Total', '% of Expenses']], body: typeRows, ...tableTheme, columnStyles: { 1: { halign: 'center', cellWidth: 20 }, 2: { halign: 'right', cellWidth: 28 }, 3: { halign: 'center', cellWidth: 28 } } }); } else { doc.setFontSize(8); doc.setTextColor(120, 120, 120); doc.text('No expenses in this period.', margin, y); y += 6; } // ========================================== // 4. MONTHLY BREAKDOWN // ========================================== y = doc.lastAutoTable ? doc.lastAutoTable.finalY + 10 : y + 10; sectionHeader('Monthly Breakdown'); const monthlyData = {}; data.payouts.forEach(p => { const d = new Date(p.date.includes('T') ? p.date : p.date + 'T12:00:00'); const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; if (!monthlyData[key]) monthlyData[key] = { payouts: 0, expenses: 0 }; monthlyData[key].payouts += convertFromUSD(p.amount || 0); }); data.expenses.forEach(e => { const d = new Date(e.date.includes('T') ? e.date : e.date + 'T12:00:00'); const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; if (!monthlyData[key]) monthlyData[key] = { payouts: 0, expenses: 0 }; monthlyData[key].expenses += getExpenseInDisplayCurrency(e); }); const monthKeys = Object.keys(monthlyData).sort(); if (monthKeys.length > 0) { const monthRows = monthKeys.map(key => { const md = monthlyData[key]; const net = md.payouts - md.expenses; const [yr, mo] = key.split('-'); const label = new Date(parseInt(yr), parseInt(mo) - 1).toLocaleDateString('en-US', { month: 'short', year: 'numeric' }); return [label, formatDisplayAmount(md.payouts), formatDisplayAmount(md.expenses), formatDisplayAmount(net)]; }); doc.autoTable({ startY: y, head: [['Month', 'Payouts', 'Expenses', 'Net P&L']], body: monthRows, ...tableTheme, columnStyles: { 1: { halign: 'right', cellWidth: 28 }, 2: { halign: 'right', cellWidth: 28 }, 3: { halign: 'right', cellWidth: 28 } }, didParseCell: function(hookData) { if (hookData.section === 'body') { if (hookData.column.index === 1) hookData.cell.styles.textColor = greenRGB; if (hookData.column.index === 2) hookData.cell.styles.textColor = redRGB; if (hookData.column.index === 3) { hookData.cell.styles.textColor = hookData.cell.raw.startsWith('+') ? greenRGB : redRGB; } } } }); } // ========================================== // 5. FULL EXPENSE TRANSACTION LIST // ========================================== y = doc.lastAutoTable ? doc.lastAutoTable.finalY + 10 : y + 10; sectionHeader('Expense Transactions'); if (data.expenses.length > 0) { const expRows = data.expenses.map(e => { const catLabel = (expenseTypeLabels[e.type] || e.type).replace(/^[^\w]*/, '').trim(); const firm = propFirmNames[e.propFirm] || e.propFirm || ''; const account = accounts.find(a => a.id === e.accountId); const acctName = account ? (account.accountNumber || account.name || '') : (e.accountId || ''); return [e.date, firm, catLabel, (e.description || '-').substring(0, 40), acctName.substring(0, 20), formatExpenseAmount(e)]; }); doc.autoTable({ startY: y, head: [['Date', 'Prop Firm', 'Type', 'Description', 'Account', 'Amount']], body: expRows, ...tableTheme, columnStyles: { 0: { cellWidth: 22 }, 1: { cellWidth: 28 }, 2: { cellWidth: 26 }, 3: { cellWidth: 48 }, 4: { cellWidth: 24 }, 5: { halign: 'right', cellWidth: 22 } }, didParseCell: function(hookData) { if (hookData.section === 'body' && hookData.column.index === 5) { hookData.cell.styles.textColor = redRGB; } } }); } else { doc.setFontSize(8); doc.setTextColor(120, 120, 120); doc.text('No expenses in this period.', margin, y); } // ========================================== // FOOTER // ========================================== const finalY = doc.lastAutoTable ? doc.lastAutoTable.finalY + 8 : y + 12; const footerY = Math.max(finalY, 270); // Add footer on last page doc.setDrawColor(200, 200, 200); doc.line(margin, footerY, pageW - margin, footerY); doc.setFontSize(7); doc.setTextColor(150, 150, 150); doc.text( `Generated by PayoutLab on ${new Date().toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })} — for informational purposes only`, margin, footerY + 4 ); // SAVE const filename = `PayoutLab_Report_${from}_to_${to}.pdf`; doc.save(filename); showNotification('PDF report downloaded', 'success'); } catch (err) { console.error('PDF export error:', err); showNotification('PDF export failed: ' + err.message, 'error'); } } function exportTaxExcel() { const from = document.getElementById('tax-from-date').value; const to = document.getElementById('tax-to-date').value; const cats = getSelectedTaxCategories(); const data = getTaxExportData(from, to, cats); const wb = XLSX.utils.book_new(); // Sheet 1: Summary const summaryRows = [ ['PayoutLab P&L Report'], [`Period: ${from} to ${to}`], [`Generated: ${new Date().toISOString().split('T')[0]}`], [], ['Metric', 'Value'], ['Total Payouts', data.summary.totalIncome], ['Total Expenses', data.summary.totalExpenses], ['Net Profit', data.summary.net], ['ROI', data.summary.totalExpenses > 0 ? ((data.summary.net / data.summary.totalExpenses) * 100).toFixed(1) + '%' : 'N/A'], ['Avg Payout', data.payouts.length > 0 ? (data.summary.totalIncome / data.payouts.length) : 0], ['Payout Count', data.payouts.length], ['Expense Count', data.expenses.length] ]; const wsSummary = XLSX.utils.aoa_to_sheet(summaryRows); wsSummary['!cols'] = [{ wch: 20 }, { wch: 18 }]; XLSX.utils.book_append_sheet(wb, wsSummary, 'Summary'); // Sheet 2: Expenses const expRows = [['Date', 'Prop Firm', 'Category', 'Description', 'Amount']]; data.expenses.forEach(e => { const catLabel = (expenseTypeLabels[e.type] || e.type).replace(/^[^\w]*/, '').trim(); expRows.push([e.date, propFirmNames[e.propFirm] || e.propFirm, catLabel, e.description || '', e.amount]); }); const wsExp = XLSX.utils.aoa_to_sheet(expRows); wsExp['!cols'] = [{ wch: 12 }, { wch: 20 }, { wch: 18 }, { wch: 35 }, { wch: 12 }]; XLSX.utils.book_append_sheet(wb, wsExp, 'Expenses'); // Sheet 3: Income (Payouts) const incRows = [['Date', 'Prop Firm', 'Account', 'Gross Amount', 'Profit Split', 'Net Amount']]; data.payouts.forEach(p => { const acc = accounts.find(a => a.id === p.accountId); const firmName = propFirmNames[acc?.propFirm || p.propFirm] || p.propFirm || ''; incRows.push([p.date, firmName, p.accountName || p.accountId || '', p.grossAmount || p.amount || 0, (p.profitSplit || 100) + '%', p.amount || 0]); }); const wsInc = XLSX.utils.aoa_to_sheet(incRows); wsInc['!cols'] = [{ wch: 12 }, { wch: 20 }, { wch: 25 }, { wch: 14 }, { wch: 12 }, { wch: 14 }]; XLSX.utils.book_append_sheet(wb, wsInc, 'Income'); // Sheet 4: Monthly Breakdown const months = calculateMonthlyBreakdown(); const activeMonths = months.filter(m => m.payouts > 0 || m.expenses > 0); const moRows = [['Month', 'Payouts', 'Expenses', 'Net P&L', 'Cumulative']]; activeMonths.forEach(m => { moRows.push([m.monthName, m.payouts, m.expenses, m.net, m.cumulative]); }); const wsMo = XLSX.utils.aoa_to_sheet(moRows); wsMo['!cols'] = [{ wch: 14 }, { wch: 12 }, { wch: 12 }, { wch: 12 }, { wch: 14 }]; XLSX.utils.book_append_sheet(wb, wsMo, 'Monthly'); const filename = `PayoutLab_Report_${from}_to_${to}.xlsx`; XLSX.writeFile(wb, filename); showNotification('Excel report downloaded', 'success'); } // Add event listeners for ROI filters document.getElementById('roi-filter-year')?.addEventListener('change', () => { expenseCurrentPage = 1; renderROITracker(); }); document.getElementById('roi-filter-month')?.addEventListener('change', () => { expenseCurrentPage = 1; renderROITracker(); }); // ==================== LABCARD FUNCTIONS (Auto-generated from payouts) ==================== // Flask background image - loaded once var _labcardStyle = localStorage.getItem('pl_labcard_style') || 'flat'; var _labcardTemplateImages = { '3dhype': 'labcard-3dhype.png', 'retro': 'labcard-retro.png' }; var _suppressPayoutRender = false; function setLabCardStyle(style) { _labcardStyle = style; localStorage.setItem('pl_labcard_style', style); document.querySelectorAll('.labcard-style-btn[data-style]').forEach(function(b) { b.classList.toggle('active', b.dataset.style === style); }); var gallery = document.getElementById('updated-labcard-gallery'); var preview = document.getElementById('labcard-template-preview'); if (gallery) gallery.style.display = ''; if (preview) preview.style.display = 'none'; if (!_suppressPayoutRender) renderUpdatedLabCardGallery(); } function downloadLabCardTemplate() { var img = document.getElementById('labcard-template-img'); if (!img || !img.src) return; var a = document.createElement('a'); a.href = img.src; a.download = 'LabCard-' + _labcardStyle + '.png'; a.click(); } var updatedLabcardBgImg = null; var updatedLabcardBgLoading = false; // User-uploaded logo var updatedLabcardUserLogo = null; var updatedLabcardUserLogoUrl = null; function loadUpdatedLabCardBg() { if (updatedLabcardBgImg || updatedLabcardBgLoading) return; updatedLabcardBgLoading = true; updatedLabcardBgImg = new Image(); updatedLabcardBgImg.crossOrigin = 'anonymous'; updatedLabcardBgImg.onload = function() { updatedLabcardBgLoading = false; }; updatedLabcardBgImg.onerror = function() { updatedLabcardBgLoading = false; updatedLabcardBgImg = null; }; updatedLabcardBgImg.src = 'share-card-bg.png'; } function loadUpdatedLabCardUserLogo() { if (!currentUser) return; db.collection('users').doc(currentUser.uid).get().then(function(doc) { var data = doc.data(); if (data && data.updatedLabcardLogoUrl) { updatedLabcardUserLogoUrl = data.updatedLabcardLogoUrl; updatedLabcardUserLogo = new Image(); updatedLabcardUserLogo.onload = function() { console.log('[LabCard] User logo loaded, naturalWidth:', updatedLabcardUserLogo.naturalWidth); updateUpdatedLabCardLogoPreview(); var page = document.querySelector('#page-share-card'); if (page && page.classList.contains('active')) renderUpdatedLabCardGallery(); }; updatedLabcardUserLogo.onerror = function() { console.error('[LabCard] User logo failed to load'); updatedLabcardUserLogo = null; }; updatedLabcardUserLogo.src = updatedLabcardUserLogoUrl; updateUpdatedLabCardLogoPreview(); } }); } function updateUpdatedLabCardLogoPreview() { var preview = document.getElementById('updated-labcard-logo-preview'); var removeBtn = document.getElementById('updated-labcard-logo-remove'); if (!preview) return; if (updatedLabcardUserLogoUrl) { preview.innerHTML = ''; if (removeBtn) removeBtn.style.display = 'inline-block'; } else { var name = (currentUser && currentUser.displayName) || (currentUser && currentUser.email ? currentUser.email.split('@')[0] : '') || '?'; preview.textContent = name.charAt(0).toUpperCase(); if (removeBtn) removeBtn.style.display = 'none'; } } async function handleUpdatedLabCardLogoUpload(event) { var file = event.target.files[0]; if (!file || !currentUser) { console.error('Logo upload: no file or no user', { file: !!file, user: !!currentUser }); return; } if (!file.type.startsWith('image/')) { showNotification('Please select an image file', 'error'); return; } try { showNotification('Processing logo...', 'info'); // Resize and convert to base64 data URL (avoids CORS issues with canvas) var dataUrl = await resizeLogoToDataUrl(file, 200); console.log('Logo resized, data URL length:', dataUrl.length); updatedLabcardUserLogoUrl = dataUrl; await db.collection('users').doc(currentUser.uid).update({ updatedLabcardLogoUrl: dataUrl }); // Load as Image object for canvas rendering updatedLabcardUserLogo = new Image(); updatedLabcardUserLogo.onload = function() { console.log('Logo Image loaded, naturalWidth:', updatedLabcardUserLogo.naturalWidth); updateUpdatedLabCardLogoPreview(); renderUpdatedLabCardGallery(); showNotification('Logo uploaded! All LabCards updated.', 'success'); }; updatedLabcardUserLogo.onerror = function() { console.error('Logo Image failed to load from data URL'); }; updatedLabcardUserLogo.src = dataUrl; updateUpdatedLabCardLogoPreview(); } catch(e) { console.error('Logo upload error:', e); showNotification('Failed to process logo: ' + (e.message || 'unknown error'), 'error'); } event.target.value = ''; } function resizeLogoToDataUrl(file, maxSize) { return new Promise(function(resolve, reject) { var reader = new FileReader(); reader.onload = function(e) { var img = new Image(); img.onload = function() { var canvas = document.createElement('canvas'); var w = img.width, h = img.height; if (w > maxSize || h > maxSize) { if (w > h) { h = Math.round(h * maxSize / w); w = maxSize; } else { w = Math.round(w * maxSize / h); h = maxSize; } } canvas.width = w; canvas.height = h; var ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0, w, h); resolve(canvas.toDataURL('image/png')); }; img.onerror = function() { reject(new Error('Failed to read image')); }; img.src = e.target.result; }; reader.onerror = function() { reject(new Error('Failed to read file')); }; reader.readAsDataURL(file); }); } async function removeUpdatedLabCardLogo() { if (!currentUser) return; try { updatedLabcardUserLogo = null; updatedLabcardUserLogoUrl = null; await db.collection('users').doc(currentUser.uid).update({ updatedLabcardLogoUrl: null }); updateUpdatedLabCardLogoPreview(); renderUpdatedLabCardGallery(); showNotification('Logo removed', 'info'); } catch(e) { showNotification('Failed to remove logo', 'error'); } } function renderUpdatedLabCardGallery() { var gallery = document.getElementById('updated-labcard-gallery'); if (!gallery) return; loadUpdatedLabCardBg(); // Tag each withdrawal with its original payouts index for stable referencing var allWithdrawals = []; payouts.forEach(function(p, i) { if (p.type === 'withdrawal') allWithdrawals.push({ payout: p, payoutIdx: i }); }); allWithdrawals.sort(function(a, b) { return new Date(b.payout.date) - new Date(a.payout.date); }); var active = allWithdrawals.filter(function(w) { return !w.payout.updatedLabcardArchived; }); var archived = allWithdrawals.filter(function(w) { return w.payout.updatedLabcardArchived; }); if (allWithdrawals.length === 0) { gallery.innerHTML = '
💳
No LabCards yet
Record a payout on the Payout Tracker page to auto-generate a verified LabCard.
'; return; } gallery.innerHTML = ''; // Toggle grid layout for cinematic mode gallery.classList.toggle('cinematic-mode', _labcardStyle === 'cinematic'); // Helper to build a card element function buildCard(w, isArchived) { var thumb = document.createElement('div'); thumb.className = 'updated-labcard-thumb'; if (_labcardStyle === 'cinematic') thumb.classList.add('labcard-cinematic-thumb'); if (isArchived) thumb.style.opacity = '0.6'; var canvas = document.createElement('canvas'); canvas.width = 1200; canvas.height = 675; canvas.style.width = '100%'; canvas.style.display = 'block'; canvas.dataset.payoutIdx = String(w.payoutIdx); thumb.appendChild(canvas); var actions = document.createElement('div'); actions.className = 'updated-labcard-actions'; var idx = w.payoutIdx; var btns = '' + '' + ''; if (isArchived) { btns += ''; } else { btns += ''; } actions.innerHTML = btns; thumb.appendChild(actions); if (updatedLabcardBgImg && updatedLabcardBgImg.complete && updatedLabcardBgImg.naturalWidth > 0) { renderLabCardByStyle(canvas, w.payout); } else if (updatedLabcardBgImg) { var imgRef = updatedLabcardBgImg; imgRef.addEventListener('load', function() { renderLabCardByStyle(canvas, w.payout); }, { once: true }); setTimeout(function() { if (!canvas._rendered) renderLabCardByStyle(canvas, w.payout); }, 500); } else { renderLabCardByStyle(canvas, w.payout); } return thumb; } // Active cards if (active.length === 0) { var empty = document.createElement('div'); empty.style.cssText = 'grid-column: 1 / -1; text-align: center; padding: 40px 20px; color: var(--text-muted);'; empty.innerHTML = 'All LabCards are archived. Expand the Archived section below to view them.'; gallery.appendChild(empty); } else { active.forEach(function(w) { gallery.appendChild(buildCard(w, false)); }); } // Archived section if (archived.length > 0) { var section = document.createElement('div'); section.style.cssText = 'grid-column: 1 / -1; margin-top: 16px;'; section.innerHTML = '
Archived LabCards (' + archived.length + ')
'; gallery.appendChild(section); var archGrid = document.createElement('div'); archGrid.className = 'updated-labcard-grid'; archGrid.id = 'updated-labcard-archived-grid'; archGrid.style.display = 'none'; archGrid.style.marginTop = '12px'; archived.forEach(function(w) { archGrid.appendChild(buildCard(w, true)); }); section.appendChild(archGrid); var det = section.querySelector('#updated-labcard-archived-details'); det.addEventListener('toggle', function() { archGrid.style.display = det.open ? 'grid' : 'none'; }); } } async function archiveUpdatedLabCard(payoutIdx) { if (!payouts[payoutIdx]) return; const _prevArchived = payouts[payoutIdx].updatedLabcardArchived; payouts[payoutIdx].updatedLabcardArchived = true; try { await savePayouts(); } catch (saveError) { if (_prevArchived === undefined) delete payouts[payoutIdx].updatedLabcardArchived; else payouts[payoutIdx].updatedLabcardArchived = _prevArchived; console.error('[Archive LabCard] Save failed:', saveError); showNotification(`Failed to archive LabCard: ${saveError?.message || saveError}`, 'error'); renderUpdatedLabCardGallery(); return; } renderUpdatedLabCardGallery(); showNotification('LabCard archived', 'info'); } async function unarchiveUpdatedLabCard(payoutIdx) { if (!payouts[payoutIdx]) return; const _prevArchived = payouts[payoutIdx].updatedLabcardArchived; delete payouts[payoutIdx].updatedLabcardArchived; try { await savePayouts(); } catch (saveError) { if (_prevArchived !== undefined) payouts[payoutIdx].updatedLabcardArchived = _prevArchived; console.error('[Unarchive LabCard] Save failed:', saveError); showNotification(`Failed to restore LabCard: ${saveError?.message || saveError}`, 'error'); renderUpdatedLabCardGallery(); return; } renderUpdatedLabCardGallery(); showNotification('LabCard restored', 'success'); } // Dispatcher: route to the correct LabCard renderer based on style function renderLabCardByStyle(canvas, payout) { if (_labcardStyle === '3dhype') return renderLabCard3DHype(canvas, payout); if (_labcardStyle === 'cinematic') return renderLabCardCinematic(canvas, payout); if (_labcardStyle === 'retro') return renderLabCardRetro(canvas, payout); return renderUpdatedLabCard(canvas, payout); } function renderUpdatedLabCard(canvas, payout) { canvas._rendered = true; // Reset cinematic-specific styles if previously applied canvas.classList.remove('labcard-cinematic'); canvas.style.maxWidth = ''; canvas.style.aspectRatio = ''; canvas.style.margin = ''; canvas.style.display = 'block'; canvas.width = 1200; canvas.height = 675; var ctx = canvas.getContext('2d'); var W = 1200, H = 675; // === FLAT STYLE THEME === var theme = { accent: '#00d4aa', accentRgb: '0,212,170', bg1: '#080c12', bg2: '#0b1018', bg3: '#0d141e', topGlow1: '#00d4aa', rayAlpha: 0.07, flaskAlpha: 0.21, badgeBg: 'rgba(0,212,170,0.15)', badgeBorder: 'rgba(0,212,170,0.3)', cellBg: 'rgba(255,255,255,0.03)', cellBorder: 'rgba(255,255,255,0.04)' }; // === BACKGROUND === var bgGrad = ctx.createLinearGradient(0, 0, W * 0.6, H); bgGrad.addColorStop(0, theme.bg1); bgGrad.addColorStop(0.4, theme.bg2); bgGrad.addColorStop(1, theme.bg3); ctx.fillStyle = bgGrad; ctx.fillRect(0, 0, W, H); // === SUNSHINE RAYS === ctx.save(); var raysCX = W * 0.42; var raysCY = H * 0.55; var raysRadius = Math.max(W, H) * 0.95; var numRays = 16; var rayWidth = Math.PI / 38; for (var ri = 0; ri < numRays; ri++) { var angle = (ri / numRays) * Math.PI * 2 + 0.3; ctx.beginPath(); ctx.moveTo(raysCX, raysCY); ctx.arc(raysCX, raysCY, raysRadius, angle - rayWidth, angle + rayWidth); ctx.closePath(); var rayGrad = ctx.createRadialGradient(raysCX, raysCY, 0, raysCX, raysCY, raysRadius); rayGrad.addColorStop(0, 'rgba(' + theme.accentRgb + ',' + theme.rayAlpha + ')'); rayGrad.addColorStop(0.25, 'rgba(' + theme.accentRgb + ',' + (theme.rayAlpha * 0.65) + ')'); rayGrad.addColorStop(0.55, 'rgba(' + theme.accentRgb + ',' + (theme.rayAlpha * 0.33) + ')'); rayGrad.addColorStop(1, 'rgba(' + theme.accentRgb + ',0)'); ctx.fillStyle = rayGrad; ctx.fill(); } ctx.restore(); // Radial glow at ray origin var raysGlow = ctx.createRadialGradient(raysCX, raysCY, 0, raysCX, raysCY, 320); raysGlow.addColorStop(0, 'rgba(' + theme.accentRgb + ',0.058)'); raysGlow.addColorStop(0.4, 'rgba(' + theme.accentRgb + ',0.023)'); raysGlow.addColorStop(1, 'rgba(' + theme.accentRgb + ',0)'); ctx.fillStyle = raysGlow; ctx.fillRect(0, 0, W, H); // === FLASK — vivid signature element with soft edge blending === if (updatedLabcardBgImg && updatedLabcardBgImg.complete && updatedLabcardBgImg.naturalWidth > 0) { ctx.save(); var imgW = updatedLabcardBgImg.naturalWidth; var imgH = updatedLabcardBgImg.naturalHeight; var drawH = H * 1.08; var drawW = drawH * (imgW / imgH); var offsetX = W - drawW + 10; var offsetY = (H - drawH) / 2; // Draw flask subtle — watermark-level visibility ctx.globalAlpha = theme.flaskAlpha; ctx.drawImage(updatedLabcardBgImg, offsetX, offsetY, drawW, drawH); ctx.globalAlpha = 1.0; ctx.restore(); // Left fade — protects text zone, reveals flask on right half var flaskFadeL = ctx.createLinearGradient(0, 0, W, 0); flaskFadeL.addColorStop(0, 'rgba(8,12,18,1)'); flaskFadeL.addColorStop(0.48, 'rgba(8,12,18,0.98)'); flaskFadeL.addColorStop(0.62, 'rgba(8,12,18,0.8)'); flaskFadeL.addColorStop(0.75, 'rgba(8,12,18,0.4)'); flaskFadeL.addColorStop(0.85, 'rgba(8,12,18,0.15)'); flaskFadeL.addColorStop(1, 'rgba(8,12,18,0)'); ctx.fillStyle = flaskFadeL; ctx.fillRect(0, 0, W, H); // Top fade — stronger, extends further down var flaskFadeT = ctx.createLinearGradient(0, 0, 0, H * 0.35); flaskFadeT.addColorStop(0, 'rgba(8,12,18,0.7)'); flaskFadeT.addColorStop(0.6, 'rgba(8,12,18,0.3)'); flaskFadeT.addColorStop(1, 'rgba(8,12,18,0)'); ctx.fillStyle = flaskFadeT; ctx.fillRect(W * 0.5, 0, W * 0.5, H * 0.35); // Bottom fade — stronger, extends further up var flaskFadeB = ctx.createLinearGradient(0, H * 0.65, 0, H); flaskFadeB.addColorStop(0, 'rgba(8,12,18,0)'); flaskFadeB.addColorStop(0.5, 'rgba(8,12,18,0.35)'); flaskFadeB.addColorStop(1, 'rgba(8,12,18,0.75)'); ctx.fillStyle = flaskFadeB; ctx.fillRect(W * 0.5, H * 0.65, W * 0.5, H * 0.35); // Right edge — wider vignette var flaskFadeR = ctx.createLinearGradient(W - 80, 0, W, 0); flaskFadeR.addColorStop(0, 'rgba(8,12,18,0)'); flaskFadeR.addColorStop(0.5, 'rgba(8,12,18,0.15)'); flaskFadeR.addColorStop(1, 'rgba(8,12,18,0.45)'); ctx.fillStyle = flaskFadeR; ctx.fillRect(W - 80, 0, 80, H); // Radial vignette over flask — "emerging from darkness" effect var flaskCenterX = offsetX + drawW * 0.5; var flaskCenterY = offsetY + drawH * 0.5; var vigRadius = Math.max(drawW, drawH) * 0.55; var radialVig = ctx.createRadialGradient(flaskCenterX, flaskCenterY, vigRadius * 0.35, flaskCenterX, flaskCenterY, vigRadius); radialVig.addColorStop(0, 'rgba(8,12,18,0)'); radialVig.addColorStop(0.6, 'rgba(8,12,18,0.1)'); radialVig.addColorStop(0.85, 'rgba(8,12,18,0.4)'); radialVig.addColorStop(1, 'rgba(8,12,18,0.7)'); ctx.fillStyle = radialVig; ctx.fillRect(offsetX, offsetY, drawW, drawH); } // Top edge glow line var topGlow = ctx.createLinearGradient(0, 0, W, 0); topGlow.addColorStop(0, theme.accent); topGlow.addColorStop(0.6, 'rgba(' + theme.accentRgb + ',0.4)'); topGlow.addColorStop(1, 'rgba(' + theme.accentRgb + ',0)'); ctx.fillStyle = topGlow; ctx.fillRect(0, 0, W, 3); // Ambient glow top-left var glow1 = ctx.createRadialGradient(100, 80, 0, 100, 80, 300); glow1.addColorStop(0, 'rgba(' + theme.accentRgb + ',0.07)'); glow1.addColorStop(1, 'rgba(' + theme.accentRgb + ',0)'); ctx.fillStyle = glow1; ctx.fillRect(0, 0, 500, 400); // === DATA === var firmKey = payout.propFirm || ''; var firmName = propFirmConfigs[firmKey] ? propFirmConfigs[firmKey].name : (propFirmNames[firmKey] || firmKey || ''); var payoutAmount = payout.amount || 0; var grossAmount = payout.grossAmount || payoutAmount; var profitSplit = payout.profitSplit || 100; var payoutDate = payout.date ? new Date(payout.date) : new Date(); var dateStr = payoutDate.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }); var acct = payout.accountId ? accounts.find(function(a) { return a.id === payout.accountId; }) : null; var accountName = payout.accountName || (acct ? acct.name : '') || ''; var accountSize = payout.accountSize || (acct ? acct.startingBalance : 0) || 0; // Stats — use snapshot if available, otherwise compute from live trades var snap = payout.statsSnapshot; var totalPnl, dailyPnl, avgDailyPnl, winRate, maxStreak, ytdPayouts, balance, profitFactor, tradingDays; var hasTrades = false; // Track whether we found any trades for graceful degradation // Helper: find trades for this payout by accountId, accountName, or propFirm fallback function findPayoutTrades() { // 1. Match by accountId (preferred) if (payout.accountId) { var byIdAcct = accounts.find(function(a) { return a.id === payout.accountId; }); var byId = getAccountTrades(byIdAcct || { id: payout.accountId }, trades); if (byId.length > 0) return byId; } // 2. Match by accountName via account lookup if (payout.accountName) { var matchedAcct = accounts.find(function(a) { return a.name === payout.accountName; }); if (matchedAcct) { var byName = getAccountTrades(matchedAcct, trades); if (byName.length > 0) return byName; } } // 3. Fallback: match by propFirm if only one account exists for that firm if (payout.propFirm) { var firmAccts = accounts.filter(function(a) { return a.propFirm === payout.propFirm; }); if (firmAccts.length === 1) { var byFirm = getAccountTrades(firmAccts[0], trades); if (byFirm.length > 0) return byFirm; } } return []; } if (snap) { totalPnl = snap.totalPnl != null ? snap.totalPnl : 0; dailyPnl = snap.dailyPnl != null ? snap.dailyPnl : 0; winRate = snap.winRate != null ? snap.winRate : 0; maxStreak = snap.winStreak != null ? snap.winStreak : 0; ytdPayouts = snap.ytdPayouts != null ? snap.ytdPayouts : 0; balance = snap.balance || (accountSize + totalPnl); profitFactor = snap.profitFactor != null ? snap.profitFactor : '0.00'; tradingDays = snap.tradingDays != null ? snap.tradingDays : 0; avgDailyPnl = snap.avgDailyPnl != null ? snap.avgDailyPnl : null; hasTrades = (snap.totalPnl != null && snap.totalPnl !== 0) || (snap.winRate != null && snap.winRate > 0); // Backward compat: payout recorded before avgDailyPnl was stored — compute from live trades if (avgDailyPnl === null) { var bcTrades = findPayoutTrades(); var bcDateKey = getDateKey(payoutDate); var bcTradesUp = bcTrades.filter(function(t) { var d = normalizeTradeDate(t); return d && d <= bcDateKey; }); var bcDays = new Set(bcTradesUp.map(function(t) { return normalizeTradeDate(t); }).filter(Boolean)).size; avgDailyPnl = bcDays > 0 ? totalPnl / bcDays : dailyPnl; } // Fix: if snapshot captured $0 daily P&L (date mismatch bug), recompute from trades if (dailyPnl === 0) { var recomputeTrades = findPayoutTrades(); if (recomputeTrades.length > 0) { hasTrades = true; var recomputeDateKey = getDateKey(payoutDate); var recomputeDay = recomputeTrades.filter(function(t) { return normalizeTradeDate(t) === recomputeDateKey; }); if (recomputeDay.length === 0) { var rdDates = []; var rdSeen = {}; recomputeTrades.forEach(function(t) { var d = normalizeTradeDate(t); if (d && d <= recomputeDateKey && !rdSeen[d]) { rdDates.push(d); rdSeen[d] = true; } }); rdDates.sort(); var rdClosest = rdDates[rdDates.length - 1]; if (rdClosest) recomputeDay = recomputeTrades.filter(function(t) { return normalizeTradeDate(t) === rdClosest; }); } if (recomputeDay.length > 0) { dailyPnl = recomputeDay.reduce(function(s, t) { return s + getNetPnl(t); }, 0); } } } // If snapshot has all zeros but we can find trades, recompute everything if (!hasTrades) { var snapRecheck = findPayoutTrades(); if (snapRecheck.length > 0) { hasTrades = true; var recomputeDateKey2 = getDateKey(payoutDate); var tradesUpToDate = snapRecheck.filter(function(t) { var td = normalizeTradeDate(t); return td && td <= recomputeDateKey2; }); if (tradesUpToDate.length > 0) { totalPnl = tradesUpToDate.reduce(function(s, t) { return s + getNetPnl(t); }, 0); var w = tradesUpToDate.filter(function(t) { return getNetPnl(t) > 0; }); winRate = Math.round((w.length / tradesUpToDate.length) * 100); var gp = w.reduce(function(s, t) { return s + getNetPnl(t); }, 0); var gl = Math.abs(tradesUpToDate.filter(function(t) { return getNetPnl(t) < 0; }).reduce(function(s, t) { return s + getNetPnl(t); }, 0)); profitFactor = gl > 0 ? (gp / gl).toFixed(2) : (gp > 0 ? '\u221e' : '0.00'); var sorted = tradesUpToDate.slice().sort(function(a, b) { return new Date(a.exitTime || a.entryTime) - new Date(b.exitTime || b.entryTime); }); maxStreak = 0; var cs = 0; sorted.forEach(function(t) { if (getNetPnl(t) > 0) { cs++; if (cs > maxStreak) maxStreak = cs; } else { cs = 0; } }); var rechkTradingDays = new Set(tradesUpToDate.map(function(t) { return normalizeTradeDate(t); }).filter(Boolean)).size; avgDailyPnl = rechkTradingDays > 0 ? totalPnl / rechkTradingDays : 0; balance = acct ? (acct.currentBalance || (accountSize + totalPnl)) : (accountSize + totalPnl); } } } } else { // Legacy fallback — compute from current trades using flexible matching var accountTrades = findPayoutTrades(); hasTrades = accountTrades.length > 0; totalPnl = accountTrades.reduce(function(s, t) { return s + getNetPnl(t); }, 0); var winnersList = accountTrades.filter(function(t) { return getNetPnl(t) > 0; }); winRate = accountTrades.length > 0 ? Math.round((winnersList.length / accountTrades.length) * 100) : 0; var grossProfit = winnersList.reduce(function(s, t) { return s + getNetPnl(t); }, 0); var grossLoss = Math.abs(accountTrades.filter(function(t) { return getNetPnl(t) < 0; }).reduce(function(s, t) { return s + getNetPnl(t); }, 0)); profitFactor = grossLoss > 0 ? (grossProfit / grossLoss).toFixed(2) : (grossProfit > 0 ? '\u221e' : '0.00'); var daySet = {}; accountTrades.forEach(function(t) { var d = normalizeTradeDate(t); if (d) daySet[d] = true; }); tradingDays = Object.keys(daySet).length; avgDailyPnl = tradingDays > 0 ? totalPnl / tradingDays : 0; balance = acct ? (acct.currentBalance || (accountSize + totalPnl)) : (accountSize + totalPnl); var sortedTrades = accountTrades.slice().sort(function(a, b) { return new Date(a.exitTime || a.entryTime) - new Date(b.exitTime || b.entryTime); }); maxStreak = 0; var curStreak = 0; sortedTrades.forEach(function(t) { if (getNetPnl(t) > 0) { curStreak++; if (curStreak > maxStreak) maxStreak = curStreak; } else { curStreak = 0; } }); var currentYear = new Date().getFullYear(); ytdPayouts = payouts.filter(function(p) { return p.type === 'withdrawal' && new Date(p.date).getFullYear() === currentYear; }).reduce(function(s, p) { return s + (p.amount || 0); }, 0); var payoutDateKey = getDateKey(payoutDate); // Daily P&L: try exact payout date first, then fall back to most recent trading day var dayTrades = accountTrades.filter(function(t) { return normalizeTradeDate(t) === payoutDateKey; }); if (dayTrades.length === 0 && accountTrades.length > 0) { // No trades on payout date — use most recent trading day on or before payout date var tradeDates = []; var seen = {}; accountTrades.forEach(function(t) { var d = normalizeTradeDate(t); if (d && d <= payoutDateKey && !seen[d]) { tradeDates.push(d); seen[d] = true; } }); tradeDates.sort(); var closestDate = tradeDates[tradeDates.length - 1]; if (closestDate) dayTrades = accountTrades.filter(function(t) { return normalizeTradeDate(t) === closestDate; }); } dailyPnl = dayTrades.reduce(function(s, t) { return s + getNetPnl(t); }, 0); } // === LAYOUT === var LX = 48; // left margin // "PAYOUT RECEIVED" badge (top-left) ctx.fillStyle = theme.badgeBg; updatedLabcardRoundRect(ctx, LX, 32, 200, 34, 17); ctx.fill(); ctx.strokeStyle = theme.badgeBorder; ctx.lineWidth = 1; updatedLabcardRoundRect(ctx, LX, 32, 200, 34, 17); ctx.stroke(); ctx.font = 'bold 13px "Segoe UI", system-ui, sans-serif'; ctx.fillStyle = theme.accent; ctx.textAlign = 'left'; ctx.fillText('\u2713 PAYOUT RECEIVED', LX + 16, 54); // "VERIFIED" small badge next to it ctx.fillStyle = 'rgba(' + theme.accentRgb + ',0.08)'; updatedLabcardRoundRect(ctx, LX + 212, 36, 72, 26, 13); ctx.fill(); ctx.font = 'bold 10px "Segoe UI", system-ui, sans-serif'; ctx.fillStyle = 'rgba(' + theme.accentRgb + ',0.7)'; ctx.fillText('VERIFIED', LX + 224, 53); // Trader logo/avatar (top-right corner) var traderName = (currentUser && currentUser.displayName) || (currentUser && currentUser.email ? currentUser.email.split('@')[0] : '') || ''; var logoMax = 140; var logoPad = 28; if (updatedLabcardUserLogo && updatedLabcardUserLogo.complete && updatedLabcardUserLogo.naturalWidth > 0) { // Draw logo at natural aspect ratio, fit within logoMax box var natW = updatedLabcardUserLogo.naturalWidth; var natH = updatedLabcardUserLogo.naturalHeight; var scale = Math.min(logoMax / natW, logoMax / natH); var drawW = Math.round(natW * scale); var drawH = Math.round(natH * scale); var logoX = W - logoPad - drawW; var logoY = logoPad; // Circular mask with soft radial fade — prevents hard rectangular edges var tmpCvs = document.createElement('canvas'); tmpCvs.width = drawW; tmpCvs.height = drawH; var tmpCtx = tmpCvs.getContext('2d'); // Draw logo with brightness filter onto temp canvas tmpCtx.filter = 'brightness(1.25)'; tmpCtx.drawImage(updatedLabcardUserLogo, 0, 0, drawW, drawH); tmpCtx.filter = 'none'; // Apply radial gradient alpha mask via destination-in tmpCtx.globalCompositeOperation = 'destination-in'; var maskCx = drawW / 2; var maskCy = drawH / 2; var maskR = Math.min(drawW, drawH) / 2; var logoMask = tmpCtx.createRadialGradient(maskCx, maskCy, maskR * 0.55, maskCx, maskCy, maskR); logoMask.addColorStop(0, 'rgba(255,255,255,1)'); logoMask.addColorStop(0.75, 'rgba(255,255,255,0.8)'); logoMask.addColorStop(1, 'rgba(255,255,255,0)'); tmpCtx.fillStyle = logoMask; tmpCtx.fillRect(0, 0, drawW, drawH); tmpCtx.globalCompositeOperation = 'source-over'; // Draw masked logo onto main canvas ctx.drawImage(tmpCvs, logoX, logoY); // Subtle circular border ring for polish ctx.save(); ctx.beginPath(); ctx.arc(logoX + maskCx, logoY + maskCy, maskR * 0.7, 0, Math.PI * 2); ctx.strokeStyle = 'rgba(' + theme.accentRgb + ',0.15)'; ctx.lineWidth = 1.5; ctx.stroke(); ctx.restore(); // Teal outer border circle ctx.strokeStyle = '#00d4aa'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(logoX + maskCx, logoY + maskCy, maskR + 3, 0, Math.PI * 2); ctx.stroke(); } else { // Fallback: initial letter circle var initial = traderName.charAt(0).toUpperCase(); var circR = logoMax / 2; var circX = W - logoPad - logoMax + circR; var circY = logoPad + circR; ctx.fillStyle = 'rgba(' + theme.accentRgb + ',0.12)'; ctx.beginPath(); ctx.arc(circX, circY, circR, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = 'rgba(' + theme.accentRgb + ',0.35)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(circX, circY, circR, 0, Math.PI * 2); ctx.stroke(); ctx.font = 'bold 58px "Segoe UI", system-ui, sans-serif'; ctx.fillStyle = theme.accent; ctx.textAlign = 'center'; ctx.fillText(initial, circX, circY + 20); // Teal outer border circle ctx.strokeStyle = '#00d4aa'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(circX, circY, circR + 3, 0, Math.PI * 2); ctx.stroke(); } // Prop Firm name (large, prominent) ctx.textAlign = 'left'; ctx.font = 'bold 28px "Segoe UI", system-ui, sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.9)'; ctx.fillText(firmName, LX, 115); // Account size subtitle (no account name/ID for privacy) if (accountSize) { ctx.font = '14px "Segoe UI", system-ui, sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.35)'; ctx.fillText(formatCurrency(accountSize, 0) + ' account', LX, 140); } // Hero payout amount — show gross (pre-split) amount var amountStr = formatCurrency(grossAmount, 0); ctx.shadowColor = 'rgba(' + theme.accentRgb + ',0.35)'; ctx.shadowBlur = 40; ctx.font = 'bold 80px "Segoe UI", system-ui, sans-serif'; ctx.fillStyle = '#ffffff'; ctx.fillText(amountStr, LX, 240); ctx.shadowBlur = 0; // Date ctx.font = '15px "Segoe UI", system-ui, sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.45)'; var dateY = profitSplit < 100 ? 290 : 270; ctx.fillText(dateStr, LX, dateY); // Divider line var divY = dateY + 25; var divGrad = ctx.createLinearGradient(LX, 0, 780, 0); divGrad.addColorStop(0, 'rgba(' + theme.accentRgb + ',0.3)'); divGrad.addColorStop(0.5, 'rgba(' + theme.accentRgb + ',0.1)'); divGrad.addColorStop(1, 'rgba(' + theme.accentRgb + ',0)'); ctx.fillStyle = divGrad; ctx.fillRect(LX, divY, 730, 1); // === STATS GRID === (4 cols x 2 rows, left 2/3 of card) // Show "—" for trade-dependent metrics when no trades exist var nd = '\u2014'; // em dash for "no data" var stats = [ { label: 'AVG DAILY P&L', value: hasTrades ? formatCurrency(avgDailyPnl, 0) : nd, color: !hasTrades ? 'rgba(255,255,255,0.3)' : (avgDailyPnl >= 0 ? theme.accent : themeRed()) }, { label: 'WIN RATE', value: hasTrades ? (winRate + '%') : nd, color: hasTrades ? '#ffffff' : 'rgba(255,255,255,0.3)' }, { label: 'BEST STREAK', value: hasTrades ? String(maxStreak) : nd, color: hasTrades ? '#ffffff' : 'rgba(255,255,255,0.3)' }, { label: 'YTD PAYOUTS', value: formatCurrency(ytdPayouts, 0), color: theme.accent }, { label: 'TOTAL P&L', value: hasTrades ? formatCurrency(totalPnl, 0) : nd, color: !hasTrades ? 'rgba(255,255,255,0.3)' : (totalPnl >= 0 ? theme.accent : themeRed()) }, { label: 'BALANCE', value: hasTrades ? formatCurrency(Math.abs(balance), 0) : nd, color: hasTrades ? '#ffffff' : 'rgba(255,255,255,0.3)' }, { label: 'ACCOUNT SIZE', value: accountSize ? formatCurrency(accountSize, 0) : nd, color: accountSize ? '#ffffff' : 'rgba(255,255,255,0.3)' }, { label: 'PROFIT FACTOR', value: hasTrades ? profitFactor.toString() : nd, color: hasTrades ? '#ffffff' : 'rgba(255,255,255,0.3)' } ]; var gridStartY = divY + 18; var gridCols = 4; var cellW = 185; var cellH = 72; var cellGap = 6; stats.forEach(function(stat, i) { var col = i % gridCols; var row = Math.floor(i / gridCols); var sx = LX + col * (cellW + cellGap); var sy = gridStartY + row * (cellH + cellGap); // Cell background ctx.fillStyle = theme.cellBg; updatedLabcardRoundRect(ctx, sx, sy, cellW, cellH, 8); ctx.fill(); ctx.strokeStyle = theme.cellBorder; ctx.lineWidth = 1; updatedLabcardRoundRect(ctx, sx, sy, cellW, cellH, 8); ctx.stroke(); // Label ctx.font = '600 10px "Segoe UI", system-ui, sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.35)'; ctx.textAlign = 'left'; ctx.fillText(stat.label, sx + 14, sy + 24); // Value ctx.font = 'bold 20px "Segoe UI", system-ui, sans-serif'; ctx.fillStyle = stat.color; ctx.fillText(stat.value, sx + 14, sy + 52); }); // === BOTTOM BAR === var bottomY = H - 60; ctx.fillStyle = 'rgba(8,12,18,0.88)'; ctx.fillRect(0, bottomY, W, 60); var bottomLine = ctx.createLinearGradient(0, 0, W, 0); bottomLine.addColorStop(0, 'rgba(' + theme.accentRgb + ',0.3)'); bottomLine.addColorStop(0.5, 'rgba(' + theme.accentRgb + ',0.08)'); bottomLine.addColorStop(1, 'rgba(' + theme.accentRgb + ',0)'); ctx.fillStyle = bottomLine; ctx.fillRect(0, bottomY, W, 1); // PayoutLab.io branding (bottom-left, prominent) ctx.textAlign = 'left'; ctx.font = 'bold 22px "Segoe UI", system-ui, sans-serif'; ctx.fillStyle = '#ffffff'; ctx.fillText('Payout', LX, H - 24); var payoutW = ctx.measureText('Payout').width; ctx.fillStyle = theme.accent; ctx.fillText('Lab', LX + payoutW, H - 24); var labW = ctx.measureText('Lab').width; ctx.font = '22px "Segoe UI", system-ui, sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.fillText('.io', LX + payoutW + labW, H - 24); // Tagline underneath branding ctx.font = '12px "Segoe UI", system-ui, sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.3)'; ctx.fillText('Command Center for Funded Traders', LX, H - 8); } function updatedLabcardRoundRect(ctx, x, y, w, h, r) { ctx.beginPath(); ctx.moveTo(x + r, y); ctx.lineTo(x + w - r, y); ctx.quadraticCurveTo(x + w, y, x + w, y + r); ctx.lineTo(x + w, y + h - r); ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h); ctx.lineTo(x + r, y + h); ctx.quadraticCurveTo(x, y + h, x, y + h - r); ctx.lineTo(x, y + r); ctx.quadraticCurveTo(x, y, x + r, y); ctx.closePath(); } // 3D Hype LabCard — HTML overlay on a template image function renderLabCard3DHype(canvas, payout) { canvas._rendered = true; var parent = canvas.parentNode; if (!parent) return; // Hide the canvas — we render an HTML overlay div instead canvas.style.display = 'none'; // Remove any prior overlay var prior = parent.querySelector('.labcard-3dhype'); if (prior) prior.remove(); // Extract data var firmKey = payout.propFirm || ''; var firmName = propFirmConfigs[firmKey] ? propFirmConfigs[firmKey].name : (propFirmNames[firmKey] || firmKey || ''); var firmInitial = (firmName.charAt(0) || '').toUpperCase(); var payoutAmount = payout.amount || 0; var grossAmount = payout.grossAmount || payoutAmount; var payoutDate = payout.date ? new Date(payout.date) : new Date(); var dateStr = payoutDate.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }); var acct = payout.accountId ? accounts.find(function(a) { return a.id === payout.accountId; }) : null; var accountSize = payout.accountSize || (acct ? acct.startingBalance : 0) || 0; var snap = payout.statsSnapshot; var totalPnl = 0, dailyPnl = 0, avgDailyPnl = 0, winRate = 0, maxStreak = 0, ytdPayouts = 0, balance = 0, profitFactor = '0.00'; var hasTrades = false; if (snap) { totalPnl = snap.totalPnl || 0; dailyPnl = snap.dailyPnl || 0; avgDailyPnl = snap.avgDailyPnl != null ? snap.avgDailyPnl : null; winRate = snap.winRate || 0; maxStreak = snap.winStreak || 0; ytdPayouts = snap.ytdPayouts || 0; profitFactor = snap.profitFactor != null ? snap.profitFactor : '0.00'; balance = snap.balance || (accountSize + totalPnl); hasTrades = (snap.totalPnl != null && snap.totalPnl !== 0); // Backward compat: compute avgDailyPnl from live trades when not stored in snapshot if (avgDailyPnl === null) { var bc3dTrades = payout.accountId ? getAccountTrades(acct || { id: payout.accountId }, trades) : []; var bc3dKey = getDateKey(payout.date ? new Date(payout.date) : new Date()); var bc3dUp = bc3dTrades.filter(function(t) { var d = normalizeTradeDate(t); return d && d <= bc3dKey; }); var bc3dDays = new Set(bc3dUp.map(function(t) { return normalizeTradeDate(t); }).filter(Boolean)).size; avgDailyPnl = bc3dDays > 0 ? totalPnl / bc3dDays : dailyPnl; } } var nd = '\u2014'; function fmt(v) { return formatCurrency(v, 0); } var dailyPnlStr = hasTrades ? fmt(avgDailyPnl) : nd; var winRateStr = hasTrades ? (winRate + '%') : nd; var streakStr = hasTrades ? String(maxStreak) : nd; var ytdStr = fmt(ytdPayouts); var totalPnlStr = hasTrades ? fmt(totalPnl) : nd; var balanceStr = hasTrades ? fmt(Math.abs(balance)) : nd; var acctSizeStr = accountSize ? fmt(accountSize) : nd; var pfStr = hasTrades ? String(profitFactor) : nd; var logoHtml; if (updatedLabcardUserLogo && updatedLabcardUserLogo.complete && updatedLabcardUserLogo.naturalWidth > 0) { logoHtml = ''; } else { logoHtml = ''; } var html = '
' + '
' + '' + '
' + firmName + '
' + '
' + (accountSize ? fmt(accountSize) + ' account' : '') + '
' + '
' + fmt(grossAmount) + '
' + '
' + dateStr + '
' + '
AVG P&L
' + dailyPnlStr + '
' + '
WIN RATE
' + winRateStr + '
' + '
BEST STREAK
' + streakStr + '
' + '
YTD PAYOUTS
' + ytdStr + '
' + '
TOTAL P&L
' + totalPnlStr + '
' + '
BALANCE
' + balanceStr + '
' + '
ACCT SIZE
' + acctSizeStr + '
' + '
PROFIT FACTOR
' + pfStr + '
' + logoHtml + '
'; var wrap = document.createElement('div'); wrap.innerHTML = html; parent.insertBefore(wrap.firstChild, canvas); // Auto-shrink amount text if too wide setTimeout(function() { var amountEl = parent.querySelector('.lc3d-amount'); if (!amountEl) return; var container = parent.querySelector('.labcard-3dhype'); if (!container) return; var maxW = container.offsetWidth * 0.35; var fontSize = parseFloat(window.getComputedStyle(amountEl).fontSize); while (amountEl.scrollWidth > maxW && fontSize > 14) { fontSize -= 1; amountEl.style.fontSize = fontSize + 'px'; } }, 50); } // ── Cinematic LabCard — portrait 9:16 with template image ── var _cinematicTemplateImg = null; function loadCinematicTemplate(cb) { if (_cinematicTemplateImg && _cinematicTemplateImg.complete && _cinematicTemplateImg.naturalWidth > 0) { cb(); return; } _cinematicTemplateImg = new Image(); _cinematicTemplateImg.crossOrigin = 'anonymous'; _cinematicTemplateImg.onload = cb; _cinematicTemplateImg.onerror = cb; _cinematicTemplateImg.src = '/labcard-cinematic-bg.png'; } function renderLabCardCinematic(canvas, payout) { canvas._rendered = true; // Cinematic display constraints (parent thumb handles max-width) canvas.classList.add('labcard-cinematic'); canvas.style.display = 'block'; canvas.style.width = '100%'; canvas.style.height = 'auto'; canvas.style.aspectRatio = '27/40'; // Remove any prior 3D Hype overlay from parent if (canvas.parentNode) { var prior = canvas.parentNode.querySelector('.labcard-3dhype'); if (prior) prior.remove(); } // Portrait 27:40 — 540x800 canvas.width = 540; canvas.height = 800; var ctx = canvas.getContext('2d'); var W = 540, H = 800; function doDraw() { // Background template (drawCover prevents stretching) if (_cinematicTemplateImg && _cinematicTemplateImg.complete && _cinematicTemplateImg.naturalWidth > 0) { drawCover(ctx, _cinematicTemplateImg, 0, 0, W, H); } else { ctx.fillStyle = '#0a0e14'; ctx.fillRect(0, 0, W, H); } // Dark gradient overlay for text legibility var grad = ctx.createLinearGradient(0, 0, 0, 700); grad.addColorStop(0, 'rgba(0,0,0,0.55)'); grad.addColorStop(0.45, 'rgba(0,0,0,0.35)'); grad.addColorStop(0.65, 'rgba(0,0,0,0.1)'); grad.addColorStop(1, 'rgba(0,0,0,0)'); ctx.fillStyle = grad; ctx.fillRect(0, 0, W, H); // === EXTRACT DATA === var firmKey = payout.propFirm || ''; var firmName = propFirmConfigs[firmKey] ? propFirmConfigs[firmKey].name : (propFirmNames[firmKey] || firmKey || ''); var firmInitial = (firmName.charAt(0) || '').toUpperCase(); var payoutAmount = payout.amount || 0; var grossAmount = payout.grossAmount || payoutAmount; var payoutDate = payout.date ? new Date(payout.date) : new Date(); var dateStr = payoutDate.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }); var acct = payout.accountId ? accounts.find(function(a) { return a.id === payout.accountId; }) : null; var accountSize = payout.accountSize || (acct ? acct.startingBalance : 0) || 0; var snap = payout.statsSnapshot; var totalPnl = 0, dailyPnl = 0, avgDailyPnl = 0, winRate = 0, maxStreak = 0, ytdPayouts = 0, balance = 0, profitFactor = '0.00'; var hasTrades = false; if (snap) { totalPnl = snap.totalPnl || 0; dailyPnl = snap.dailyPnl || 0; avgDailyPnl = snap.avgDailyPnl != null ? snap.avgDailyPnl : null; winRate = snap.winRate || 0; maxStreak = snap.winStreak || 0; ytdPayouts = snap.ytdPayouts || 0; profitFactor = snap.profitFactor != null ? snap.profitFactor : '0.00'; balance = snap.balance || (accountSize + totalPnl); hasTrades = (snap.totalPnl != null && snap.totalPnl !== 0); // Backward compat: compute avgDailyPnl from live trades when not stored in snapshot if (avgDailyPnl === null) { var bcCinTrades = payout.accountId ? getAccountTrades(acct || { id: payout.accountId }, trades) : []; var bcCinKey = getDateKey(payout.date ? new Date(payout.date) : new Date()); var bcCinUp = bcCinTrades.filter(function(t) { var d = normalizeTradeDate(t); return d && d <= bcCinKey; }); var bcCinDays = new Set(bcCinUp.map(function(t) { return normalizeTradeDate(t); }).filter(Boolean)).size; avgDailyPnl = bcCinDays > 0 ? totalPnl / bcCinDays : dailyPnl; } } var nd = '\u2014'; ctx.textAlign = 'left'; // === LOGO — top right corner === var logoX = W - 70; var logoY = 70; var logoR = 32; // Teal border circle ctx.strokeStyle = '#00d4aa'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(logoX, logoY, logoR + 3, 0, Math.PI * 2); ctx.stroke(); if (updatedLabcardUserLogo && updatedLabcardUserLogo.complete && updatedLabcardUserLogo.naturalWidth > 0) { ctx.save(); ctx.beginPath(); ctx.arc(logoX, logoY, logoR, 0, Math.PI * 2); ctx.clip(); ctx.drawImage(updatedLabcardUserLogo, logoX - logoR, logoY - logoR, logoR * 2, logoR * 2); ctx.restore(); } else { ctx.fillStyle = '#1a3a2a'; ctx.beginPath(); ctx.arc(logoX, logoY, logoR, 0, Math.PI * 2); ctx.fill(); ctx.font = 'bold 22px "Segoe UI", system-ui, sans-serif'; ctx.fillStyle = '#00d4aa'; ctx.textAlign = 'center'; ctx.fillText(firmInitial, logoX, logoY + 8); ctx.textAlign = 'left'; } // Helper for rounded pill function pill(x, y, text, fill, textColor, borderColor) { ctx.font = 'bold 13px "Segoe UI", system-ui, sans-serif'; var padX = 14, padY = 6; var tw = ctx.measureText(text).width; var pw = tw + padX * 2; var ph = 28; var r = 14; ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + pw, y, x + pw, y + ph, r); ctx.arcTo(x + pw, y + ph, x, y + ph, r); ctx.arcTo(x, y + ph, x, y, r); ctx.arcTo(x, y, x + pw, y, r); ctx.closePath(); ctx.fillStyle = fill; ctx.fill(); if (borderColor) { ctx.strokeStyle = borderColor; ctx.lineWidth = 1; ctx.stroke(); } ctx.fillStyle = textColor; ctx.fillText(text, x + padX, y + ph / 2 + 4); return pw; } // 1. PAYOUT RECEIVED badge var badgeW = pill(32, 48, '\u2713 PAYOUT RECEIVED', '#00d4aa', '#000', null); // 2. VERIFIED badge pill(32 + badgeW + 10, 48, 'VERIFIED', 'rgba(255,255,255,0.15)', '#fff', 'rgba(255,255,255,0.3)'); // 3. FIRM NAME ctx.font = 'bold 34px "Segoe UI", system-ui, sans-serif'; ctx.fillStyle = '#fff'; ctx.shadowColor = 'rgba(255,255,255,0.2)'; ctx.shadowBlur = 10; ctx.fillText(firmName, 32, 140); ctx.shadowBlur = 0; // 4. ACCOUNT SIZE ctx.font = '15px "Segoe UI", system-ui, sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.5)'; ctx.fillText(accountSize ? formatCurrency(accountSize, 0) + ' account' : '', 32, 170); // 5. PAYOUT AMOUNT — large with teal glow ctx.font = 'bold 86px "Segoe UI", system-ui, sans-serif'; ctx.fillStyle = '#e0fff9'; ctx.shadowColor = 'rgba(0,212,170,0.8)'; ctx.shadowBlur = 30; ctx.fillText(formatCurrency(grossAmount, 0), 32, 280); ctx.shadowBlur = 0; // 6. DATE var dateY = 390; ctx.font = '15px "Segoe UI", system-ui, sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.fillText(dateStr, 32, dateY); // 7. STATS — 3 columns x 2 rows with dark box backgrounds function drawStatBox(ctx, x, y, w, h, label, value, valueColor) { // Dark rounded box ctx.fillStyle = 'rgba(0,0,0,0.55)'; var r = 8; ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath(); ctx.fill(); // Label ctx.font = '500 11px Arial'; ctx.fillStyle = 'rgba(255,255,255,0.5)'; ctx.textAlign = 'left'; ctx.fillText(label, x + 10, y + 22); // Value ctx.font = 'bold 20px Arial'; ctx.fillStyle = valueColor; ctx.fillText(value, x + 10, y + 52); } var col1 = 32, col2 = 192, col3 = 352; var row1Y = 410, row2Y = 490; var boxW = 148, boxH = 70; var dailyPnlStr = hasTrades ? formatCurrency(avgDailyPnl, 0) : nd; var winRateStr = hasTrades ? (winRate + '%') : nd; var streakStr = hasTrades ? String(maxStreak) : nd; var totalPnlStr = hasTrades ? formatCurrency(totalPnl, 0) : nd; var ytdStr = formatCurrency(ytdPayouts, 0); var pfStr = hasTrades ? String(profitFactor) : nd; var muted = 'rgba(255,255,255,0.3)'; // Row 1 drawStatBox(ctx, col1, row1Y, boxW, boxH, 'AVG DAILY P&L', dailyPnlStr, !hasTrades ? muted : (avgDailyPnl < 0 ? '#ef4444' : '#00d4aa')); drawStatBox(ctx, col2, row1Y, boxW, boxH, 'WIN RATE', winRateStr, hasTrades ? '#fff' : muted); drawStatBox(ctx, col3, row1Y, boxW, boxH, 'BEST STREAK', streakStr, hasTrades ? '#fff' : muted); // Row 2 drawStatBox(ctx, col1, row2Y, boxW, boxH, 'TOTAL P&L', totalPnlStr, !hasTrades ? muted : (totalPnl < 0 ? '#ef4444' : '#00d4aa')); drawStatBox(ctx, col2, row2Y, boxW, boxH, 'YTD PAYOUTS', ytdStr, '#00d4aa'); drawStatBox(ctx, col3, row2Y, boxW, boxH, 'PROFIT FACTOR', pfStr, hasTrades ? '#fff' : muted); // PayoutLab.io branding (bottom-LEFT @ x=32, matches card left margin) — replaces branding cropped from PNG template drawCenteredBranding(ctx, W, H - 50, '#00d4aa', 32); } if (_cinematicTemplateImg && _cinematicTemplateImg.complete && _cinematicTemplateImg.naturalWidth > 0) { doDraw(); } else { loadCinematicTemplate(function() { doDraw(); }); } } function renderLabCardRetro(canvas, payout) { canvas._rendered = true; canvas.classList.remove('labcard-cinematic'); canvas.style.maxWidth = ''; canvas.style.aspectRatio = ''; canvas.style.margin = ''; canvas.style.display = 'block'; canvas.width = 1200; canvas.height = 675; var ctx = canvas.getContext('2d'); var W = 1200, H = 675; // === PARCHMENT BACKGROUND === var parch = ctx.createLinearGradient(0, 0, W, H); parch.addColorStop(0, '#d4c5a0'); parch.addColorStop(0.3, '#ddd0b0'); parch.addColorStop(0.5, '#c8b890'); parch.addColorStop(0.7, '#d2c4a0'); parch.addColorStop(1, '#c0b288'); ctx.fillStyle = parch; ctx.fillRect(0, 0, W, H); // Noise/grain texture ctx.save(); for (var ni = 0; ni < 3000; ni++) { var nx = Math.random() * W, ny = Math.random() * H; var na = Math.random() * 0.08; ctx.fillStyle = 'rgba(80,60,20,' + na + ')'; ctx.fillRect(nx, ny, 1, 1); } ctx.restore(); // Scanlines ctx.save(); ctx.fillStyle = 'rgba(0,0,0,0.02)'; for (var sl = 0; sl < H; sl += 2) { ctx.fillRect(0, sl, W, 1); } ctx.restore(); // Aged edges — darker vignette var vig = ctx.createRadialGradient(W / 2, H / 2, Math.min(W, H) * 0.35, W / 2, H / 2, Math.max(W, H) * 0.7); vig.addColorStop(0, 'rgba(0,0,0,0)'); vig.addColorStop(0.7, 'rgba(60,40,10,0.15)'); vig.addColorStop(1, 'rgba(40,25,5,0.4)'); ctx.fillStyle = vig; ctx.fillRect(0, 0, W, H); // === ORNATE BORDER === var bm = 30; // border margin ctx.strokeStyle = '#8b6914'; ctx.lineWidth = 3; ctx.strokeRect(bm, bm, W - bm * 2, H - bm * 2); ctx.lineWidth = 1; ctx.strokeRect(bm + 8, bm + 8, W - bm * 2 - 16, H - bm * 2 - 16); // Corner ornaments var corners = [[bm + 4, bm + 4], [W - bm - 4, bm + 4], [bm + 4, H - bm - 4], [W - bm - 4, H - bm - 4]]; corners.forEach(function(c) { ctx.fillStyle = '#8b6914'; ctx.font = 'bold 18px serif'; ctx.textAlign = 'center'; ctx.fillText('\u2726', c[0], c[1] + 6); // diamond four star }); // === DATA === var firmKey = payout.propFirm || ''; var firmName = propFirmConfigs[firmKey] ? propFirmConfigs[firmKey].name : (propFirmNames[firmKey] || firmKey || ''); var payoutAmount = payout.amount || 0; var grossAmount = payout.grossAmount || payoutAmount; var payoutDate = payout.date ? new Date(payout.date) : new Date(); var dateStr = payoutDate.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }); var acct = payout.accountId ? accounts.find(function(a) { return a.id === payout.accountId; }) : null; var accountSize = payout.accountSize || (acct ? acct.startingBalance : 0) || 0; var snap = payout.statsSnapshot; var totalPnl = 0, winRate = 0, profitFactor = '0.00'; var hasTrades = false; if (snap) { totalPnl = snap.totalPnl || 0; winRate = snap.winRate || 0; profitFactor = snap.profitFactor != null ? snap.profitFactor : '0.00'; hasTrades = (snap.totalPnl != null && snap.totalPnl !== 0); } var amber = '#8b6914'; var darkBrown = '#3a2a0a'; // === PAYOUT AMOUNT (centered, huge) === ctx.textAlign = 'center'; ctx.shadowColor = 'rgba(139,105,20,0.3)'; ctx.shadowBlur = 20; ctx.font = 'bold 90px Georgia, "Times New Roman", serif'; ctx.fillStyle = darkBrown; ctx.fillText(formatCurrency(grossAmount, 0), W / 2, 200); ctx.shadowBlur = 0; // "PAYOUT RECEIVED" ctx.font = 'bold 36px Georgia, "Times New Roman", serif'; ctx.fillStyle = amber; ctx.fillText('PAYOUT RECEIVED', W / 2, 260); // "from [Firm Name]" in italic ctx.font = 'italic 28px Georgia, "Times New Roman", serif'; ctx.fillStyle = darkBrown; ctx.fillText('from ' + firmName, W / 2, 310); // Decorative line var lineW = 400; ctx.strokeStyle = amber; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(W / 2 - lineW / 2, 330); ctx.lineTo(W / 2 + lineW / 2, 330); ctx.stroke(); // Performance certificate info ctx.font = 'bold 14px "Courier New", monospace'; ctx.fillStyle = darkBrown; ctx.textAlign = 'center'; ctx.fillText('Performance Certificate', W / 2, 365); ctx.font = '13px "Courier New", monospace'; ctx.fillStyle = 'rgba(58,42,10,0.7)'; var certY = 390; if (accountSize) ctx.fillText('Account Size: ' + formatCurrency(accountSize, 0), W / 2, certY); certY += 20; ctx.fillText('Date: ' + dateStr, W / 2, certY); certY += 20; if (hasTrades) { ctx.fillText('Win Rate: ' + winRate + '% | Total P&L: ' + formatCurrency(totalPnl, 0) + ' | PF: ' + profitFactor, W / 2, certY); } // Wax seal (bottom-left area) var sealX = 200, sealY = H - 120; // Outer seal ctx.beginPath(); ctx.arc(sealX, sealY, 40, 0, Math.PI * 2); var sealGrad = ctx.createRadialGradient(sealX - 8, sealY - 8, 0, sealX, sealY, 40); sealGrad.addColorStop(0, '#2a6b2a'); sealGrad.addColorStop(0.5, '#1a5a1a'); sealGrad.addColorStop(1, '#0d3d0d'); ctx.fillStyle = sealGrad; ctx.fill(); ctx.strokeStyle = '#3a8a3a'; ctx.lineWidth = 2; ctx.stroke(); // P in seal ctx.font = 'bold 32px Georgia, serif'; ctx.fillStyle = '#90c090'; ctx.textAlign = 'center'; ctx.fillText('P', sealX, sealY + 11); // "Certified: [firmName]" ctx.font = 'italic 14px Georgia, serif'; ctx.fillStyle = darkBrown; ctx.textAlign = 'left'; ctx.fillText('Certified: ' + firmName, sealX + 55, sealY + 5); // PayoutLab.io branding (bottom-right) ctx.textAlign = 'right'; ctx.font = 'bold 22px Georgia, serif'; ctx.fillStyle = darkBrown; ctx.fillText('Payout', W - 80, H - 110); var pw2 = ctx.measureText('Payout').width; ctx.fillStyle = '#2a6b2a'; ctx.fillText('Lab', W - 80, H - 110); // Actually need to position Lab after Payout ctx.textAlign = 'left'; var brandX = W - 80 - pw2; ctx.textAlign = 'right'; // Simpler approach ctx.font = 'bold 22px Georgia, serif'; ctx.fillStyle = darkBrown; var brandText = 'PayoutLab'; ctx.fillText(brandText + '.io', W - 60, H - 110); ctx.font = '11px "Courier New", monospace'; ctx.fillStyle = 'rgba(58,42,10,0.5)'; ctx.fillText('Est. 2025', W - 60, H - 90); // Ticker tape border data (top and bottom edges) ctx.font = '9px "Courier New", monospace'; ctx.fillStyle = 'rgba(139,105,20,0.3)'; ctx.textAlign = 'left'; var tickerData = 'DJI 12,000 NYSE 2,500 DJI 12,000 S&P 1,500 NASDAQ 4,200 DJI 12,000 NYSE 2,500 DJI 12,000'; ctx.fillText(tickerData, bm + 20, bm + 20); ctx.fillText(tickerData, bm + 20, H - bm - 10); } // Capture the 3D Hype HTML overlay div as a canvas via html2canvas async function capture3DHype(index) { // Find the .labcard-3dhype div for this payout's thumbnail var thumbs = document.querySelectorAll('.updated-labcard-thumb'); var el = null; for (var i = 0; i < thumbs.length; i++) { if (thumbs[i].querySelector && thumbs[i].querySelector('canvas')) { var c = thumbs[i].querySelector('canvas'); if (c && c.dataset.payoutIdx == String(index)) { el = thumbs[i].querySelector('.labcard-3dhype'); break; } } } // Fallback: use the first 3dhype el on the page if (!el) el = document.querySelector('.labcard-3dhype'); if (!el || typeof html2canvas === 'undefined') return null; return await html2canvas(el, { useCORS: true, allowTaint: false, backgroundColor: null, scale: 2, width: el.offsetWidth, height: el.offsetHeight }); } function downloadUpdatedLabCard(index) { var payout = payouts[index]; if (!payout) return; // 3D Hype: capture the HTML overlay via html2canvas if (_labcardStyle === '3dhype') { capture3DHype(index).then(function(canvas) { if (!canvas) { showNotification('Could not capture LabCard', 'error'); return; } var firmName = (propFirmConfigs[payout.propFirm] ? propFirmConfigs[payout.propFirm].name : (propFirmNames[payout.propFirm] || payout.propFirm || 'payout')); var safeFileName = firmName.split(' ').join('-'); var dateStr = payout.date ? new Date(payout.date).toISOString().split('T')[0] : 'card'; var link = document.createElement('a'); link.download = 'LabCard-3DHype-' + safeFileName + '-' + dateStr + '.png'; link.href = canvas.toDataURL('image/png'); link.click(); showNotification('LabCard downloaded!', 'success'); }).catch(function(e) { showNotification('Download failed: ' + e.message, 'error'); }); return; } function doDownload() { var canvas = document.createElement('canvas'); canvas.width = 1200; canvas.height = 675; renderLabCardByStyle(canvas, payout); var firmName = (propFirmConfigs[payout.propFirm] ? propFirmConfigs[payout.propFirm].name : (propFirmNames[payout.propFirm] || payout.propFirm || 'payout')); var safeFileName = firmName.split(' ').join('-'); var dateStr = payout.date ? new Date(payout.date).toISOString().split('T')[0] : 'card'; var link = document.createElement('a'); link.download = 'LabCard-' + safeFileName + '-' + dateStr + '.png'; link.href = canvas.toDataURL('image/png'); link.click(); showNotification('LabCard downloaded!', 'success'); } // Ensure background image is loaded before rendering if (updatedLabcardBgImg && updatedLabcardBgImg.complete && updatedLabcardBgImg.naturalWidth > 0) { doDownload(); } else if (updatedLabcardBgImg && !updatedLabcardBgImg.complete) { updatedLabcardBgImg.addEventListener('load', doDownload, { once: true }); setTimeout(doDownload, 1500); // fallback if image never loads } else { loadUpdatedLabCardBg(); if (updatedLabcardBgImg) { updatedLabcardBgImg.addEventListener('load', doDownload, { once: true }); setTimeout(doDownload, 1500); } else { doDownload(); } } } async function copyUpdatedLabCard(index) { var payout = payouts[index]; if (!payout) return; // 3D Hype branch if (_labcardStyle === '3dhype') { try { var canvas3d = await capture3DHype(index); if (!canvas3d) { showNotification('Could not capture LabCard', 'error'); return; } var blob3d = await new Promise(function(resolve) { canvas3d.toBlob(resolve, 'image/png'); }); await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob3d })]); showNotification('LabCard copied to clipboard!', 'success'); } catch(e) { showNotification('Could not copy \u2014 try downloading instead', 'error'); } return; } async function doCopy() { var canvas = document.createElement('canvas'); canvas.width = 1200; canvas.height = 675; renderLabCardByStyle(canvas, payout); try { var blob = await new Promise(function(resolve) { canvas.toBlob(resolve, 'image/png'); }); await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]); showNotification('LabCard copied to clipboard!', 'success'); } catch(e) { showNotification('Could not copy \u2014 try downloading instead', 'error'); } } if (updatedLabcardBgImg && updatedLabcardBgImg.complete && updatedLabcardBgImg.naturalWidth > 0) { doCopy(); } else if (updatedLabcardBgImg && !updatedLabcardBgImg.complete) { updatedLabcardBgImg.addEventListener('load', function() { doCopy(); }, { once: true }); setTimeout(doCopy, 1500); } else { loadUpdatedLabCardBg(); if (updatedLabcardBgImg) { updatedLabcardBgImg.addEventListener('load', function() { doCopy(); }, { once: true }); setTimeout(doCopy, 1500); } else { doCopy(); } } } function shareUpdatedLabCard(index) { var payout = payouts[index]; if (!payout) return; var caption = 'Payout received! \ud83d\udcb0 #PayoutLab #FundedTrader #PropFirm'; // 3D Hype branch — capture HTML overlay then share if (_labcardStyle === '3dhype') { capture3DHype(index).then(function(canvas3d) { if (!canvas3d) { showNotification('Could not capture LabCard', 'error'); return; } canvas3d.toBlob(async function(blob) { if (!blob) { showNotification('Could not generate image', 'error'); return; } var file = new File([blob], 'LabCard.png', { type: 'image/png' }); if (navigator.share && navigator.canShare && navigator.canShare({ files: [file] })) { try { await navigator.share({ text: caption, files: [file] }); return; } catch(e) { if (e.name === 'AbortError') return; } } try { await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]); showNotification('Image copied to clipboard! Paste it into your tweet (Ctrl+V / Cmd+V)', 'success', 5000); } catch(e) { var link = document.createElement('a'); link.download = 'LabCard.png'; link.href = canvas3d.toDataURL('image/png'); link.click(); showNotification('Image downloaded! Attach it to your tweet manually', 'info', 5000); } window.open('https://x.com/intent/tweet?text=' + encodeURIComponent(caption), '_blank'); }, 'image/png'); }); return; } function doShare() { var canvas = document.createElement('canvas'); canvas.width = 1200; canvas.height = 675; renderLabCardByStyle(canvas, payout); canvas.toBlob(async function(blob) { if (!blob) { showNotification('Could not generate image', 'error'); return; } var file = new File([blob], 'LabCard.png', { type: 'image/png' }); // Try Web Share API with file (works on mobile + some desktop browsers) if (navigator.share && navigator.canShare && navigator.canShare({ files: [file] })) { try { await navigator.share({ text: caption, files: [file] }); return; } catch(e) { if (e.name === 'AbortError') return; // user cancelled } } // Desktop fallback: copy image to clipboard, then open Twitter compose try { await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]); showNotification('Image copied to clipboard! Paste it into your tweet (Ctrl+V / Cmd+V)', 'success', 5000); } catch(e) { // Clipboard failed — download instead var link = document.createElement('a'); link.download = 'LabCard.png'; link.href = canvas.toDataURL('image/png'); link.click(); showNotification('Image downloaded! Attach it to your tweet manually', 'info', 5000); } var tweetText = encodeURIComponent(caption); window.open('https://x.com/intent/tweet?text=' + tweetText, '_blank'); }, 'image/png'); } if (updatedLabcardBgImg && updatedLabcardBgImg.complete && updatedLabcardBgImg.naturalWidth > 0) { doShare(); } else if (updatedLabcardBgImg && !updatedLabcardBgImg.complete) { updatedLabcardBgImg.addEventListener('load', doShare, { once: true }); setTimeout(doShare, 1500); } else { loadUpdatedLabCardBg(); if (updatedLabcardBgImg) { updatedLabcardBgImg.addEventListener('load', doShare, { once: true }); setTimeout(doShare, 1500); } else { doShare(); } } } // ============================================ // SETUP NOTIFICATION WIDGET (floating) // ============================================ function updateSetupWidget() { var widget = document.getElementById('setup-float-widget'); var countEl = document.getElementById('setup-float-count'); if (!widget || !countEl) return; var _needsSetupFn = function(a) { return !a.propFirm || a.propFirm === 'unknown' || !a.startingBalance || !a.plan || !a.stage; }; var unconfigured = accounts.filter(function(a) { return !a.archived && _needsSetupFn(a); }); var count = unconfigured.length; countEl.textContent = count; // Only show on dashboard page var dashboardActive = document.getElementById('page-dashboard')?.classList.contains('active'); if (count > 0 && dashboardActive) { widget.style.display = 'flex'; positionSetupWidget(); } else { widget.style.display = 'none'; } } // One-time position setter — retries via rAF until streamer button is painted function positionSetupWidget() { var widget = document.getElementById('setup-float-widget'); var streamerBtn = document.querySelector('.streamer-toggle-btn'); if (widget && streamerBtn) { var rect = streamerBtn.getBoundingClientRect(); if (rect.bottom > 0) { widget.style.top = (rect.bottom + 24) + 'px'; widget.style.right = '40px'; return; } } requestAnimationFrame(positionSetupWidget); } requestAnimationFrame(positionSetupWidget); // Setup queue state window.setupQueue = []; window.setupQueueIndex = 0; function openSetupAccountsModal() { var _needsSetupFn = function(a) { return !a.propFirm || a.propFirm === 'unknown' || !a.startingBalance || !a.plan || !a.stage; }; var unconfigured = accounts.filter(function(a) { return !a.archived && _needsSetupFn(a); }); if (unconfigured.length === 0) { showNotification('All accounts are configured! ✓', 'success'); return; } // Build queue and start walkthrough window.setupQueue = unconfigured.map(function(a) { return a.id; }); window.setupQueueIndex = 0; _openSetupQueueAccount(); } function _openSetupQueueAccount() { var q = window.setupQueue; var idx = window.setupQueueIndex; if (!q || idx >= q.length) { _finishSetupQueue(); return; } var accId = q[idx]; var acc = accounts.find(function(a) { return a.id === accId; }); var name = acc ? (acc.name || accId) : accId; // Show progress banner var banner = document.getElementById('setup-queue-banner'); var text = document.getElementById('setup-queue-text'); var skip = document.getElementById('setup-queue-skip'); if (banner) banner.style.display = 'flex'; if (text) text.textContent = 'Setting up account ' + (idx + 1) + ' of ' + q.length + ' — ' + name; if (skip) skip.style.display = ''; // Open edit modal for this account editAccount(accId); } function skipSetupQueueAccount() { window.setupQueueIndex++; if (window.setupQueueIndex >= window.setupQueue.length) { _finishSetupQueue(); } else { _openSetupQueueAccount(); } } function _advanceSetupQueue() { if (!window.setupQueue || window.setupQueue.length === 0) return; window.setupQueueIndex++; if (window.setupQueueIndex >= window.setupQueue.length) { _finishSetupQueue(); } else { _openSetupQueueAccount(); } } function _finishSetupQueue() { var banner = document.getElementById('setup-queue-banner'); var text = document.getElementById('setup-queue-text'); var skip = document.getElementById('setup-queue-skip'); if (text) { text.textContent = 'All accounts configured ✓'; text.style.color = '#00d4aa'; } if (skip) skip.style.display = 'none'; if (banner) banner.style.borderColor = '#00d4aa'; setTimeout(function() { exitSetupQueue(); closeEditAccountModal(); }, 1500); } function exitSetupQueue() { window.setupQueue = []; window.setupQueueIndex = 0; var banner = document.getElementById('setup-queue-banner'); var text = document.getElementById('setup-queue-text'); var skip = document.getElementById('setup-queue-skip'); if (banner) banner.style.display = 'none'; if (text) { text.style.color = '#ffb400'; } if (skip) skip.style.display = 'none'; if (banner) banner.style.borderColor = '#ffb400'; updateSetupWidget(); } function closeSetupAccountsModal() { var overlay = document.getElementById('setup-accounts-modal-overlay'); if (overlay) overlay.classList.remove('active'); } // ============================================ // DAY CARDS // ============================================ function openDayCardForDate(dateKey) { window._dayCardPreselectedDate = dateKey; showPage('share-card'); setTimeout(function() { setLabCardType('day'); var datePicker = document.getElementById('daycard-date'); if (datePicker && dateKey) { datePicker.value = dateKey; } // Auto-generate the day card generateDayCards(); }, 300); } window.selectedDayCardAccounts = []; var _labcardType = localStorage.getItem('pl_labcard_type') || 'payout'; var _savedPayoutStyle = null; function setLabCardType(type) { _labcardType = type; localStorage.setItem('pl_labcard_type', type); var btnPayout = document.getElementById('lc-type-payout'); var btnDay = document.getElementById('lc-type-day'); if (btnPayout) btnPayout.classList.toggle('active', type === 'payout'); if (btnDay) btnDay.classList.toggle('active', type === 'day'); var payoutGallery = document.getElementById('updated-labcard-gallery'); var dayGallery = document.getElementById('daycard-gallery'); var dayControls = document.getElementById('daycard-controls'); var styleRow = document.getElementById('labcard-style-row'); if (type === 'day') { // Force cinematic, hide style row — suppress payout re-render _savedPayoutStyle = _labcardStyle; _suppressPayoutRender = true; setLabCardStyle('cinematic'); _suppressPayoutRender = false; if (styleRow) styleRow.style.display = 'none'; if (payoutGallery) { payoutGallery.innerHTML = ''; payoutGallery.style.display = 'none'; } if (dayGallery) dayGallery.style.display = ''; if (dayControls) dayControls.style.display = ''; populateDayCardAccounts(); var dateEl = document.getElementById('daycard-date'); if (dateEl && !dateEl.value) dateEl.value = new Date().toISOString().split('T')[0]; } else { // FIX 4: Clean up day card state, restore payout style if (dayGallery) { dayGallery.innerHTML = ''; dayGallery.style.display = 'none'; } window._dayCardCanvas = null; if (dayControls) dayControls.style.display = 'none'; if (styleRow) styleRow.style.display = ''; if (_savedPayoutStyle) { _suppressPayoutRender = true; setLabCardStyle(_savedPayoutStyle); _suppressPayoutRender = false; _savedPayoutStyle = null; } if (payoutGallery) payoutGallery.style.display = ''; renderUpdatedLabCardGallery(); } } // FIX 3: Draw image with cover behavior (no stretching) function drawCover(ctx, img, x, y, w, h) { var iR = img.naturalWidth / img.naturalHeight; var cR = w / h; var sx, sy, sw, sh; if (iR > cR) { sh = img.naturalHeight; sw = sh * cR; sx = (img.naturalWidth - sw) / 2; sy = 0; } else { sw = img.naturalWidth; sh = sw / cR; sx = 0; sy = (img.naturalHeight - sh) / 2; } ctx.drawImage(img, sx, sy, sw, sh, x, y, w, h); } // Draws a subtle legibility gradient across the bottom of the card, // then renders the "Payout|Lab|.io" branding + tagline LEFT-ALIGNED at leftX. // (Function name retained for git-blame continuity; alignment is now left.) // leftX should match the card's left margin used by other content (typically 32). function drawCenteredBranding(ctx, W, baseY, accentColor, leftX) { accentColor = accentColor || '#00d4aa'; if (leftX == null) leftX = 32; // 1) Bottom legibility gradient (transparent → 50% dark, extends to canvas bottom // so there's no internal seam — only edge is the soft top fade-in). var bandTop = Math.max(0, baseY - 36); var H = ctx.canvas.height; var grad = ctx.createLinearGradient(0, bandTop, 0, H); grad.addColorStop(0, 'rgba(0,0,0,0)'); grad.addColorStop(0.55, 'rgba(0,0,0,0.5)'); grad.addColorStop(1, 'rgba(0,0,0,0.5)'); ctx.fillStyle = grad; ctx.fillRect(0, bandTop, W, H - bandTop); // 2) Measure widths (for cursor advance, not centering) ctx.font = 'bold 25px "Segoe UI", system-ui, sans-serif'; var payoutW = ctx.measureText('Payout').width; var labW = ctx.measureText('Lab').width; var startX = leftX; // 3) "Payout" white bold 25px ctx.font = 'bold 25px "Segoe UI", system-ui, sans-serif'; ctx.fillStyle = '#ffffff'; ctx.textAlign = 'left'; ctx.fillText('Payout', startX, baseY); // 4) "Lab" cyan bold 25px ctx.fillStyle = accentColor; ctx.fillText('Lab', startX + payoutW, baseY); // 5) ".io" white-40% non-bold 25px ctx.font = '25px "Segoe UI", system-ui, sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.fillText('.io', startX + payoutW + labW, baseY); // 6) Tagline 14px left-aligned to same X, sits 18px below main baseline ctx.font = '14px "Segoe UI", system-ui, sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.3)'; ctx.textAlign = 'left'; ctx.fillText('Command Center for Funded Traders', startX, baseY + 18); } function populateDayCardAccounts() { var firmSelect = document.getElementById('daycard-firm-filter'); if (firmSelect) { var activeAccounts = accounts.filter(function(a) { return !a.archived; }); var firms = {}; activeAccounts.forEach(function(a) { var fk = a.propFirm || ''; if (fk && !firms[fk]) { firms[fk] = propFirmConfigs[fk] ? propFirmConfigs[fk].name : (propFirmNames[fk] || fk); } }); var fhtml = ''; Object.keys(firms).sort().forEach(function(fk) { fhtml += ''; }); firmSelect.innerHTML = fhtml; } updateDayCardAccountDropdown(); } function updateDayCardAccountDropdown() { var listEl = document.getElementById('daycard-account-dropdown-list'); if (!listEl) return; var firmVal = document.getElementById('daycard-firm-filter')?.value || ''; var activeAccounts = accounts.filter(function(a) { return !a.archived && a.propFirm && a.propFirm !== 'personal' && a.propFirm !== 'other'; }); if (firmVal) activeAccounts = activeAccounts.filter(function(a) { return a.propFirm === firmVal; }); // If no selections yet, default to all if (window.selectedDayCardAccounts.length === 0) { window.selectedDayCardAccounts = activeAccounts.map(function(a) { return a.id; }); } // Group by firm var byFirm = {}; activeAccounts.forEach(function(a) { if (!byFirm[a.propFirm]) byFirm[a.propFirm] = []; byFirm[a.propFirm].push(a); }); var html = ''; Object.keys(byFirm).sort(function(a, b) { var na = propFirmConfigs[a] ? propFirmConfigs[a].name : a; var nb = propFirmConfigs[b] ? propFirmConfigs[b].name : b; return na.localeCompare(nb); }).forEach(function(fk) { var firmName = propFirmConfigs[fk] ? propFirmConfigs[fk].name : (propFirmNames[fk] || fk); var firmAccts = byFirm[fk]; firmAccts.sort(function(a, b) { var ae = (a.stage === 'evaluation' || a.isEvaluation) ? 1 : 0; var be = (b.stage === 'evaluation' || b.isEvaluation) ? 1 : 0; return ae - be || (a.name || '').localeCompare(b.name || ''); }); html += '
' + firmName + '
'; firmAccts.forEach(function(acc) { var isEval = acc.stage === 'evaluation' || acc.isEvaluation; var checked = window.selectedDayCardAccounts.includes(acc.id); html += ''; }); }); if (activeAccounts.length === 0) { html = '
No accounts match filters
'; } listEl.innerHTML = html; updateDayCardAccountLabel(); } function toggleDayCardAccountDropdown(e) { e.stopPropagation(); var panel = document.getElementById('daycard-account-panel'); if (!panel) return; var isVisible = panel.style.display !== 'none'; panel.style.display = isVisible ? 'none' : ''; if (!isVisible) { setTimeout(function() { document.addEventListener('click', closeDayCardAccountDropdown); }, 0); } } function closeDayCardAccountDropdown(e) { var panel = document.getElementById('daycard-account-panel'); var trigger = document.getElementById('daycard-account-dropdown'); if (panel && !panel.contains(e?.target) && !trigger?.contains(e?.target)) { panel.style.display = 'none'; document.removeEventListener('click', closeDayCardAccountDropdown); } } function onDayCardAccountCheckboxChange() { var cbs = document.querySelectorAll('#daycard-account-dropdown-list input[type="checkbox"]'); window.selectedDayCardAccounts = []; cbs.forEach(function(cb) { if (cb.checked) window.selectedDayCardAccounts.push(cb.value); }); updateDayCardAccountLabel(); } function selectAllDayCardAccounts() { var cbs = document.querySelectorAll('#daycard-account-dropdown-list input[type="checkbox"]'); cbs.forEach(function(cb) { cb.checked = true; }); onDayCardAccountCheckboxChange(); } function deselectAllDayCardAccounts() { var cbs = document.querySelectorAll('#daycard-account-dropdown-list input[type="checkbox"]'); cbs.forEach(function(cb) { cb.checked = false; }); onDayCardAccountCheckboxChange(); } function updateDayCardAccountLabel() { var label = document.getElementById('daycard-account-filter-label'); if (!label) return; var total = accounts.filter(function(a) { return !a.archived; }).length; var selected = window.selectedDayCardAccounts.length; label.textContent = (selected === 0 || selected === total) ? 'All Accounts (' + total + ')' : selected + ' Account' + (selected > 1 ? 's' : '') + ' Selected'; } function getDayCardData(dateStr, accountIds) { // Filter trades by date and selected accounts var dayTrades = trades.filter(function(t) { var tDate = normalizeTradeDate(t); if (!tDate) return false; var tKey = getDateKey(tDate); return tKey === dateStr && accountIds.includes(t.accountId); }); if (dayTrades.length === 0) return null; // Group by account var byAccount = {}; dayTrades.forEach(function(t) { if (!byAccount[t.accountId]) byAccount[t.accountId] = []; byAccount[t.accountId].push(t); }); var accountBreakdown = []; var totalPnl = 0, totalTrades = 0, totalWins = 0, totalLosses = 0, bestTrade = 0; var grossWin = 0, grossLoss = 0; Object.keys(byAccount).forEach(function(accId) { var acct = accounts.find(function(a) { return a.id === accId; }); var accTrades = byAccount[accId]; var accPnl = 0, wins = 0, losses = 0; accTrades.forEach(function(t) { var pnl = getNetPnl(t); accPnl += pnl; if (pnl > 0) { wins++; grossWin += pnl; if (pnl > bestTrade) bestTrade = pnl; } else if (pnl < 0) { losses++; grossLoss += Math.abs(pnl); } }); var firmKey = acct ? acct.propFirm : ''; var firmName = propFirmConfigs[firmKey] ? propFirmConfigs[firmKey].name : (propFirmNames[firmKey] || firmKey || 'Unknown'); var acctNum = acct ? (acct.name || acct.accountNumber || acct.rithmicAccountId || acct.tradovateAccountId || accId) : accId; accountBreakdown.push({ accountId: accId, firmKey: firmKey, firmName: firmName, firmInitial: (firmName.charAt(0) || '').toUpperCase(), accountNumber: acctNum, accountSize: acct ? acct.startingBalance : 0, dailyPnl: accPnl, tradeCount: accTrades.length, wins: wins, losses: losses }); totalPnl += accPnl; totalTrades += accTrades.length; totalWins += wins; totalLosses += losses; }); // Sort by P&L descending accountBreakdown.sort(function(a, b) { return b.dailyPnl - a.dailyPnl; }); var pf = grossLoss > 0 ? (grossWin / grossLoss).toFixed(2) : grossWin > 0 ? '∞' : '0.00'; return { date: dateStr, dateStr: new Date(dateStr + 'T12:00:00').toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }), totalDayPnl: totalPnl, totalTrades: totalTrades, totalWins: totalWins, totalLosses: totalLosses, winRate: totalTrades > 0 ? Math.round(totalWins / totalTrades * 100) : 0, isGreenDay: totalPnl >= 0, profitFactor: pf, bestTrade: bestTrade, topAccount: accountBreakdown[0] || null, accountBreakdown: accountBreakdown, accountCount: accountBreakdown.length, greenAccounts: accountBreakdown.filter(function(a) { return a.dailyPnl > 0; }).length, redAccounts: accountBreakdown.filter(function(a) { return a.dailyPnl < 0; }).length, }; } function generateDayCards() { var dateEl = document.getElementById('daycard-date'); if (!dateEl || !dateEl.value) { showNotification('Select a date', 'warning'); return; } var firmVal = document.getElementById('daycard-firm-filter')?.value || ''; var typeVal = document.getElementById('daycard-type-filter')?.value || ''; var activeAccounts = accounts.filter(function(a) { return !a.archived; }); if (firmVal) activeAccounts = activeAccounts.filter(function(a) { return a.propFirm === firmVal; }); // Use multi-select account filter if (window.selectedDayCardAccounts && window.selectedDayCardAccounts.length > 0 && window.selectedDayCardAccounts.length < activeAccounts.length) { activeAccounts = activeAccounts.filter(function(a) { return window.selectedDayCardAccounts.includes(a.id); }); } if (typeVal === 'funded') activeAccounts = activeAccounts.filter(function(a) { return a.stage === 'funded' || a.stage === 'sim_funded' || a.stage === 'live_funded'; }); if (typeVal === 'eval') activeAccounts = activeAccounts.filter(function(a) { return a.stage === 'evaluation' || a.isEvaluation; }); var accountIds = activeAccounts.map(function(a) { return a.id; }); if (accountIds.length === 0) { showNotification('No accounts match filters', 'warning'); return; } var dayData = getDayCardData(dateEl.value, accountIds); if (!dayData || dayData.totalTrades === 0) { showNotification('No trades found for ' + dateEl.value, 'info'); return; } renderDayCardGallery(dayData); } function renderDayCardGallery(dayData) { var gallery = document.getElementById('daycard-gallery'); if (!gallery) return; gallery.innerHTML = ''; gallery.classList.toggle('cinematic-mode', _labcardStyle === 'cinematic'); var thumb = document.createElement('div'); thumb.className = 'updated-labcard-thumb'; if (_labcardStyle === 'cinematic') thumb.classList.add('labcard-cinematic-thumb'); var canvas = document.createElement('canvas'); canvas.style.width = '100%'; canvas.style.display = 'block'; thumb.appendChild(canvas); // Action buttons var actions = document.createElement('div'); actions.className = 'updated-labcard-actions'; actions.innerHTML = '' + '' + ''; thumb.appendChild(actions); gallery.appendChild(thumb); // Store ref for download/copy/share window._dayCardCanvas = canvas; window._dayCardData = dayData; // Render based on active style if (_labcardStyle === '3dhype') { renderDayCard3DHype(canvas, dayData); } else if (_labcardStyle === 'cinematic') { renderDayCardCinematic(canvas, dayData); } else { renderDayCardOG(canvas, dayData); } } // ── Day Card OG (landscape canvas) ── function renderDayCardOG(canvas, d) { canvas.width = 1200; canvas.height = 675; var ctx = canvas.getContext('2d'); var W = 1200, H = 675; // Background — same as The OG payout card if (updatedLabcardBgImg && updatedLabcardBgImg.complete && updatedLabcardBgImg.naturalWidth > 0) { ctx.drawImage(updatedLabcardBgImg, 0, 0, W, H); } else { ctx.fillStyle = '#080c12'; ctx.fillRect(0, 0, W, H); } // Dark overlay for text clarity var grad = ctx.createLinearGradient(0, 0, W, 0); grad.addColorStop(0, 'rgba(0,0,0,0.7)'); grad.addColorStop(0.6, 'rgba(0,0,0,0.4)'); grad.addColorStop(1, 'rgba(0,0,0,0.2)'); ctx.fillStyle = grad; ctx.fillRect(0, 0, W, H); ctx.textAlign = 'left'; var isGreen = d.isGreenDay; var accent = isGreen ? '#00d4aa' : '#ef4444'; var accentGlow = isGreen ? 'rgba(0,212,170,0.7)' : 'rgba(220,50,50,0.7)'; // GREEN DAY / RED DAY badge ctx.font = 'bold 12px "Segoe UI", system-ui, sans-serif'; var badgeText = isGreen ? '✓ GREEN DAY' : '✗ RED DAY'; var badgeTW = ctx.measureText(badgeText).width; var bx = 40, by = 36, bpx = 16, bpy = 6, bh = 28, br = 14; ctx.fillStyle = accent; ctx.beginPath(); ctx.moveTo(bx + br, by); ctx.arcTo(bx + badgeTW + bpx * 2, by, bx + badgeTW + bpx * 2, by + bh, br); ctx.arcTo(bx + badgeTW + bpx * 2, by + bh, bx, by + bh, br); ctx.arcTo(bx, by + bh, bx, by, br); ctx.arcTo(bx, by, bx + badgeTW + bpx * 2, by, br); ctx.closePath(); ctx.fill(); ctx.fillStyle = '#000'; ctx.fillText(badgeText, bx + bpx, by + 19); // Date — top right ctx.font = '16px "Segoe UI", system-ui, sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.5)'; ctx.textAlign = 'right'; ctx.fillText(d.dateStr, W - 40, 55); ctx.textAlign = 'left'; // User logo — top right below date var logoCX = W - 80, logoCY = 110, logoR = 32; ctx.strokeStyle = '#00d4aa'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(logoCX, logoCY, logoR + 3, 0, Math.PI * 2); ctx.stroke(); if (updatedLabcardUserLogo && updatedLabcardUserLogo.complete && updatedLabcardUserLogo.naturalWidth > 0) { ctx.save(); ctx.beginPath(); ctx.arc(logoCX, logoCY, logoR, 0, Math.PI * 2); ctx.clip(); ctx.drawImage(updatedLabcardUserLogo, logoCX - logoR, logoCY - logoR, logoR * 2, logoR * 2); ctx.restore(); } else { ctx.fillStyle = '#1a3a2a'; ctx.beginPath(); ctx.arc(logoCX, logoCY, logoR, 0, Math.PI * 2); ctx.fill(); ctx.font = 'bold 24px Arial'; ctx.fillStyle = '#00d4aa'; ctx.textAlign = 'center'; ctx.fillText((currentUser?.displayName || '?').charAt(0).toUpperCase(), logoCX, logoCY + 8); ctx.textAlign = 'left'; } // TOTAL DAY P&L label ctx.font = '600 13px "Segoe UI", system-ui, sans-serif'; ctx.fillStyle = accent; ctx.fillText('TOTAL DAY P&L', 40, 120); // Hero amount ctx.font = 'bold 52px "Segoe UI", system-ui, sans-serif'; ctx.fillStyle = '#e0fff9'; ctx.shadowColor = accentGlow; ctx.shadowBlur = 25; ctx.fillText(formatCurrency(d.totalDayPnl, 2), 40, 180); ctx.shadowBlur = 0; // Account breakdown table var tableY = 220; var maxShow = Math.min(d.accountBreakdown.length, 4); for (var i = 0; i < maxShow; i++) { var a = d.accountBreakdown[i]; var rowY = tableY + i * 52; // Dark bg ctx.fillStyle = 'rgba(0,0,0,0.4)'; ctx.beginPath(); var rx = 40, rw = 700, rh = 44, rr = 6; ctx.moveTo(rx + rr, rowY); ctx.arcTo(rx + rw, rowY, rx + rw, rowY + rh, rr); ctx.arcTo(rx + rw, rowY + rh, rx, rowY + rh, rr); ctx.arcTo(rx, rowY + rh, rx, rowY, rr); ctx.arcTo(rx, rowY, rx + rw, rowY, rr); ctx.closePath(); ctx.fill(); // Firm initial circle ctx.fillStyle = 'rgba(0,212,170,0.15)'; ctx.beginPath(); ctx.arc(70, rowY + 22, 14, 0, Math.PI * 2); ctx.fill(); ctx.font = 'bold 12px Arial'; ctx.fillStyle = '#00d4aa'; ctx.textAlign = 'center'; ctx.fillText(a.firmInitial, 70, rowY + 27); ctx.textAlign = 'left'; // Account number var acctLabel = a.accountNumber.length > 20 ? a.accountNumber.substring(0, 20) + '…' : a.accountNumber; ctx.font = '14px "Segoe UI", system-ui, sans-serif'; ctx.fillStyle = '#fff'; ctx.fillText(acctLabel, 96, rowY + 27); // P&L amount — right aligned var pnlStr = formatCurrency(a.dailyPnl, 2); ctx.font = 'bold 16px "Segoe UI", system-ui, sans-serif'; ctx.fillStyle = a.dailyPnl >= 0 ? '#00d4aa' : '#ef4444'; ctx.textAlign = 'right'; ctx.fillText(pnlStr, 720, rowY + 28); ctx.textAlign = 'left'; } if (d.accountCount > 4) { ctx.font = '14px "Segoe UI", system-ui, sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.8)'; ctx.fillText('+' + (d.accountCount - 4) + ' more accounts', 40, tableY + maxShow * 52 + 16); } // Bottom stats var statY = H - 100; function drawStat(x, label, value, color) { ctx.fillStyle = 'rgba(0,0,0,0.45)'; ctx.beginPath(); var sw = 180, sh = 54; ctx.moveTo(x + 6, statY); ctx.arcTo(x + sw, statY, x + sw, statY + sh, 6); ctx.arcTo(x + sw, statY + sh, x, statY + sh, 6); ctx.arcTo(x, statY + sh, x, statY, 6); ctx.arcTo(x, statY, x + sw, statY, 6); ctx.closePath(); ctx.fill(); ctx.font = '600 10px "Segoe UI", system-ui, sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.5)'; ctx.fillText(label, x + 12, statY + 20); ctx.font = 'bold 18px "Segoe UI", system-ui, sans-serif'; ctx.fillStyle = color; ctx.fillText(value, x + 12, statY + 44); } drawStat(40, 'TRADES', String(d.totalTrades), '#fff'); drawStat(240, 'WIN RATE', d.winRate + '%', '#fff'); drawStat(440, 'PROFIT FACTOR', String(d.profitFactor), '#fff'); // PayoutLab.io branding ctx.font = '11px "Segoe UI", system-ui, sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.25)'; ctx.textAlign = 'right'; ctx.fillText('PayoutLab.io', W - 40, H - 20); ctx.textAlign = 'left'; } // ── Day Card 3D Hype (HTML overlay — placeholder, renders OG for now) ── function renderDayCard3DHype(canvas, d) { // TODO: full HTML overlay day card — use OG as fallback for now renderDayCardOG(canvas, d); } // ── Day Card Cinematic (portrait canvas — placeholder, renders simplified version) ── function renderDayCardCinematic(canvas, d) { canvas.classList.add('labcard-cinematic'); canvas.style.display = 'block'; canvas.style.width = '100%'; canvas.style.height = 'auto'; canvas.style.aspectRatio = '27/40'; canvas.width = 540; canvas.height = 800; var ctx = canvas.getContext('2d'); var W = 540, H = 800; var isGreen = d.isGreenDay; var accent = isGreen ? '#00d4aa' : '#ef4444'; var accentGlow = isGreen ? 'rgba(0,212,170,0.8)' : 'rgba(220,50,50,0.8)'; var truncAcct = function(s) { if (!s) return '—'; s = String(s); return s.length > 10 ? s.substring(0, 10) + '...' : s; }; function doDraw() { // Background if (_cinematicTemplateImg && _cinematicTemplateImg.complete && _cinematicTemplateImg.naturalWidth > 0) { drawCover(ctx, _cinematicTemplateImg, 0, 0, W, H); } else { ctx.fillStyle = '#0a0e14'; ctx.fillRect(0, 0, W, H); } // Dark overlay var grad = ctx.createLinearGradient(0, 0, 0, 700); grad.addColorStop(0, 'rgba(0,0,0,0.55)'); grad.addColorStop(0.45, 'rgba(0,0,0,0.35)'); grad.addColorStop(0.65, 'rgba(0,0,0,0.1)'); grad.addColorStop(1, 'rgba(0,0,0,0)'); ctx.fillStyle = grad; ctx.fillRect(0, 0, W, H); ctx.textAlign = 'left'; // ── TOP SECTION ── // GREEN/RED DAY badge ctx.font = 'bold 13px Arial'; var badgeText = isGreen ? '✓ GREEN DAY' : '✗ RED DAY'; var btw = ctx.measureText(badgeText).width; var bx = 32, by = 48, bh = 28, br = 14, bpw = btw + 28; ctx.fillStyle = accent; ctx.beginPath(); ctx.moveTo(bx + br, by); ctx.arcTo(bx + bpw, by, bx + bpw, by + bh, br); ctx.arcTo(bx + bpw, by + bh, bx, by + bh, br); ctx.arcTo(bx, by + bh, bx, by, br); ctx.arcTo(bx, by, bx + bpw, by, br); ctx.closePath(); ctx.fill(); ctx.fillStyle = '#000'; ctx.fillText(badgeText, bx + 14, by + 19); // Date — top right ctx.font = '13px "Segoe UI", system-ui, sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.5)'; ctx.textAlign = 'right'; ctx.fillText(d.dateStr, W - 32, 66); ctx.textAlign = 'left'; // User logo var logoX = W - 70, logoY = 110, logoR = 28; ctx.strokeStyle = '#00d4aa'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(logoX, logoY, logoR + 3, 0, Math.PI * 2); ctx.stroke(); if (updatedLabcardUserLogo && updatedLabcardUserLogo.complete && updatedLabcardUserLogo.naturalWidth > 0) { ctx.save(); ctx.beginPath(); ctx.arc(logoX, logoY, logoR, 0, Math.PI * 2); ctx.clip(); ctx.drawImage(updatedLabcardUserLogo, logoX - logoR, logoY - logoR, logoR * 2, logoR * 2); ctx.restore(); } else { ctx.fillStyle = '#1a3a2a'; ctx.beginPath(); ctx.arc(logoX, logoY, logoR, 0, Math.PI * 2); ctx.fill(); ctx.font = 'bold 18px Arial'; ctx.fillStyle = '#00d4aa'; ctx.textAlign = 'center'; ctx.fillText((currentUser?.displayName || '?').charAt(0).toUpperCase(), logoX, logoY + 6); ctx.textAlign = 'left'; } // DAILY SUMMARY ctx.font = 'bold 24px "Segoe UI", system-ui, sans-serif'; ctx.fillStyle = '#fff'; ctx.shadowColor = 'rgba(255,255,255,0.2)'; ctx.shadowBlur = 10; ctx.fillText('DAILY SUMMARY', 32, 140); ctx.shadowBlur = 0; // Hero amount ctx.font = 'bold 72px "Segoe UI", system-ui, sans-serif'; ctx.fillStyle = isGreen ? '#e0fff9' : '#ffcccc'; ctx.shadowColor = accentGlow; ctx.shadowBlur = 30; var heroStr = isGreen ? formatCurrency(d.totalDayPnl, 2) : '(' + formatCurrency(Math.abs(d.totalDayPnl), 2) + ')'; ctx.fillText(heroStr, 32, 230); ctx.shadowBlur = 0; // ── MIDDLE SECTION — Account list ── var listY = 270; var rowH = 42; var listPad = 12; var listW = W - 64; // 32px padding each side var maxRows = d.accountBreakdown.length > 6 ? 5 : d.accountBreakdown.length; var showMore = d.accountBreakdown.length > 6; for (var i = 0; i < maxRows; i++) { var a = d.accountBreakdown[i]; var ry = listY + i * (rowH + 2); // Dark row bg ctx.fillStyle = 'rgba(0,0,0,0.4)'; var rr = 6; ctx.beginPath(); ctx.moveTo(32 + rr, ry); ctx.arcTo(32 + listW, ry, 32 + listW, ry + rowH, rr); ctx.arcTo(32 + listW, ry + rowH, 32, ry + rowH, rr); ctx.arcTo(32, ry + rowH, 32, ry, rr); ctx.arcTo(32, ry, 32 + listW, ry, rr); ctx.closePath(); ctx.fill(); // Account number — left ctx.font = '14px "Segoe UI", system-ui, sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.7)'; ctx.textAlign = 'left'; ctx.fillText(truncAcct(a.accountNumber), 44, ry + 26); // P&L — right ctx.font = 'bold 16px "Segoe UI", system-ui, sans-serif'; ctx.fillStyle = a.dailyPnl >= 0 ? '#00d4aa' : '#ef4444'; ctx.textAlign = 'right'; ctx.fillText(formatCurrency(a.dailyPnl, 2), W - 44, ry + 26); ctx.textAlign = 'left'; // Separator line (except last) if (i < maxRows - 1) { ctx.strokeStyle = 'rgba(0,212,170,0.2)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(44, ry + rowH + 1); ctx.lineTo(W - 44, ry + rowH + 1); ctx.stroke(); } } if (showMore) { var moreY = listY + maxRows * (rowH + 2); ctx.font = '600 15px "Segoe UI", system-ui, sans-serif'; ctx.fillStyle = '#00d4aa'; ctx.textAlign = 'center'; ctx.fillText('+' + (d.accountBreakdown.length - 5) + ' more accounts', W / 2, moreY + 20); ctx.textAlign = 'left'; } // ── BOTTOM SECTION — Stats ── var statsStartY = showMore ? listY + (maxRows + 1) * (rowH + 2) + 20 : listY + maxRows * (rowH + 2) + 24; function drawStatBox2(x, y, w, h, label, value, valueColor) { ctx.fillStyle = 'rgba(0,0,0,0.55)'; var r = 8; ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath(); ctx.fill(); ctx.font = '500 11px Arial'; ctx.fillStyle = 'rgba(255,255,255,0.5)'; ctx.textAlign = 'left'; ctx.fillText(label, x + 10, y + 22); ctx.font = 'bold 20px Arial'; ctx.fillStyle = valueColor; ctx.fillText(value, x + 10, y + 50); } var sCol1 = 32, sCol2 = 192, sCol3 = 352, sBoxW = 148, sBoxH = 66; drawStatBox2(sCol1, statsStartY, sBoxW, sBoxH, 'TRADES', String(d.totalTrades), '#fff'); drawStatBox2(sCol2, statsStartY, sBoxW, sBoxH, 'WIN RATE', d.winRate + '%', '#fff'); drawStatBox2(sCol3, statsStartY, sBoxW, sBoxH, 'PROFIT FACTOR', String(d.profitFactor), '#fff'); // Account summary line — coloured segments var sumY = statsStartY + sBoxH + 24; ctx.font = '600 15px "Segoe UI", system-ui, sans-serif'; ctx.textAlign = 'left'; var _s1 = d.accountCount + ' Accounts | '; var _s2 = d.greenAccounts + ' Green'; var _s3 = ' | '; var _s4 = d.redAccounts + ' Red'; var _sumTotalW = ctx.measureText(_s1 + _s2 + _s3 + _s4).width; var _sx = W / 2 - _sumTotalW / 2; ctx.fillStyle = 'rgba(255,255,255,0.85)'; ctx.fillText(_s1, _sx, sumY); _sx += ctx.measureText(_s1).width; ctx.fillStyle = '#00d4aa'; ctx.fillText(_s2, _sx, sumY); _sx += ctx.measureText(_s2).width; ctx.fillStyle = 'rgba(255,255,255,0.85)'; ctx.fillText(_s3, _sx, sumY); _sx += ctx.measureText(_s3).width; ctx.fillStyle = '#ef4444'; ctx.fillText(_s4, _sx, sumY); ctx.textAlign = 'left'; // PayoutLab.io branding (bottom-LEFT @ x=32, matches card left margin) — replaces branding cropped from PNG template drawCenteredBranding(ctx, W, H - 50, '#00d4aa', 32); } // end doDraw // Load template if needed, then draw if (_cinematicTemplateImg && _cinematicTemplateImg.complete && _cinematicTemplateImg.naturalWidth > 0) { doDraw(); } else { loadCinematicTemplate(function() { doDraw(); }); } } // ── Day Card Download/Copy/Share ── function downloadDayCard() { var canvas = window._dayCardCanvas; if (!canvas) return; var link = document.createElement('a'); link.download = 'DayCard-' + (window._dayCardData?.date || 'card') + '.png'; link.href = canvas.toDataURL('image/png'); link.click(); showNotification('Day Card downloaded!', 'success'); } async function copyDayCard() { var canvas = window._dayCardCanvas; if (!canvas) return; try { var blob = await new Promise(function(resolve) { canvas.toBlob(resolve, 'image/png'); }); await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]); showNotification('Day Card copied to clipboard!', 'success'); } catch(e) { showNotification('Could not copy — try downloading instead', 'error'); } } function shareDayCard() { var canvas = window._dayCardCanvas; if (!canvas) return; var caption = (window._dayCardData?.isGreenDay ? '🟢 Green Day!' : '🔴 Red Day') + ' ' + formatCurrency(window._dayCardData?.totalDayPnl || 0, 2) + ' #PayoutLab #FundedTrader #PropFirm'; canvas.toBlob(async function(blob) { if (!blob) return; var file = new File([blob], 'DayCard.png', { type: 'image/png' }); if (navigator.share && navigator.canShare && navigator.canShare({ files: [file] })) { try { await navigator.share({ text: caption, files: [file] }); return; } catch(e) { if (e.name === 'AbortError') return; } } try { await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]); showNotification('Image copied to clipboard! Paste it into your tweet', 'success', 5000); } catch(e) { var link = document.createElement('a'); link.download = 'DayCard.png'; link.href = canvas.toDataURL('image/png'); link.click(); showNotification('Image downloaded! Attach it to your tweet', 'info', 5000); } window.open('https://x.com/intent/tweet?text=' + encodeURIComponent(caption), '_blank'); }, 'image/png'); } // ============================================ // RITHMIC AUTO-SYNC FUNCTIONS // ============================================ function switchImportTab(tab) { // Legacy redirect — import tabs now live in Settings > Accounts showPage('settings'); currentSettingsSection = 'accounts'; switchSettingsSection('accounts'); switchAccountsTab(tab); } function populateRithmicAccountDropdown() { // No longer needed - accounts are auto-mapped from Rithmic } function showRithmicError(message) { const errorDiv = document.getElementById('rithmic-error'); document.getElementById('rithmic-error-message').textContent = message; errorDiv.style.display = 'block'; } function hideRithmicError() { document.getElementById('rithmic-error').style.display = 'none'; } function updateRithmicGateway() { const system = document.getElementById('rithmic-system').value; const gateway = document.getElementById('rithmic-gateway'); if (system === 'Rithmic Test') { gateway.value = 'Orangeburg'; } else { gateway.value = 'Chicago'; } } async function startRithmicSync() { // Re-show credentials form if hidden (after previous sync results) const credsSection = document.getElementById('rithmic-creds-section'); if (credsSection) credsSection.style.display = ''; document.getElementById('rithmic-results').style.display = 'none'; const system = document.getElementById('rithmic-system').value; const userId = document.getElementById('rithmic-user-id').value.trim(); const password = document.getElementById('rithmic-password').value; const startDateRaw = document.getElementById('rithmic-start-date').value; const endDateRaw = document.getElementById('rithmic-end-date').value; // Validation hideRithmicError(); if (!system) { showRithmicError('Please select a prop firm / system'); return; } if (!userId) { showRithmicError('Please enter your Rithmic User ID'); return; } if (!password) { showRithmicError('Please enter your Rithmic password'); return; } if (!startDateRaw || !endDateRaw) { showRithmicError('Please select a start and end date'); return; } if (startDateRaw > endDateRaw) { showRithmicError('Start date must be before end date'); return; } // Convert HTML date (YYYY-MM-DD) to Rithmic format (YYYYMMDD) const startDate = startDateRaw.replace(/-/g, ''); const endDate = endDateRaw.replace(/-/g, ''); // Show progress, hide button document.getElementById('rithmic-sync-btn').style.display = 'none'; document.getElementById('rithmic-progress').style.display = 'block'; document.getElementById('rithmic-results').style.display = 'none'; const progressBar = document.getElementById('rithmic-progress-bar'); const progressText = document.getElementById('rithmic-progress-text'); progressBar.style.width = '20%'; progressText.textContent = 'Connecting to Rithmic servers...'; try { const user = firebase.auth().currentUser; if (!user) throw new Error('You must be logged in to sync trades'); const token = await user.getIdToken(); progressBar.style.width = '30%'; progressText.textContent = 'Authenticating with ' + system + '...'; // Load saved account preferences const prefsDoc = await db.collection('users').doc(user.uid) .collection('settings').doc('rithmicPreferences').get(); const savedPrefs = prefsDoc.exists ? (prefsDoc.data().accountPreferences || {}) : {}; progressBar.style.width = '40%'; progressText.textContent = 'Fetching trade data...'; // Call the sync function const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 240000); const response = await fetch('https://us-central1-trade-journal-fc3ba.cloudfunctions.net/rithmicSync', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }, body: JSON.stringify({ userId: userId, password: password, systemName: system, startDate: startDate, endDate: endDate }), signal: controller.signal }); clearTimeout(timeoutId); progressBar.style.width = '70%'; progressText.textContent = 'Processing trade data...'; const result = await response.json(); console.log('[Rithmic Sync] Raw response:', { accounts: result.accounts?.map(a => a.accountId), tradesByAccountKeys: Object.keys(result.tradesByAccount || {}), totalTrades: result.trades?.length, fills: result.fills }); if (result.error) throw new Error(result.error); // Check which accounts are new (not in saved preferences) const rithmicAccounts = result.accounts || []; const newAccounts = rithmicAccounts.filter(a => a.accountId && !(a.accountId in savedPrefs)); const excludedAccounts = rithmicAccounts.filter(a => a.accountId && savedPrefs[a.accountId]?.include === false); // If there are new accounts, show the account picker if (newAccounts.length > 0) { progressBar.style.width = '75%'; progressText.textContent = 'New accounts detected — select which to import...'; // Pause and show account picker modal const selectedAccounts = await showRithmicAccountPicker(rithmicAccounts, savedPrefs, system); // If user cancelled (selected nothing), stop sync if (selectedAccounts.length === 0) { document.getElementById('rithmic-progress').style.display = 'none'; document.getElementById('rithmic-sync-btn').style.display = 'block'; return; } // Save updated preferences const updatedPrefs = { ...savedPrefs }; rithmicAccounts.forEach(a => { if (a.accountId) { updatedPrefs[a.accountId] = { include: selectedAccounts.includes(a.accountId), system: system, lastSeen: new Date().toISOString() }; } }); await db.collection('users').doc(user.uid) .collection('settings').doc('rithmicPreferences') .set({ accountPreferences: updatedPrefs }, { merge: true }); // Filter result to only selected accounts result.accounts = rithmicAccounts.filter(a => selectedAccounts.includes(a.accountId)); if (result.tradesByAccount) { const filteredTrades = {}; for (const acctId of selectedAccounts) { if (result.tradesByAccount[acctId]) { filteredTrades[acctId] = result.tradesByAccount[acctId]; } } result.tradesByAccount = filteredTrades; } } else { // Filter out previously excluded accounts const includedIds = rithmicAccounts .filter(a => a.accountId && savedPrefs[a.accountId]?.include !== false) .map(a => a.accountId); result.accounts = rithmicAccounts.filter(a => includedIds.includes(a.accountId)); if (result.tradesByAccount) { const filteredTrades = {}; for (const acctId of includedIds) { if (result.tradesByAccount[acctId]) { filteredTrades[acctId] = result.tradesByAccount[acctId]; } } result.tradesByAccount = filteredTrades; } if (excludedAccounts.length > 0) { console.log(`Skipping ${excludedAccounts.length} excluded account(s): ${excludedAccounts.map(a => a.accountId).join(', ')}`); } } progressBar.style.width = '85%'; progressText.textContent = 'Mapping accounts and saving trades...'; console.log('[Rithmic Sync] Filtered accounts:', result.accounts?.map(a => a.accountId)); console.log('[Rithmic Sync] tradesByAccount keys:', Object.keys(result.tradesByAccount || {})); console.log('[Rithmic Sync] Trade counts:', Object.entries(result.tradesByAccount || {}).map(([k, v]) => `${k}: ${v.length}`)); // Get the explicit firm from dropdown data-firm attribute (e.g. Bulenox → "Rithmic Paper Trading" system but data-firm="bulenox") const systemSelect = document.getElementById('rithmic-system'); const selectedOption = systemSelect?.selectedOptions?.[0]; const explicitFirm = selectedOption?.dataset?.firm || ''; // Auto-map Rithmic accounts to PayoutLab accounts and save trades const syncResult = await autoMapAndSaveRithmicTrades(result, system, explicitFirm); progressBar.style.width = '100%'; // Show success results setTimeout(() => { showRithmicResults({ ...result, ...syncResult, _systemName: system, _explicitFirm: explicitFirm }, syncResult.savedCount); }, 500); } catch (error) { console.error('Rithmic sync error:', error); document.getElementById('rithmic-progress').style.display = 'none'; document.getElementById('rithmic-sync-btn').style.display = 'block'; let errorMsg = error.message || 'Failed to connect to Rithmic. Please check your credentials.'; if (error.name === 'AbortError' || errorMsg.includes('Failed to fetch') || errorMsg.includes('ERR_FAILED')) { errorMsg = 'Connection timed out. Try selecting a smaller date range (e.g., last 7 days).'; } showRithmicError(errorMsg); } } // ===== Saved Rithmic Connections ===== const RITHMIC_API_BASE = 'https://us-central1-trade-journal-fc3ba.cloudfunctions.net'; let _savedRithmicConnections = []; let _tradovateHasToken = false; let _tradovateLastSync = null; let _connectingFirmKey = 'unknown'; // firmKey being connected via OAuth let _syncingFirmKey = 'unknown'; // firmKey being synced let _tradovateFirmTokens = {}; // { firmKey: { exists, expired, lastSync, environment } } let _tradovateDisconnectedFirms = new Set(); // firms the user has "disconnected" from a shared token let _tradovateSessionCheckPromise = null; // awaited by triggerStaleConnectionSync to avoid races let _tradovateTokenExpired = false; // true when all firm tokens are confirmed expired; reset on reconnect let _tradovateExpiredEnv = null; // last seen env for expired token; used by status div Reconnect link to pre-select env let _tradovateExpiredToastShown = false; // rate-limits the expired toast to once per session // Resolve the best firmKey from available firm tokens function getValidTradovateFirmKey() { const valid = Object.entries(_tradovateFirmTokens).filter(([fk, ft]) => ft.exists && !ft.expired && !_tradovateDisconnectedFirms.has(fk)); if (valid.length > 0) return valid[0][0]; // Fallback: any existing token (even expired) const any = Object.keys(_tradovateFirmTokens); return any.length > 0 ? any[0] : 'unknown'; } async function loadRithmicConnections() { const user = firebase.auth().currentUser; if (!user) return; try { const snap = await db.collection('users').doc(user.uid) .collection('rithmicConnections').orderBy('createdAt', 'desc').get(); _savedRithmicConnections = []; snap.forEach(doc => { _savedRithmicConnections.push({ id: doc.id, ...doc.data() }); }); console.log(`[Rithmic] Loaded ${_savedRithmicConnections.length} saved connections`); renderRithmicConnections(); } catch (e) { console.error('Error loading connections:', e); } // Set default dates on the manual sync form (last 7 days) const manualStart = document.getElementById('rithmic-start-date'); const manualEnd = document.getElementById('rithmic-end-date'); if (manualStart && !manualStart.value) { const d = new Date(); d.setDate(d.getDate() - 90); manualStart.value = d.toISOString().slice(0, 10); } if (manualEnd && !manualEnd.value) { manualEnd.value = new Date().toISOString().slice(0, 10); } } function renderRithmicConnections() { const container = document.getElementById('rithmic-saved-connections'); const list = document.getElementById('rithmic-connections-list'); const addToggle = document.getElementById('rithmic-add-new-toggle'); const newConnForm = document.getElementById('rithmic-new-connection-form'); // Saved connections UI was removed from modal — just ensure the form is visible if (!container || !list) { if (newConnForm) newConnForm.style.display = 'block'; return; } if (_savedRithmicConnections.length === 0) { container.style.display = 'block'; if (addToggle) addToggle.style.display = 'none'; if (newConnForm) newConnForm.style.display = 'block'; list.innerHTML = '
No saved connections yet.
'; return; } container.style.display = 'block'; if (addToggle) addToggle.style.display = 'block'; if (newConnForm) newConnForm.style.display = 'none'; // System name to display name mapping const systemDisplayNames = { 'Apex': 'Apex Trader Funding', 'TopstepTrader': 'TopStep', 'tradesea': 'MyFundedFutures', 'Tradeify': 'Tradeify', 'Earn2Trade': 'Earn2Trade', 'LegendsTrading': 'Legends Trading', 'LucidTrading': 'Lucid Trading', 'FundedFuturesNetwork': 'Funded Futures Network', 'TakeProfitTrader': 'Take Profit Trader', 'Rithmic Paper Trading': 'Rithmic Paper Trading' }; list.innerHTML = _savedRithmicConnections.map(conn => { const displayName = systemDisplayNames[conn.systemName] || conn.systemName; const lastSync = conn.lastSyncAt ? new Date(conn.lastSyncAt.seconds * 1000).toLocaleString() : 'Never'; const statusIcon = conn.lastSyncStatus === 'success' ? '✅' : conn.lastSyncStatus === 'error' ? '❌' : '⏳'; const statusColor = conn.lastSyncStatus === 'success' ? 'var(--green)' : conn.lastSyncStatus === 'error' ? 'var(--red)' : 'var(--text-muted)'; const tradesInfo = conn.lastSyncTrades ? `${conn.lastSyncTrades} trades` : ''; // Show stored Rithmic account IDs from last sync const syncedAcctIds = conn.lastSyncAccountIds || []; const acctChips = syncedAcctIds.length > 0 ? `
📋 Accounts: ${syncedAcctIds.map(id => '' + id + '').join('')}
` : ''; const defaultStart = new Date(); defaultStart.setDate(defaultStart.getDate() - 90); const defaultStartStr = defaultStart.toISOString().slice(0, 10); const defaultEndStr = new Date().toISOString().slice(0, 10); return `
🔗
${displayName}
${conn.userIdHint || '***'} · ${conn.gateway || 'Chicago'}
${statusIcon} Last sync: ${lastSync} ${tradesInfo ? '· ' + tradesInfo : ''} ${conn.lastSyncStatus === 'error' && conn.lastSyncError ? `
${conn.lastSyncError}` : ''}
${acctChips}
to
`; }).join(''); } function toggleNewRithmicConnection() { const form = document.getElementById('rithmic-new-connection-form'); form.style.display = form.style.display === 'none' ? 'block' : 'none'; } async function saveRithmicConnection(systemName, gateway, userId, password, autoSync, firmKey = '') { const user = firebase.auth().currentUser; if (!user) return null; try { console.log('[Rithmic] Saving connection for system:', systemName, 'firm:', firmKey); const token = await user.getIdToken(); const payload = { systemName, gateway, userId, password, autoSync }; if (firmKey) payload.firmKey = firmKey; const response = await fetch(RITHMIC_API_BASE + '/rithmicSaveConnection', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }, body: JSON.stringify(payload) }); if (!response.ok) { console.error('[Rithmic] Save connection HTTP error:', response.status); showRithmicError('Failed to save connection (HTTP ' + response.status + '). The save function may not be deployed.'); return null; } const result = await response.json(); if (result.success) { console.log('[Rithmic] Connection saved:', result.connectionId); await loadRithmicConnections(); renderConnectionsPage(); loadDashboardSyncBar(); showToast('Connected successfully', 'success'); return result.connectionId; } else { console.error('Save connection error:', result.error); showRithmicError('Failed to save connection: ' + (result.error || 'Unknown error')); return null; } } catch (e) { console.error('Save connection error:', e); showRithmicError('Failed to save connection: ' + e.message); return null; } } async function manageConnectionAccounts(connectionId) { console.log('[ManageAccounts] Called with connectionId:', connectionId); const user = firebase.auth().currentUser; if (!user) { console.log('[ManageAccounts] No user'); return; } const conn = _savedRithmicConnections.find(c => c.id === connectionId); if (!conn) { console.log('[ManageAccounts] Connection not found in _savedRithmicConnections:', connectionId, 'available:', _savedRithmicConnections.map(c => c.id)); showToast('Connection not found', 'error'); return; } // Read current exclusions from Firestore const connDoc = await db.collection('users').doc(user.uid) .collection('rithmicConnections').doc(connectionId).get({ source: 'server' }); const connData = connDoc.exists ? connDoc.data() : {}; const excluded = connData.excludedAccountIds || []; const lastSyncAccountIds = connData.lastSyncAccountIds || []; if (lastSyncAccountIds.length === 0) { showToast('No accounts found — sync this connection first to discover accounts', 'info'); return; } // Build modal const modal = document.createElement('div'); modal.className = 'modal'; modal.id = 'manage-accounts-modal'; modal.style.display = 'flex'; modal.onclick = function(e) { if (e.target === this) this.remove(); }; modal.innerHTML = ` `; document.body.appendChild(modal); } async function saveConnectionAccountExclusions(connectionId) { const checkboxes = document.querySelectorAll('#manage-accounts-list input[type=checkbox]'); const excludedIds = []; checkboxes.forEach(cb => { if (!cb.checked) excludedIds.push(cb.value); }); const user = firebase.auth().currentUser; if (!user) return; await db.collection('users').doc(user.uid) .collection('rithmicConnections').doc(connectionId) .update({ excludedAccountIds: excludedIds }); document.getElementById('manage-accounts-modal')?.remove(); showToast(excludedIds.length > 0 ? `${excludedIds.length} account(s) excluded from sync` : 'All accounts included', 'success'); await loadRithmicConnections(); renderConnectionsPage(); } // Clears rithmicPreferences.accountPreferences entries for accounts belonging to this connection. // Recovers the "14 accounts hidden forever" case: when a user set include:false during the first-time // account picker, those accounts had no way back. Clearing the prefs re-triggers the picker on the next // manual sync, and makes syncSavedConnection re-include all accounts by default. async function resetConnectionAccountVisibility(connectionId) { if (!confirm('This will re-show the account picker on your next sync. Continue?')) return; const user = firebase.auth().currentUser; if (!user) return; try { // Find which account IDs belong to this connection const connDoc = await db.collection('users').doc(user.uid) .collection('rithmicConnections').doc(connectionId).get({ source: 'server' }); const connData = connDoc.exists ? connDoc.data() : {}; const lastSyncAccountIds = connData.lastSyncAccountIds || []; if (lastSyncAccountIds.length === 0) { showToast('No account IDs known for this connection yet — run a sync first', 'info'); return; } // Clear this connection's account entries from rithmicPreferences.accountPreferences. // FieldValue.delete() per-key preserves any other user-level settings fields on the doc. const prefsRef = db.collection('users').doc(user.uid) .collection('settings').doc('rithmicPreferences'); const prefsDoc = await prefsRef.get({ source: 'server' }); if (prefsDoc.exists) { const updates = {}; lastSyncAccountIds.forEach(id => { updates[`accountPreferences.${id}`] = firebase.firestore.FieldValue.delete(); }); if (Object.keys(updates).length > 0) { await prefsRef.update(updates); } } document.getElementById('manage-accounts-modal')?.remove(); showToast('Account visibility reset. Run a sync to re-select your accounts.', 'success'); } catch (e) { console.error('[ResetVisibility] error:', e); showToast('Reset failed — check console for details', 'error'); } } async function deleteRithmicConnection(connectionId, propFirm) { const user = firebase.auth().currentUser; if (!user) return; if (!propFirm) { console.error('[deleteRithmicConnection] propFirm is required'); showToast('Cannot delete: missing firm scope. Please refresh and try again.', 'error'); return; } let response; try { const token = await user.getIdToken(); response = await fetch(RITHMIC_API_BASE + '/rithmicDeleteConnection', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }, body: JSON.stringify({ connectionId, propFirm, cascade: false }) }); } catch (netErr) { console.error('[deleteRithmicConnection] network error:', netErr); showToast('Connection error. Please try again.', 'error'); return; } if (!response.ok) { const data = await response.json().catch(() => ({})); console.error('[deleteRithmicConnection] backend error:', response.status, data); showToast('Disconnect failed: ' + (data.error || `HTTP ${response.status}`), 'error'); return; } await loadRithmicConnections(); await fullDataRefresh(); showToast('Connection removed', 'success'); } async function toggleConnectionAutoSync(connectionId, enable) { const user = firebase.auth().currentUser; if (!user) return; try { await db.collection('users').doc(user.uid) .collection('rithmicConnections').doc(connectionId) .update({ autoSync: enable }); // Update local state const conn = _savedRithmicConnections.find(c => c.id === connectionId); if (conn) conn.autoSync = enable; renderRithmicConnections(); renderConnectionsPage(); } catch (e) { console.error('Toggle auto-sync error:', e); } } async function syncSavedConnection(connectionId, silent = false) { const user = firebase.auth().currentUser; if (!user) return null; // Track syncing state — quickSyncRithmic may have already set this const callerSetFlag = _syncingConnections.has(connectionId); if (!callerSetFlag) { if (!silent) _lastManualSyncTime = Date.now(); _syncingConnections.add(connectionId); updateSyncButtonStates(); } // Read date range from connection card inputs, default to last 90 days const startInput = document.getElementById('conn-start-date-' + connectionId); const endInput = document.getElementById('conn-end-date-' + connectionId); let startDateRaw = startInput ? startInput.value : ''; let endDateRaw = endInput ? endInput.value : ''; // Default to last 90 days if no dates provided if (!startDateRaw) { const d = new Date(); d.setDate(d.getDate() - 90); startDateRaw = d.toISOString().slice(0, 10); } if (!endDateRaw) { endDateRaw = new Date().toISOString().slice(0, 10); } if (!silent && startDateRaw > endDateRaw) { alert('Start date must be before end date.'); return null; } const startDate = startDateRaw.replace(/-/g, ''); const endDate = endDateRaw.replace(/-/g, ''); try { const token = await user.getIdToken(); const conn = _savedRithmicConnections.find(c => c.id === connectionId); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 240000); const requestBody = { connectionId }; if (startDate) requestBody.startDate = startDate; if (endDate) requestBody.endDate = endDate; const response = await fetch(RITHMIC_API_BASE + '/rithmicSyncConnection', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }, body: JSON.stringify(requestBody), signal: controller.signal }); clearTimeout(timeoutId); const result = await response.json(); if (result.error) throw new Error(result.error); // Process trades using the same autoMapAndSaveRithmicTrades flow const system = conn?.systemName || ''; // Load saved account preferences // Force server read — client cache can lag after the user resets account visibility, // causing a just-cleared include:false to reappear and hide accounts on the next sync. const prefsDoc = await db.collection('users').doc(user.uid) .collection('settings').doc('rithmicPreferences').get({ source: 'server' }); const savedPrefs = prefsDoc.exists ? (prefsDoc.data().accountPreferences || {}) : {}; // Filter to only included accounts const rithmicAccounts = result.accounts || []; const includedIds = rithmicAccounts .filter(a => a.accountId && savedPrefs[a.accountId]?.include !== false) .map(a => a.accountId); result.accounts = rithmicAccounts.filter(a => includedIds.includes(a.accountId)); if (result.tradesByAccount) { const filteredTrades = {}; for (const acctId of includedIds) { if (result.tradesByAccount[acctId]) filteredTrades[acctId] = result.tradesByAccount[acctId]; } result.tradesByAccount = filteredTrades; } const explicitFirm = conn?.firmKey || ''; const syncResult = await autoMapAndSaveRithmicTrades(result, system, explicitFirm); // Save discovered account IDs to connection doc for the manage accounts UI if (result.discoveredAccountIds && result.discoveredAccountIds.length > 0) { try { await db.collection('users').doc(user.uid) .collection('rithmicConnections').doc(connectionId) .update({ lastSyncAccountIds: result.discoveredAccountIds }); } catch (e) { /* ignore — non-critical */ } } // Reload connections to update last sync time await loadRithmicConnections(); if (!silent && syncResult.savedCount > 0) { renderAll(); } return { ...result, ...syncResult }; } catch (e) { console.error('Sync connection error:', e); return null; } finally { if (!callerSetFlag) { _syncingConnections.delete(connectionId); updateSyncButtonStates(); } } } async function syncAllRithmicConnections() { if (_savedRithmicConnections.length === 0) return; const syncAllBtn = document.getElementById('rithmic-sync-all-btn'); const progressDiv = document.getElementById('rithmic-sync-all-progress'); const progressBar = document.getElementById('rithmic-sync-all-bar'); const statusText = document.getElementById('rithmic-sync-all-status'); if (syncAllBtn) { syncAllBtn.disabled = true; syncAllBtn.textContent = '⏳ Syncing...'; } if (progressDiv) progressDiv.style.display = 'block'; let totalSaved = 0; let totalErrors = 0; for (let i = 0; i < _savedRithmicConnections.length; i++) { const conn = _savedRithmicConnections[i]; const displayName = conn.systemName; const progress = ((i + 1) / _savedRithmicConnections.length * 100).toFixed(0); const msg = `Syncing ${displayName} (${i + 1}/${_savedRithmicConnections.length})...`; if (statusText) statusText.textContent = msg; if (progressBar) progressBar.style.width = progress + '%'; try { const result = await syncSavedConnection(conn.id, true); if (result?.savedCount) totalSaved += result.savedCount; } catch (e) { totalErrors++; console.error(`Error syncing ${conn.id}:`, e); } } if (syncAllBtn) { syncAllBtn.disabled = false; syncAllBtn.textContent = '⚡ Sync All'; } const doneMsg = `✅ Complete! ${totalSaved} new executions imported.${totalErrors > 0 ? ` ${totalErrors} connection(s) had errors.` : ''}`; if (statusText) statusText.textContent = doneMsg; if (progressBar) progressBar.style.width = '100%'; setTimeout(() => { if (progressDiv) progressDiv.style.display = 'none'; }, 5000); if (totalSaved > 0) renderAll(); renderConnectionsPage(); loadDashboardSyncBar(); } function showRithmicResults(result, savedCount) { document.getElementById('rithmic-progress').style.display = 'none'; document.getElementById('rithmic-results').style.display = 'block'; document.getElementById('rithmic-sync-btn').style.display = 'block'; document.getElementById('rithmic-sync-btn').textContent = '🔄 Sync Again'; // Hide credential form to prevent scrollbar overflow const credsSection = document.getElementById('rithmic-creds-section'); if (credsSection) credsSection.style.display = 'none'; // Refresh connection status renderConnectionsPage(); loadDashboardSyncBar(); if (savedCount > 0) showToast(`Sync complete — ${savedCount} executions imported`, 'success'); // Calculate total P&L let totalPnl = 0; if (result.trades) { totalPnl = result.trades.reduce((sum, t) => sum + getNetPnl(t), 0); } // Update stats document.getElementById('rithmic-stat-trades').textContent = savedCount || result.trades?.length || 0; document.getElementById('rithmic-stat-days').textContent = result.dates?.length || 0; document.getElementById('rithmic-stat-accounts').textContent = result.accountsSynced?.length || result.accounts?.length || 0; const pnlEl = document.getElementById('rithmic-stat-pnl'); pnlEl.textContent = formatCurrency(totalPnl); pnlEl.style.color = totalPnl >= 0 ? 'var(--green)' : 'var(--red)'; // Update result message const skipped = result.skippedDuplicates || 0; const newAccts = result.newAccounts || []; if (savedCount > 0) { document.getElementById('rithmic-result-icon').textContent = '✅'; document.getElementById('rithmic-result-title').textContent = 'Sync Complete!'; let msg = `Imported ${savedCount} new executions across ${result.accountsSynced?.length || 1} account(s).`; if (newAccts.length > 0) msg += ` Created ${newAccts.length} new account(s): ${newAccts.join(', ')}.`; if (skipped > 0) msg += ` ${skipped} duplicate trades skipped.`; // Show imbalance warnings const warnings = result.accountWarnings || {}; if (Object.keys(warnings).length > 0) { msg += '
'; msg += '
⚠️ Fill Discrepancy Detected
'; for (const [acctId, acctWarnings] of Object.entries(warnings)) { for (const w of acctWarnings) { msg += `
Account ${acctId}: ${w.imbalance} missing ${w.missingSide} fill(s) for ${w.symbol} — individual trade entries/exits may be inaccurate, but daily P&L totals have been reconciled.
`; } } msg += '
For exact trade-level accuracy on affected accounts, use R|Trader CSV import instead.
'; msg += '
'; // Show pop-up warning modal let warningHtml = ''; for (const [acctId, acctWarnings] of Object.entries(warnings)) { for (const w of acctWarnings) { warningHtml += `
${acctId}
${w.imbalance} missing ${w.missingSide} fill(s) for ${w.symbol} (API returned ${w.buys} buys, ${w.sells} sells)
`; } } document.getElementById('rithmic-warning-details').innerHTML = warningHtml; setTimeout(() => { document.getElementById('rithmic-warning-modal').style.display = 'flex'; }, 1000); } document.getElementById('rithmic-result-message').innerHTML = msg; } else if (skipped > 0) { document.getElementById('rithmic-result-icon').textContent = '✅'; document.getElementById('rithmic-result-title').textContent = 'Already Up to Date'; document.getElementById('rithmic-result-message').innerHTML = `Connected successfully. All ${skipped} trades already imported — nothing new to sync.`; } else if (result.trades?.length === 0) { document.getElementById('rithmic-result-icon').textContent = '📭'; document.getElementById('rithmic-result-title').textContent = 'No Trades Found'; let msg = 'Connected successfully, but no trade history was found for the selected date range.'; if (newAccts.length > 0) msg += ` Created ${newAccts.length} new account(s): ${newAccts.join(', ')}.`; document.getElementById('rithmic-result-message').innerHTML = msg; } else { document.getElementById('rithmic-result-icon').textContent = '✅'; document.getElementById('rithmic-result-title').textContent = 'Connection Successful'; let msg = `Connected to Rithmic. Found ${result.accountsSynced?.length || 0} account(s).`; if (newAccts.length > 0) msg += ` Created ${newAccts.length} new account(s): ${newAccts.join(', ')}.`; document.getElementById('rithmic-result-message').innerHTML = msg; } // Launch setup wizard if new accounts were created if (newAccts.length > 0) { // Determine the prop firm used for this sync const systemToPropFirm = { 'Apex': 'apex', 'TopstepTrader': 'topstep', 'tradesea': 'myfundedfutures', 'Tradeify': 'tradeify', 'Earn2Trade': 'earn2trade', 'LegendsTrading': 'legendstrading', 'LucidTrading': 'lucid', 'FundedFuturesNetwork': 'fundedfuturesnetwork', 'TakeProfitTrader': 'takeprofittrader', 'DayTraders.com': 'daytraders', 'TheTradingPit': 'thetradingpit' }; const syncPropFirm = result._explicitFirm || systemToPropFirm[result._systemName] || 'other'; showRithmicSetupWizard(newAccts, result._systemName || '', syncPropFirm); } // Always save connection after successful sync if (savedCount > 0 || result.trades?.length > 0 || result.accountsSynced?.length > 0) { const system = document.getElementById('rithmic-system').value; const gateway = document.getElementById('rithmic-gateway')?.value || 'Chicago'; const userId = document.getElementById('rithmic-user-id').value.trim(); const password = document.getElementById('rithmic-password').value; const firmKeyForSave = result._explicitFirm || ''; saveRithmicConnection(system, gateway, userId, password, true, firmKeyForSave).then(connId => { if (connId) { // Clear password field for security document.getElementById('rithmic-password').value = ''; } }); } } // Shared CSV staging helper — writes trades to tradingStage and calls syncTradingStage async function saveTradesToStaging(pendingTrades, accountId) { const uid = currentUser.uid; // Multi-contract sibling disambiguator: the Rithmic FIFO matcher expands an N-lot fill // into N byte-identical qty=1 round-trips that share an identical checksum and would // collide on the staging doc ID (silent .set() overwrite → lost contracts). Number the // identical siblings 0,1,2,... so each persists. The ordinal is derived from the trade's // own checksum + sibling count (NOT import order), so re-importing the same CSV yields // the same ID set → cross-import dedup still works. FORMAT-AGNOSTIC-SAFE: seq===0 keeps // the byte-identical old ID, so single-contract trades and any format that does not // produce identical siblings (Tradovate / TopstepX distinct fills) are completely // unchanged — only a 2nd+ identical sibling ever gets a suffix. const seqByChecksum = {}; for (let i = 0; i < pendingTrades.length; i += 450) { const chunk = pendingTrades.slice(i, i + 450); const batch = db.batch(); chunk.forEach(t => { // Deterministic doc ID from trade checksum so re-importing the same row produces // the same staging ID (idempotent), while distinct partial fills at the same // second produce different IDs (no silent overwrite when qty/price/pnl differ). const tradeData = { ...t, accountId }; const checksum = computeTradeChecksumFE(tradeData); const seq = (seqByChecksum[checksum] || 0); seqByChecksum[checksum] = seq + 1; const idChecksum = seq === 0 ? checksum : checksum + '_' + seq; // 1st sibling keeps old id (back-compat) const rawStageId = 'csv_' + accountId + '_' + idChecksum; const stageId = rawStageId.replace(/[^a-zA-Z0-9_-]/g, '_').substring(0, 500); const ref = db.collection('users').doc(uid).collection('tradingStage').doc(stageId); batch.set(ref, { id: stageId, source: 'csv', accountId, rawData: t, mappedData: tradeData, stagedAt: firebase.firestore.FieldValue.serverTimestamp(), syncStatus: 'pending', syncedAt: null, checksum, }); }); await batch.commit(); } // Promote staging → trades via Cloud Function const idToken = await currentUser.getIdToken(); const resp = await fetch('https://us-central1-trade-journal-fc3ba.cloudfunctions.net/syncTradingStage', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + idToken }, body: JSON.stringify({ accountId, source: 'csv' }), }); // Consume response to ensure Cloud Function completed try { await resp.json(); } catch(e) { /* response may not be JSON */ } // Allow Firestore index propagation before reading back await new Promise(resolve => setTimeout(resolve, 2000)); // Full UI refresh await fullDataRefresh(); } // Centralized full data refresh — call after ANY data mutation // Forces server reads to bypass Firestore local cache async function fullDataRefresh() { console.log('[fullDataRefresh] Forcing server reads...'); await loadAllData(null, true); console.log('[fullDataRefresh] Server read complete: ' + trades.length + ' trades, ' + accounts.length + ' accounts'); renderAll(); renderCalendar(); renderROITracker(); renderPayoutTracker(); renderJournalMiniCal(); renderDayView(); if (document.getElementById('unified-accounts-table')) renderConnectionsPage(); loadDashboardSyncBar(); populatePayoutAccountDropdown(); updateFilterDropdowns(); updateSetupWidget(); } // FNV-1a hash for staging checksum — must match backend computeTradeChecksum() in stagingUtils.js function fnv1a(str) { let h = 0x811c9dc5; for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 0x01000193); } return (h >>> 0).toString(16).padStart(8, '0'); } function computeTradeChecksumFE(data) { const parts = [ (data.symbol || ''), (data.side || ''), String(data.qty || data.quantity || ''), (data.entryTime || ''), (data.exitTime || ''), String(data.entryPrice || ''), String(data.exitPrice || ''), String(data.pnl || ''), (data.buyFillId || data.buyOrderId || ''), (data.sellFillId || data.sellOrderId || ''), ]; return fnv1a(parts.join('|')); } async function autoMapAndSaveRithmicTrades(result, systemName, explicitFirm = '') { const user = firebase.auth().currentUser; if (!user) return { savedCount: 0, newAccounts: [], skippedDuplicates: 0 }; const rithmicAccounts = result.accounts || []; const tradesByAccount = result.tradesByAccount || {}; const newAccountsCreated = []; let totalSaved = 0; let totalSkipped = 0; // Determine prop firm from system name // Normalize: strip spaces, lowercase, remove dots to match PayoutLab's internal keys function normalizePropFirmKey(name) { return (name || '').toLowerCase().replace(/[\s.]/g, ''); } const systemToPropFirm = { 'Rithmic Test': 'other', 'Rithmic Paper Trading': 'other', 'Rithmic 01': 'other', 'Rithmic 04 Colo': 'other', 'Apex': 'apex', 'TopstepTrader': 'topstep', 'tradesea': 'myfundedfutures', 'Tradeify': 'tradeify', 'Earn2Trade': 'earn2trade', 'ThriveTrading': 'other', 'LegendsTrading': 'legendstrading', 'LucidTrading': 'lucid', 'FundedFuturesNetwork': 'fundedfuturesnetwork', 'TakeProfitTrader': 'takeprofittrader', 'DayTraders.com': 'daytraders', 'TheTradingPit': 'thetradingpit' }; // Use explicit firm if provided (e.g. user selected "Bulenox" which maps to "Rithmic Paper Trading" system) // Otherwise fall back to systemToPropFirm mapping const propFirm = explicitFirm || systemToPropFirm[systemName] || Object.entries(systemToPropFirm).find(([k]) => normalizePropFirmKey(k) === normalizePropFirmKey(systemName))?.[1] || normalizePropFirmKey(systemName); /** * Detect if account is funded or evaluation based on naming conventions: * - Apex: funded accounts start with "PA", evals do not * - TakeProfitTrader: funded has "PRO" in name * - LucidTrading: evals have "TEST" in name * - Tradeify: funded starts with "FTDY", evals start with "TDY" * - MyFundedFutures (tradesea): "SF" = funded, "EV" = evaluation */ function detectAccountType(accountId, system) { const id = (accountId || '').toUpperCase(); switch (system) { case 'Apex': // PA prefix = funded (e.g., PA-APEX-12345), otherwise eval return id.startsWith('PA') ? { stage: 'funded', isEvaluation: false } : { stage: 'evaluation', isEvaluation: true }; case 'TopstepTrader': // "PRO" in name = funded return id.includes('PRO') ? { stage: 'funded', isEvaluation: false } : { stage: 'evaluation', isEvaluation: true }; case 'LucidTrading': // "TEST" in name = evaluation return id.includes('TEST') ? { stage: 'evaluation', isEvaluation: true } : { stage: 'funded', isEvaluation: false }; case 'Tradeify': // "FTDY" prefix = funded, "TDY" prefix = evaluation return id.includes('FTDY') ? { stage: 'funded', isEvaluation: false } : { stage: 'evaluation', isEvaluation: true }; case 'tradesea': // "SF" = funded, "EV" = evaluation if (id.includes('SF')) return { stage: 'funded', isEvaluation: false }; if (id.includes('EV')) return { stage: 'evaluation', isEvaluation: true }; return { stage: 'funded', isEvaluation: false }; default: // Default to evaluation — most new Rithmic accounts are evals. // Users can change to funded in the setup wizard or account settings. return { stage: 'evaluation', isEvaluation: true }; } } for (const rithmicAcct of rithmicAccounts) { const rithmicId = rithmicAcct.accountId; if (!rithmicId) continue; // 2026-06-07: Rithmic trade import disabled per policy change. // Account auto-create remains functional below this block. // CSV upload is the only supported path for Rithmic trades. const rithmicTrades = []; // Try to find matching PayoutLab account by name/number/rithmicId let payoutLabAccount = accounts.find(a => !a.archived && ( a.name === rithmicId || a.accountNumber === rithmicId || a.rithmicAccountId === rithmicId || (a.name || '').includes(rithmicId) || normalizePropFirmKey(a.name) === normalizePropFirmKey(rithmicId) ) ); console.log(`[Rithmic Sync] Account ${rithmicId}: ${rithmicTrades.length} trades, matched PayoutLab: ${payoutLabAccount?.id || 'NONE — will create'}`); // If no match found, check if an archived account exists before creating a new one if (!payoutLabAccount) { const archivedMatch = accounts.find(a => a.archived && ( a.name === rithmicId || a.accountNumber === rithmicId || a.rithmicAccountId === rithmicId || (a.name || '').includes(rithmicId) || normalizePropFirmKey(a.name) === normalizePropFirmKey(rithmicId) ) ); if (archivedMatch) { console.log(`[Rithmic Sync] Skipping archived account: ${rithmicId} (PayoutLab ID: ${archivedMatch.id})`); continue; } const acctType = detectAccountType(rithmicId, systemName); const newAccount = { name: rithmicId, propFirm: propFirm, startingBalance: 0, connectionType: 'rithmic', rithmicAccountId: rithmicId, rithmicSystem: systemName, stage: acctType.stage, status: 'active', isEvaluation: acctType.isEvaluation, createdAt: new Date().toISOString(), autoCreated: true, needsSetup: true, source: 'rithmic-auto-sync' }; // Deterministic doc ID prevents duplicates on re-sync const rSanitized = rithmicId.replace(/[^a-zA-Z0-9_-]/g, '_').substring(0, 60); const rDocId = `${propFirm}_${rSanitized}`; await db.collection('users').doc(user.uid).collection('accounts').doc(rDocId).set(newAccount); payoutLabAccount = { id: rDocId, ...newAccount }; // Push immediately so Connections page renders without waiting for onSnapshot accounts.push(payoutLabAccount); newAccountsCreated.push(rithmicId); console.log(`Created new account for Rithmic ID: ${rithmicId} → PayoutLab ID: ${rDocId}`); } if (rithmicTrades.length === 0) continue; // Get ALL existing trades for this account to deduplicate (including CSV imports) const existingTradesSnap = await db.collection('users').doc(user.uid) .collection('trades') .where('accountId', '==', payoutLabAccount.id) .get(); const existingKeyCounts = {}; // count-based dedup: how many trades share each base key const existingLegacyKeys = new Set(); const existingDatesBySource = {}; // Track which dates have non-rithmic trades existingTradesSnap.forEach(doc => { const t = doc.data(); // Robust dedup key: symbol + date + side + entry + exit + pnl (not affected by FIFO reordering) const pnlRound = Math.round((t.pnl || 0) * 100); const tSymbol = (t.symbol || '').replace(/[FGHJKMNQUVXZ]\d{1,2}$/i, ''); const baseKey = `${tSymbol}|${t.date || ''}|${t.side}|${t.entryPrice}|${t.exitPrice}|${pnlRound}`; existingKeyCounts[baseKey] = (existingKeyCounts[baseKey] || 0) + 1; // Also keep legacy key for backward compat with existing rithmic trades if (t.source === 'rithmic-sync' || t.source === 'rithmic-auto-sync' || t.source === 'rithmic') { existingLegacyKeys.add(`${t.symbol}|${t.entryTime}|${t.exitTime}|${t.side}`); } // Track dates with CSV/manual imports — don't overwrite these with rithmic if (t.source !== 'rithmic-sync' && t.source !== 'rithmic-auto-sync' && t.source !== 'rithmic' && t.date) { existingDatesBySource[t.date] = t.source; } }); // Save only new trades (batch writes, max 500 per batch) let batch = db.batch(); let batchCount = 0; let acctSaved = 0; let acctSkipped = 0; let pendingLocalTrades = []; const incomingKeyCounts = {}; // Track how many trades with each key FIFO is producing for (const trade of rithmicTrades) { const baseSymbol = (trade.symbol || '').replace(/[FGHJKMNQUVXZ]\d{1,2}$/i, ''); // Rithmic's fillDate is ALREADY the trading session date — don't apply session logic again const extractRithmicDate = (ts) => { if (!ts || typeof ts !== 'string' || ts.length < 8) return null; // Already hyphenated: 2026-03-18T... → extract YYYY-MM-DD if (ts[4] === '-') return ts.substring(0, 10); // Compact: 20260318T... → insert hyphens return ts.substring(0, 4) + '-' + ts.substring(4, 6) + '-' + ts.substring(6, 8); }; const tradeDate = extractRithmicDate(trade.exitTime) || extractRithmicDate(trade.entryTime) || ''; // Skip if this date already has CSV/manual trades (user manually corrected) if (existingDatesBySource[tradeDate]) { acctSkipped++; continue; } const pnlRound = Math.round((trade.pnl || 0) * 100); const dedupKey = `${baseSymbol || trade.symbol}|${tradeDate}|${trade.side}|${trade.entryPrice}|${trade.exitPrice}|${pnlRound}`; const legacyKey = `${baseSymbol || trade.symbol}|${trade.entryTime}|${trade.exitTime}|${trade.side}`; // Count-based dedup: FIFO may split qty=N into N identical qty=1 trades. // Track how many we're trying to save vs how many already exist. // Only save if incoming count exceeds existing count. const incomingCount = (incomingKeyCounts[dedupKey] = (incomingKeyCounts[dedupKey] || 0) + 1); const existingCount = existingKeyCounts[dedupKey] || 0; if (incomingCount <= existingCount) { acctSkipped++; continue; } if (existingLegacyKeys.has(legacyKey)) { acctSkipped++; continue; } const tradeId = 'rithmic_' + rithmicId + '_' + Date.now() + '_' + acctSaved; const stageRef = db.collection('users').doc(user.uid).collection('tradingStage').doc(tradeId); // Build mappedData (normalized PayoutLab trade schema) const qty = trade.quantity || 1; const mappedData = { rithmicAccountId: rithmicId, symbol: baseSymbol || trade.symbol, side: trade.side || 'Long', qty: qty, entryPrice: trade.entryPrice || 0, exitPrice: trade.exitPrice || 0, entryTime: trade.entryTime || new Date().toISOString(), exitTime: trade.exitTime || new Date().toISOString(), date: tradeDate || new Date().toISOString().split('T')[0], pnl: trade.pnl || 0, netPnl: trade.pnl || 0, fees: trade.fees || 0, commission: 0, exchangeFees: 0, importedAt: new Date().toISOString(), fillImbalance: trade._imbalanced || false }; // Apply commission from user's commission settings table const accountPropFirm = payoutLabAccount.propFirm || propFirm; if (!mappedData.commission || mappedData.commission === 0) { const acctConnType = payoutLabAccount.connectionType || 'rithmic'; const commRate = getCommissionRate(accountPropFirm, acctConnType, baseSymbol || trade.symbol) || getCommissionRate(accountPropFirm, 'rithmic', baseSymbol || trade.symbol) || getCommissionRate(accountPropFirm, 'tradovate', baseSymbol || trade.symbol); if (commRate) { mappedData.commission = commRate * qty * 2; } if (acctSaved === 0) { console.log(`[Rithmic Sync] Commission lookup: firm=${accountPropFirm}, connType=${acctConnType}, symbol=${baseSymbol || trade.symbol}, rate=${commRate || 'NOT FOUND'}`); } } mappedData.fees = 0; // Rithmic API does not provide exchange fee data mappedData.netPnl = Math.round((mappedData.pnl - mappedData.commission) * 100) / 100; mappedData.propFirm = accountPropFirm; // Build staging document matching backend schema const checksum = computeTradeChecksumFE(mappedData); const stagingDoc = { id: tradeId, source: 'rithmic', accountId: payoutLabAccount.id, rawData: trade, mappedData, stagedAt: firebase.firestore.FieldValue.serverTimestamp(), syncStatus: 'pending', syncedAt: null, checksum, }; batch.set(stageRef, stagingDoc); acctSaved++; batchCount++; // Firestore batch limit is 500 if (batchCount >= 450) { await batch.commit(); batch = db.batch(); batchCount = 0; } } if (batchCount > 0) { await batch.commit(); } // Run staging sync to promote staged trades to trades collection if (acctSaved > 0) { try { const idToken = await user.getIdToken(); const syncResp = await fetch('https://us-central1-trade-journal-fc3ba.cloudfunctions.net/syncTradingStage', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + idToken }, body: JSON.stringify({ accountId: payoutLabAccount.id, source: 'rithmic' }), }); const syncResult = await syncResp.json(); console.log(`[Rithmic Sync] Staging sync for ${rithmicId}:`, syncResult); } catch (syncErr) { console.error(`[Rithmic Sync] Staging sync failed for ${rithmicId}:`, syncErr); } } totalSaved += acctSaved; totalSkipped += acctSkipped; console.log(`Account ${rithmicId}: ${acctSaved} trades staged, ${acctSkipped} duplicates skipped`); } if (totalSaved > 0 || newAccountsCreated.length > 0) { // Full UI refresh after Rithmic sync — always refresh when accounts were created, // even if no new trades were staged (ensures Connections page shows new accounts) await fullDataRefresh(); let msg; if (totalSaved > 0) { msg = `Imported ${totalSaved} executions from Rithmic`; if (newAccountsCreated.length > 0) { msg += ` (${newAccountsCreated.length} new account${newAccountsCreated.length > 1 ? 's' : ''} created)`; } if (totalSkipped > 0) { msg += ` — ${totalSkipped} duplicates skipped`; } } else { msg = `${newAccountsCreated.length} new account${newAccountsCreated.length > 1 ? 's' : ''} added from Rithmic`; if (totalSkipped > 0) msg += ` — ${totalSkipped} executions already imported`; } showToast(msg, 'success'); if (totalSaved > 0) trackTradesImported('rithmic', totalSaved); } return { savedCount: totalSaved, newAccounts: newAccountsCreated, skippedDuplicates: totalSkipped }; } // ============================================ // TRADOVATE AUTO-SYNC FUNCTIONS (OAuth) // ============================================ const TRADOVATE_CF_BASE = 'https://us-central1-trade-journal-fc3ba.cloudfunctions.net'; async function getTradovateAuthHeader() { const user = firebase.auth().currentUser; if (!user) throw new Error('Not logged in'); const token = await user.getIdToken(); return { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }; } // Check URL params on load for OAuth callback result async function checkTradovateOAuthCallback() { const params = new URLSearchParams(window.location.search); // Bail immediately if no Tradovate params — normal page load if (!params.get('tradovate_connected') && !params.get('tradovate_error')) return; // Fallback: if dedicated callback page couldn't close popup, handle here if (params.get('tradovate_connected') === '1') { // Set pl_sync_tab eagerly so checkPendingAccountSetups fires when the // auto-sync (triggered later by triggerStaleConnectionSync) auto-creates // accounts. Without this set early, there's a race where the wizard check // runs before any accounts exist in the local accounts array. sessionStorage.setItem('pl_sync_tab', '1'); window.history.replaceState({}, '', window.location.pathname); switchImportTab('tradovate'); showToast('✅ Tradovate connected successfully!', 'success'); await checkTradovateSession(); quickSyncTradovate(null, _connectingFirmKey || 'unknown').catch(e => console.warn('[Tradovate] Post-connect sync failed:', e.message)); } else if (params.get('tradovate_error')) { window.history.replaceState({}, '', window.location.pathname); switchImportTab('tradovate'); const errorDiv = document.getElementById('tradovate-auth-error'); if (errorDiv) { errorDiv.style.display = 'block'; document.getElementById('tradovate-auth-error-msg').textContent = 'OAuth error: ' + decodeURIComponent(params.get('tradovate_error')); } } } async function checkTradovateSession() { try { const user = firebase.auth().currentUser; if (!user) return; // _tradovateDisconnectedFirms is loaded in loadAllData() before rendering // Read all per-firm token subcollections const firmsSnap = await db.collection('tradovate_tokens').doc(user.uid).collection('firms').get(); if (firmsSnap.empty) { _tradovateFirmTokens = {}; showTradovateLoginForm(); return; } console.log(`[Tradovate] Found ${firmsSnap.size} firm token(s). Checking each...`); const headers = await getTradovateAuthHeader(); let anyValid = false; let lastTokenData = null; _tradovateFirmTokens = {}; // Check each firm token with a per-firm timeout to prevent hangs const REFRESH_TIMEOUT_MS = 15000; for (const firmDoc of firmsSnap.docs) { const fk = firmDoc.id; const tokenData = firmDoc.data(); lastTokenData = tokenData; // Quick local expiry check — skip expensive server call if clearly expired and no refresh token const localExpiresAt = tokenData.tokenIssuedAt + ((tokenData.expiresIn || 5400) * 1000); const localExpired = Date.now() >= localExpiresAt - 60000; try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), REFRESH_TIMEOUT_MS); const refreshRes = await fetch(`${TRADOVATE_CF_BASE}/tradovateRefreshToken`, { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify({ firmKey: fk }), signal: controller.signal }); clearTimeout(timeoutId); if (!refreshRes.ok) throw new Error(`Server returned ${refreshRes.status}`); const refreshData = await refreshRes.json(); if (refreshData.status === 'valid' || refreshData.status === 'refreshed') { console.log(`[Tradovate] firm=${fk}: ${refreshData.status}, expires in ${refreshData.expiresInSeconds}s`); _tradovateFirmTokens[fk] = { exists: true, expired: false, lastSync: tokenData.lastSync || null, environment: tokenData.environment }; anyValid = true; } else { console.log(`[Tradovate] firm=${fk}: expired`); _tradovateFirmTokens[fk] = { exists: true, expired: true, lastSync: tokenData.lastSync || null, environment: tokenData.environment }; } } catch (e) { const reason = e.name === 'AbortError' ? 'timed out' : e.message; console.warn(`[Tradovate] firm=${fk}: refresh failed — ${reason}`); _tradovateFirmTokens[fk] = { exists: true, expired: true, lastSync: tokenData.lastSync || null, environment: tokenData.environment }; } } if (anyValid) { // Build aggregate data for the connected UI const aggData = { ...lastTokenData }; // Use the most recent lastSync across all firms let latestSync = null; Object.values(_tradovateFirmTokens).forEach(ft => { if (ft.lastSync) { const ts = ft.lastSync.toDate ? ft.lastSync.toDate() : new Date(ft.lastSync); if (!latestSync || ts > latestSync) latestSync = ts; } }); if (latestSync) aggData.lastSync = latestSync; showTradovateConnected(aggData); populateTradovatePayoutlabAccounts(); try { await loadTradovateAccounts(); } catch (loadErr) { console.warn('[Tradovate] loadTradovateAccounts failed:', loadErr.message); } } else { showTradovateExpired(lastTokenData || {}); } } catch (e) { console.error('checkTradovateSession error:', e); showTradovateLoginForm(); } } function showTradovateLoginForm() { document.getElementById('tradovate-login-form').style.display = 'block'; document.getElementById('tradovate-connected-state').style.display = 'none'; _tradovateHasToken = false; _tradovateTokenExpired = false; } function selectTradovateEnv(env) { const liveBtn = document.getElementById('tradovate-env-live-btn'); const demoBtn = document.getElementById('tradovate-env-demo-btn'); if (env === 'live') { liveBtn.style.border = '2px solid var(--primary)'; liveBtn.style.background = 'rgba(0,212,170,0.15)'; liveBtn.style.color = 'var(--primary)'; liveBtn.style.boxShadow = '0 0 0 3px rgba(0,212,170,0.25)'; demoBtn.style.border = '2px solid rgba(255,255,255,0.1)'; demoBtn.style.background = 'transparent'; demoBtn.style.color = 'var(--text-secondary)'; demoBtn.style.boxShadow = 'none'; } else { demoBtn.style.border = '2px solid var(--primary)'; demoBtn.style.background = 'rgba(0,212,170,0.15)'; demoBtn.style.color = 'var(--primary)'; demoBtn.style.boxShadow = '0 0 0 3px rgba(0,212,170,0.25)'; liveBtn.style.border = '2px solid rgba(255,255,255,0.1)'; liveBtn.style.background = 'transparent'; liveBtn.style.color = 'var(--text-secondary)'; liveBtn.style.boxShadow = 'none'; } document.getElementById(`tradovate-env-${env}`).checked = true; } function getSelectedTradovateEnv() { const demo = document.getElementById('tradovate-env-demo'); return (demo && demo.checked) ? 'demo' : 'live'; } function showTradovateConnected(data) { document.getElementById('tradovate-login-form').style.display = 'none'; document.getElementById('tradovate-connected-state').style.display = 'block'; _tradovateHasToken = true; _tradovateTokenExpired = false; _tradovateExpiredToastShown = false; // allow toast again if token expires in a future session _tradovateLastSync = data.lastSync || null; if (document.getElementById('unified-accounts-table')) renderConnectionsPage(); loadDashboardSyncBar(); const env = data.environment === 'demo' ? 'Simulated (Demo)' : 'Live'; const envColor = data.environment === 'demo' ? '#60a5fa' : 'var(--primary)'; document.getElementById('tradovate-connected-info').innerHTML = `Connected as ${data.username || 'Tradovate User'}  |  Environment: ${env}` + (data.lastSync ? `  |  Last sync: ${new Date(data.lastSync.toDate ? data.lastSync.toDate() : data.lastSync).toLocaleString()}` : ''); _tradovateExpiredEnv = null; // clear stale expired-env on successful (re)connect renderTradovateImportSyncStatus(); } function showTradovateExpired(data) { document.getElementById('tradovate-login-form').style.display = 'none'; document.getElementById('tradovate-connected-state').style.display = 'block'; _tradovateHasToken = true; _tradovateTokenExpired = true; _tradovateLastSync = data.lastSync || null; if (document.getElementById('unified-accounts-table')) renderConnectionsPage(); const env = data.environment === 'demo' ? 'Simulated (Demo)' : 'Live'; const envColor = data.environment === 'demo' ? '#60a5fa' : 'var(--primary)'; document.getElementById('tradovate-connected-info').innerHTML = `⚠️ Session expired  |  Environment: ${env}` + (data.lastSync ? `  |  Last sync: ${new Date(data.lastSync.toDate ? data.lastSync.toDate() : data.lastSync).toLocaleString()}` : '') + `
Tradovate tokens expire after ~2 hours. Click Reconnect to re-authorize.`; _tradovateExpiredEnv = data.environment || null; renderTradovateImportSyncStatus(); } async function connectTradovate() { const btn = document.getElementById('tradovate-connect-btn'); const errorDiv = document.getElementById('tradovate-auth-error'); const environment = getSelectedTradovateEnv(); errorDiv.style.display = 'none'; btn.disabled = true; btn.textContent = '🔄 Opening Tradovate...'; try { const headers = await getTradovateAuthHeader(); const res = await fetch(`${TRADOVATE_CF_BASE}/tradovateOAuthUrl?environment=${environment}&firmKey=${encodeURIComponent(_connectingFirmKey || 'unknown')}`, { method: 'POST', headers }); const data = await res.json(); if (!res.ok || !data.url) throw new Error(data.error || 'Could not get OAuth URL'); // Open Tradovate login in a popup window const popup = window.open( data.url, 'tradovate_oauth', 'width=520,height=700,top=100,left=100,resizable=yes,scrollbars=yes' ); if (!popup) { throw new Error('Popup was blocked. Please allow popups for app.payoutlab.io and try again.'); } btn.textContent = '⏳ Waiting for Tradovate login...'; // Poll for popup closure — use a flag to prevent overlapping async work let _popupHandled = false; const pollInterval = setInterval(() => { try { if (popup.closed && !_popupHandled) { _popupHandled = true; clearInterval(pollInterval); btn.disabled = false; btn.textContent = 'Connect Tradovate Account'; // Run async post-connect work outside the interval (async () => { try { await checkTradovateSession(); // Clear this firm from disconnectedFirms on successful reconnect if (_connectingFirmKey && _tradovateDisconnectedFirms.has(_connectingFirmKey)) { _tradovateDisconnectedFirms.delete(_connectingFirmKey); try { await db.collection('users').doc(firebase.auth().currentUser.uid) .collection('settings').doc('tradovatePreferences') .set({ disconnectedFirms: [..._tradovateDisconnectedFirms] }, { merge: true }); } catch (e) { /* non-critical */ } } quickSyncTradovate(null, _connectingFirmKey || 'unknown').catch(e => console.warn('[Tradovate] Post-connect sync failed:', e.message)); if (document.getElementById('unified-accounts-table')) renderConnectionsPage(); } catch (e) { console.error('[Tradovate] Post-connect error:', e); } })(); } } catch (e) { // Cross-origin errors are expected while popup is on tradovate.com — ignore } }, 500); } catch (e) { errorDiv.style.display = 'block'; document.getElementById('tradovate-auth-error-msg').textContent = e.message; btn.disabled = false; btn.textContent = 'Connect Tradovate Account'; } } let _tradovatePendingNewAccounts = []; async function loadTradovateAccounts() { // Called after checkTradovateSession has validated/renewed the token(s). // Fetches account lists per firm token for display — does NOT sync trade data. const user = firebase.auth().currentUser; if (!user) return; let firmsSnap; try { firmsSnap = await db.collection('tradovate_tokens').doc(user.uid).collection('firms').get(); } catch (e) { console.warn('[Tradovate] Could not read firm tokens:', e.message); return; } if (firmsSnap.empty) { console.log('[Tradovate] No firm tokens in Firestore — skipping account load'); return; } try { const headers = await getTradovateAuthHeader(); const ACCT_TIMEOUT_MS = 15000; // Fetch accounts from each firm token and merge let tvAccounts = []; for (const firmDoc of firmsSnap.docs) { const fk = firmDoc.id; const ftInfo = _tradovateFirmTokens[fk]; if (ftInfo && ftInfo.expired) continue; // skip expired firm tokens try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), ACCT_TIMEOUT_MS); const acctRes = await fetch(`${TRADOVATE_CF_BASE}/tradovateGetAccounts?firmKey=${encodeURIComponent(fk)}`, { headers, signal: controller.signal }); clearTimeout(timeoutId); if (!acctRes.ok) { console.log(`[Tradovate] Could not fetch accounts for firm=${fk}:`, acctRes.status); continue; } const acctData = await acctRes.json(); const firmAccts = (acctData.accounts || []).map(a => ({ ...a, _firmKey: fk })); tvAccounts = tvAccounts.concat(firmAccts); console.log(`[Tradovate] firm=${fk}: ${firmAccts.length} account(s)`); } catch (e) { const reason = e.name === 'AbortError' ? 'timed out' : e.message; console.log(`[Tradovate] Account load skipped for firm=${fk}: ${reason}`); } } console.log(`[Tradovate] Loaded ${tvAccounts.length} total account(s) from Tradovate`); if (tvAccounts.length === 0 && _tradovateHasToken) { console.warn('[Tradovate] Connected but no accounts found'); } // Re-render connections page to show/hide inline warnings if (document.getElementById('unified-accounts-table')) renderConnectionsPage(); } catch (e) { console.log('[Tradovate] Account load skipped:', e.message); } } function populateTradovatePayoutlabAccounts() { /* no-op */ } function onTradovateAccountSelect() { /* no-op */ } // Point value lookup (mirrors Cloud Function) function getTradovatePointValue(symbol) { const base = (symbol || '').replace(/[FGHJKMNQUVXZ]\d{1,2}$/i, '').toUpperCase(); const map = { 'ES': 50, 'MES': 5, 'NQ': 20, 'MNQ': 2, 'RTY': 50, 'M2K': 10, 'YM': 5, 'MYM': 0.5, 'GC': 100, 'MGC': 10, 'SI': 5000, 'HG': 25000, 'CL': 1000, 'MCL': 100, 'NG': 10000, 'ZB': 1000, 'ZN': 1000, 'ZF': 1000, 'ZT': 2000, '6E': 125000, '6B': 62500, '6J': 12500000 }; return map[base] || 50; } // FIFO match fills into completed trades (mirrors Cloud Function logic) function fifoMatchFills(fills, contracts, tradovateAccountId) { const contractMap = {}; (contracts || []).forEach(c => { contractMap[c.id] = c; }); const positionQueues = {}; const matchedTrades = []; fills.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp)); fills.forEach(fill => { const key = `${fill.accountId}_${fill.contractId}`; if (!positionQueues[key]) positionQueues[key] = []; const queue = positionQueues[key]; const contract = contractMap[fill.contractId] || {}; const symbolName = contract.name || `CONTRACT_${fill.contractId}`; const symbol = symbolName.replace(/[FGHJKMNQUVXZ]\d{1,2}$/i, ''); const pointValue = getTradovatePointValue(symbol); let remainingQty = fill.qty; if (queue.length === 0 || queue[0].action === fill.action) { queue.push({ action: fill.action, price: fill.price, timestamp: fill.timestamp, qty: remainingQty, symbol }); } else { while (remainingQty > 0 && queue.length > 0) { const open = queue[0]; const closedQty = Math.min(open.qty, remainingQty); const isLong = open.action === 'Buy'; const grossPnl = isLong ? (fill.price - open.price) * pointValue * closedQty : (open.price - fill.price) * pointValue * closedQty; const exitDate = open.timestamp ? open.timestamp.substring(0, 10) : ''; matchedTrades.push({ symbol, side: isLong ? 'Long' : 'Short', quantity: closedQty, entryPrice: open.price, exitPrice: fill.price, entryTime: open.timestamp, exitTime: fill.timestamp, date: fill.timestamp ? getTradingSessionDate(fill.timestamp) : '', pnl: Math.round(grossPnl * 100) / 100, netPnl: Math.round(grossPnl * 100) / 100, fees: 0, commission: 0, source: 'tradovate-sync', tradovateAccountId: String(fill.accountId), tradovateContractId: String(fill.contractId), importedAt: new Date().toISOString() }); remainingQty -= closedQty; open.qty -= closedQty; if (open.qty <= 0) queue.shift(); } if (remainingQty > 0) { queue.push({ action: fill.action, price: fill.price, timestamp: fill.timestamp, qty: remainingQty, symbol }); } } }); return matchedTrades; } // Temporary: Probe Tradovate Reporting API for historical data async function runTradovateReportProbe() { const btn = document.getElementById('tradovate-report-probe-btn'); const resultsEl = document.getElementById('tradovate-probe-results'); if (!btn || !resultsEl) return; btn.disabled = true; btn.textContent = '⏳ Probing...'; resultsEl.style.display = 'block'; resultsEl.textContent = 'Calling Tradovate Reporting API...'; try { const user = firebase.auth().currentUser; if (!user) { resultsEl.textContent = 'Error: Not logged in'; return; } const idToken = await user.getIdToken(); // Get the user's first Tradovate account ID for the report test let accountId = null; try { const acctRes = await fetch(`${TRADOVATE_CF_BASE}/tradovateGetAccounts`, { method: 'POST', headers: { 'Authorization': `Bearer ${idToken}`, 'Content-Type': 'application/json' } }); if (acctRes.ok) { const acctData = await acctRes.json(); if (acctData.accounts && acctData.accounts.length > 0) { accountId = acctData.accounts[0].id; } } } catch (e) { /* no account, still probe definitions */ } const body = { accountId }; // Use date range from the UI if set const startDate = document.getElementById('tradovate-start-date')?.value; const endDate = document.getElementById('tradovate-end-date')?.value; if (startDate) body.startDate = startDate; if (endDate) body.endDate = endDate; const res = await fetch(`${TRADOVATE_CF_BASE}/tradovateReportProbe`, { method: 'POST', headers: { 'Authorization': `Bearer ${idToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const data = await res.json(); resultsEl.textContent = JSON.stringify(data, null, 2); console.log('[ReportProbe] Full response:', data); } catch (e) { resultsEl.textContent = `Error: ${e.message}`; console.error('[ReportProbe] Error:', e); } finally { btn.disabled = false; btn.textContent = '🔬 Probe Reporting API (Dev Test)'; } } async function manageTradovateAccounts() { console.log('[ManageTVAccounts] Called'); const user = firebase.auth().currentUser; if (!user) return; const prefsDoc = await db.collection('users').doc(user.uid) .collection('settings').doc('tradovatePreferences').get({ source: 'server' }); const prefs = prefsDoc.exists ? prefsDoc.data() : {}; const excluded = prefs.excludedAccountIds || []; const lastSync = prefs.lastSyncAccountIds || []; if (lastSync.length === 0) { showToast('No accounts found — sync Tradovate first to discover accounts', 'info'); return; } const modal = document.createElement('div'); modal.className = 'modal'; modal.id = 'manage-tv-accounts-modal'; modal.style.display = 'flex'; modal.onclick = function(e) { if (e.target === this) this.remove(); }; modal.innerHTML = ` `; document.body.appendChild(modal); } async function saveTradovateAccountExclusions() { const checkboxes = document.querySelectorAll('#manage-tv-accounts-list input[type=checkbox]'); const excludedIds = []; checkboxes.forEach(cb => { if (!cb.checked) excludedIds.push(cb.value); }); const user = firebase.auth().currentUser; if (!user) return; await db.collection('users').doc(user.uid) .collection('settings').doc('tradovatePreferences') .set({ excludedAccountIds: excludedIds }, { merge: true }); document.getElementById('manage-tv-accounts-modal')?.remove(); showToast(excludedIds.length > 0 ? `${excludedIds.length} account(s) excluded from Tradovate sync` : 'All accounts included', 'success'); } function renderTradovateImportSyncStatus() { const status = document.getElementById('tradovate-sync-status'); if (!status) return; // State C — Session expired (orange) if (_tradovateTokenExpired) { status.style.background = 'rgba(245,158,11,0.10)'; status.style.border = '1px solid rgba(245,158,11,0.35)'; status.style.color = 'var(--text-primary)'; status.innerHTML = '⚠️ Session expired · ' + 'Reconnect'; const link = document.getElementById('tradovate-status-reconnect'); if (link) link.onclick = (e) => { e.preventDefault(); showTradovateLoginForm(); if (_tradovateExpiredEnv) selectTradovateEnv(_tradovateExpiredEnv); }; return; } const count = unconfiguredTradovateCount(); // State A — Setup needed (yellow) if (count > 0) { status.style.background = 'rgba(245,158,11,0.10)'; status.style.border = '1px solid rgba(245,158,11,0.35)'; status.style.color = 'var(--text-primary)'; status.innerHTML = '⚙️ Setup needed: complete configuration to start syncing trades for ' + count + ' account' + (count > 1 ? 's' : '') + ' · ' + 'Configure'; const link = document.getElementById('tradovate-status-configure'); if (link) link.onclick = (e) => { e.preventDefault(); relaunchPendingTradovateSetup(); }; return; } // State B — All synced (green) const lastSyncStr = _tradovateLastSync ? new Date(_tradovateLastSync.toDate ? _tradovateLastSync.toDate() : _tradovateLastSync).toLocaleString() : ''; status.style.background = 'rgba(0,212,170,0.10)'; status.style.border = '1px solid rgba(0,212,170,0.35)'; status.style.color = 'var(--text-primary)'; status.innerHTML = '✅ Connected — trades sync automatically.' + (lastSyncStr ? '
Last sync: ' + lastSyncStr + '' : '') + '
Manage from the Connections page.'; } async function startTradovateSync(silent = false, firmKey = null) { const rawFk = firmKey || _syncingFirmKey || 'unknown'; // Fallback: if no firm-specific token exists, use 'unknown' (migrated users) const syncFk = _tradovateFirmTokens[rawFk] ? rawFk : (_tradovateFirmTokens['unknown'] ? 'unknown' : rawFk); sessionStorage.setItem('pl_sync_tab', '1'); // Mark this tab as the sync initiator const progress = document.getElementById('tradovate-progress'); const progressText = document.getElementById('tradovate-progress-text'); const progressBar = document.getElementById('tradovate-progress-bar'); const results = document.getElementById('tradovate-results'); if (!silent) { if (progress) progress.style.display = 'block'; if (results) results.style.display = 'none'; if (progressBar) progressBar.style.width = '10%'; if (progressText) progressText.textContent = 'Fetching Tradovate accounts...'; } try { const user = firebase.auth().currentUser; if (!user) throw new Error('Not logged in'); // Guard: if token is locally confirmed expired, fail fast before any network call if (_tradovateTokenExpired) { if (!silent) showToast('Tradovate session expired — Reconnect to sync', 'warning', 7000); return; } const headers = await getTradovateAuthHeader(); const startDate = document.getElementById('tradovate-start-date')?.value || ''; const endDate = document.getElementById('tradovate-end-date')?.value || ''; // Step 1: Get accounts from Tradovate using firm-specific token const acctRes = await fetch(`${TRADOVATE_CF_BASE}/tradovateGetAccounts?firmKey=${encodeURIComponent(syncFk)}`, { headers }); if (acctRes.status === 401) { const expTokenDoc = await db.collection('tradovate_tokens').doc(user.uid).collection('firms').doc(syncFk).get(); if (expTokenDoc.exists) { showTradovateExpired(expTokenDoc.data()); } else { showTradovateLoginForm(); } if (!silent) showToast('Tradovate session expired. Please reconnect.', 'warning'); return; } const acctData = await acctRes.json(); if (!acctData.accounts || acctData.accounts.length === 0) { if (!silent) showToast('No Tradovate accounts found.', 'info'); return; } const tradovateAccounts = acctData.accounts; console.log(`[Tradovate] Tradovate returned ${tradovateAccounts.length} accounts:`, tradovateAccounts.map(a => `${a.name} (id:${a.id})`)); if (!silent && progressBar) progressBar.style.width = '20%'; if (!silent && progressText) progressText.textContent = `Found ${tradovateAccounts.length} account(s). Checking for new accounts...`; // Step 2: Check which Tradovate accounts don't exist in PayoutLab yet // Look up by tradovateAccountId in the actual accounts collection, NOT preferences const accountsWithoutPayoutLab = []; const archivedTradovateIds = new Set(); // Track archived accounts to skip during trade sync for (const tvAcct of tradovateAccounts) { const tvId = String(tvAcct.id); // Check local accounts array first (faster) const localMatch = accounts.find(a => a.tradovateAccountId === tvId || a.name === tvAcct.name); if (localMatch) { // If matched account is archived, skip it entirely — don't sync trades if (localMatch.archived) { archivedTradovateIds.add(tvId); console.log(`[Tradovate] Skipping archived account: ${tvAcct.name} (tvId:${tvId})`); } continue; } // Double-check Firestore in case local array is stale const fbSnap = await db.collection('users').doc(user.uid).collection('accounts') .where('tradovateAccountId', '==', tvId).limit(1).get(); if (!fbSnap.empty) { const fbData = fbSnap.docs[0].data(); if (fbData.archived) { archivedTradovateIds.add(tvId); console.log(`[Tradovate] Skipping archived account (Firestore): ${tvAcct.name} (tvId:${tvId})`); } } else { // Also check if an archived account exists by name — don't create a duplicate const archivedByName = accounts.find(a => a.archived && a.name === tvAcct.name); if (archivedByName) { archivedTradovateIds.add(tvId); console.log(`[Tradovate] Skipping archived account (by name): ${tvAcct.name}`); } else { accountsWithoutPayoutLab.push(tvAcct); } } } console.log(`[Tradovate] Accounts not yet in PayoutLab: ${accountsWithoutPayoutLab.length}`, accountsWithoutPayoutLab.map(a => a.name)); let selectedAccountIds; if (accountsWithoutPayoutLab.length > 0 && !silent) { // Show picker with ALL Tradovate accounts — new ones are pre-checked if (progress) progress.style.display = 'none'; const prefsDoc = await db.collection('users').doc(user.uid) .collection('settings').doc('tradovatePreferences').get(); const savedPrefs = prefsDoc.exists ? (prefsDoc.data().accountPreferences || {}) : {}; selectedAccountIds = await showTradovateAccountPicker(tradovateAccounts, savedPrefs); if (!selectedAccountIds || selectedAccountIds.length === 0) { return; } if (progress) progress.style.display = 'block'; if (progressBar) progressBar.style.width = '25%'; if (progressText) progressText.textContent = 'Setting up new accounts...'; // Save preferences const updatedPrefs = {}; tradovateAccounts.forEach(a => { updatedPrefs[String(a.id)] = { include: selectedAccountIds.includes(String(a.id)), name: a.name, lastSeen: new Date().toISOString() }; }); await db.collection('users').doc(user.uid) .collection('settings').doc('tradovatePreferences') .set({ accountPreferences: updatedPrefs }, { merge: true }); // Step 3: Create accounts in PayoutLab for each NEW selected account const newAccountNames = []; const newAccountsForWizard = accountsWithoutPayoutLab.filter(a => selectedAccountIds.includes(String(a.id))); for (const tvAcct of newAccountsForWizard) { const tvId = String(tvAcct.id); // Check if already exists by name (user may have manually created it) const nameMatch = accounts.find(a => a.name === tvAcct.name); if (nameMatch) { // Link existing account to Tradovate await db.collection('users').doc(user.uid).collection('accounts') .doc(nameMatch.id).update({ tradovateAccountId: tvId, connectionType: 'tradovate' }); nameMatch.tradovateAccountId = tvId; continue; } // Create new account — propFirm comes from sync firmKey (per-firm OAuth token) const detectedFirm = syncFk || 'unknown'; const newAcct = { name: tvAcct.name, propFirm: detectedFirm, connectionType: 'tradovate', tradovateAccountId: tvId, stage: 'funded', startingBalance: 0, currentBalance: 0, buffer: 0, archived: false, createdAt: new Date().toISOString(), autoCreated: true, needsSetup: true, source: 'tradovate-sync' }; // Deterministic doc ID prevents duplicates on re-sync const tvSanitized = tvAcct.name.replace(/[^a-zA-Z0-9_-]/g, '_').substring(0, 60); const tvDocId = `${detectedFirm}_${tvSanitized}`; await db.collection('users').doc(user.uid).collection('accounts').doc(tvDocId).set(newAcct); newAcct.id = tvDocId; // Do NOT push to accounts array — onSnapshot listener will update it newAccountNames.push(tvAcct.name); console.log(`[Tradovate] Created PayoutLab account: ${tvAcct.name} (tvId:${tvId}) firm=${detectedFirm} → ${tvDocId}`); } // Step 4: Show setup wizard and WAIT for user to configure before syncing trades if (newAccountNames.length > 0) { console.log(`[Tradovate] Launching setup wizard for ${newAccountNames.length} accounts:`, newAccountNames); if (progress) progress.style.display = 'none'; await waitForSetupWizard(newAccountNames, 'Tradovate', ''); // Reload accounts from Firestore to get wizard updates const acctSnap = await db.collection('users').doc(user.uid).collection('accounts').get(); accounts.length = 0; acctSnap.forEach(doc => accounts.push({ id: doc.id, ...doc.data() })); if (progress) progress.style.display = 'block'; } } else { // Silent mode OR all accounts already exist const prefsDoc = await db.collection('users').doc(user.uid) .collection('settings').doc('tradovatePreferences').get(); const savedPrefs = prefsDoc.exists ? (prefsDoc.data().accountPreferences || {}) : {}; selectedAccountIds = tradovateAccounts .filter(a => savedPrefs[String(a.id)]?.include !== false) .map(a => String(a.id)); if (selectedAccountIds.length === 0) { selectedAccountIds = tradovateAccounts.map(a => String(a.id)); } // Auto-create any new accounts found during silent sync if (accountsWithoutPayoutLab.length > 0) { let newCount = 0; for (const tvAcct of accountsWithoutPayoutLab) { const tvId = String(tvAcct.id); if (!selectedAccountIds.includes(tvId)) continue; // Check if already exists by name const nameMatch = accounts.find(a => a.name === tvAcct.name); if (nameMatch) { await db.collection('users').doc(user.uid).collection('accounts') .doc(nameMatch.id).update({ tradovateAccountId: tvId, connectionType: 'tradovate' }); nameMatch.tradovateAccountId = tvId; continue; } // Create new account with needsSetup flag — propFirm comes from sync firmKey const silentDetectedFirm = syncFk || 'unknown'; const newAcct = { name: tvAcct.name, propFirm: silentDetectedFirm, connectionType: 'tradovate', tradovateAccountId: tvId, stage: 'funded', startingBalance: 0, currentBalance: 0, buffer: 0, archived: false, createdAt: new Date().toISOString(), autoCreated: true, needsSetup: true, source: 'tradovate-sync' }; const tvSanitized = tvAcct.name.replace(/[^a-zA-Z0-9_-]/g, '_').substring(0, 60); const tvDocId = `${silentDetectedFirm}_${tvSanitized}`; await db.collection('users').doc(user.uid).collection('accounts').doc(tvDocId).set(newAcct); newCount++; console.log(`[Tradovate] Auto-created account: ${tvAcct.name} (tvId:${tvId}) firm=${silentDetectedFirm} → ${tvDocId}`); } if (newCount > 0) { // Reload accounts from Firestore so needsSetup accounts appear const acctSnap = await db.collection('users').doc(user.uid).collection('accounts').get(); accounts.length = 0; acctSnap.forEach(doc => accounts.push({ id: doc.id, ...doc.data() })); // Launch setup modal for newly created accounts _pendingSetupAccounts = accounts.filter(a => a.needsSetup === true && !a.archived && !_setupAccountsSeen.has(a.id)); if (_pendingSetupAccounts.length > 0) { showNextAccountSetup(); } showToast(`${newCount} new account${newCount > 1 ? 's' : ''} detected — complete setup below`, 'info', 5000); } } } // Filter out archived accounts from sync if (archivedTradovateIds.size > 0) { selectedAccountIds = selectedAccountIds.filter(id => !archivedTradovateIds.has(id)); console.log(`[Tradovate] Filtered out ${archivedTradovateIds.size} archived account(s) from sync`); } // Filter out user-excluded accounts const _tvPrefsForExcl = await db.collection('users').doc(user.uid) .collection('settings').doc('tradovatePreferences').get(); const _tvExcluded = _tvPrefsForExcl.exists ? (_tvPrefsForExcl.data().excludedAccountIds || []) : []; if (_tvExcluded.length > 0) { const beforeCount = selectedAccountIds.length; selectedAccountIds = selectedAccountIds.filter(id => !_tvExcluded.includes(id)); if (beforeCount > selectedAccountIds.length) { console.log(`[Tradovate] Excluded ${beforeCount - selectedAccountIds.length} account(s) by user preference: ${_tvExcluded.join(', ')}`); } } if (!silent && progressBar) progressBar.style.width = '40%'; if (!silent && progressText) progressText.textContent = 'Fetching trade history from Tradovate...'; // Step 5: NOW sync trades — accounts are already created and configured const syncRes = await fetch(`${TRADOVATE_CF_BASE}/tradovateSync`, { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify({ startDate, endDate, firmKey: syncFk }) }); if (syncRes.status === 401) { const expTokenDoc = await db.collection('tradovate_tokens').doc(user.uid).collection('firms').doc(syncFk).get(); if (expTokenDoc.exists) { showTradovateExpired(expTokenDoc.data()); } else { showTradovateLoginForm(); } if (!silent) showToast('Tradovate session expired. Please reconnect.', 'warning'); return; } const syncData = await syncRes.json(); if (!syncData.success) { if (!silent) showToast(`Sync failed: ${syncData.error}`, 'error'); return; } const totalSaved = syncData.saved || 0; const totalSkipped = syncData.skipped || 0; const sources = syncData.sources || {}; const skippedTrades = syncData.skippedTrades || []; const savedTrades = syncData.savedTrades || []; console.log(`[Tradovate] Server sync complete: ${totalSaved} saved, ${totalSkipped} skipped`); console.log(`[Tradovate] Fill sources:`, sources); console.log(`[Tradovate] Total fills fetched: ${(syncData.fills || []).length}`); if (skippedTrades.length > 0) console.log(`[Tradovate] Skipped trades:`, skippedTrades); // Save discovered account IDs for Manage Accounts UI try { await db.collection('users').doc(user.uid) .collection('settings').doc('tradovatePreferences') .set({ lastSyncAccountIds: tradovateAccounts.map(a => ({ id: String(a.id), name: a.name })) }, { merge: true }); } catch (e) { /* non-critical */ } if (!silent && progressBar) progressBar.style.width = '100%'; if (!silent && progress) progress.style.display = 'none'; // Show results if (!silent && results) { results.style.display = 'block'; document.getElementById('tradovate-result-icon').textContent = totalSaved > 0 ? '✅' : '📭'; document.getElementById('tradovate-result-title').textContent = totalSaved > 0 ? 'Sync Complete!' : 'No New Trades'; document.getElementById('tradovate-result-message').textContent = totalSkipped > 0 ? `${totalSkipped} duplicate(s) skipped.` : ''; document.getElementById('tradovate-stat-imported').textContent = totalSaved; document.getElementById('tradovate-stat-matched').textContent = totalSaved + totalSkipped; document.getElementById('tradovate-stat-skipped').textContent = totalSkipped; // Saved trades detail const savedDiv = document.getElementById('tradovate-saved-details'); if (savedDiv) { if (savedTrades.length > 0) { const savedRows = savedTrades.map(t => ` ${t.date || '-'} ${t.symbol || '-'} ${t.side} ${t.qty} ${t.pnl != null ? formatCurrency(Number(t.pnl)) : '-'} ${t.account || '-'} ` ).join(''); savedDiv.innerHTML = `
View ${savedTrades.length} synced trade(s)
${savedRows}
Date Symbol Side Qty P&L Account
`; savedDiv.style.display = 'block'; } else { savedDiv.style.display = 'none'; savedDiv.innerHTML = ''; } } // Skipped trades detail const skippedDiv = document.getElementById('tradovate-skipped-details'); if (skippedDiv) { if (skippedTrades.length > 0) { const rows = skippedTrades.map(t => ` ${t.date || '-'} ${t.symbol || '-'} ${t.side} ${t.qty} ${t.pnl != null ? formatCurrency(Number(t.pnl)) : '-'} ${t.reason} ` ).join(''); skippedDiv.innerHTML = `
View ${skippedTrades.length} skipped trade(s)
${rows}
Date Symbol Side Qty P&L Reason
`; skippedDiv.style.display = 'block'; } else { skippedDiv.style.display = 'none'; skippedDiv.innerHTML = ''; } } } // Full UI refresh after sync await fullDataRefresh(); if (totalSaved > 0) { if (!silent) showToast(`✅ Sync complete — ${totalSaved} executions imported`, 'success'); } else if (!silent) { showToast('No new trades found — already up to date.', 'info'); } } catch (e) { if (!silent) showToast(`Sync failed: ${e.message}`, 'error'); console.error('[Tradovate] Sync error:', e); } finally { if (progress) progress.style.display = 'none'; } } // Account picker modal for Tradovate (mirrors Rithmic's showRithmicAccountPicker) function showTradovateAccountPicker(tradovateAccounts, savedPrefs) { return new Promise((resolve) => { const overlay = document.createElement('div'); overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.7);z-index:9999;display:flex;align-items:center;justify-content:center;'; const modal = document.createElement('div'); modal.style.cssText = 'background:var(--bg-card);border-radius:12px;padding:28px;max-width:480px;width:90%;box-shadow:0 8px 32px rgba(0,0,0,0.4);'; const checkboxes = tradovateAccounts.map(a => ` `).join(''); modal.innerHTML = `
🔗 New Tradovate Accounts Detected
Select which accounts you want to import trades from. You can change this later.
${checkboxes}
`; overlay.appendChild(modal); document.body.appendChild(overlay); document.getElementById('tv-picker-confirm').onclick = () => { const selected = [...modal.querySelectorAll('input[type=checkbox]:checked')].map(c => c.value); document.body.removeChild(overlay); resolve(selected); }; document.getElementById('tv-picker-cancel').onclick = () => { document.body.removeChild(overlay); resolve([]); }; }); } async function clearTradovatePreferences() { if (!confirm('Clear Tradovate account preferences? Next sync will re-detect all accounts as new and show the setup wizard.')) return; try { const user = firebase.auth().currentUser; if (!user) return; await db.collection('users').doc(user.uid) .collection('settings').doc('tradovatePreferences').delete(); showToast('Tradovate preferences cleared. Click Sync to re-setup accounts.', 'success'); } catch (e) { showToast('Error clearing preferences: ' + e.message, 'error'); } } async function disconnectTradovate(firmKey) { if (!firmKey) { console.error('[disconnectTradovate] firmKey is required'); showToast('Cannot disconnect: missing firm scope. Please refresh and try again.', 'error'); return; } // Call backend per-firm (firmKey-required API; backend deletes the firm's // token doc + best-effort calls Tradovate /auth/logout). State mutations // happen ONLY after the backend confirms success — see below. let response; try { const headers = await getTradovateAuthHeader(); response = await fetch(`${TRADOVATE_CF_BASE}/tradovateDisconnect`, { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify({ firmKey, cascade: false }) }); } catch (netErr) { console.error('[disconnectTradovate] network error:', netErr); showToast('Connection error. Please try again.', 'error'); return; } if (!response.ok) { const data = await response.json().catch(() => ({})); console.error('[disconnectTradovate] backend error:', response.status, data); showToast('Disconnect failed: ' + (data.error || `HTTP ${response.status}`), 'error'); return; } // Backend confirmed success — now safe to mutate local state. _tradovateDisconnectedFirms.add(firmKey); // Update in-memory token state — remove this firm's token. If no firms // remain, flip the global has-token flag. if (_tradovateFirmTokens) { delete _tradovateFirmTokens[firmKey]; if (Object.keys(_tradovateFirmTokens).length === 0) { _tradovateHasToken = false; } } // Persist disconnectedFirms — cleared only on reconnect, not on disconnect try { await db.collection('users').doc(firebase.auth().currentUser.uid) .collection('settings').doc('tradovatePreferences') .set({ disconnectedFirms: [..._tradovateDisconnectedFirms] }, { merge: true }); } catch (e) { console.warn('[disconnectTradovate] Failed to persist disconnectedFirms:', e.message); } await fullDataRefresh(); showToast('Connection removed', 'success'); } // Listen for Tradovate OAuth popup completion message window.addEventListener('message', async (event) => { if (event.origin !== 'https://app.payoutlab.io') return; if (event.data?.type === 'tradovate_connected') { showToast('✅ Tradovate connected successfully!', 'success'); await checkTradovateSession(); quickSyncTradovate(null, _connectingFirmKey || 'unknown').catch(e => console.warn('[Tradovate] Post-connect sync failed:', e.message)); const btn = document.getElementById('tradovate-connect-btn'); if (btn) { btn.disabled = false; btn.textContent = 'Connect Tradovate Account'; } } else if (event.data?.type === 'tradovate_error') { const errorDiv = document.getElementById('tradovate-auth-error'); if (errorDiv) { errorDiv.style.display = 'block'; document.getElementById('tradovate-auth-error-msg').textContent = 'OAuth error: ' + decodeURIComponent(event.data.error || 'unknown'); } const btn = document.getElementById('tradovate-connect-btn'); if (btn) { btn.disabled = false; btn.textContent = 'Connect Tradovate Account'; } } }); // ============================================ // DASHBOARD SYNC BAR // ============================================ let _connPopGroups = {}; // { rithmic: [...], tradovate: [...], csv: [...] } let _connHasIssue = false; let _connAllBad = true; let _connHasAny = false; const _syncingConnections = new Set(); // tracks connectionIds currently syncing let _syncingTradovate = false; // legacy compat — true if ANY tradovate sync running let _syncingTradovateFirms = new Set(); // per-firm sync tracking let _loadDashboardSyncBarRunning = false; async function loadDashboardSyncBar() { if (!currentUser) return; if (_loadDashboardSyncBarRunning) return; // prevent concurrent runs _loadDashboardSyncBarRunning = true; try { // Use local groups to avoid race conditions with concurrent calls const groups = { rithmic: [], tradovate: [], csv: [] }; let hasIssue = false; let allBad = true; let hasAny = false; // Build a map of propFirm → connectionType from accounts const firmConnMap = {}; // { propFirmKey: { type, lastSync, ... } } accounts.filter(a => !a.archived).forEach(a => { const firm = a.propFirm || 'other'; const ct = a.connectionType || 'csv'; if (!firmConnMap[firm]) firmConnMap[firm] = { type: ct, accounts: [] }; firmConnMap[firm].accounts.push(a); }); // Rithmic connection status per saved connection const rithmicConnStatus = {}; try { const rithmicSnap = await db.collection('users').doc(currentUser.uid) .collection('rithmicConnections').orderBy('createdAt', 'desc').get(); rithmicSnap.docs.forEach(doc => { const conn = doc.data(); rithmicConnStatus[doc.id] = { lastSync: conn.lastSyncAt ? new Date(conn.lastSyncAt.seconds * 1000) : null, isError: conn.lastSyncStatus === 'error', docId: doc.id, accountIds: conn.lastSyncAccountIds || [] }; }); } catch (e) { console.warn('[ConnStatus] Rithmic error:', e.message); } // Tradovate token status — read per-firm subcollections let tvIsExpired = true; let tvLastSync = null; let tvTokenExists = false; try { const tvFirmsSnap = await db.collection('tradovate_tokens').doc(currentUser.uid).collection('firms').get(); if (!tvFirmsSnap.empty) { tvTokenExists = true; tvFirmsSnap.docs.forEach(firmDoc => { const data = firmDoc.data(); const ls = data.lastSync ? new Date(data.lastSync.toDate ? data.lastSync.toDate() : data.lastSync) : null; if (ls && (!tvLastSync || ls > tvLastSync)) tvLastSync = ls; const tvExpiresAt = data.tokenIssuedAt + ((data.expiresIn || 5400) * 1000); if (Date.now() < tvExpiresAt - 60000) tvIsExpired = false; }); } } catch (e) { console.warn('[ConnStatus] Tradovate error:', e.message); } // Sync local Tradovate state so Connections page matches popup _tradovateHasToken = tvTokenExists && !tvIsExpired; _tradovateLastSync = tvLastSync; if (tvTokenExists) _tradovateTokenExpired = tvIsExpired; // Build grouped rows from accounts Object.entries(firmConnMap).forEach(([firmKey, info]) => { const firmName = propFirmNames[firmKey] || firmKey; const ct = info.type; hasAny = true; if (ct === 'rithmic') { // Find matching rithmic connection let matchedConn = null; for (const [id, rc] of Object.entries(rithmicConnStatus)) { const hasMatch = info.accounts.some(a => a.name && rc.accountIds.some(aid => a.name.includes(aid)) ); if (hasMatch) { matchedConn = { ...rc, id }; break; } } // Fallback: use first rithmic connection if we have rithmic accounts if (!matchedConn && Object.keys(rithmicConnStatus).length > 0) { const firstId = Object.keys(rithmicConnStatus)[0]; matchedConn = { ...rithmicConnStatus[firstId], id: firstId }; } if (!matchedConn) { // No saved connection — show as disconnected hasIssue = true; groups.rithmic.push({ firmName, status: 'disconnected', lastSync: null, dotColor: 'var(--red)', syncId: null }); } else { const isErr = matchedConn.isError; if (isErr) hasIssue = true; else allBad = false; groups.rithmic.push({ firmName, status: isErr ? 'error' : 'connected', lastSync: matchedConn.lastSync, dotColor: isErr ? 'var(--red)' : 'var(--green)', syncId: matchedConn.id }); } } else if (ct === 'tradovate') { // Check if any account in this firm group is actually linked to Tradovate const hasLinked = info.accounts.some(a => a.tradovateAccountId); const tvDisconnected = !tvTokenExists; let tvStatus, tvDotColor; if (tvDisconnected) { tvStatus = 'disconnected'; tvDotColor = 'var(--red)'; } else if (tvIsExpired) { tvStatus = 'expired'; tvDotColor = 'var(--red)'; } else if (!hasLinked) { tvStatus = 'not linked'; tvDotColor = 'var(--text-muted)'; } else { tvStatus = 'connected'; tvDotColor = 'var(--green)'; } if (tvStatus === 'connected') allBad = false; else hasIssue = true; groups.tradovate.push({ firmName, firmKey, status: tvStatus, lastSync: tvLastSync, dotColor: tvDotColor }); } else { allBad = false; groups.csv.push({ firmName, status: 'manual', dotColor: 'var(--text-muted)' }); } }); // Also add rithmic connections that don't match any account Object.entries(rithmicConnStatus).forEach(([id, rc]) => { const alreadyMapped = groups.rithmic.some(r => r.syncId === id); if (!alreadyMapped) { hasAny = true; const isErr = rc.isError; if (isErr) hasIssue = true; else allBad = false; groups.rithmic.push({ firmName: 'Rithmic Connection', status: isErr ? 'error' : 'connected', lastSync: rc.lastSync, dotColor: isErr ? 'var(--red)' : 'var(--green)', syncId: id }); } }); // If user has tradovate token but no tradovate accounts, still show it if (groups.tradovate.length === 0 && tvTokenExists) { hasAny = true; const orphanStatus = tvIsExpired ? 'expired' : 'connected'; if (tvIsExpired) hasIssue = true; else allBad = false; groups.tradovate.push({ firmName: 'Tradovate', status: orphanStatus, lastSync: tvLastSync, dotColor: tvIsExpired ? 'var(--red)' : 'var(--green)' }); } // Atomically assign to globals — prevents partial state from concurrent calls _connPopGroups = groups; _connHasIssue = hasIssue; _connAllBad = allBad; _connHasAny = hasAny; // Update header dot const dot = document.getElementById('conn-status-dot'); if (dot) { if (!_connHasAny) { dot.style.background = 'var(--text-muted)'; dot.classList.remove('conn-dot-pulse'); } else if (_connHasIssue && _connAllBad) { dot.style.background = 'var(--red)'; dot.classList.add('conn-dot-pulse'); } else if (_connHasIssue) { dot.style.background = 'var(--orange)'; dot.classList.add('conn-dot-pulse'); } else { dot.style.background = 'var(--green)'; dot.classList.remove('conn-dot-pulse'); } } // Re-render popover if open const popover = document.getElementById('conn-status-popover'); if (popover && popover.style.display === 'block') renderConnectionPopover(); // Re-render Connections page if visible so it stays in sync with popup if (document.getElementById('unified-accounts-table')) renderConnectionsPage(); } finally { _loadDashboardSyncBarRunning = false; } } function toggleConnectionPopover() { const popover = document.getElementById('conn-status-popover'); if (!popover) return; if (popover.style.display === 'block') { popover.style.display = 'none'; } else { // Refresh from Firestore every time popover is opened to avoid stale status loadDashboardSyncBar(); popover.style.display = 'block'; } } function renderConnectionPopover() { const list = document.getElementById('conn-status-list'); if (!list) return; if (!_connHasAny) { list.innerHTML = '
No connections
'; return; } let html = ''; const sectionColor = (type) => type === 'rithmic' ? 'var(--green)' : type === 'tradovate' ? '#5b6af5' : 'var(--orange)'; const renderGroup = (label, rows, type) => { if (rows.length === 0) return; html += `
${label}
`; rows.forEach(row => { let statusText = ''; let syncTimeStr = ''; let actionHtml = ''; if (row.status === 'connected') { const staleHours = row.lastSync ? Math.floor((Date.now() - row.lastSync.getTime()) / 3600000) : null; const isStale = staleHours !== null && staleHours >= 24; statusText = isStale ? `Last sync ${staleHours}h ago` : `Connected`; syncTimeStr = row.lastSync ? formatSyncTimeShort(row.lastSync) : ''; if (isStale) row.dotColor = 'var(--orange)'; if (type === 'rithmic') { if (row.syncId) { const isSyncing = _syncingConnections.has(row.syncId); actionHtml = ``; } else { actionHtml = `Connect`; } } else if (type === 'tradovate') { const tvFk = row.firmKey || 'unknown'; const tvSyncing = _syncingTradovateFirms.has(tvFk); actionHtml = ``; } } else if (row.status === 'expired') { statusText = `Expired`; syncTimeStr = row.lastSync ? formatSyncTimeShort(row.lastSync) : ''; actionHtml = `Reconnect`; } else if (row.status === 'disconnected') { statusText = `Disconnected`; syncTimeStr = row.lastSync ? formatSyncTimeShort(row.lastSync) : ''; const connType = type === 'tradovate' ? 'tradovate' : 'rithmic'; actionHtml = `Connect`; } else if (row.status === 'error') { statusText = `Error`; syncTimeStr = row.lastSync ? formatSyncTimeShort(row.lastSync) : ''; if (row.syncId) { const isSyncingErr = _syncingConnections.has(row.syncId); actionHtml = ``; } } else if (row.status === 'manual') { statusText = `Manual`; syncTimeStr = 'Manual'; } const syncLabel = row.status === 'manual' ? 'Last imported' : 'Last synced'; const syncSubLine = row.lastSync ? `${syncLabel}: ${formatSyncTimeShort(row.lastSync)}` : (row.status === 'manual' ? '' : `${syncLabel}: Never`); html += `
${row.firmName}
${statusText}
${syncSubLine ? `
${syncSubLine}
` : ''}
${actionHtml}
`; }); }; renderGroup('RITHMIC', _connPopGroups.rithmic, 'rithmic'); renderGroup('TRADOVATE', _connPopGroups.tradovate, 'tradovate'); renderGroup('CSV IMPORTS', _connPopGroups.csv, 'csv'); list.innerHTML = html; } function formatSyncTimeShort(date) { if (!date) return ''; const now = new Date(); const diff = Math.floor((now - date) / 1000); if (diff < 60) return 'Just now'; if (diff < 3600) return Math.floor(diff / 60) + 'm ago'; if (diff < 86400) return Math.floor(diff / 3600) + 'h ago'; const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; const h = date.getHours(); const m = date.getMinutes(); const ampm = h >= 12 ? 'PM' : 'AM'; const h12 = h % 12 || 12; return `${months[date.getMonth()]} ${date.getDate()}, ${h12}:${m.toString().padStart(2,'0')} ${ampm}`; } function formatSyncTime(date) { const now = new Date(); const diff = Math.floor((now - date) / 1000); if (diff < 60) return 'Just now'; if (diff < 3600) return Math.floor(diff / 60) + 'm ago'; if (diff < 86400) return Math.floor(diff / 3600) + 'h ago'; return date.toLocaleDateString(); } function updateSyncButtonStates() { // Update Connections page sync buttons _syncingConnections.forEach(connId => { const pageBtn = document.getElementById('conn-sync-btn-' + connId); if (pageBtn) { pageBtn.disabled = true; pageBtn.innerHTML = '🔄 Syncing...'; } }); // Restore non-syncing Connections page buttons (_savedRithmicConnections || []).forEach(conn => { if (!_syncingConnections.has(conn.id)) { const pageBtn = document.getElementById('conn-sync-btn-' + conn.id); if (pageBtn && pageBtn.disabled) { pageBtn.disabled = false; pageBtn.innerHTML = '⚡ Sync'; } } }); // Update Connections page group header sync buttons (Rithmic + Tradovate) document.querySelectorAll('.conn-page-sync-btn').forEach(btn => { const connId = btn.getAttribute('data-conn-id'); const isTv = btn.getAttribute('data-conn-type') === 'tradovate'; if (isTv) { const fk = btn.getAttribute('data-firm-key') || ''; const isSyncing = fk ? _syncingTradovateFirms.has(fk) : _syncingTradovate; if (isSyncing) { btn.disabled = true; btn.textContent = '⏳ Syncing...'; btn.style.opacity = '0.5'; } else { btn.disabled = false; btn.style.opacity = ''; btn.textContent = btn.textContent.includes('Link') ? 'Sync to Link' : 'Sync'; } } else if (connId) { if (_syncingConnections.has(connId)) { btn.disabled = true; btn.textContent = '⏳ Syncing...'; btn.style.opacity = '0.5'; } else { btn.disabled = false; btn.style.opacity = ''; btn.textContent = 'Sync'; } } }); // Update popover sync buttons document.querySelectorAll('.conn-pop-sync-btn').forEach(btn => { const connId = btn.getAttribute('data-conn-id'); const isTv = btn.getAttribute('data-conn-type') === 'tradovate'; if (isTv) { const fk = btn.getAttribute('data-firm-key') || ''; const isSyncing = fk ? _syncingTradovateFirms.has(fk) : _syncingTradovate; if (isSyncing) { btn.disabled = true; btn.textContent = '⏳'; } else { btn.disabled = false; btn.textContent = 'Sync'; } } else if (connId) { if (_syncingConnections.has(connId)) { btn.disabled = true; btn.textContent = '⏳'; } else { btn.disabled = false; btn.textContent = 'Sync'; } } }); } function setSyncProgress(pct, label, title) { const card = document.getElementById('sync-progress-card'); const bar = document.getElementById('sync-progress-bar'); const txt = document.getElementById('sync-progress-text'); const pctEl = document.getElementById('sync-progress-pct'); const titleEl = document.getElementById('sync-progress-title'); if (card) card.style.display = 'block'; if (bar) { bar.style.width = pct + '%'; bar.style.background = pct >= 100 ? '#10b981' : '#00d4aa'; } if (txt) txt.textContent = label; if (pctEl) pctEl.textContent = Math.round(pct) + '%'; if (titleEl && title) titleEl.textContent = title; } function clearSyncProgress() { const card = document.getElementById('sync-progress-card'); const bar = document.getElementById('sync-progress-bar'); const pctEl = document.getElementById('sync-progress-pct'); if (card) card.style.display = 'none'; if (bar) { bar.style.width = '0%'; bar.style.background = '#00d4aa'; } if (pctEl) pctEl.textContent = '0%'; } async function quickSyncRithmic(connectionId, btn) { if (_syncingConnections.has(connectionId)) return; _lastManualSyncTime = Date.now(); _syncingConnections.add(connectionId); updateSyncButtonStates(); setSyncProgress(5, 'Connecting to Rithmic...', 'Syncing Rithmic Trades'); const rithmicLabels = ['Connecting to Rithmic...', 'Fetching trade history...', 'Processing fills...', 'Matching positions...', 'Saving trades...']; const rithmicInterval = setInterval(() => { const bar = document.getElementById('sync-progress-bar'); const current = parseFloat(bar?.style.width) || 5; if (current < 85) { const idx = Math.floor((current - 5) / 16); setSyncProgress(Math.min(current + 4, 85), rithmicLabels[Math.min(idx, rithmicLabels.length - 1)]); } }, 2000); try { await syncSavedConnection(connectionId); clearInterval(rithmicInterval); setSyncProgress(100, '✅ Sync complete!'); setTimeout(clearSyncProgress, 2000); await loadDashboardSyncBar(); renderConnectionsPage(); } catch (e) { clearInterval(rithmicInterval); clearSyncProgress(); showToast('Rithmic sync failed: ' + e.message, 'error'); } finally { _syncingConnections.delete(connectionId); updateSyncButtonStates(); } } async function quickSyncTradovate(btn, firmKey) { const qsFk = firmKey || _syncingFirmKey || 'unknown'; if (_syncingTradovateFirms.has(qsFk)) return; _lastManualSyncTime = Date.now(); _syncingTradovateFirms.add(qsFk); _syncingTradovate = _syncingTradovateFirms.size > 0; updateSyncButtonStates(); setSyncProgress(5, 'Connecting to Tradovate...', 'Syncing Tradovate Trades'); const tradovateLabels = ['Connecting to Tradovate...', 'Fetching order history...', 'Processing executions...', 'Matching FIFO positions...', 'Saving trades...']; const tradovateInterval = setInterval(() => { const bar = document.getElementById('sync-progress-bar'); const current = parseFloat(bar?.style.width) || 5; if (current < 85) { const idx = Math.floor((current - 5) / 16); setSyncProgress(Math.min(current + 4, 85), tradovateLabels[Math.min(idx, tradovateLabels.length - 1)]); } }, 2000); try { await startTradovateSync(true, qsFk); clearInterval(tradovateInterval); setSyncProgress(100, '✅ Sync complete!'); setTimeout(clearSyncProgress, 2000); const user = firebase.auth().currentUser; if (user) { let tokenDoc = await db.collection('tradovate_tokens').doc(user.uid).collection('firms').doc(qsFk).get(); if (!tokenDoc.exists && qsFk !== 'unknown') tokenDoc = await db.collection('tradovate_tokens').doc(user.uid).collection('firms').doc('unknown').get(); if (tokenDoc.exists) _tradovateLastSync = tokenDoc.data().lastSync || null; } await loadDashboardSyncBar(); renderConnectionsPage(); } catch (e) { clearInterval(tradovateInterval); clearSyncProgress(); showToast('Tradovate sync failed: ' + e.message, 'error'); } finally { _syncingTradovateFirms.delete(qsFk); _syncingTradovate = _syncingTradovateFirms.size > 0; updateSyncButtonStates(); } } // ===== AUTO-SYNC ON DASHBOARD LOAD ===== let _lastManualSyncTime = 0; // timestamp of last manual sync async function triggerStaleConnectionSync() { try { // Guard: skip if a manual sync happened within the last 5 minutes if (Date.now() - _lastManualSyncTime < 5 * 60 * 1000) return; if (!currentUser) return; let syncCount = 0; const RITHMIC_STALE_MS = 4 * 60 * 60 * 1000; // 4 hours const TRADOVATE_STALE_MS = 1 * 60 * 60 * 1000; // 1 hour // 2026-06-07: Rithmic page-load auto-sync disabled per policy change. // Existing connections remain in Firestore. Users can manually click // Sync to refresh account list, but no trades will be imported. // CSV upload is the only supported path for Rithmic trades. // ── Tradovate: sync each firm token if valid and stale ── // Wait for checkTradovateSession to finish first to avoid duplicate account fetches if (_tradovateSessionCheckPromise) { try { await _tradovateSessionCheckPromise; } catch (e) { /* already handled */ } _tradovateSessionCheckPromise = null; } try { const tvFirmsSnap = await db.collection('tradovate_tokens').doc(currentUser.uid).collection('firms').get(); const expiredFirmNames = []; for (const firmDoc of tvFirmsSnap.docs) { const fk = firmDoc.id; const data = firmDoc.data(); const tvExpiresAt = data.tokenIssuedAt + ((data.expiresIn || 5400) * 1000); const tvIsExpired = Date.now() >= tvExpiresAt - 60000; // Skip firms already marked as disconnected — user already knows if (_tradovateDisconnectedFirms.has(fk)) continue; if (tvIsExpired) { const firmName = (propFirmConfigs[fk] && propFirmConfigs[fk].name) || fk; expiredFirmNames.push(firmName); continue; } if (_syncingTradovateFirms.has(fk)) continue; const lastSync = data.lastSync ? new Date(data.lastSync.toDate ? data.lastSync.toDate() : data.lastSync).getTime() : 0; if ((Date.now() - lastSync) > TRADOVATE_STALE_MS) { _syncingTradovateFirms.add(fk); _syncingTradovate = true; updateSyncButtonStates(); try { await startTradovateSync(true, fk); syncCount++; } finally { _syncingTradovateFirms.delete(fk); _syncingTradovate = _syncingTradovateFirms.size > 0; updateSyncButtonStates(); } } } if (expiredFirmNames.length > 0 && syncCount === 0 && !_tradovateExpiredToastShown) { _tradovateExpiredToastShown = true; const firmList = expiredFirmNames.join(', '); showToast(`${firmList} Tradovate session expired — Reconnect to sync`, 'warning', 8000); } } catch (e) { console.warn('[AutoSync] Tradovate check failed:', e.message); } // Refresh connection status after syncs if (syncCount > 0) { await loadDashboardSyncBar(); renderConnectionsPage(); showToast(`Auto-synced ${syncCount} connection${syncCount > 1 ? 's' : ''}`, 'success'); } } catch (e) { console.error('[AutoSync] Error:', e); } } // ===== TEST CONNECTION FUNCTIONS ===== async function testRithmicConnection(connectionId) { if (!connectionId) { showToast('No connection ID — cannot test', 'error'); return; } showToast('Testing Rithmic connection...', 'info'); try { const token = await firebase.auth().currentUser.getIdToken(); const resp = await fetch('https://us-central1-trade-journal-fc3ba.cloudfunctions.net/rithmicSyncConnection', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }, body: JSON.stringify({ connectionId, testOnly: true }) }); const result = await resp.json(); if (resp.ok && result.success) { showToast('Rithmic connection is valid', 'success'); } else { showToast('Rithmic connection failed: ' + (result.error || 'Unknown error'), 'error'); } await loadDashboardSyncBar(); renderConnectionsPage(); } catch (e) { showToast('Test failed: ' + e.message, 'error'); } } async function testTradovateConnection(firmKey) { const testFk = firmKey || 'unknown'; showToast('Testing Tradovate connection...', 'info'); try { const token = await firebase.auth().currentUser.getIdToken(); const resp = await fetch('https://us-central1-trade-journal-fc3ba.cloudfunctions.net/tradovateRefreshToken', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }, body: JSON.stringify({ firmKey: testFk }) }); const result = await resp.json(); if (resp.ok && (result.status === 'valid' || result.status === 'refreshed')) { showToast('Tradovate connection is valid' + (result.expiresInSeconds ? ` (expires in ${Math.round(result.expiresInSeconds / 60)}m)` : ''), 'success'); _tradovateHasToken = true; } else if (result.requiresReconnect || result.status === 'expired') { showToast('Tradovate token expired — please reconnect', 'error'); _tradovateHasToken = false; } else if (result.status === 'not_connected') { showToast('No Tradovate connection found', 'error'); } else { showToast('Tradovate test failed: ' + (result.error || 'Unknown'), 'error'); } await loadDashboardSyncBar(); renderConnectionsPage(); } catch (e) { showToast('Test failed: ' + e.message, 'error'); } } // ===== ADMIN REPORTING DASHBOARD (Widget System) ===== window._adminCharts = {}; window._adminReport = null; window._adminWidgetSpans = JSON.parse(localStorage.getItem('adminWidgetSpans') || '{}'); const ADMIN_WIDGETS = { summary_stats: { id: 'summary_stats', title: '📊 Platform Summary', render: 'renderAdminSummaryStats' }, mrr_trend: { id: 'mrr_trend', title: '📈 MRR Trend', render: 'renderAdminMRRTrend' }, pipeline_donut: { id: 'pipeline_donut', title: '🍩 User Pipeline', render: 'renderAdminPipelineDonut' }, revenue_by_coupon:{ id: 'revenue_by_coupon', title: '💰 Revenue by Affiliate', render: 'renderAdminRevenueByCoupon' }, churn_dist: { id: 'churn_dist', title: '⚠️ Churn Risk Distribution', render: 'renderAdminChurnDist' }, cash_flow: { id: 'cash_flow', title: '🗓️ Cash Flow Forecast', render: 'renderAdminCashFlow' }, signups_trend: { id: 'signups_trend', title: '👥 New Signups Trend', render: 'renderAdminSignupsTrend' }, engagement: { id: 'engagement', title: '🔥 Platform Engagement', render: 'renderAdminEngagement' }, referral_source: { id: 'referral_source', title: '🎯 Referral Sources', render: 'renderAdminReferralSource' }, user_pipeline: { id: 'user_pipeline', title: '👤 User Pipeline Table', render: 'renderAdminUserPipelineTable' }, // scraper widgets moved to standalone Scraper Tools page churned_users: { id: 'churned_users', title: '🚨 Churned Users', render: 'renderAdminChurnedUsers' }, }; const ADMIN_WIDGET_DESCRIPTIONS = { summary_stats: 'Key business metrics: MRR (net and gross), ARR, paid users, total users, warm leads, churn risk, and total accounts/trades tracked across the platform.', pipeline_donut: 'Breakdown of all users by subscription status: full price paid, discounted, free (100% coupon), warm leads (no subscription), and canceled.', referral_source: 'How users heard about PayoutLab, collected at checkout. Shows distribution of referral sources like Discord, Twitter, referral links, and organic.', mrr_trend: 'Monthly Recurring Revenue trend over time. Shows net MRR (after discounts) and gross MRR side by side. Runs daily — needs multiple report generations to populate.', cash_flow: '12-week revenue view: last 3 weeks of actual Stripe charges (solid), current week actuals, and 8-week forward forecast based on active subscription renewal dates.', revenue_by_coupon: 'Monthly revenue attributed to each affiliate coupon code vs organic signups. Based on net revenue after discounts.', churn_dist: 'Distribution of active paying users by days since last login. Green = recently active, red = at risk of churning.', signups_trend: 'New user signups per month for the last 6 months. Counts all Firebase Auth registrations regardless of subscription status.', engagement: 'Scatter plot of active paying users by accounts tracked vs trades tracked. Color indicates recency: green = logged in recently, red = inactive.', user_pipeline: 'Full user table with subscription status, payment amounts, coupon codes, next payment date, last login, and account/trade counts. Filterable by status.', // scraper widget descriptions moved to standalone Scraper Tools page churned_users: 'Cancelled subscribers from Stripe sorted by recency. Red = cancelled within 30 days (hot lead), orange = 30-90 days, gray = 90+ days. Includes total revenue paid, subscription duration, and one-click re-engagement email.', }; const ADMIN_WIDGET_SECTIONS = { pipeline_donut: { label: '🎯 Acquisition & Growth', color: '#06b6d4' }, mrr_trend: { label: '💰 Revenue & Finance', color: '#00d4aa' }, churn_dist: { label: '⚠️ Risk & Engagement', color: '#f59e0b' }, churned_users: { label: '🚨 Churn & Recovery', color: '#ef4444' }, user_pipeline: { label: '👤 User Management', color: '#00d4aa' }, }; let _adminActiveWidgets = JSON.parse(localStorage.getItem('adminWidgets') || 'null') || ['summary_stats', 'pipeline_donut', 'referral_source', 'mrr_trend', 'cash_flow', 'revenue_by_coupon', 'churn_dist', 'signups_trend', 'engagement', 'churned_users', 'user_pipeline']; async function renderAdminDashboard() { const container = document.getElementById('admin-dashboard-content'); if (!container || !isAdmin) return; // Show cached data immediately if available const cached = cacheGet('adminReport'); if (cached) { window._adminReport = cached; _renderAdminDashboardHTML(container); // Background refresh db.collection('adminReports').doc('latest').get({ source: 'server' }).then(doc => { if (doc.exists) { window._adminReport = doc.data(); cacheSet('adminReport', doc.data(), 2 * 60 * 1000); _renderAdminDashboardHTML(container); } }).catch(() => {}); return; } container.innerHTML = '
'; try { const reportDoc = await db.collection('adminReports').doc('latest').get({ source: 'server' }); if (!reportDoc.exists) { container.innerHTML = '
📊

No report yet

Click Refresh to generate the first admin report.

'; return; } window._adminReport = reportDoc.data(); cacheSet('adminReport', reportDoc.data(), 2 * 60 * 1000); _renderAdminDashboardHTML(container); } catch (err) { container.innerHTML = '
Error: ' + err.message + '
'; } } function _renderAdminDashboardHTML(container) { const genAt = window._adminReport.generatedAt?.toDate ? window._adminReport.generatedAt.toDate().toLocaleString() : 'Unknown'; const cAge = cacheAge('adminReport'); const cacheLabel = cAge !== null ? ' · Cached ' + (cAge < 1 ? 'just now' : cAge + 'm ago') : ''; let html = ''; html += ''; html += '
'; html += '

🛡️ Admin Dashboard

Last updated: ' + genAt + cacheLabel + '
'; const savedCols = localStorage.getItem('adminColumns') || '2'; html += '
'; html += '
'; html += ''; html += '
'; html += ''; container.innerHTML = html; renderAdminWidgets(); } function toggleAdminWidgetPicker() { const el = document.getElementById('admin-widget-picker'); el.style.display = el.style.display === 'none' ? 'block' : 'none'; if (el.style.display === 'block') { const opts = document.getElementById('admin-widget-options'); opts.innerHTML = ''; Object.values(ADMIN_WIDGETS).forEach(w => { const active = _adminActiveWidgets.includes(w.id); opts.innerHTML += ''; }); } } function toggleAdminWidget(id) { const idx = _adminActiveWidgets.indexOf(id); if (idx >= 0) _adminActiveWidgets.splice(idx, 1); else _adminActiveWidgets.push(id); localStorage.setItem('adminWidgets', JSON.stringify(_adminActiveWidgets)); toggleAdminWidgetPicker(); renderAdminWidgets(); } function renderAdminWidgets() { const grid = document.getElementById('admin-widget-grid'); if (!grid) return; // Destroy old charts Object.keys(window._adminCharts).forEach(k => { window._adminCharts[k].destroy(); delete window._adminCharts[k]; }); grid.innerHTML = ''; // Apply saved column count const savedCols = localStorage.getItem('adminColumns') || '2'; grid.style.gridTemplateColumns = 'repeat(' + savedCols + ', 1fr)'; _adminActiveWidgets.forEach(wid => { const w = ADMIN_WIDGETS[wid]; if (!w) return; // Section divider if (ADMIN_WIDGET_SECTIONS[wid]) { const divider = document.createElement('div'); divider.style.cssText = 'grid-column: 1 / -1; padding: 12px 0 4px 0; border-bottom: 1px solid var(--border-color); margin-top: 8px;'; divider.innerHTML = '' + ADMIN_WIDGET_SECTIONS[wid].label + ''; grid.appendChild(divider); } const savedH = localStorage.getItem('adminWidgetHeight_' + w.id); const card = document.createElement('div'); card.className = 'card admin-widget'; const isFullWidth = w.id === 'user_pipeline' || w.id === 'summary_stats' || w.id === 'scraper_analytics' || w.id === 'churned_users'; const isSpanned = !isFullWidth && window._adminWidgetSpans[w.id]; const defaultH = isFullWidth ? 'auto' : (w.id === 'cash_flow' ? '380' : '340'); card.style.cssText = 'overflow: hidden; position: relative; height: ' + (savedH || defaultH) + 'px;' + ((isFullWidth || isSpanned) ? ' grid-column: 1 / -1;' : ''); card.dataset.widgetId = w.id; const spanBtn = isFullWidth ? '' : ''; const infoTip = ADMIN_WIDGET_DESCRIPTIONS[w.id] ? 'i' : ''; card.innerHTML = '
' + w.title + '' + infoTip + '
' + spanBtn + '
' + ((isFullWidth || isSpanned) ? '' : '
'); grid.appendChild(card); }); _adminActiveWidgets.forEach(wid => { const w = ADMIN_WIDGETS[wid]; if (!w) return; try { if (typeof window[w.render] === 'function') window[w.render](); } catch(e) { console.warn('Admin widget render failed:', w.id, e); } }); initAdminWidgetDragDrop(); } // ── Widget renderers ── function renderAdminSummaryStats() { const body = document.getElementById('admin-wb-summary_stats'); if (!body) return; const s = window._adminReport?.summary || {}; const users = window._adminReport?.users || []; const paid = users.filter(u => (u.subscriptionStatus === 'active' || u.subscriptionStatus === 'trialing') && !u.excluded); const grossMrr = paid.filter(u => u.grossAmount > 0).reduce((sum, u) => sum + u.grossAmount, 0); const totalAccounts = users.filter(u => !u.excluded).reduce((sum, u) => sum + (u.accountsTracked || 0), 0); const totalTrades = users.filter(u => !u.excluded).reduce((sum, u) => sum + (u.tradesTracked || 0), 0); const statCard = (label, value, color) => '
' + label + '
' + value + '
'; body.innerHTML = '
' + statCard('MRR (Net)', '$' + (s.mrr || 0).toFixed(2), '#00d4aa') + statCard('MRR (Gross)', '$' + grossMrr.toFixed(2)) + statCard('ARR (Net)', '$' + (s.arr || 0).toFixed(2)) + statCard('Paid Users', s.paidUsers || 0, '#00d4aa') + statCard('Total Users', s.totalUsers || 0) + statCard('Warm Leads', s.warmLeads || 0, '#f59e0b') + statCard('Churn Risk', s.churnRisk || 0, '#ef4444') + statCard('Accounts / Trades', totalAccounts + ' / ' + totalTrades.toLocaleString()) + '
'; } function _mrrGroupByWeek(points) { const weeks = {}; points.forEach(p => { const d = new Date(p.date + 'T12:00:00Z'); const day = d.getUTCDay(); const diff = d.getUTCDate() - day + (day === 0 ? -6 : 1); const monday = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), diff)); const wk = monday.toISOString().substring(0, 10); weeks[wk] = { date: wk, label: 'Wk ' + wk.substring(5), mrr: p.mrr, gross: p.gross }; }); return Object.keys(weeks).sort().map(wk => weeks[wk]); } function _mrrGroupByMonth(points) { const months = {}; const mNames = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; points.forEach(p => { const mo = p.date.substring(0, 7); const [y, m] = mo.split('-'); months[mo] = { date: mo, label: mNames[parseInt(m)-1] + "'" + y.substring(2), mrr: p.mrr, gross: p.gross }; }); return Object.keys(months).sort().map(mo => months[mo]); } function renderAdminMRRTrend(range) { const body = document.getElementById('admin-wb-mrr_trend'); if (!body) return; range = range || window._adminMRRRange || '30d'; window._adminMRRRange = range; const presets = [['7d','Last 7d'],['30d','Last 30d'],['3m','3 Months'],['6m','6 Months'],['12m','12 Months'],['all','All Time']]; const limitMap = { '7d': 7, '30d': 30, '3m': 90, '6m': 180, '12m': 365, 'all': 730 }; let btnH = '
'; presets.forEach(([p, lbl]) => { const active = p === range; btnH += ``; }); btnH += '
'; body.innerHTML = '
' + btnH + '
'; if (window._adminCharts['mrr_trend']) { try { window._adminCharts['mrr_trend'].destroy(); } catch(e) {} window._adminCharts['mrr_trend'] = null; } db.collection('adminReports').orderBy('generatedAt', 'desc').limit(limitMap[range] || 30).get().then(snap => { const points = []; snap.forEach(doc => { if (doc.id !== 'latest' && doc.id.match(/^\d{4}-\d{2}-\d{2}$/)) { const d = doc.data(); points.push({ date: doc.id, mrr: d.summary?.mrr || 0, gross: d.summary?.grossMrr || 0 }); }}); points.reverse(); if (points.length < 2) { body.querySelector('div').lastElementChild.innerHTML = '
Not enough data yet — report runs daily at 6 AM CT
'; return; } let grouped; if (range === '7d' || range === '30d') grouped = points; else if (range === '3m' || range === '6m') grouped = _mrrGroupByWeek(points); else grouped = _mrrGroupByMonth(points); const labels = grouped.map(p => p.label || p.date.substring(5)); const ctx = document.getElementById('admin-chart-mrr'); if (!ctx) return; window._adminCharts['mrr_trend'] = new Chart(ctx, { type: 'line', data: { labels, datasets: [{ label: 'Net MRR', data: grouped.map(p => p.mrr), borderColor: '#00d4aa', backgroundColor: 'rgba(0,212,170,0.1)', fill: true, tension: 0.3 }, { label: 'Gross MRR', data: grouped.map(p => p.gross), borderColor: '#64748b', borderDash: [5,5], fill: false, tension: 0.3 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { labels: { color: '#94a3b8', font: { size: 11 } } } }, scales: { x: { ticks: { color: '#64748b', font: { size: 10 }, maxRotation: 45 }, grid: { color: 'rgba(255,255,255,0.04)' } }, y: { ticks: { color: '#64748b', callback: v => '$' + v }, grid: { color: 'rgba(255,255,255,0.04)' } } } } }); }); } function renderAdminPipelineDonut() { const body = document.getElementById('admin-wb-pipeline_donut'); if (!body) return; body.innerHTML = '
'; const users = (window._adminReport?.users || []).filter(u => !u.excluded); const paidFull = users.filter(u => (u.subscriptionStatus === 'active' || u.subscriptionStatus === 'trialing') && u.netAmount > 0 && !u.couponApplied).length; const discounted = users.filter(u => (u.subscriptionStatus === 'active' || u.subscriptionStatus === 'trialing') && u.netAmount > 0 && u.couponApplied).length; const free = users.filter(u => (u.subscriptionStatus === 'active' || u.subscriptionStatus === 'trialing') && u.netAmount === 0).length; const warm = users.filter(u => u.isWarmLead).length; const canceled = users.filter(u => u.subscriptionStatus === 'canceled').length; const ctx = document.getElementById('admin-chart-pipeline'); if (!ctx) return; window._adminCharts['pipeline_donut'] = new Chart(ctx, { type: 'doughnut', data: { labels: ['Paid ($10)', 'Discounted', 'Free (100%)', 'Warm Leads', 'Canceled'], datasets: [{ data: [paidFull, discounted, free, warm, canceled], backgroundColor: ['#00d4aa', '#06b6d4', '#64748b', '#f59e0b', '#ef4444'] }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'bottom', labels: { color: '#94a3b8', font: { size: 11 }, padding: 12 } } } } }); } function renderAdminRevenueByCoupon() { const body = document.getElementById('admin-wb-revenue_by_coupon'); if (!body) return; body.innerHTML = '
'; const cb = window._adminReport?.couponBreakdown || {}; const entries = Object.entries(cb).sort((a, b) => b[1].revenue - a[1].revenue); if (entries.length === 0) { body.innerHTML = '
No revenue data
'; return; } const ctx = document.getElementById('admin-chart-coupon'); if (!ctx) return; window._adminCharts['revenue_by_coupon'] = new Chart(ctx, { type: 'bar', data: { labels: entries.map(e => e[0]), datasets: [{ label: 'Monthly Revenue', data: entries.map(e => e[1].revenue), backgroundColor: entries.map(e => e[0] === 'Organic' ? '#00d4aa' : '#06b6d4') }] }, options: { indexAxis: 'y', responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: { callbacks: { label: (c) => '$' + c.raw.toFixed(2) + '/mo (' + entries[c.dataIndex][1].count + ' users)' } } }, scales: { x: { ticks: { color: '#64748b', callback: v => '$' + v }, grid: { color: 'rgba(255,255,255,0.04)' } }, y: { ticks: { color: '#94a3b8', font: { size: 11 } }, grid: { display: false } } } } }); } function renderAdminChurnDist() { const body = document.getElementById('admin-wb-churn_dist'); if (!body) return; body.innerHTML = '
'; const buckets = window._adminReport?.daysSinceLoginBuckets || {}; const labels = ['0-14', '15-30', '31-60', '61-90', '90+']; const colors = ['#00d4aa', '#eab308', '#f59e0b', '#ef4444', '#991b1b']; const ctx = document.getElementById('admin-chart-churn'); if (!ctx) return; window._adminCharts['churn_dist'] = new Chart(ctx, { type: 'bar', data: { labels: labels.map(l => l + 'd'), datasets: [{ label: 'Users', data: labels.map(l => buckets[l] || 0), backgroundColor: colors }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { ticks: { color: '#94a3b8' }, grid: { display: false } }, y: { ticks: { color: '#64748b' }, grid: { color: 'rgba(255,255,255,0.04)' } } } } }); } function renderAdminCashFlow() { const body = document.getElementById('admin-wb-cash_flow'); if (!body) return; const wr = window._adminReport?.weeklyRevenue || []; if (wr.length === 0) { // Fallback to old cashFlow format const cf = window._adminReport?.cashFlow || {}; const weeks = Object.keys(cf).sort().slice(0, 13); if (weeks.length === 0) { body.innerHTML = '
No cash flow data — click Refresh Report to generate
'; return; } body.innerHTML = '
'; const labels = weeks.map(w => cf[w]?.weekLabel || w); const totals = weeks.map(w => (cf[w]?.entries || []).reduce((s, e) => s + (e.netAmount || 0), 0)); const ctx = document.getElementById('admin-chart-cashflow'); if (!ctx) return; window._adminCharts['cash_flow'] = new Chart(ctx, { type: 'bar', data: { labels, datasets: [{ label: 'Forecast', data: totals, backgroundColor: 'rgba(0,212,170,0.4)', borderColor: '#00d4aa', borderWidth: 1 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { ticks: { color: '#94a3b8', font: { size: 10 }, maxRotation: 45 }, grid: { display: false } }, y: { ticks: { color: '#64748b', callback: v => '$' + v }, grid: { color: 'rgba(255,255,255,0.04)' } } } } }); return; } // New weeklyRevenue format const labels = wr.map(w => w.weekLabel); const actualData = wr.map(w => w.isFuture ? null : w.actual); const forecastData = wr.map(w => (w.isFuture || w.isCurrentWeek) ? w.forecast : null); const currentIdx = wr.findIndex(w => w.isCurrentWeek); // Stats const actualTotal = wr.filter(w => !w.isFuture).reduce((s, w) => s + w.actual, 0); const forecastTotal = wr.filter(w => w.isFuture || w.isCurrentWeek).reduce((s, w) => s + w.forecast, 0); const prevWeek = currentIdx > 0 ? wr[currentIdx - 1] : null; const currWeek = currentIdx >= 0 ? wr[currentIdx] : null; const wowChange = prevWeek && prevWeek.actual > 0 && currWeek ? Math.round((currWeek.actual - prevWeek.actual) / prevWeek.actual * 100) : null; let h = '
'; h += '
'; h += 'Last 4wk: $' + actualTotal.toFixed(0) + ''; h += 'Next 8wk: $' + forecastTotal.toFixed(0) + ''; if (wowChange !== null) h += 'WoW: ' + (wowChange >= 0 ? '+' : '') + wowChange + '%'; h += '
'; body.innerHTML = h; const ctx = document.getElementById('admin-chart-cashflow'); if (!ctx) return; // Today marker plugin const todayLine = { id: 'todayLine', afterDraw: (chart) => { if (currentIdx < 0) return; const xScale = chart.scales.x; const x = xScale.getPixelForValue(currentIdx) + (xScale.getPixelForValue(1) - xScale.getPixelForValue(0)) / 2; const ctx2 = chart.ctx; ctx2.save(); ctx2.strokeStyle = 'rgba(255,255,255,0.3)'; ctx2.lineWidth = 1; ctx2.setLineDash([4, 4]); ctx2.beginPath(); ctx2.moveTo(x, chart.scales.y.top); ctx2.lineTo(x, chart.scales.y.bottom); ctx2.stroke(); ctx2.fillStyle = 'rgba(255,255,255,0.4)'; ctx2.font = '9px system-ui'; ctx2.textAlign = 'center'; ctx2.fillText('Today', x, chart.scales.y.top - 4); ctx2.restore(); } }; // Highlight current week labels const labelColors = wr.map(w => w.isCurrentWeek ? '#00d4aa' : '#94a3b8'); window._adminCharts['cash_flow'] = new Chart(ctx, { type: 'bar', plugins: [todayLine], data: { labels, datasets: [ { label: 'Actual', data: actualData, backgroundColor: 'rgba(0,212,170,0.8)', borderColor: '#00d4aa', borderWidth: 1, order: 2 }, { label: 'Forecast', data: forecastData, backgroundColor: 'rgba(0,212,170,0.25)', borderColor: 'rgba(0,212,170,0.4)', borderWidth: 1, borderDash: [3, 3], order: 3 }, ] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { labels: { color: '#94a3b8', font: { size: 10 }, usePointStyle: true, pointStyle: 'rect' } }, tooltip: { callbacks: { label: (c) => { const w = wr[c.dataIndex]; return c.dataset.label + ': $' + (c.raw || 0).toFixed(2) + (w.isCurrentWeek ? ' (this week)' : ''); } } } }, scales: { x: { stacked: false, ticks: { color: (ctx) => labelColors[ctx.index] || '#94a3b8', font: { size: 9, weight: (ctx) => wr[ctx.index]?.isCurrentWeek ? 'bold' : 'normal' }, maxRotation: 45 }, grid: { display: false } }, y: { ticks: { color: '#64748b', callback: v => '$' + v }, grid: { color: 'rgba(255,255,255,0.04)' } } } } }); } function renderAdminSignupsTrend() { const body = document.getElementById('admin-wb-signups_trend'); if (!body) return; body.innerHTML = '
'; const sb = window._adminReport?.signupsByMonth || {}; const months = Object.keys(sb).sort().slice(-6); if (months.length === 0) { body.innerHTML = '
No signup data
'; return; } const labels = months.map(m => new Date(m + '-01').toLocaleDateString('en-US', { month: 'short' })); const ctx = document.getElementById('admin-chart-signups'); if (!ctx) return; window._adminCharts['signups_trend'] = new Chart(ctx, { type: 'bar', data: { labels, datasets: [{ label: 'New Signups', data: months.map(m => sb[m] || 0), backgroundColor: 'rgba(0,212,170,0.5)', borderColor: '#00d4aa', borderWidth: 1 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { ticks: { color: '#94a3b8' }, grid: { display: false } }, y: { ticks: { color: '#64748b', stepSize: 1 }, grid: { color: 'rgba(255,255,255,0.04)' } } } } }); } function renderAdminEngagement() { const body = document.getElementById('admin-wb-engagement'); if (!body) return; body.innerHTML = '
'; const users = (window._adminReport?.users || []).filter(u => !u.excluded && (u.subscriptionStatus === 'active' || u.subscriptionStatus === 'trialing') && u.netAmount > 0); if (users.length === 0) { body.innerHTML = '
No active paying users
'; return; } const points = users.map(u => ({ x: u.accountsTracked || 0, y: u.tradesTracked || 0, email: u.email, days: u.daysSinceSignIn || 0 })); const colorForDays = d => d <= 7 ? '#00d4aa' : d <= 30 ? '#eab308' : d <= 60 ? '#f59e0b' : '#ef4444'; const ctx = document.getElementById('admin-chart-engagement'); if (!ctx) return; window._adminCharts['engagement'] = new Chart(ctx, { type: 'scatter', data: { datasets: [{ label: 'Users', data: points, backgroundColor: points.map(p => colorForDays(p.days)), pointRadius: 6 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: { callbacks: { label: c => c.raw.email + ' (' + c.raw.x + ' accts, ' + c.raw.y + ' trades, ' + c.raw.days + 'd ago)' } } }, scales: { x: { title: { display: true, text: 'Accounts', color: '#64748b' }, ticks: { color: '#64748b' }, grid: { color: 'rgba(255,255,255,0.04)' } }, y: { title: { display: true, text: 'Trades', color: '#64748b' }, ticks: { color: '#64748b' }, grid: { color: 'rgba(255,255,255,0.04)' } } } } }); } function renderAdminReferralSource() { const body = document.getElementById('admin-wb-referral_source'); if (!body) return; body.innerHTML = '
'; const rs = window._adminReport?.referralSources || {}; const entries = Object.entries(rs).sort((a, b) => b[1] - a[1]); if (entries.length === 0) { body.innerHTML = '
No referral data
'; return; } const palette = ['#00d4aa', '#06b6d4', '#8b5cf6', '#f59e0b', '#ef4444', '#ec4899', '#14b8a6', '#f97316', '#64748b']; const ctx = document.getElementById('admin-chart-referral'); if (!ctx) return; window._adminCharts['referral_source'] = new Chart(ctx, { type: 'pie', data: { labels: entries.map(e => e[0] + ' (' + e[1] + ')'), datasets: [{ data: entries.map(e => e[1]), backgroundColor: entries.map((_, i) => entries[i][0] === 'Not specified' ? '#64748b' : palette[i % palette.length]) }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'right', labels: { color: '#94a3b8', font: { size: 11 }, padding: 8 } } } } }); } function renderAdminAuditLog() { const body = document.getElementById('admin-wb-audit_log'); if (!body) return; body.innerHTML = '
Loading audit log...
'; db.collectionGroup('audit_log').orderBy('timestamp', 'desc').limit(50).get().then(snap => { if (snap.empty) { body.innerHTML = '
No audit entries yet — confirm or dismiss a scraper alert to create the first entry.
'; return; } let h = '
'; h += ''; h += ''; ['Date/Time','Firm','Action','Type','Summary','Fields','Conf.','Page','Reviewer'].forEach(c => { h += ''; }); h += ''; snap.forEach(doc => { const d = doc.data(); const ts = d.timestamp?.toDate ? d.timestamp.toDate().toLocaleString() : '—'; const isConfirm = d.action === 'confirmed'; const actionBadge = isConfirm ? '✅ Confirmed' : '❌ Dismissed'; const typeColors = { rule_change: '#06b6d4', new_plan: '#8b5cf6', discontinued_plan: '#ef4444', mixed: '#f59e0b', noise: '#64748b' }; const ct = d.changeType || 'rule_change'; const typeBadge = '' + ct.replace('_', ' ') + ''; const fields = d.fields || []; const fieldCount = fields.length; const avgConf = isConfirm && fieldCount > 0 ? Math.round(fields.reduce((s, f) => s + (parseFloat(f.confidence) || 0), 0) / fieldCount * 100) : 0; const confColor = avgConf >= 90 ? '#00d4aa' : avgConf >= 70 ? '#f59e0b' : '#ef4444'; const confStr = isConfirm && fieldCount > 0 ? '' + avgConf + '%' : '—'; const summary = (d.summary || '').substring(0, 60) + ((d.summary || '').length > 60 ? '…' : ''); const pageLabel = d.pageId || '—'; // Store full data for expand const rowData = JSON.stringify({ fields: d.fields || [], applied: d.appliedChanges || [], summary: d.summary || '', changeId: d.changeId || '', pageUrl: d.pageUrl || '' }).replace(/'/g, "\\'").replace(/"/g, '"'); h += ''; h += ''; h += ''; h += ''; h += ''; h += ''; h += ''; h += ''; h += ''; h += ''; h += ''; }); h += '
' + c + '
' + ts + '' + (d.firm || '—') + '' + actionBadge + '' + typeBadge + '' + summary + '' + (isConfirm ? fieldCount : '—') + '' + confStr + '' + (d.pageUrl ? '' + pageLabel + '' : pageLabel) + 'Discord
'; body.innerHTML = h; }).catch(err => { body.innerHTML = '
Error: ' + err.message + '
'; }); } function toggleAuditDetail(row, dataHtml) { const existing = row.nextElementSibling; if (existing && existing.classList.contains('audit-detail-row')) { existing.remove(); return; } document.querySelectorAll('.audit-detail-row').forEach(r => r.remove()); let data = {}; try { data = JSON.parse(dataHtml.replace(/"/g, '"')); } catch(e) {} const fields = data.fields || []; const applied = data.applied || []; let detail = ''; // Summary if (data.summary) detail += '
' + data.summary + '
'; // Field table if (fields.length > 0) { detail += ''; detail += ''; fields.forEach(f => { detail += ''; }); detail += '
PathOldNew
' + (f.path || f.field || '—') + '' + (f.oldValue || '—') + '' + (f.newValue || '—') + '
'; } // Applied if (applied.length > 0) { detail += '
Applied: ' + applied.join(' | ') + '
'; } // Meta detail += '
'; if (data.changeId) detail += 'Change ID: ' + data.changeId + ''; if (data.pageUrl) detail += 'Page: ' + data.pageUrl.substring(0, 60) + ''; detail += '
'; if (!detail) detail = '
No details available.
'; const dr = document.createElement('tr'); dr.className = 'audit-detail-row'; dr.innerHTML = '' + detail + ''; row.after(dr); } // ── Scraper Coverage Widget ── function renderAdminScraperCoverage() { const body = document.getElementById('admin-wb-scraper_coverage'); if (!body) return; body.innerHTML = '
Loading coverage data...
'; const firmIds = ['apex','topstep','tradeify','myfundedfutures','takeprofittrader','bulenox','elitetraderfunding','lucid','fundedfuturesnetwork','earn2trade','fundednext','legendstrading','alphafutures','daytraders','phidias','thefuturesdesk','thetradingpit','tradeday']; Promise.all(firmIds.map(async fid => { const firmDoc = await db.collection('prop_firm_monitor').doc(fid).get(); const pagesSnap = await db.collection('prop_firm_monitor').doc(fid).collection('pages').get(); if (!firmDoc.exists && pagesSnap.empty) return null; const firmData = firmDoc.exists ? firmDoc.data() : {}; let pages = 0, errors = 0, totalFields = 0, latestCheck = null, oldestBaseline = null; const pageDetails = []; pagesSnap.forEach(doc => { const p = doc.data(); pages++; if ((p.consecutive_failures || 0) > 0) errors++; totalFields += (p.baseline_rules || []).length; const ck = p.checked_at?.toDate ? p.checked_at.toDate() : null; if (ck && (!latestCheck || ck > latestCheck)) latestCheck = ck; const bl = p.baseline_extracted_at?.toDate ? p.baseline_extracted_at.toDate() : null; if (bl && (!oldestBaseline || bl < oldestBaseline)) oldestBaseline = bl; pageDetails.push({ id: doc.id, label: p.label || doc.id, url: p.url, checkedAt: ck, baselineAt: bl, fields: (p.baseline_rules || []).length, failures: p.consecutive_failures || 0, contentLen: (p.last_content || '').length }); }); return { fid, pages, errors, totalFields, latestCheck, oldestBaseline, status: firmData.status, pageDetails }; })).then(results => { const firms = results.filter(Boolean); if (firms.length === 0) { body.innerHTML = '
No scraper data found.
'; return; } const now = new Date(); const ago = (d) => { if (!d) return '—'; const ms = now - d; if (ms < 3600000) return Math.floor(ms/60000) + 'm ago'; if (ms < 86400000) return Math.floor(ms/3600000) + 'h ago'; return Math.floor(ms/86400000) + 'd ago'; }; let totalPages = 0, totalFields = 0, globalLatest = null; firms.forEach(f => { totalPages += f.pages; totalFields += f.totalFields; if (f.latestCheck && (!globalLatest || f.latestCheck > globalLatest)) globalLatest = f.latestCheck; }); let h = '
'; h += ''; h += ''; ['Firm','Pages','Last Scrape','Errors','Fields','Baseline Age','Status'].forEach(c => { h += ''; }); h += ''; firms.sort((a,b) => b.pages - a.pages); firms.forEach(f => { const statusIcon = f.errors === 0 ? '🟢' : f.errors <= 2 ? '🟡' : '🔴'; const borderColor = f.errors === 0 ? '#00d4aa' : f.errors <= 2 ? '#f59e0b' : '#ef4444'; const detailJson = JSON.stringify(f.pageDetails).replace(/'/g, "\\'").replace(/"/g, '"'); h += ''; h += ''; h += ''; h += ''; h += ''; h += ''; h += ''; h += ''; h += ''; }); h += '
' + c + '
' + f.fid + '' + f.pages + '' + ago(f.latestCheck) + '' + f.errors + '' + f.totalFields + '' + ago(f.oldestBaseline) + '' + statusIcon + '
'; h += '
Monitoring ' + totalPages + ' pages across ' + firms.length + ' firms | Last full run: ' + ago(globalLatest) + ' | Total fields tracked: ' + totalFields + '
'; h += '
'; body.innerHTML = h; }).catch(err => { body.innerHTML = '
Error: ' + err.message + '
'; }); } function toggleScraperFirmDetail(row, firmId, detailJson) { const existing = row.nextElementSibling; if (existing && existing.classList.contains('scraper-detail-row')) { existing.remove(); return; } document.querySelectorAll('.scraper-detail-row').forEach(r => r.remove()); let pages = []; try { pages = JSON.parse(detailJson.replace(/"/g, '"')); } catch(e) {} let d = ''; d += ''; const now = new Date(); const ago = (d) => { if (!d) return '—'; const ms = now - new Date(d); if (ms < 3600000) return Math.floor(ms/60000) + 'm'; if (ms < 86400000) return Math.floor(ms/3600000) + 'h'; return Math.floor(ms/86400000) + 'd'; }; pages.forEach(p => { d += ''; d += ''; d += ''; d += ''; d += ''; d += ''; d += ''; d += ''; }); d += '
PageCheckedBaselineFieldsFailuresContent
' + (p.url ? '' + p.label + '' : p.label) + '' + ago(p.checkedAt) + '' + ago(p.baselineAt) + '' + p.fields + '' + p.failures + '' + (p.contentLen > 0 ? (p.contentLen / 1000).toFixed(1) + 'KB' : '—') + '
'; d += '
'; const dr = document.createElement('tr'); dr.className = 'scraper-detail-row'; dr.innerHTML = '' + d + ''; row.after(dr); } // ── Scraper Analytics Widget ── function renderAdminScraperAnalytics() { const body = document.getElementById('admin-wb-scraper_analytics'); if (!body) return; body.innerHTML = '
Loading analytics...
'; db.collectionGroup('changes').orderBy('detected_at', 'desc').limit(200).get().then(snap => { const changes = []; snap.forEach(doc => { changes.push(doc.data()); }); if (changes.length === 0) { body.innerHTML = '
No changes detected yet.
'; return; } const now = new Date(); // Buckets for last 12 weeks const weeks = []; for (let i = 11; i >= 0; i--) { const d = new Date(now); d.setDate(d.getDate() - i * 7); const mon = new Date(d); mon.setDate(mon.getDate() - mon.getDay() + 1); weeks.push({ start: new Date(mon), label: mon.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), total: 0, confirmed: 0 }); } changes.forEach(c => { const det = c.detected_at?.toDate ? c.detected_at.toDate() : new Date(c.detected_at); for (let i = weeks.length - 1; i >= 0; i--) { const end = i < weeks.length - 1 ? weeks[i + 1].start : new Date(9999, 0); if (det >= weeks[i].start && det < end) { weeks[i].total++; if (c.status === 'confirmed') weeks[i].confirmed++; break; } } }); // Decision breakdown const confirmed = changes.filter(c => c.status === 'confirmed').length; const dismissed = changes.filter(c => c.status === 'dismissed').length; const pending = changes.filter(c => c.status === 'pending').length; const noise = changes.filter(c => c.is_noise === true).length; // Most active firm const firmCounts = {}; changes.forEach(c => { const f = c.page_url?.split('/')[2]?.split('.')[0] || 'unknown'; firmCounts[f] = (firmCounts[f] || 0) + 1; }); // Try to get firm from page_id parent path const firmCounts2 = {}; snap.forEach(doc => { const parts = doc.ref.path.split('/'); if (parts.length >= 2) firmCounts2[parts[1]] = (firmCounts2[parts[1]] || 0) + 1; }); const topFirm = Object.entries(firmCounts2).sort((a,b) => b[1] - a[1])[0]; // Most changed field const fieldCounts = {}; changes.forEach(c => { (c.extracted_changes || []).forEach(ec => { const f = ec.field || 'unknown'; fieldCounts[f] = (fieldCounts[f] || 0) + 1; }); }); const topField = Object.entries(fieldCounts).sort((a,b) => b[1] - a[1])[0]; const avgPerWeek = (changes.length / 12).toFixed(1); let h = '
'; // Charts side by side h += '
'; h += '
'; h += '
'; h += '
'; // Stats row h += '
'; const statBox = (label, value, color) => '
' + label + '
' + value + '
'; h += statBox('Total Detected', changes.length); h += statBox('Confirmed', confirmed + ' (' + (changes.length > 0 ? Math.round(confirmed/changes.length*100) : 0) + '%)', '#00d4aa'); h += statBox('Dismissed', dismissed + ' (' + (changes.length > 0 ? Math.round(dismissed/changes.length*100) : 0) + '%)', '#ef4444'); h += statBox('Avg/Week', avgPerWeek); h += statBox('Top Firm', topFirm ? topFirm[0] + ' (' + topFirm[1] + ')' : '—'); h += statBox('Top Field', topField ? topField[0] + ' (' + topField[1] + ')' : '—'); h += '
'; body.innerHTML = h; // Render charts const ctx1 = document.getElementById('admin-chart-changes-time'); if (ctx1) { window._adminCharts['scraper_time'] = new Chart(ctx1, { type: 'line', data: { labels: weeks.map(w => w.label), datasets: [{ label: 'Total', data: weeks.map(w => w.total), borderColor: '#00d4aa', backgroundColor: 'rgba(0,212,170,0.1)', fill: true, tension: 0.3 }, { label: 'Confirmed', data: weeks.map(w => w.confirmed), borderColor: '#10b981', borderDash: [5, 5], fill: false, tension: 0.3 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { labels: { color: '#94a3b8', font: { size: 10 } } } }, scales: { x: { ticks: { color: '#64748b', font: { size: 9 } }, grid: { color: 'rgba(255,255,255,0.04)' } }, y: { ticks: { color: '#64748b', stepSize: 1 }, grid: { color: 'rgba(255,255,255,0.04)' } } } } }); } const ctx2 = document.getElementById('admin-chart-decision'); if (ctx2) { window._adminCharts['scraper_decision'] = new Chart(ctx2, { type: 'doughnut', data: { labels: ['Confirmed (' + confirmed + ')', 'Dismissed (' + dismissed + ')', 'Pending (' + pending + ')', 'Noise (' + noise + ')'], datasets: [{ data: [confirmed, dismissed, pending, noise], backgroundColor: ['#00d4aa', '#ef4444', '#64748b', '#f59e0b'] }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'bottom', labels: { color: '#94a3b8', font: { size: 10 }, padding: 8 } } } } }); } }).catch(err => { body.innerHTML = '
Error: ' + err.message + '
'; }); } function renderAdminChurnedUsers() { const body = document.getElementById('admin-wb-churned_users'); if (!body) return; const subs = window._adminReport?.cancelledSubs || []; if (subs.length === 0) { body.innerHTML = '
No churned users — click Refresh Report to generate data
'; return; } const hot = subs.filter(s => s.daysSinceCancellation < 30).length; const lostMrr = subs.filter(s => s.daysSinceCancellation < 90).reduce((sum, s) => sum + (s.totalRevenue > 0 ? 10 : 0), 0); const formatDuration = (days) => days < 30 ? days + ' days' : days < 365 ? Math.floor(days / 30) + ' months' : Math.floor(days / 365) + 'y ' + Math.floor((days % 365) / 30) + 'm'; let h = '
'; h += '
'; h += '' + subs.length + ' churned users'; h += 'Est. lost MRR: $' + lostMrr + ''; h += 'Hot leads (<30d): ' + hot + ''; h += '
'; h += '
'; h += ''; ['Email','Name','Cancelled','Subscribed For','Revenue','Coupon','Days Ago','Reason','Action'].forEach(c => { h += ''; }); h += ''; subs.forEach(s => { const borderColor = s.daysSinceCancellation < 30 ? '#ef4444' : s.daysSinceCancellation < 90 ? '#f59e0b' : '#64748b'; const cancelDate = new Date(s.cancelledAt).toLocaleDateString(); const daysAgo = s.daysSinceCancellation + 'd ago'; const duration = formatDuration(s.daysSubscribed); const subject = encodeURIComponent('We miss you at PayoutLab!'); const emailBody = encodeURIComponent('Hi ' + (s.name || 'there') + ',\n\nWe noticed you recently cancelled your PayoutLab subscription. We\'d love to understand what we could improve.\n\nIf there\'s anything we can do to help with your trading journey, please let us know.\n\nBest,\nJason\nPayoutLab'); const mailto = 'mailto:' + s.email + '?subject=' + subject + '&body=' + emailBody; h += ''; h += ''; h += ''; h += ''; h += ''; h += ''; h += ''; h += ''; h += ''; h += ''; h += ''; }); h += '
' + c + '
' + (s.email || '—') + '' + (s.name || '—') + '' + cancelDate + '' + duration + '$' + s.totalRevenue.toFixed(2) + '' + (s.couponUsed || '—') + '' + daysAgo + '' + (s.cancelReason || '—') + '📧 Re-engage
'; body.innerHTML = h; } function renderAdminScraperTools() { const body = document.getElementById('admin-wb-scraper_tools'); if (!body) return; const firms = ['apex','topstep','tradeify','myfundedfutures','takeprofittrader','bulenox','elitetraderfunding','lucid','fundedfuturesnetwork','earn2trade','fundednext','legendstrading','alphafutures','daytraders','phidias','thefuturesdesk','thetradingpit','tradeday']; let h = '
'; h += '
'; h += ' '; h += ' '; h += ' '; h += '
'; h += '
Clears stored page hashes, forcing a fresh AI re-extraction on the next scraper run (6 AM CT daily).
'; h += '
'; body.innerHTML = h; } function renderAdminUserPipelineTable() { const body = document.getElementById('admin-wb-user_pipeline'); if (!body) return; const users = window._adminReport?.users || []; const tabs = [ { key: 'all', label: 'All', filter: u => !u.excluded }, { key: 'warm', label: 'Warm Leads', filter: u => u.isWarmLead && !u.excluded }, { key: 'active', label: 'Active', filter: u => u.subscriptionStatus === 'active' && !u.excluded }, { key: 'churn', label: 'Churn Risk', filter: u => u.isChurnRisk && !u.excluded }, { key: 'past_due', label: 'Past Due', filter: u => u.subscriptionStatus === 'past_due' && !u.excluded }, { key: 'canceled', label: 'Canceled', filter: u => u.subscriptionStatus === 'canceled' && !u.excluded }, { key: 'excluded', label: 'Excluded', filter: u => u.excluded }, ]; window._adminReportUsers = users; window._adminPipelineTabs = tabs; if (!window._adminSortCol) { window._adminSortCol = 'netAmount'; window._adminSortDir = 'desc'; } const cols = [ { label: 'Email', field: 'email', type: 'text' }, { label: 'Status', field: 'subscriptionStatus', type: 'text' }, { label: 'Gross', field: 'grossAmount', type: 'numeric', align: 'right' }, { label: 'Coupon', field: 'couponApplied', type: 'text' }, { label: 'Referral', field: 'referralSource', type: 'text' }, { label: 'Net/mo', field: 'netAmount', type: 'numeric', align: 'right' }, { label: 'Next Payment', field: 'nextPaymentDate', type: 'date' }, { label: 'Last Sign In', field: 'lastSignIn', type: 'date' }, { label: 'Accts', field: 'accountsTracked', type: 'numeric', align: 'center' }, { label: 'Trades', field: 'tradesTracked', type: 'numeric', align: 'center' }, { label: 'Flags', field: null, type: null }, { label: 'Excl.', field: null, type: null, align: 'center' }, ]; window._adminCols = cols; let h = '
'; h += '
'; // Tab buttons rendered by reusable function after innerHTML set h += '
'; h += ''; h += _buildAdminSortHeaders(); h += ''; h += window._renderAdminRows(users.filter(tabs[0].filter)); h += '
'; body.innerHTML = h; renderAdminPipelineTabs(); } function renderAdminPipelineTabs(activeKey) { const container = document.getElementById('admin-pipeline-tabs'); if (!container) return; const tabs = window._adminPipelineTabs || []; const users = window._adminReportUsers || []; const current = activeKey || 'all'; let h = ''; tabs.forEach(t => { const count = users.filter(t.filter).length; const isActive = t.key === current; h += ''; }); container.innerHTML = h; } // Shared row renderer window._renderAdminRows = function(filteredUsers) { if (filteredUsers.length === 0) return 'No users match this filter'; let rows = ''; const col = window._adminSortCol || 'netAmount'; const dir = window._adminSortDir || 'desc'; const colDef = (window._adminCols || []).find(c => c.field === col); const type = colDef?.type || 'numeric'; filteredUsers.sort((a, b) => { let av = col === 'referralSource' ? (a.howDidYouHear || '—') : a[col]; let bv = col === 'referralSource' ? (b.howDidYouHear || '—') : b[col]; if (av == null && bv == null) return 0; if (av == null) return 1; if (bv == null) return -1; let cmp = 0; if (type === 'numeric') cmp = (Number(av) || 0) - (Number(bv) || 0); else if (type === 'date') cmp = new Date(av) - new Date(bv); else cmp = String(av).localeCompare(String(bv)); return dir === 'desc' ? -cmp : cmp; }); filteredUsers.forEach(u => { const refSrc = u.howDidYouHear || '—'; const sc = ({ active: '#00d4aa', trialing: '#00d4aa', past_due: '#f59e0b', canceled: '#ef4444' })[u.subscriptionStatus] || '#64748b'; const flags = []; if (u.isWarmLead) flags.push('🔥'); if (u.isChurnRisk) flags.push('⚠️'); if (u.couponApplied) flags.push('💰'); if (u.isAdmin) flags.push('👑'); if (u.isFree) flags.push('🆓'); const nextPay = u.nextPaymentDate ? new Date(u.nextPaymentDate).toLocaleDateString() : '—'; const lastSign = u.lastSignIn ? new Date(u.lastSignIn).toLocaleDateString() : '—'; const dim = u.excluded ? 'opacity: 0.4;' : ''; const exclBtn = u.excluded ? '✓ Excl' : 'Exclude'; rows += ''; rows += '' + (u.email || '—') + ''; rows += '' + (u.subscriptionStatus || 'none') + ''; rows += '$' + (u.grossAmount || 0).toFixed(2) + ''; rows += '' + (u.couponApplied || '—') + ''; rows += '' + refSrc + ''; rows += '$' + (u.netAmount || 0).toFixed(2) + ''; rows += '' + nextPay + ''; rows += '' + lastSign + (u.daysSinceSignIn > 30 ? ' (' + u.daysSinceSignIn + 'd)' : '') + ''; rows += '' + (u.accountsTracked || 0) + ''; rows += '' + (u.tradesTracked || 0) + ''; rows += '' + (flags.join(' ') || '—') + ''; rows += '' + exclBtn + ''; rows += ''; }); return rows; }; function _buildAdminSortHeaders() { const cols = window._adminCols || []; let h = ''; cols.forEach(c => { const align = c.align ? ' text-align: ' + c.align + ';' : ''; if (!c.field) { h += '' + c.label + ''; } else { const isActive = window._adminSortCol === c.field; const arrow = isActive ? (window._adminSortDir === 'asc' ? ' ↑' : ' ↓') : ' '; h += '' + c.label + arrow + ''; } }); return h; } function sortAdminPipeline(field, type) { if (window._adminSortCol === field) { window._adminSortDir = window._adminSortDir === 'desc' ? 'asc' : 'desc'; } else { window._adminSortCol = field; window._adminSortDir = (type === 'text') ? 'asc' : 'desc'; } // Re-render headers + rows const thead = document.getElementById('admin-pipeline-thead'); if (thead) thead.innerHTML = _buildAdminSortHeaders(); const activeKey = window._adminActiveTab || 'all'; const tab = (window._adminPipelineTabs || []).find(t => t.key === activeKey); const tbody = document.getElementById('admin-pipeline-tbody'); if (tbody && tab) tbody.innerHTML = window._renderAdminRows((window._adminReportUsers || []).filter(tab.filter)); } function filterAdminPipeline(key, btn) { const tabs = window._adminPipelineTabs || []; const users = window._adminReportUsers || []; const tab = tabs.find(t => t.key === key); if (!tab) return; window._adminActiveTab = key; renderAdminPipelineTabs(key); const tbody = document.getElementById('admin-pipeline-tbody'); if (tbody) tbody.innerHTML = window._renderAdminRows(users.filter(tab.filter)); } async function toggleExcludeUser(uid, currentlyExcluded) { const newValue = !currentlyExcluded; try { await db.collection('users').doc(uid).set({ excluded: newValue }, { merge: true }); const users = window._adminReportUsers || []; const user = users.find(u => u.uid === uid); if (user) user.excluded = newValue; // Re-render tabs with updated counts + re-render rows for current tab const activeKey = window._adminActiveTab || 'all'; renderAdminPipelineTabs(activeKey); const tab = (window._adminPipelineTabs || []).find(t => t.key === activeKey); const tbody = document.getElementById('admin-pipeline-tbody'); if (tbody && tab) tbody.innerHTML = window._renderAdminRows(users.filter(tab.filter)); showToast(newValue ? 'User excluded from metrics' : 'User included in metrics', 'success'); } catch (err) { showToast('Error: ' + err.message, 'error'); } } function toggleAdminUserDetail(row, userJson) { const existing = row.nextElementSibling; if (existing && existing.classList.contains('admin-detail-row')) { existing.remove(); return; } document.querySelectorAll('.admin-detail-row').forEach(r => r.remove()); const u = JSON.parse(userJson); const dr = document.createElement('tr'); dr.className = 'admin-detail-row'; dr.innerHTML = '
UID: ' + (u.uid || '—') + '
Stripe: ' + (u.stripeCustomerId || '—') + '
Sub: ' + (u.stripeSubscriptionId || '—') + '
' + (u.howDidYouHear ? '
Source: ' + u.howDidYouHear + '
' : '') + '
' + (u.stripeCustomerId ? 'View in Stripe' : '') + '
'; row.after(dr); } // ── Drag-and-drop + Resize ── function initAdminWidgetDragDrop() { const grid = document.getElementById('admin-widget-grid'); if (!grid) return; grid.querySelectorAll('.admin-widget').forEach(card => { const header = card.querySelector('.card-header'); if (header) { header.draggable = true; header.addEventListener('dragstart', e => { e.dataTransfer.setData('text/plain', card.dataset.widgetId); card.style.opacity = '0.5'; }); header.addEventListener('dragend', () => { card.style.opacity = '1'; }); } card.addEventListener('dragover', e => { e.preventDefault(); card.style.borderTop = '2px solid var(--cyan)'; }); card.addEventListener('dragleave', () => { card.style.borderTop = ''; }); card.addEventListener('drop', e => { e.preventDefault(); card.style.borderTop = ''; const fromId = e.dataTransfer.getData('text/plain'); const toId = card.dataset.widgetId; if (fromId && toId && fromId !== toId) { const fi = _adminActiveWidgets.indexOf(fromId), ti = _adminActiveWidgets.indexOf(toId); if (fi >= 0 && ti >= 0) { _adminActiveWidgets.splice(fi, 1); _adminActiveWidgets.splice(ti, 0, fromId); localStorage.setItem('adminWidgets', JSON.stringify(_adminActiveWidgets)); renderAdminWidgets(); } } }); }); // Resize handles grid.querySelectorAll('.admin-resize-handle').forEach(handle => { handle.addEventListener('mousedown', e => { e.preventDefault(); const wid = handle.dataset.widgetId; const card = handle.closest('.admin-widget'); const startY = e.clientY, startH = card.offsetHeight; const onMove = ev => { const newH = Math.max(150, startH + (ev.clientY - startY)); card.style.height = newH + 'px'; }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); localStorage.setItem('adminWidgetHeight_' + wid, card.style.height.replace('px', '')); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }); }); } function setReportProgress(pct, label) { const card = document.getElementById('admin-report-progress'); const barInner = document.getElementById('admin-report-progress-bar'); const txt = document.getElementById('admin-report-progress-text'); const pctEl = document.getElementById('admin-report-progress-pct'); if (card) card.style.display = 'block'; if (barInner) { barInner.style.width = pct + '%'; barInner.style.background = pct >= 100 ? '#10b981' : '#00d4aa'; } if (txt) txt.textContent = label; if (pctEl) pctEl.textContent = Math.round(pct) + '%'; } function clearReportProgress() { const card = document.getElementById('admin-report-progress'); const barInner = document.getElementById('admin-report-progress-bar'); const pctEl = document.getElementById('admin-report-progress-pct'); if (card) card.style.display = 'none'; if (barInner) { barInner.style.width = '0%'; barInner.style.background = '#00d4aa'; } if (pctEl) pctEl.textContent = '0%'; } async function refreshAdminReport() { const btn = document.getElementById('admin-refresh-btn'); if (btn) { btn.disabled = true; btn.innerHTML = '⏳ Generating...'; } setReportProgress(5, 'Connecting to Stripe...'); const progressLabels = ['Fetching subscriptions...', 'Calculating revenue...', 'Analyzing churn...', 'Building cash flow...', 'Finalizing report...']; const progressInterval = setInterval(() => { const barInner = document.getElementById('admin-report-progress-bar'); const current = parseFloat(barInner?.style.width) || 15; if (current < 85) { const labelIdx = Math.floor((current - 15) / 14); setReportProgress(Math.min(current + 3, 85), progressLabels[Math.min(labelIdx, progressLabels.length - 1)]); } }, 2000); try { setReportProgress(15, 'Fetching subscriptions...'); const token = await currentUser.getIdToken(); const resp = await fetch('https://us-central1-trade-journal-fc3ba.cloudfunctions.net/generateAdminReport', { headers: { 'Authorization': 'Bearer ' + token } }); const data = await resp.json(); if (!resp.ok) throw new Error(data.error || 'Failed'); clearInterval(progressInterval); setReportProgress(100, '✅ Report complete!'); setTimeout(clearReportProgress, 1500); showToast('Report generated', 'success'); renderAdminDashboard(); } catch (err) { clearInterval(progressInterval); clearReportProgress(); showToast('Error: ' + err.message, 'error'); if (btn) { btn.disabled = false; btn.innerHTML = '🔄 Refresh Report'; } } } function showAdminTooltip(e, el) { const tip = document.getElementById('admin-tooltip'); if (!tip) return; tip.textContent = el.dataset.desc || ''; tip.style.display = 'block'; _positionAdminTooltip(e.clientX, e.clientY, tip); document.addEventListener('mousemove', _adminTooltipMove); } function hideAdminTooltip() { const tip = document.getElementById('admin-tooltip'); if (tip) tip.style.display = 'none'; document.removeEventListener('mousemove', _adminTooltipMove); } function _adminTooltipMove(e) { const tip = document.getElementById('admin-tooltip'); if (tip) _positionAdminTooltip(e.clientX, e.clientY, tip); } function _positionAdminTooltip(cx, cy, tip) { let left = cx + 12; if (left + 260 > window.innerWidth) left = cx - 272; let top = cy - 10; if (top + tip.offsetHeight > window.innerHeight) top = window.innerHeight - tip.offsetHeight - 8; if (top < 4) top = 4; tip.style.left = left + 'px'; tip.style.top = top + 'px'; } function toggleAdminWidgetSpan(wid, btn) { const isNowSpanned = !window._adminWidgetSpans[wid]; window._adminWidgetSpans[wid] = isNowSpanned; if (!isNowSpanned) delete window._adminWidgetSpans[wid]; localStorage.setItem('adminWidgetSpans', JSON.stringify(window._adminWidgetSpans)); const card = btn.closest('.admin-widget'); if (card) { card.style.gridColumn = isNowSpanned ? '1 / -1' : ''; btn.style.color = isNowSpanned ? '#00d4aa' : 'var(--text-muted)'; } // Re-render chart if it has one (so it resizes correctly) if (window._adminCharts[wid]) { window._adminCharts[wid].destroy(); delete window._adminCharts[wid]; } const w = ADMIN_WIDGETS[wid]; if (w) setTimeout(() => { if (typeof window[w.render] === 'function') window[w.render](); }, 50); } function setAdminColumns(n) { const grid = document.getElementById('admin-widget-grid'); if (grid) grid.style.gridTemplateColumns = 'repeat(' + n + ', 1fr)'; localStorage.setItem('adminColumns', String(n)); initAdminWidgetDragDrop(); } async function refreshScraperBaseline() { const firm = document.getElementById('scraper-firm-select')?.value || 'all'; const btn = document.getElementById('scraper-refresh-btn'); const status = document.getElementById('scraper-status'); if (btn) { btn.disabled = true; btn.innerHTML = '⏳ Resetting...'; } if (status) status.textContent = ''; try { const token = await currentUser.getIdToken(); const resp = await fetch('https://us-central1-trade-journal-fc3ba.cloudfunctions.net/refreshScraperBaseline?firm=' + encodeURIComponent(firm), { headers: { 'Authorization': 'Bearer ' + token } }); const data = await resp.json(); if (!resp.ok) throw new Error(data.error || 'Failed'); const total = (data.results || []).reduce((s, r) => s + (r.pagesReset || 0), 0); showToast('Baseline reset: ' + total + ' pages queued for re-extraction', 'success'); if (status) status.textContent = total + ' pages will re-extract at next 6 AM CT run'; } catch (err) { showToast('Error: ' + err.message, 'error'); if (status) status.textContent = 'Error: ' + err.message; } if (btn) { btn.disabled = false; btn.innerHTML = '🔄 Refresh Baseline'; } } // ===== ANALYTICS PAGE ===== let _analyticsRange = '28d'; // ── Global cache helper ── const _cache = {}; function cacheGet(key) { const entry = _cache[key]; if (!entry) return null; if (Date.now() - entry.ts > entry.ttl) { delete _cache[key]; return null; } return entry.data; } function cacheSet(key, data, ttlMs) { _cache[key] = { data, ts: Date.now(), ttl: ttlMs }; } function cacheClear(key) { delete _cache[key]; } function cacheAge(key) { const entry = _cache[key]; if (!entry) return null; return Math.round((Date.now() - entry.ts) / 60000); } function _fmtDur(s) { const m = Math.floor(s / 60); return m + 'm ' + Math.round(s % 60) + 's'; } function _wowHtml(curr, prev) { if (!prev) return ''; const pct = Math.round(((curr - prev) / prev) * 100); const up = pct >= 0; return '
' + (up ? '↑' : '↓') + ' ' + Math.abs(pct) + '% vs last week
'; } async function renderAnalyticsPage() { const container = document.getElementById('analytics-content'); if (!container) return; const cacheKey = 'analytics_' + _analyticsRange; // Show cached immediately if available const cached = cacheGet(cacheKey); if (cached) { _renderAnalyticsHTML(container, cached); // Background refresh currentUser.getIdToken().then(token => fetch('https://us-central1-trade-journal-fc3ba.cloudfunctions.net/getAnalyticsData?range=' + _analyticsRange, { headers: { 'Authorization': 'Bearer ' + token } }) ).then(r => r.json()).then(data => { if (data.success) { cacheSet(cacheKey, data, 15 * 60 * 1000); _renderAnalyticsHTML(container, data); } }).catch(() => {}); return; } container.innerHTML = '
' + Array(8).fill('
').join('') + '
'; try { const token = await currentUser.getIdToken(); const resp = await fetch('https://us-central1-trade-journal-fc3ba.cloudfunctions.net/getAnalyticsData?range=' + _analyticsRange, { headers: { 'Authorization': 'Bearer ' + token } }); const data = await resp.json(); if (!data.success) throw new Error(data.error); cacheSet(cacheKey, data, 15 * 60 * 1000); _renderAnalyticsHTML(container, data); } catch(e) { container.innerHTML = '
Error: ' + e.message + '
'; } } function _renderAnalyticsHTML(container, data) { const { thisWeek: tw, lastWeek: lw, trafficSources, topPages, events, geography, devices, redditReferrals, dailyTrend, landingSources } = data; const _card = 'background:var(--bg-card); border:1px solid var(--border-color); border-radius:12px; padding:20px;'; let html = '
'; // Header + range buttons const aAge = cacheAge('analytics_' + _analyticsRange); const aLabel = aAge !== null ? ' · Updated ' + (aAge < 1 ? 'just now' : aAge + 'm ago') : ''; html += '

📈 Website Analytics

Site traffic & conversions via Google Analytics' + aLabel + '
'; html += '
'; ['7d','14d','28d','90d'].forEach(r => { const active = r === _analyticsRange; html += ''; }); html += '
'; // ROW 1 — 8 stat cards html += '
'; [{l:'SESSIONS (7D)',v:tw.sessions,w:_wowHtml(tw.sessions,lw.sessions),c:'#00d4aa'}, {l:'SESSIONS (PREV)',v:lw.sessions,w:'',c:'var(--text-muted)'}, {l:'TOTAL USERS',v:tw.users,w:_wowHtml(tw.users,lw.users),c:'#00d4aa'}, {l:'NEW USERS',v:tw.newUsers,w:_wowHtml(tw.newUsers,lw.newUsers),c:'#00d4aa'}, {l:'RETURNING',v:tw.users-tw.newUsers,w:'',c:'var(--text-muted)'}, {l:'AVG DURATION',v:_fmtDur(tw.avgDuration||0),w:'',c:'var(--text-primary)'}, {l:'ENGAGED',v:Math.round(tw.engagedSessions||0),w:_wowHtml(tw.engagedSessions,lw.engagedSessions),c:'#06b6d4'}, {l:'BOUNCE RATE',v:(tw.bounceRate||0).toFixed(1)+'%',w:'',c:tw.bounceRate>50?'#ef4444':'#00d4aa'} ].forEach(s => { html += '
' + s.l + '
' + s.v + '
' + s.w + '
'; }); html += '
'; // ROW 2 — 3 columns: Sources | Devices | Reddit html += '
'; // Traffic sources html += '
Traffic Sources
'; const totalSess = trafficSources.reduce((s, r) => s + r.metrics[0], 0); const srcColors = {'Organic Search':'#00d4aa','Direct':'#06b6d4','Referral':'#8b5cf6','Organic Social':'#f59e0b','Email':'#10b981','Paid Search':'#ef4444','Unassigned':'#64748b'}; trafficSources.forEach(r => { const src = r.dimensions[0] || 'Unknown'; const sess = r.metrics[0]; const pct = totalSess > 0 ? Math.round((sess / totalSess) * 100) : 0; html += '
' + src + '' + sess + ' · ' + pct + '%
'; }); html += '
'; // Devices html += '
Devices
'; const totalDevSess = devices.reduce((s, r) => s + r.metrics[0], 0); const devIcons = {desktop:'🖥️',mobile:'📱',tablet:'📋'}; const devColors = {desktop:'#06b6d4',mobile:'#00d4aa',tablet:'#f59e0b'}; devices.forEach(r => { const dev = r.dimensions[0]?.toLowerCase() || 'unknown'; const sess = r.metrics[0]; const users = r.metrics[1]; const pct = totalDevSess > 0 ? Math.round((sess / totalDevSess) * 100) : 0; html += '
' + (devIcons[dev]||'❓') + '
' + dev + '
' + users + ' users
' + pct + '%
' + sess + ' sessions
'; }); html += '
'; // Reddit html += '
🔴 Reddit Traffic
'; const redditSess = redditReferrals.reduce((s, r) => s + r.metrics[0], 0); const redditNew = redditReferrals.reduce((s, r) => s + r.metrics[1], 0); html += '
'; html += '
SESSIONS
' + redditSess + '
'; html += '
NEW USERS
' + redditNew + '
'; html += '
'; if (redditReferrals.length > 0) { redditReferrals.forEach(r => { html += '
' + r.dimensions[0] + '' + r.metrics[0] + ' sessions
'; }); } else { html += '
No Reddit traffic detected.
'; } html += '
'; // ROW 3 — Daily Trend + Geography html += '
'; // Daily trend SVG html += '
Daily Sessions
'; if (dailyTrend.length > 0) { const maxSess = Math.max(...dailyTrend.map(d => d.metrics[0]), 1); const barW = Math.max(2, Math.floor(600 / dailyTrend.length) - 2); const svgW = dailyTrend.length * (barW + 2); html += '
'; dailyTrend.forEach((d, i) => { const h = Math.max(2, Math.round((d.metrics[0] / maxSess) * 80)); const x = i * (barW + 2); const dateStr = d.dimensions[0]; const label = dateStr.substring(4, 6) + '/' + dateStr.substring(6, 8); html += '' + label + ': ' + d.metrics[0] + ' sessions'; if (i === 0 || i === dailyTrend.length - 1 || i === Math.floor(dailyTrend.length / 2)) { html += '' + label + ''; } }); html += '
'; } else { html += '
No daily data.
'; } html += '
'; // Geography html += '
Top Locations
'; const maxGeoSess = geography.length > 0 ? geography[0].metrics[0] : 1; const countryFlags = {US:'🇺🇸',GB:'🇬🇧',CA:'🇨🇦',AU:'🇦🇺',DE:'🇩🇪',FR:'🇫🇷',IN:'🇮🇳',NL:'🇳🇱',BR:'🇧🇷',JP:'🇯🇵',CH:'🇨🇭',SE:'🇸🇪',MX:'🇲🇽',IT:'🇮🇹',ES:'🇪🇸',PH:'🇵🇭',SG:'🇸🇬',IE:'🇮🇪',NZ:'🇳🇿'}; geography.slice(0, 8).forEach(r => { const country = r.dimensions[0] || '?'; const city = r.dimensions[1] || ''; const sess = r.metrics[0]; const pct = Math.round((sess / maxGeoSess) * 100); const flag = countryFlags[country] || '🌍'; html += '
' + flag + ' ' + (city || country) + '' + sess + '
'; }); html += '
'; // ROW 4 — Top Pages html += '
Top Pages
'; html += ''; topPages.forEach(r => { html += ''; }); html += '
PageViewsSessions
' + (r.dimensions[0] || '/') + '' + r.metrics[0] + '' + r.metrics[1] + '
'; // ROW 5 — Landing Pages + Sources html += '
Landing Pages by Channel
'; html += ''; (landingSources || []).forEach(r => { html += ''; }); html += '
Landing PageChannelSessionsNew Users
' + (r.dimensions[0] || '/') + '' + (r.dimensions[1] || '—') + '' + r.metrics[0] + '' + r.metrics[1] + '
'; // ROW 6 — Key Events html += '
Key Events
'; if (events.length > 0) { const evtColors = {'sign_up':'#00d4aa','login':'#06b6d4','purchase':'#10b981','subscription_start':'#f59e0b','page_view':'#64748b'}; html += '
'; events.forEach(r => { const evt = r.dimensions[0]; const clr = evtColors[evt] || '#8b5cf6'; html += '
' + evt.replace(/_/g, ' ').toUpperCase() + '
' + r.metrics[0] + '
'; }); html += '
'; } else { html += '
No events tracked.
'; } html += '
'; // ROW 7 — Channel Conversion Funnel const channelFunnel = data.channelFunnel || []; const channelEvents = data.channelEvents || []; if (channelFunnel.length > 0) { // Build event counts by channel const evtByChannel = {}; channelEvents.forEach(r => { const ch = r.dimensions[0] || ''; const evt = r.dimensions[1] || ''; if (!evtByChannel[ch]) evtByChannel[ch] = {}; evtByChannel[ch][evt] = r.metrics[0]; }); html += '
Channel Conversion Funnel
'; html += ''; channelFunnel.forEach(r => { const ch = r.dimensions[0] || 'Unknown'; const sess = r.metrics[0]; const newU = r.metrics[1]; const conv = r.metrics[3] || 0; const convRate = sess > 0 ? (conv / sess * 100) : 0; const convClr = convRate > 1 ? '#00d4aa' : convRate > 0.1 ? '#f59e0b' : '#ef4444'; const ce = evtByChannel[ch] || {}; html += ''; }); html += '
ChannelSessionsNew UsersSign UpsLoginsPurchasesConv Rate
' + ch + '' + sess + '' + newU + '' + (ce.sign_up || 0) + '' + (ce.login || 0) + '' + (ce.purchase || 0) + '' + convRate.toFixed(2) + '%
'; } // ROW 8 — Referral Sources + Social Platforms const referralSources = data.referralSources || []; const socialSources = data.socialSources || []; if (referralSources.length > 0 || socialSources.length > 0) { html += '
'; // Referral sources html += '
Referral Sources
'; if (referralSources.length > 0) { html += ''; referralSources.forEach(r => { const src = r.dimensions[0] || ''; const med = r.dimensions[1] || ''; const isReddit = src.toLowerCase().includes('reddit'); html += ''; }); html += '
SourceMediumSessionsNew Users
' + src + '' + med + '' + r.metrics[0] + '' + r.metrics[1] + '
'; } else { html += '
No referral traffic.
'; } html += '
'; // Social platforms const socialIcons = {discord:'💬','t.co':'🐦',twitter:'🐦',x:'🐦',youtube:'📺',reddit:'🔴',linkedin:'💼',facebook:'👥',instagram:'📸',tiktok:'🎵'}; html += '
Social Platforms
'; if (socialSources.length > 0) { html += ''; socialSources.forEach(r => { const plat = r.dimensions[0] || ''; const platLower = plat.toLowerCase(); const icon = Object.entries(socialIcons).find(([k]) => platLower.includes(k))?.[1] || '🌐'; html += ''; }); html += '
PlatformSessionsNew Users
' + icon + ' ' + plat + '' + r.metrics[0] + '' + r.metrics[1] + '
'; } else { html += '
No social traffic.
'; } html += '
'; } html += '
'; container.innerHTML = html; } // ===== FINANCIAL PERFORMANCE PAGE ===== // ── Financial Performance: state ── let _fpYear = new Date().getFullYear(); let _fpData = null; // cached API response let _fpDataTs = 0; // timestamp of last fetch let _fpBusy = false; let _fpExpandedRows = new Set(); function toggleFPLineItem(id) { if (_fpExpandedRows.has(id)) _fpExpandedRows.delete(id); else _fpExpandedRows.add(id); const c = document.getElementById('financial-performance-content'); if (c) _renderFPContent(c); } async function renderFinancialPerformance() { const container = document.getElementById('financial-performance-content'); if (!container) return; // Use cached data if < 2 minutes old if (_fpData && Date.now() - _fpDataTs < 120000) { _renderFPContent(container); return; } // Show cached immediately if stale but available, skeleton if not if (_fpData) { _renderFPContent(container); } else { container.innerHTML = '
' + Array(8).fill('
').join('') + '
'; } try { const token = await currentUser.getIdToken(); const resp = await fetch('https://us-central1-trade-journal-fc3ba.cloudfunctions.net/getFinancialPerformance', { headers: { 'Authorization': 'Bearer ' + token } }); const data = await resp.json(); if (!data.success) throw new Error(data.error); _fpData = data; _fpDataTs = Date.now(); _renderFPContent(container); } catch(e) { if (!_fpData) container.innerHTML = '
Error: ' + e.message + '
'; } } function _renderFPContent(container) { const data = _fpData; const allMonths = data.months; const lineItems = data.lineItems || []; const yr = _fpYear; const months = allMonths.filter(m => m.year === yr); // Pad to 12 months if year data is partial const monthNums = Array.from({length:12}, (_,i) => i+1); const monthMap = {}; months.forEach(m => { monthMap[m.month] = m; }); const fmt = v => '$' + Math.abs(v || 0).toFixed(2); const fmtC = v => (v < 0 ? '-$' : '$') + Math.abs(v || 0).toFixed(2); const fmtShort = v => v ? (v < 0 ? '-$' : '$') + Math.abs(v).toFixed(2) : ''; const clr = v => v >= 0 ? '#00d4aa' : '#ef4444'; const costClr = v => v > 0 ? '#ef4444' : 'var(--text-muted)'; const monthLabels = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; // Keyword map for fuzzy matching untagged expenses to line items const _fpKW = { zoho: ['zoho'], claude_max: ['claude max', 'claude pro'], anthropic_api: ['anthropic api', 'anthropic credits', 'api credits'], n8n: ['n8n'], rithmic: ['rithmic'], higgsfield: ['higgsfield'], buffer: ['buffer'], domain: ['domain', 'payoutlab.io'], firebase: ['firebase'], cloud_run: ['cloud run'], crawlbase: ['crawlbase'], resend: ['resend'], misc: [], stripe_fees: [], dist_jason: ['jason'], dist_catalyst: ['catalyst'], dist_merc: ['merc'], dist_scalprule: ['scalprule'], }; // Build expense lookup: { lineItemId -> { month -> {id, amount} } } // Also fuzzy-match untagged expenses by keyword const expLookup = {}; const matchedExpIds = new Set(); // track which expenses got matched allMonths.forEach(m => { if (m.year !== yr) return; (m.expenses || []).forEach(e => { // Exact lineItemId match if (e.lineItemId) { if (!expLookup[e.lineItemId]) expLookup[e.lineItemId] = {}; if (!expLookup[e.lineItemId][m.month]) expLookup[e.lineItemId][m.month] = { id: e.id, amount: e.amount }; else expLookup[e.lineItemId][m.month].amount += e.amount; // sum multiples matchedExpIds.add(e.id); return; } // Fuzzy keyword match for untagged expenses const desc = (e.description || '').toLowerCase(); if (!desc) return; for (const liId of Object.keys(_fpKW)) { const kws = _fpKW[liId]; if (kws.length === 0) continue; if (kws.some(kw => desc.includes(kw))) { if (!expLookup[liId]) expLookup[liId] = {}; if (!expLookup[liId][m.month]) expLookup[liId][m.month] = { id: e.id, amount: e.amount }; else expLookup[liId][m.month].amount += e.amount; matchedExpIds.add(e.id); return; // first match wins } } }); }); // Build detail lookup: lineItemId -> [{ expense with month }] const expDetailByLI = {}; allMonths.forEach(m => { if (m.year !== yr) return; (m.expenses || []).forEach(e => { let liId = e.lineItemId || ''; // Fuzzy match for untagged if (!liId) { const desc = (e.description || '').toLowerCase(); for (const k of Object.keys(_fpKW)) { if (_fpKW[k].length && _fpKW[k].some(kw => desc.includes(kw))) { liId = k; break; } } } if (!liId) return; if (!expDetailByLI[liId]) expDetailByLI[liId] = []; expDetailByLI[liId].push({ ...e, _month: m.month, _monthLabel: monthLabels[m.month - 1] }); }); }); // Collect unmatched expenses per category per month const unmatchedByCat = { cogs: {}, opex: {}, distribution: {} }; allMonths.forEach(m => { if (m.year !== yr) return; (m.expenses || []).forEach(e => { if (matchedExpIds.has(e.id)) return; const cat = e.category || 'opex'; if (!unmatchedByCat[cat]) unmatchedByCat[cat] = {}; if (!unmatchedByCat[cat][m.month]) unmatchedByCat[cat][m.month] = []; unmatchedByCat[cat][m.month].push(e); }); }); // YTD summary const ytdGross = monthNums.reduce((s,mn) => s + (monthMap[mn]?.grossRevenue || 0), 0); const ytdFees = monthNums.reduce((s,mn) => s + (monthMap[mn]?.stripeFees || 0), 0); const ytdNet = monthNums.reduce((s,mn) => s + (monthMap[mn]?.netRevenue || 0), 0); const ytdCogs = monthNums.reduce((s,mn) => s + (monthMap[mn]?.cogs || 0), 0); const ytdGP = monthNums.reduce((s,mn) => s + (monthMap[mn]?.grossProfit || 0), 0); const ytdOpex = monthNums.reduce((s,mn) => s + (monthMap[mn]?.opex || 0), 0); const ytdEbitda = monthNums.reduce((s,mn) => s + (monthMap[mn]?.ebitda || 0), 0); const ytdDist = monthNums.reduce((s,mn) => s + (monthMap[mn]?.distributions || 0), 0); const ytdNI = monthNums.reduce((s,mn) => s + (monthMap[mn]?.netIncome || 0), 0); let html = '
'; // ── Header with year selector ── html += '
'; html += '

Financial Performance

PayoutLab P&L — Revenue auto-pulled from Stripe · Click any cell to edit
'; html += '
'; [2024,2025,2026].forEach(y => { const active = y === yr; html += ''; }); html += '
'; // ── YTD summary cards ── html += '
'; [{l:'GROSS REVENUE',v:fmt(ytdGross),c:'#00d4aa'},{l:'STRIPE FEES',v:ytdFees>0?'-'+fmt(ytdFees):'$0.00',c:costClr(ytdFees)},{l:'NET REVENUE',v:fmt(ytdNet),c:'#00d4aa'},{l:'COGS',v:ytdCogs>0?'-'+fmt(ytdCogs):'$0.00',c:costClr(ytdCogs)},{l:'GROSS PROFIT',v:fmtC(ytdGP),c:clr(ytdGP)},{l:'OP EXPENSES',v:ytdOpex>0?'-'+fmt(ytdOpex):'$0.00',c:costClr(ytdOpex)},{l:'EBITDA',v:fmtC(ytdEbitda),c:clr(ytdEbitda)},{l:'NET INCOME',v:fmtC(ytdNI),c:clr(ytdNI)}].forEach(s => { html += '
' + s.l + '
' + s.v + '
YTD ' + yr + '
'; }); html += '
'; // ── Spreadsheet-style Income Statement ── const thS = 'padding:8px 6px; text-align:right; color:var(--text-muted); font-weight:600; font-size:10px; white-space:nowrap;'; const tdS = 'padding:6px 6px; text-align:right; font-size:11px; white-space:nowrap;'; const labelS = 'padding:6px 12px; text-align:left; font-size:11px; white-space:nowrap;'; const sectionS = 'padding:8px 12px; font-weight:700; font-size:10px; text-transform:uppercase; letter-spacing:0.05em; color:rgba(0,212,170,0.7); background:rgba(0,212,170,0.04);'; const totalRowS = 'background:var(--bg-card-inner); border-top:2px solid var(--border-color);'; const cellW = 'min-width:62px; max-width:80px;'; html += '
'; html += '
'; html += ''; // Header row html += ''; monthNums.forEach(mn => { html += ''; }); html += ''; html += ''; // ── REVENUE section ── html += ''; // Gross Revenue html += ''; monthNums.forEach(mn => { const v = monthMap[mn]?.grossRevenue || 0; html += ''; }); html += ''; // Stripe Fees html += ''; monthNums.forEach(mn => { const v = monthMap[mn]?.stripeFees || 0; html += ''; }); html += ''; // Net Revenue total html += ''; monthNums.forEach(mn => { const v = monthMap[mn]?.netRevenue || 0; html += ''; }); html += ''; // ── COGS section ── const cogsItems = lineItems.filter(li => li.category === 'cogs'); html += ''; // Helper: render line item row + expandable detail sub-rows function _fpLineItemRow(li, valColor) { const isExpanded = _fpExpandedRows.has(li.id); const arrow = isExpanded ? '▼' : '▶'; const isAutoCalc = li.autoCalc && li.id === 'stripe_fees'; html += ''; html += ''; html += ''; html += ''; let rowYtd = 0; monthNums.forEach(mn => { if (isAutoCalc) { const v = monthMap[mn]?.stripeFees || 0; rowYtd += v; html += ''; } else { const exp = expLookup[li.id]?.[mn]; const v = exp?.amount || 0; rowYtd += v; html += ''; } }); const ytdColor = li.category === 'distribution' ? (rowYtd > 0 ? '#f59e0b' : 'var(--text-muted)') : (rowYtd > 0 ? '#ef4444' : 'var(--text-muted)'); html += ''; // Detail sub-rows when expanded if (isExpanded && !isAutoCalc) { const details = expDetailByLI[li.id] || []; if (details.length === 0) { html += ''; } else { details.forEach(e => { const expDate = e.date ? new Date(e.date + 'T12:00:00').toLocaleDateString('en-US', {month:'short', day:'numeric'}) : e._monthLabel; html += ''; html += ''; html += ''; html += ''; monthNums.forEach(mn => { if (mn === e._month) { html += ''; } else { html += ''; } }); html += ''; html += ''; }); } } } cogsItems.forEach(li => _fpLineItemRow(li, '#00d4aa')); // Unmatched COGS const unmCogs = unmatchedByCat['cogs'] || {}; if (Object.keys(unmCogs).some(mn => unmCogs[mn]?.length > 0)) { html += ''; html += ''; let unmYtd = 0; monthNums.forEach(mn => { const items = unmCogs[mn] || []; const total = items.reduce((s,e) => s + (e.amount||0), 0); unmYtd += total; html += ''; }); html += ''; } // Total COGS html += ''; let ytdCogsCalc = 0; monthNums.forEach(mn => { const v = monthMap[mn]?.cogs || 0; ytdCogsCalc += v; html += ''; }); html += ''; // Gross Profit html += ''; monthNums.forEach(mn => { const v = monthMap[mn]?.grossProfit || 0; html += ''; }); html += ''; // ── OPEX section ── const opexItems = lineItems.filter(li => li.category === 'opex'); html += ''; opexItems.forEach(li => _fpLineItemRow(li, '#00d4aa')); // Unmatched OpEx const unmOpex = unmatchedByCat['opex'] || {}; if (Object.keys(unmOpex).some(mn => unmOpex[mn]?.length > 0)) { html += ''; html += ''; let unmYtd = 0; monthNums.forEach(mn => { const items = unmOpex[mn] || []; const total = items.reduce((s,e) => s + (e.amount||0), 0); unmYtd += total; html += ''; }); html += ''; } // Total OpEx html += ''; let ytdOpexCalc = 0; monthNums.forEach(mn => { const v = monthMap[mn]?.opex || 0; ytdOpexCalc += v; html += ''; }); html += ''; // EBITDA html += ''; monthNums.forEach(mn => { const v = monthMap[mn]?.ebitda || 0; html += ''; }); html += ''; // ── DISTRIBUTIONS section ── const distItems = lineItems.filter(li => li.category === 'distribution'); html += ''; distItems.forEach(li => _fpLineItemRow(li, '#f59e0b')); // Unmatched Distributions const unmDist = unmatchedByCat['distribution'] || {}; if (Object.keys(unmDist).some(mn => unmDist[mn]?.length > 0)) { html += ''; html += ''; let unmYtd = 0; monthNums.forEach(mn => { const items = unmDist[mn] || []; const total = items.reduce((s,e) => s + (e.amount||0), 0); unmYtd += total; html += ''; }); html += ''; } // Total Distributions html += ''; let ytdDistCalc = 0; monthNums.forEach(mn => { const v = monthMap[mn]?.distributions || 0; ytdDistCalc += v; html += ''; }); html += ''; // ── NET INCOME ── html += ''; monthNums.forEach(mn => { const v = monthMap[mn]?.netIncome || 0; html += ''; }); html += ''; html += '
Line ItemNote' + monthLabels[mn-1] + 'YTD
Revenue
Gross Revenue (Stripe)Auto from Stripe' + (v ? fmtShort(v) : '') + '' + fmt(ytdGross) + '
Stripe Processing Fees2.9% + $0.30' + (v ? '-' + fmtShort(v) : '') + '-' + fmt(ytdFees) + '
Net Revenue' + (v ? fmtShort(v) : '') + '' + fmtC(ytdNet) + '
Cost of Revenue (COGS)
' + (isAutoCalc ? '' : arrow) + '' + li.label + '' + (li.note || '') + '' + (v ? fmtShort(v) : '') + '' + (v ? fmtShort(v) : '—') + '' + (rowYtd ? fmt(rowYtd) : '—') + '
No entries
' + expDate + ' · ' + (e.vendor || '') + '' + (e.description || '') + '' + fmtShort(e.amount) + '
Unmatched ExpensesNo line item' + (total ? fmtShort(total) : '') + '' + (unmYtd ? fmt(unmYtd) : '—') + '
Total COGS' + (v ? '-' + fmtShort(v) : '') + '-' + fmt(ytdCogsCalc) + '
Gross Profit' + (v ? fmtShort(v) : '') + '' + fmtC(ytdGP) + '
Operating Expenses
Unmatched ExpensesNo line item' + (total ? fmtShort(total) : '') + '' + (unmYtd ? fmt(unmYtd) : '—') + '
Total OpEx' + (v ? '-' + fmtShort(v) : '') + '-' + fmt(ytdOpexCalc) + '
EBITDA' + (v ? fmtShort(v) : '') + '' + fmtC(ytdEbitda) + '
Distributions
Unmatched DistributionsNo line item' + (total ? fmtShort(total) : '') + '' + (unmYtd ? fmt(unmYtd) : '—') + '
Total Distributions' + (v ? '-' + fmtShort(v) : '') + '-' + fmt(ytdDistCalc) + '
NET INCOME' + (v ? fmtShort(v) : '') + '' + fmtC(ytdNI) + '
'; // ── SECTION 3: Quick Entry ── const _is = 'padding:5px 8px; background:var(--bg-card-inner); border:1px solid var(--border-color); border-radius:5px; color:var(--text-primary); font-size:12px;'; html += '
'; html += '
Quick Entry
'; html += '
'; html += ''; // Line item dropdown (required) html += ''; html += ''; html += ''; html += ''; html += ''; html += '
'; // ── SECTION 4: Recent Expenses (non-distribution) ── const allExpenses = []; allMonths.forEach(m => { if (m.year !== yr) return; (m.expenses || []).forEach(e => { if (e.category !== 'distribution') allExpenses.push({ ...e, _month: m.month }); }); }); allExpenses.sort((a,b) => (b.date || '').localeCompare(a.date || '')); const recentExpenses = allExpenses.slice(0, 20); if (recentExpenses.length > 0) { html += '
'; html += '
Recent Expenses
'; html += '
'; html += ''; recentExpenses.forEach(e => { const expDate = e.date ? new Date(e.date + 'T12:00:00').toLocaleDateString('en-US', {month:'short', day:'numeric'}) : monthLabels[(e._month || 1) - 1]; const liMatch = (lineItems || []).find(l => l.id === e.lineItemId); const liLabel = liMatch ? liMatch.label.split('(')[0].trim() : (e.lineItemId || '—'); const catC = e.category === 'cogs' ? '#06b6d4' : '#8b5cf6'; const catL = e.category === 'cogs' ? 'COGS' : 'OpEx'; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; }); html += '
DateLine ItemVendorDescriptionAmount
' + expDate + '' + liLabel + ' ' + catL + '' + (e.vendor || '—') + '' + (e.description || '—') + '-$' + (e.amount || 0).toFixed(2) + '
'; } // ── SECTION 5: Distribution History ── const allDistExpenses = []; allMonths.forEach(m => { (m.expenses || []).forEach(e => { if (e.category === 'distribution') allDistExpenses.push({ ...e, monthLabel: m.label, year: m.year, month: m.month }); }); }); allDistExpenses.sort((a,b) => (b.date || '').localeCompare(a.date || '')); if (allDistExpenses.length > 0) { html += '
'; html += '
Distribution History
'; html += '
'; html += ''; allDistExpenses.forEach(e => { const expDate = e.date ? new Date(e.date + 'T12:00:00').toLocaleDateString('en-US', {month:'short', day:'numeric', year:'numeric'}) : e.monthLabel; const partnerItem = (lineItems || []).find(l => l.id === e.lineItemId); const partnerName = partnerItem ? partnerItem.label.split('(')[0].trim() : (e.description || '—'); html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; }); html += '
DatePartnerAmountNote
' + expDate + '' + partnerName + '$' + (e.amount || 0).toFixed(2) + '' + (e.vendor || '—') + '
'; } html += '
'; container.innerHTML = html; // Auto-populate vendor from line item note const liSelect = document.getElementById('fp-lineitem'); if (liSelect) { liSelect.addEventListener('change', function() { const opt = this.options[this.selectedIndex]; const note = opt?.getAttribute('data-note') || ''; const vendorEl = document.getElementById('fp-vendor'); if (vendorEl && note) vendorEl.value = note; }); } } // (inline cell editing removed — use Quick Entry instead) let _saveFPBusy = false; async function saveFPExpense() { if (_saveFPBusy) return; const lineItemId = document.getElementById('fp-lineitem')?.value || ''; if (!lineItemId) { showToast('Please select a line item', 'warning'); return; } const li = (_fpData?.lineItems || []).find(l => l.id === lineItemId); const category = li ? li.category : 'opex'; const dateVal = document.getElementById('fp-date')?.value; const vendor = document.getElementById('fp-vendor')?.value.trim() || ''; const desc = document.getElementById('fp-desc')?.value.trim() || (li ? li.label : ''); const amount = parseFloat(document.getElementById('fp-amount')?.value) || 0; if (amount <= 0) { showToast('Enter an amount', 'warning'); return; } const d = dateVal ? new Date(dateVal + 'T12:00:00') : new Date(); const year = d.getFullYear(); const month = d.getMonth() + 1; // Stash form values for rollback, then clear immediately (optimistic) const _restore = { vendor, desc, amount: document.getElementById('fp-amount')?.value, lineItemId }; document.getElementById('fp-vendor').value = ''; document.getElementById('fp-desc').value = ''; document.getElementById('fp-amount').value = ''; document.getElementById('fp-lineitem').value = ''; // Optimistic local update — inject into _fpData const tempId = '_tmp_' + Date.now(); const newExp = { id: tempId, description: desc, amount, category, year, month, vendor, date: dateVal, lineItemId }; if (_fpData) { const m = _fpData.months.find(m => m.year === year && m.month === month); if (m) { if (!m.expenses) m.expenses = []; m.expenses.push(newExp); } // Update summary fields const mData = _fpData.months.find(m => m.year === year && m.month === month); if (mData) { if (category === 'cogs') { mData.cogs = (mData.cogs || 0) + amount; mData.grossProfit = mData.netRevenue - mData.cogs; mData.ebitda = mData.grossProfit - mData.opex; mData.netIncome = mData.ebitda - mData.distributions; } else if (category === 'opex') { mData.opex = (mData.opex || 0) + amount; mData.ebitda = mData.grossProfit - mData.opex; mData.netIncome = mData.ebitda - mData.distributions; } else if (category === 'distribution') { mData.distributions = (mData.distributions || 0) + amount; mData.netIncome = mData.ebitda - mData.distributions; } } const container = document.getElementById('financial-performance-content'); if (container) _renderFPContent(container); } showToast('Saving...', 'info'); _saveFPBusy = true; try { const token = await currentUser.getIdToken(); const resp = await fetch('https://us-central1-trade-journal-fc3ba.cloudfunctions.net/saveBusinessExpense', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }, body: JSON.stringify({ description: desc, amount, category, year, month, vendor, date: dateVal, lineItemId }) }); const data = await resp.json(); if (!data.success) throw new Error(data.error); showToast('Saved ✓', 'success'); // Background sync to get real IDs setTimeout(() => { _fpDataTs = 0; renderFinancialPerformance(); }, 100); } catch(e) { showToast('Error: ' + e.message, 'error'); // Rollback form values const fpV = document.getElementById('fp-vendor'); if (fpV) fpV.value = _restore.vendor; const fpD = document.getElementById('fp-desc'); if (fpD) fpD.value = _restore.desc; const fpA = document.getElementById('fp-amount'); if (fpA) fpA.value = _restore.amount; const fpL = document.getElementById('fp-lineitem'); if (fpL) fpL.value = _restore.lineItemId; // Rollback local data _fpDataTs = 0; renderFinancialPerformance(); } _saveFPBusy = false; } async function deleteFPExpense(id) { if (!confirm('Delete this expense?')) return; // Optimistic local removal let removedExp = null; let removedMonth = null; if (_fpData) { for (const m of _fpData.months) { const idx = (m.expenses || []).findIndex(e => e.id === id); if (idx > -1) { removedExp = m.expenses[idx]; removedMonth = m; m.expenses.splice(idx, 1); // Update summary fields const amt = removedExp.amount || 0; const cat = removedExp.category; if (cat === 'cogs') { m.cogs = (m.cogs || 0) - amt; m.grossProfit = m.netRevenue - m.cogs; m.ebitda = m.grossProfit - m.opex; m.netIncome = m.ebitda - m.distributions; } else if (cat === 'opex') { m.opex = (m.opex || 0) - amt; m.ebitda = m.grossProfit - m.opex; m.netIncome = m.ebitda - m.distributions; } else if (cat === 'distribution') { m.distributions = (m.distributions || 0) - amt; m.netIncome = m.ebitda - m.distributions; } break; } } const container = document.getElementById('financial-performance-content'); if (container) _renderFPContent(container); } try { const token = await currentUser.getIdToken(); await fetch('https://us-central1-trade-journal-fc3ba.cloudfunctions.net/deleteBusinessExpense', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) }); showToast('Deleted ✓', 'success'); setTimeout(() => { _fpDataTs = 0; renderFinancialPerformance(); }, 100); } catch(e) { showToast('Error: ' + e.message, 'error'); // Rollback — re-insert if (removedExp && removedMonth) { removedMonth.expenses.push(removedExp); } _fpDataTs = 0; renderFinancialPerformance(); } } // ===== SCRAPER TOOLS PAGE (standalone) ===== async function renderScraperToolsPage() { const container = document.getElementById('scraper-tools-content'); if (!container) return; let html = '
'; html += '

🕷️ Scraper Tools

Monitor prop firm rule changes, manage baselines, review scraper analytics
'; html += ''; html += '
'; // Three widget cards html += '
'; // Coverage card html += '
🕷️ Scraper Coverage
'; // Tools card html += '
🛠️ Baseline Tools
'; html += '
'; // Analytics card (full width) html += '
📈 Scraper Analytics
'; // Audit log card (full width) html += '
📋 Scraper Audit Log
'; container.innerHTML = html; // Render widgets into the standalone containers // Coverage const covBody = document.getElementById('scraper-page-coverage'); if (covBody) { const origId = 'admin-wb-scraper_coverage'; covBody.id = origId; renderAdminScraperCoverage(); covBody.id = 'scraper-page-coverage'; } // Tools const toolsBody = document.getElementById('scraper-page-tools'); if (toolsBody) { const origId = 'admin-wb-scraper_tools'; toolsBody.id = origId; renderAdminScraperTools(); toolsBody.id = 'scraper-page-tools'; } // Analytics — needs charts const analyticsBody = document.getElementById('scraper-page-analytics'); if (analyticsBody) { const origId = 'admin-wb-scraper_analytics'; analyticsBody.id = origId; renderAdminScraperAnalytics(); analyticsBody.id = 'scraper-page-analytics'; } // Audit log const auditBody = document.getElementById('scraper-page-audit'); if (auditBody) { const origId = 'admin-wb-audit_log'; auditBody.id = origId; renderAdminAuditLog(); auditBody.id = 'scraper-page-audit'; } } // ===== AFFILIATE SYSTEM ===== const FUNCTIONS_BASE = 'https://us-central1-trade-journal-fc3ba.cloudfunctions.net'; // Legacy wrapper — old nav trigger calls this async function renderAffiliatePage() { renderAffiliateSelfView(); } async function _oldRenderAffiliatePage() { const container = document.getElementById('affiliate-page-content'); if (!container) return; container.innerHTML = '
Loading...
'; const userEmail = currentUser?.email; const userIsAdmin = isAdmin; // Read user doc fresh to check affiliate status let userIsAffiliate = false; let userDocData = null; try { const userDoc = await db.collection('users').doc(currentUser.uid).get(); userDocData = userDoc.exists ? userDoc.data() : null; userIsAffiliate = !!userDocData?.affiliateCouponCode; } catch(e) { console.error('Affiliate check error:', e); } // ── Admin view ── if (userIsAdmin) { await renderAffiliateAdminPanel(container); return; } // ── Affiliate self-view ── if (userIsAffiliate) { await renderAffiliateSelfView(container, userEmail, userDocData); return; } container.innerHTML = '
You don\'t have affiliate access.
'; } // ── ADMIN PANEL ── async function renderAffiliateAdminPanel(container) { container.innerHTML = '
Loading affiliates...
'; try { const _adminToken = await currentUser.getIdToken(); const resp = await fetch(`${FUNCTIONS_BASE}/getAffiliates`, { headers: { 'Authorization': 'Bearer ' + _adminToken } }); const data = await resp.json(); if (!resp.ok) throw new Error(data.error || 'Failed to load'); const affiliates = data.affiliates || []; let html = `

🤝 Manage Affiliates

`; if (affiliates.length === 0) { html += ''; } else { affiliates.forEach(a => { html += ` `; }); } html += '
Name Email Coupon Code Actions
No affiliates found
${a.name || a.displayName || 'N/A'} ${a.email} ${a.audienceCoupon || a.affiliateCouponCode || 'N/A'}
'; container.innerHTML = html; } catch (err) { container.innerHTML = `
Error: ${err.message}
`; } } // ── VIEW AFFILIATE DETAILS (admin click-through) ── async function viewAffiliateDetails(affiliateId, name) { const container = document.getElementById('affiliate-page-content'); container.innerHTML = '
Loading details...
'; try { const _detailToken = await currentUser.getIdToken(); const resp = await fetch(`${FUNCTIONS_BASE}/getAffiliateDetails?affiliateId=${affiliateId}`, { headers: { 'Authorization': 'Bearer ' + _detailToken } }); const data = await resp.json(); if (!resp.ok) throw new Error(data.error || 'Failed to load'); const referrals = data.referralDetails || []; const payouts = data.payouts || []; let html = `

${name}

Coupon: ${data.couponCode || 'N/A'}
Active Referrals
${data.activeCount}
Total Signups
${data.totalSignups}
Monthly Earnings
$${data.monthlyEarnings?.toFixed(2) || '0.00'}
Total Paid
$${data.totalPaid?.toFixed(2) || '0.00'}
Record Payout:

Referrals

`; if (referrals.length === 0) { html += ''; } else { referrals.forEach(r => { const statusColor = r.status === 'active' ? 'var(--green)' : 'var(--red)'; const dateStr = r.createdAt ? new Date(r.createdAt).toLocaleDateString() : 'N/A'; html += ` `; }); } html += `
Subscriber Joined Status Commission
No referrals yet
${r.email} ${dateStr} ${r.status} $${r.commission?.toFixed(2) || '0.00'}/mo

Payout History

`; if (payouts.length === 0) { html += ''; } else { payouts.forEach(p => { const dateStr = p.date ? new Date(p.date).toLocaleDateString() : 'N/A'; html += ` `; }); } html += '
Date Amount Method Note
No payouts recorded
${dateStr} $${p.amount?.toFixed(2)} ${p.method} ${p.note || '—'}
'; container.innerHTML = html; } catch (err) { container.innerHTML = `
Error: ${err.message}
`; } } // ── RECORD PAYOUT ── async function recordPayout(affiliateId, name) { const amount = document.getElementById('aff-payout-amount')?.value; const method = document.getElementById('aff-payout-method')?.value; const note = document.getElementById('aff-payout-note')?.value; if (!amount || parseFloat(amount) <= 0) { showToast('Enter a valid amount', 'error'); return; } try { const _payoutToken = await currentUser.getIdToken(); const resp = await fetch(`${FUNCTIONS_BASE}/recordAffiliatePayout`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + _payoutToken }, body: JSON.stringify({ affiliateId, amount: parseFloat(amount), method, note }) }); const data = await resp.json(); if (!resp.ok) throw new Error(data.error || 'Failed'); showToast('Payout recorded', 'success'); viewAffiliateDetails(affiliateId, name); } catch (err) { showToast('Error: ' + err.message, 'error'); } } // ── ADD AFFILIATE MODAL ── function openAddAffiliateModal() { const modal = document.createElement('div'); modal.className = 'modal'; modal.id = 'add-affiliate-modal'; modal.style.display = 'flex'; modal.onclick = function(e) { if (e.target === this) this.remove(); }; modal.innerHTML = ` `; document.body.appendChild(modal); // Search user on blur document.getElementById('aff-search-email').addEventListener('blur', async function() { const email = this.value.trim().toLowerCase(); const resultEl = document.getElementById('aff-search-result'); if (!email) { resultEl.textContent = ''; return; } const snap = await db.collection('users').where('email', '==', email).limit(1).get(); if (snap.empty) { resultEl.innerHTML = 'No user found with this email'; } else { const u = snap.docs[0].data(); resultEl.innerHTML = `Found: ${u.displayName || email} (${snap.docs[0].id})`; resultEl.dataset.userId = snap.docs[0].id; } }); } async function syncAffiliateReferralsFromUI() { const btn = document.getElementById('aff-sync-btn'); if (btn) { btn.disabled = true; btn.innerHTML = '⏳ Syncing...'; } setSyncProgress(5, 'Connecting to Stripe...', 'Syncing Affiliate Referrals'); const syncLabels = ['Connecting to Stripe...', 'Fetching referrals...', 'Calculating commissions...', 'Updating dashboard...']; const syncInterval = setInterval(() => { const bar = document.getElementById('sync-progress-bar'); const current = parseFloat(bar?.style.width) || 5; if (current < 85) { const idx = Math.floor((current - 5) / 20); setSyncProgress(Math.min(current + 5, 85), syncLabels[Math.min(idx, syncLabels.length - 1)]); } }, 2000); try { const token = await currentUser.getIdToken(); const resp = await fetch('https://us-central1-trade-journal-fc3ba.cloudfunctions.net/syncAffiliateReferrals', { headers: { 'Authorization': 'Bearer ' + token } }); const data = await resp.json(); if (!resp.ok) throw new Error(data.error || 'Failed'); clearInterval(syncInterval); setSyncProgress(100, '✅ Sync complete! ' + data.synced + ' new, ' + data.skipped + ' existing'); setTimeout(clearSyncProgress, 2000); showToast('Referrals synced: ' + data.synced + ' new, ' + data.skipped + ' existing', 'success'); renderAffiliatePage(); } catch (err) { clearInterval(syncInterval); clearSyncProgress(); showToast('Sync failed: ' + err.message, 'error'); } if (btn) { btn.disabled = false; btn.innerHTML = '🔄 Sync Referrals'; } } async function submitAddAffiliate() { const email = document.getElementById('aff-search-email')?.value.trim().toLowerCase(); const coupon = document.getElementById('aff-coupon-code')?.value.trim().toUpperCase(); const rate = parseInt(document.getElementById('aff-commission-rate')?.value) || 20; const resultEl = document.getElementById('aff-search-result'); const userId = resultEl?.dataset?.userId; if (!email || !coupon) { showToast('Email and coupon code required', 'error'); return; } if (!userId) { showToast('Search for the user first', 'error'); return; } try { // Set affiliate fields on user doc (don't touch subscription — Stripe manages that) await db.collection('users').doc(userId).set({ affiliateCouponCode: coupon, affiliateCommissionRate: rate / 100 }, { merge: true }); showToast(`${email} added as affiliate with coupon ${coupon}`, 'success'); document.getElementById('add-affiliate-modal')?.remove(); renderAffiliatePage(); } catch (err) { showToast('Error: ' + err.message, 'error'); } } // ── AFFILIATE SELF-VIEW ── async function renderAffiliateSelfView(container, email, userDocData) { try { // Fetch affiliate data via Cloud Function (avoids Firestore permission issues) const idToken = await currentUser.getIdToken(); const resp = await fetch(FUNCTIONS_BASE + '/getMyAffiliateData', { headers: { 'Authorization': 'Bearer ' + idToken } }); const data = await resp.json(); if (!resp.ok) throw new Error(data.error || 'Failed to load affiliate data'); const couponCode = data.couponCode || ''; const refCode = couponCode.replace(/\d+$/, '').toLowerCase(); const commissionRate = data.commissionRate || 0.20; const commissionPct = Math.round(commissionRate * 100); const activeCount = data.activeCount || 0; const referrals = data.referrals || []; const payouts = data.payouts || []; const monthlyEarnings = data.monthlyEarnings || 0; const totalPaid = data.totalPaid || 0; const refLink = 'https://payoutlab.io?ref=' + (refCode || couponCode.toLowerCase()); let html = ''; // ── Header ── html += '
'; html += '
'; html += '

🤝 Affiliate Dashboard

'; html += '
'; html += ' Active'; html += ' ' + commissionPct + '% commission'; html += '
'; html += '
'; html += '
'; // ── Referral Tools (prominent, top of page) ── html += '
'; html += '

Your Referral Tools

'; // Referral link html += '
'; html += ' '; html += '
'; html += ' '; html += ' '; html += '
'; html += '
Share this link — visitors who sign up within 30 days are attributed to you
'; html += '
'; // Coupon code html += '
'; html += ' '; html += '
'; html += '
' + couponCode + '
'; html += ' '; html += '
'; html += '
Gives new users 20% off the current price for life — automatically tracked to your account
'; html += '
'; // Tips html += '
'; html += ' Sharing tips: Add your link to YouTube descriptions, Twitter bio, Discord servers, and trading community posts. When someone uses your coupon code at checkout, the referral is tracked automatically.'; html += '
'; html += '
'; // ── Stats row ── html += '
'; const stats = [ { label: 'Active Referrals', value: activeCount, color: 'var(--cyan)' }, { label: 'Est. This Month', value: '$' + monthlyEarnings.toFixed(2), color: 'var(--green)' }, { label: 'Total Paid Out', value: '$' + totalPaid.toFixed(2), color: 'var(--text-primary)' }, { label: 'Total Signups', value: referrals.length, color: 'var(--text-primary)' }, ]; stats.forEach(s => { html += '
'; html += '
' + s.label + '
'; html += '
' + s.value + '
'; html += '
'; }); html += '
'; // ── Commission structure info ── html += '
'; html += '

Commission Structure

'; html += '
'; html += '
' + commissionPct + '%
'; html += '
'; html += '
Flat commission — every month, for life
'; html += '
You earn ' + commissionPct + '% of each referral\'s monthly subscription for as long as they remain active.
'; html += '
'; html += '
'; html += '
'; // ── Referrals table ── html += '

Your Referrals

'; html += '
'; html += '
'; html += ''; html += ''; html += ' '; html += ' '; html += ' '; html += ' '; html += ' '; html += ' '; html += ' '; html += ''; if (referrals.length === 0) { html += ''; } else { referrals.forEach(r => { const statusColor = r.status === 'active' ? 'var(--green)' : 'var(--red)'; const dateStr = r.joinedAt ? new Date(r.joinedAt).toLocaleDateString() : 'N/A'; const commission = r.commission || 0; const monthlyPayment = r.monthlyPayment || 0; const monthsActive = r.monthsActive || 0; const totalEarned = r.totalEarned || 0; const rowOpacity = r.status !== 'active' ? 'opacity: 0.5;' : ''; html += ''; html += ' '; html += ' '; html += ' '; html += ' '; html += ' '; html += ' '; html += ' '; html += ''; }); } html += '
SubscriberJoinedPaysYour CutMonthsTotal EarnedStatus
'; html += ' No referrals yet — share your link and coupon code to get started!'; html += '
' + (r.email || 'Unknown') + '' + dateStr + '$' + monthlyPayment.toFixed(2) + '/mo$' + commission.toFixed(2) + '/mo' + monthsActive + '$' + totalEarned.toFixed(2) + '' + r.status + '
'; // ── Payout history ── html += '

Payout History

'; html += '
'; html += ''; html += ''; html += ' '; html += ' '; html += ' '; html += ' '; html += ''; if (payouts.length === 0) { html += ''; } else { payouts.forEach(p => { const dateStr = p.date ? new Date(p.date).toLocaleDateString() : 'N/A'; html += ''; html += ' '; html += ' '; html += ' '; html += ' '; html += ''; }); } html += '
DateAmountMethodNote
No payouts yet — commissions are paid monthly
' + dateStr + '$' + (p.amount || 0).toFixed(2) + '' + (p.method || 'PayPal') + '' + (p.note || '—') + '
'; container.innerHTML = html; } catch (err) { container.innerHTML = '
Error loading affiliate data: ' + err.message + '
'; } } // ===== AFFILIATE SELF-VIEW (new) ===== async function renderAffiliateSelfView() { const container = document.getElementById('affiliate-page-content'); if (!container) return; // Show cached immediately if available const cached = cacheGet('affiliateSelf'); if (cached) { _renderAffiliateSelfHTML(container, cached); // Background refresh currentUser.getIdToken().then(token => fetch(FUNCTIONS_BASE + '/getAffiliateSelfStats', { headers: { 'Authorization': 'Bearer ' + token } }) ).then(r => r.json()).then(data => { if (data.success) { cacheSet('affiliateSelf', data, 5 * 60 * 1000); _renderAffiliateSelfHTML(container, data); } }).catch(() => {}); return; } container.innerHTML = '
'; try { const token = await currentUser.getIdToken(); const resp = await fetch(FUNCTIONS_BASE + '/getAffiliateSelfStats', { headers: { 'Authorization': 'Bearer ' + token } }); const data = await resp.json(); if (!data.success) throw new Error(data.error || 'Failed'); cacheSet('affiliateSelf', data, 5 * 60 * 1000); _renderAffiliateSelfHTML(container, data); } catch(e) { container.innerHTML = '
Error: ' + e.message + '
'; } } function _renderAffiliateSelfHTML(container, data) { const fmt = v => '$' + (v || 0).toFixed(2); const asAge = cacheAge('affiliateSelf'); const asLabel = asAge !== null ? ' · Updated ' + (asAge < 1 ? 'just now' : asAge + 'm ago') : ''; let html = '
'; html += '

🤝 Affiliate Dashboard

Welcome back, ' + data.name + ' · Code: ' + data.couponCode + '' + asLabel + '
'; html += '
'; [{label:'ACTIVE REFERRALS',value:data.activeReferrals,color:'#00d4aa'},{label:'MONTHLY MRR',value:fmt(data.monthlyMRR),color:'#00d4aa'},{label:'YOUR COMMISSION/MO',value:fmt(data.monthlyCommission),color:'#f59e0b'},{label:'TOTAL PAID TO YOU',value:fmt(data.totalPaid),color:'var(--text-muted)'}].forEach(s => { html += '
' + s.label + '
' + s.value + '
'; }); html += '
'; html += '
Your Referral Link
Share — they get 20% off, you earn ' + Math.round(data.commissionRate * 100) + '% commission
payoutlab.io/?ref=' + data.couponCode + '
'; html += '
Your Referrals (' + data.totalReferrals + ')
'; if (data.referrals.length > 0) { html += ''; data.referrals.forEach(r => { const sc = r.status === 'active' ? '#00d4aa' : '#64748b'; const bg = r.status === 'active' ? 'rgba(0,212,170,0.15)' : 'rgba(100,116,139,0.15)'; html += ''; }); html += '
EMAILSTATUSMONTHLY VALUEYOUR COMMISSIONJOINED
' + r.email + '' + r.status + '' + fmt(r.monthlyRate) + '' + fmt(r.commission) + '' + (r.signupDate ? new Date(r.signupDate).toLocaleDateString() : '—') + '
'; } else { html += '
No referrals yet — share your link to get started!
'; } html += '
'; if (data.payouts.length > 0) { html += '
Payout History
'; data.payouts.forEach(p => { html += '
' + (p.paidAt ? new Date(p.paidAt).toLocaleDateString() : '—') + '' + fmt(p.amount) + '
'; }); html += '
'; } // Giveaway campaigns placeholder — populated asynchronously by _loadGiveawayCampaigns html += '
'; html += '
'; container.innerHTML = html; // Fire-and-forget load of giveaway campaigns for this affiliate _loadGiveawayCampaigns(data.couponCode); } async function _loadGiveawayCampaigns(affiliateCouponCode) { const el = document.getElementById('affiliate-giveaway-campaigns'); if (!el || !affiliateCouponCode) return; el.innerHTML = '
Loading giveaway campaigns…
'; try { const snap = await db.collection('giveawayCodes') .where('affiliateCouponCode', '==', affiliateCouponCode) .get(); if (snap.empty) { el.innerHTML = ''; return; } const rows = []; snap.forEach(d => rows.push({ id: d.id, ...d.data() })); // Group by campaign, sort campaigns by createdAt desc (use newest code in each group) const groups = {}; rows.forEach(r => { const key = r.campaign || 'Uncategorized'; if (!groups[key]) groups[key] = []; groups[key].push(r); }); const campaignOrder = Object.keys(groups).sort((a, b) => { const newestA = Math.max(...groups[a].map(r => r.createdAt?.toMillis?.() || 0)); const newestB = Math.max(...groups[b].map(r => r.createdAt?.toMillis?.() || 0)); return newestB - newestA; }); let html = '
'; html += '
🎁 Giveaway Campaigns (' + rows.length + ' code' + (rows.length === 1 ? '' : 's') + ')
'; html += '
'; campaignOrder.forEach(campaign => { const campaignRows = groups[campaign].sort((a, b) => (b.createdAt?.toMillis?.() || 0) - (a.createdAt?.toMillis?.() || 0)); const totalRed = campaignRows.reduce((s, r) => s + (r.redemptions || 0), 0); const totalMax = campaignRows.reduce((s, r) => s + (r.maxRedemptions || 0), 0); html += '
'; html += '
'; html += '' + campaign + ''; html += '' + totalRed + ' / ' + totalMax + ' redeemed'; html += '
'; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; campaignRows.forEach(r => { const active = r.active === true && (r.redemptions || 0) < (r.maxRedemptions || 1); const statusColor = active ? '#00d4aa' : '#64748b'; const statusBg = active ? 'rgba(0,212,170,0.15)' : 'rgba(100,116,139,0.15)'; const statusLabel = active ? 'Active' : 'Redeemed'; const created = r.createdAt?.toDate ? r.createdAt.toDate().toLocaleDateString() : '—'; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; }); html += '
CODEREDEMPTIONSMAXSTATUSCREATED
' + r.id + '' + (r.redemptions || 0) + '' + (r.maxRedemptions || 1) + '' + statusLabel + '' + created + '
'; }); html += '
'; el.innerHTML = html; } catch (err) { console.error('[Affiliate] Giveaway campaigns load failed:', err); el.innerHTML = '
Unable to load giveaway campaigns right now.
'; } } // ===== AFFILIATE MANAGEMENT (admin only) ===== async function renderAffiliateManagement() { const container = document.getElementById('page-affiliate-management'); if (!container) return; // Show cached immediately if available const cached = cacheGet('affiliateStats'); if (cached) { _renderAffMgmtHTML(container, cached); // Background refresh currentUser.getIdToken().then(token => fetch(FUNCTIONS_BASE + '/getAffiliateStats', { headers: { 'Authorization': 'Bearer ' + token } }) ).then(r => r.json()).then(data => { if (data.success) { cacheSet('affiliateStats', data, 5 * 60 * 1000); _renderAffMgmtHTML(container, data); } }).catch(() => {}); return; } container.innerHTML = '
' + Array(6).fill('
').join('') + '
'; try { const token = await currentUser.getIdToken(); const resp = await fetch(FUNCTIONS_BASE + '/getAffiliateStats', { headers: { 'Authorization': 'Bearer ' + token } }); const data = await resp.json(); if (!data.success) throw new Error(data.error || 'Failed'); cacheSet('affiliateStats', data, 5 * 60 * 1000); _renderAffMgmtHTML(container, data); } catch(e) { container.innerHTML = '
Error: ' + e.message + '
'; } } function _renderAffMgmtHTML(container, data) { const { summary, affiliates } = data; const fmt = v => '$' + (v || 0).toFixed(2); const amAge = cacheAge('affiliateStats'); const amLabel = amAge !== null ? ' · Updated ' + (amAge < 1 ? 'just now' : amAge + 'm ago') : ''; let html = '
'; html += '

🤝 Affiliate Management

Manage affiliates, track referrals and commissions' + amLabel + '
'; html += '
'; [{label:'TOTAL AFFILIATES',value:summary.totalAffiliates,color:'var(--text-primary)'},{label:'ACTIVE AFFILIATES',value:summary.activeAffiliates,color:'#00d4aa'},{label:'TOTAL REFERRALS',value:summary.totalActiveReferrals+' active',color:'#00d4aa'},{label:'AFFILIATE MRR',value:fmt(summary.totalMRR),color:'#00d4aa'},{label:'MONTHLY COMMISSION',value:fmt(summary.totalMonthlyCommission),color:'#f59e0b'},{label:'TOTAL PAID OUT',value:fmt(summary.totalPaid),color:'var(--text-muted)'}].forEach(s => { html += '
' + s.label + '
' + s.value + '
'; }); html += '
'; affiliates.forEach(a => { html += '
'; html += '
'; html += '
🤝
' + a.name + '
' + a.email + ' · ' + a.couponCode + ' · ' + Math.round(a.commissionRate * 100) + '% commission
'; html += '
'; [{l:'ACTIVE',v:a.activeReferrals,c:'#00d4aa'},{l:'CHURNED',v:a.churnedReferrals,c:'var(--text-muted)'},{l:'MRR',v:fmt(a.monthlyMRR),c:'#00d4aa'},{l:'COMM/MO',v:fmt(a.monthlyCommission),c:'#f59e0b'},{l:'PAID',v:fmt(a.totalPaid),c:'var(--text-muted)'}].forEach(s => { html += '
' + s.l + '
' + s.v + '
'; }); html += ''; html += ''; html += '
'; html += '
'; }); html += '
'; container.innerHTML = html; } function toggleAffiliateCard(couponCode) { const el = document.getElementById('aff-card-' + couponCode); if (el) el.style.display = el.style.display === 'none' ? 'block' : 'none'; } async function markAffiliatePaid(couponCode, name, userUid, amount) { if (!userUid) { showToast('No PayoutLab account found for this affiliate', 'error'); return; } if (!confirm('Mark ' + name + ' as paid $' + amount.toFixed(2) + ' commission?')) return; try { await db.collection('users').doc(userUid).collection('affiliatePayouts').add({ amount: amount, couponCode: couponCode, paidAt: firebase.firestore.FieldValue.serverTimestamp(), note: 'Manual payout - ' + new Date().toLocaleDateString(), paidBy: currentUser.uid, }); showToast('Payout recorded for ' + name, 'success'); renderAffiliateManagement(); } catch(e) { showToast('Error: ' + e.message, 'error'); } } // ===== GUIDES SYSTEM ===== const GUIDE_STORE = 'https://storage.googleapis.com/trade-journal-fc3ba.firebasestorage.app/walkthrough'; const GUIDES = { 'tradovate-import': { title: 'Tradovate Import Guide', heading: 'Importing Tradovate Historical Trades', subtitle: 'Follow these steps to import your complete trade history into PayoutLab', note: 'The Tradovate API only syncs trades from today forward. To import historical trades, you need to export a CSV from Tradovate first.', steps: [ { title: 'Login to your Tradovate account', desc: 'Login to your Tradovate account.', gif: GUIDE_STORE + '/tradovate-step-1.gif' }, { title: 'Open Performance Reports', desc: 'Access the Performance Report menu on the right panel. Select your account from the drop-down.', gif: GUIDE_STORE + '/tradovate-step-2.gif' }, { title: 'Select your full date range', desc: 'Set the date range to cover all your trading history from the very beginning of your account. Click GO to present all your performance data within Tradovate.', gif: GUIDE_STORE + '/tradovate-step-3.gif' }, { title: 'Download your trade history CSV', desc: 'Click Download CSV to store on your local machine to prepare for file import.', gif: GUIDE_STORE + '/tradovate-step-4.gif' }, { title: 'Import your CSV into PayoutLab', desc: 'Click Import CSV, select your Tradovate account, and upload the file. Your full trade history will be imported automatically.', gif: GUIDE_STORE + '/tradovate-step-5.gif' }, ], cta: { label: 'Import CSV Now', action: function() { closeGuideWindow(); showPage('settings'); switchSettingsSection('accounts'); setTimeout(() => openAddConnectionModal(), 300); } } } }; let _activeGuideWindow = null; function openGuideWindow(guideId) { const guide = GUIDES[guideId]; if (!guide) return; // Close existing if (_activeGuideWindow) { _activeGuideWindow.remove(); _activeGuideWindow = null; } const win = document.createElement('div'); win.className = 'guide-window'; win.id = 'guide-window'; win.style.width = '480px'; win.style.height = '680px'; win.style.top = '20px'; win.style.right = '20px'; win.style.left = 'auto'; // Header const header = document.createElement('div'); header.className = 'guide-window-header'; header.innerHTML = '
' + guide.title + '
' + '
' + '' + '' + '' + '
'; // Drag logic let isDragging = false, dragOffsetX = 0, dragOffsetY = 0; header.addEventListener('mousedown', (e) => { if (e.target.closest('.guide-window-btn')) return; isDragging = true; const rect = win.getBoundingClientRect(); dragOffsetX = e.clientX - rect.left; dragOffsetY = e.clientY - rect.top; win.style.right = 'auto'; win.style.left = rect.left + 'px'; e.preventDefault(); }); document.addEventListener('mousemove', (e) => { if (!isDragging) return; win.style.left = (e.clientX - dragOffsetX) + 'px'; win.style.top = (e.clientY - dragOffsetY) + 'px'; }); document.addEventListener('mouseup', () => { isDragging = false; }); // Body content const body = document.createElement('div'); body.className = 'guide-window-body'; let html = ''; html += '
'; html += '
' + guide.heading + '
'; html += '
' + guide.subtitle + '
'; html += '
'; if (guide.note) { html += '
Note: ' + guide.note + '
'; } guide.steps.forEach((step, i) => { html += '
'; html += '
' + (i + 1) + '
'; html += '
' + step.title + '
'; html += '
' + step.desc + '
'; if (step.gif) { html += 'Step ' + (i + 1) + ''; } html += '
'; }); if (guide.cta) { html += '
'; html += ''; html += '
'; } body.innerHTML = html; // Resize handles — all 8 edges/corners const edges = ['n','s','w','e','nw','ne','sw','se']; let _resizeDir = null; let _resizeStartX = 0, _resizeStartY = 0, _resizeStartRect = null; edges.forEach(dir => { const handle = document.createElement('div'); handle.className = 'guide-resize guide-resize-' + dir; handle.addEventListener('mousedown', (e) => { _resizeDir = dir; _resizeStartX = e.clientX; _resizeStartY = e.clientY; _resizeStartRect = win.getBoundingClientRect(); e.preventDefault(); e.stopPropagation(); }); win.appendChild(handle); }); document.addEventListener('mousemove', (e) => { if (!_resizeDir || !_resizeStartRect) return; const dx = e.clientX - _resizeStartX; const dy = e.clientY - _resizeStartY; const r = _resizeStartRect; const MIN_W = 360, MIN_H = 300; let newLeft = parseFloat(win.style.left) || r.left; let newTop = parseFloat(win.style.top) || r.top; let newW = r.width; let newH = r.height; if (_resizeDir.includes('e')) newW = Math.max(MIN_W, r.width + dx); if (_resizeDir.includes('s')) newH = Math.max(MIN_H, r.height + dy); if (_resizeDir.includes('w')) { const w2 = Math.max(MIN_W, r.width - dx); newLeft = r.left + (r.width - w2); newW = w2; } if (_resizeDir.includes('n')) { const h2 = Math.max(MIN_H, r.height - dy); newTop = r.top + (r.height - h2); newH = h2; } win.style.left = newLeft + 'px'; win.style.top = newTop + 'px'; win.style.right = 'auto'; win.style.width = newW + 'px'; win.style.height = newH + 'px'; }); document.addEventListener('mouseup', () => { _resizeDir = null; _resizeStartRect = null; }); win.appendChild(header); win.appendChild(body); document.body.appendChild(win); _activeGuideWindow = win; // GIF reset on scroll — reload each GIF when it scrolls into view const gifObserver = new IntersectionObserver((entries) => { entries.forEach(entry => { const img = entry.target; const src = img.dataset.src; if (!src) return; if (entry.isIntersecting) { // Reset GIF by re-assigning src with cache-buster img.src = src + (src.includes('?') ? '&' : '?') + '_t=' + Date.now(); } }); }, { root: body, threshold: 0.3 }); body.querySelectorAll('.guide-step-gif').forEach(img => { // Load first GIF immediately if (img === body.querySelector('.guide-step-gif')) { img.src = img.dataset.src; } try { gifObserver.observe(img); } catch(e) {} }); } function closeGuideWindow() { if (_activeGuideWindow) { _activeGuideWindow.remove(); _activeGuideWindow = null; } } function toggleGuideMinimize() { if (_activeGuideWindow) _activeGuideWindow.classList.toggle('minimized'); } function popOutGuideWindow(guideId) { const guide = GUIDES[guideId]; if (!guide) return; closeGuideWindow(); let html = '' + guide.title + ''; html += ''; html += '
' + guide.heading + '
'; html += '
' + guide.subtitle + '
'; if (guide.note) html += '
Note: ' + guide.note + '
'; guide.steps.forEach(function(step, i) { html += '
' + (i + 1) + '
'; html += '
' + step.title + '
'; html += '
' + step.desc + '
'; if (step.gif) html += ''; html += '
'; }); html += ''; const popup = window.open('', '_blank', 'width=560,height=720,scrollbars=yes'); if (popup) { popup.document.write(html); popup.document.close(); } } // ===== TRADOVATE INFO POPOVER (group header) ===== let _activeTvPopover = null; function toggleTvInfoPopover(iconEl) { // Close existing if (_activeTvPopover) { _activeTvPopover.remove(); _activeTvPopover = null; return; } const rect = iconEl.getBoundingClientRect(); const pop = document.createElement('div'); pop.className = 'tv-info-popover'; pop.style.position = 'fixed'; pop.style.left = Math.min(rect.left, window.innerWidth - 320) + 'px'; pop.style.top = (rect.bottom + 8) + 'px'; pop.innerHTML = ` Tradovate syncs today's trades automatically. Historical trades need a manual CSV export. View walkthrough → `; document.body.appendChild(pop); _activeTvPopover = pop; // Dismiss on outside click (after a tick to avoid immediate close) setTimeout(() => { document.addEventListener('click', _closeTvPopoverOnClick, { once: true }); }, 0); } function _closeTvPopoverOnClick(e) { if (_activeTvPopover && !_activeTvPopover.contains(e.target)) { closeTvPopover(); } else if (_activeTvPopover) { // Re-listen if click was inside popover document.addEventListener('click', _closeTvPopoverOnClick, { once: true }); } } function closeTvPopover() { if (_activeTvPopover) { _activeTvPopover.remove(); _activeTvPopover = null; } } // ===== LAB TRAINING SYSTEM v2 ===== const LAB_DEMO_ACCOUNT_ID = 'lucid_LUCIDDIRECT_DEMO_LAB'; function getDemoDate(businessDaysAgo, hour, minute) { // Walk back N business days. Each offset always lands on a distinct // trading day so multiple LAB_DEMO trades don't collapse onto the // same Friday when the tour is run on a weekend (would skew the // best-day consistency calc). const d = new Date(); d.setHours(hour, minute, 0, 0); let count = 0; while (count < businessDaysAgo) { d.setDate(d.getDate() - 1); const dow = d.getDay(); if (dow !== 0 && dow !== 6) count++; } return d.toISOString(); } const LAB_DEMO_TRADES = [ // All qty=1, ~$160 per trade (doubled), 15 trades across 8 days → ~$2,400 total // Max day ≤ $328 → under 15% of total → passes Lucid Direct 20% consistency rule { symbol: 'MNQ', side: 'Long', qty: 1, entryPrice: 19820.5, exitPrice: 19860.5, entryTime: getDemoDate(14, 9, 31), exitTime: getDemoDate(14, 10, 15), pnl: 160.00, commission: 1.90, source: 'tradovate', netPnl: 160.00 }, { symbol: 'MNQ', side: 'Long', qty: 1, entryPrice: 19865.25, exitPrice: 19905.25, entryTime: getDemoDate(14, 10, 45), exitTime: getDemoDate(14, 11, 15), pnl: 160.00, commission: 1.90, source: 'tradovate', netPnl: 160.00 }, { symbol: 'MNQ', side: 'Long', qty: 1, entryPrice: 19830.75, exitPrice: 19872.75, entryTime: getDemoDate(13, 9, 45), exitTime: getDemoDate(13, 10, 30), pnl: 168.00, commission: 1.90, source: 'tradovate', netPnl: 168.00 }, { symbol: 'MNQ', side: 'Long', qty: 1, entryPrice: 19880.0, exitPrice: 19918.0, entryTime: getDemoDate(13, 11, 15), exitTime: getDemoDate(13, 11, 45), pnl: 152.00, commission: 1.90, source: 'tradovate', netPnl: 152.00 }, { symbol: 'MNQ', side: 'Long', qty: 1, entryPrice: 19840.25, exitPrice: 19882.25, entryTime: getDemoDate(12, 9, 30), exitTime: getDemoDate(12, 10, 0), pnl: 168.00, commission: 1.90, source: 'tradovate', netPnl: 168.00 }, { symbol: 'MNQ', side: 'Long', qty: 1, entryPrice: 19890.5, exitPrice: 19928.5, entryTime: getDemoDate(12, 10, 30), exitTime: getDemoDate(12, 11, 0), pnl: 152.00, commission: 1.90, source: 'tradovate', netPnl: 152.00 }, { symbol: 'MNQ', side: 'Long', qty: 1, entryPrice: 19850.0, exitPrice: 19890.0, entryTime: getDemoDate(11, 9, 32), exitTime: getDemoDate(11, 10, 15), pnl: 160.00, commission: 1.90, source: 'tradovate', netPnl: 160.00 }, { symbol: 'MNQ', side: 'Long', qty: 1, entryPrice: 19900.75, exitPrice: 19940.75, entryTime: getDemoDate(11, 11, 0), exitTime: getDemoDate(11, 11, 30), pnl: 160.00, commission: 1.90, source: 'tradovate', netPnl: 160.00 }, { symbol: 'MNQ', side: 'Long', qty: 1, entryPrice: 19860.5, exitPrice: 19898.5, entryTime: getDemoDate(10, 9, 45), exitTime: getDemoDate(10, 10, 30), pnl: 152.00, commission: 1.90, source: 'tradovate', netPnl: 152.00 }, { symbol: 'MNQ', side: 'Long', qty: 1, entryPrice: 19910.25, exitPrice: 19952.25, entryTime: getDemoDate(10, 11, 15), exitTime: getDemoDate(10, 11, 45), pnl: 168.00, commission: 1.90, source: 'tradovate', netPnl: 168.00 }, { symbol: 'MNQ', side: 'Long', qty: 1, entryPrice: 19870.0, exitPrice: 19910.0, entryTime: getDemoDate(9, 9, 30), exitTime: getDemoDate(9, 10, 0), pnl: 160.00, commission: 1.90, source: 'tradovate', netPnl: 160.00 }, { symbol: 'MNQ', side: 'Long', qty: 1, entryPrice: 19880.5, exitPrice: 19920.5, entryTime: getDemoDate(8, 9, 45), exitTime: getDemoDate(8, 10, 30), pnl: 160.00, commission: 1.90, source: 'tradovate', netPnl: 160.00 }, { symbol: 'MNQ', side: 'Long', qty: 1, entryPrice: 19930.25, exitPrice: 19968.25, entryTime: getDemoDate(8, 11, 0), exitTime: getDemoDate(8, 11, 45), pnl: 152.00, commission: 1.90, source: 'tradovate', netPnl: 152.00 }, { symbol: 'MNQ', side: 'Long', qty: 1, entryPrice: 19890.75, exitPrice: 19932.75, entryTime: getDemoDate(7, 9, 30), exitTime: getDemoDate(7, 10, 15), pnl: 168.00, commission: 1.90, source: 'tradovate', netPnl: 168.00 }, { symbol: 'MNQ', side: 'Long', qty: 1, entryPrice: 19940.0, exitPrice: 19980.0, entryTime: getDemoDate(7, 11, 0), exitTime: getDemoDate(7, 11, 30), pnl: 160.00, commission: 1.90, source: 'tradovate', netPnl: 160.00 }, ]; // Click-override map: { stepIndex: (nextIdx) => { ... } } // When a step is in this map, clicking the spotlighted element runs the override // function (which is responsible for advancing) instead of the default 400ms auto-advance. const LAB_STEP_CLICK_OVERRIDES = { 2: (nextIdx) => { // Step 3 (index 2) — user clicked Tradovate card // Show sync animation, then advance simulateLabTradovateSync(nextIdx); }, // Filter by Account — clicking a filter dropdown shouldn't advance the // tour. No-op override blocks the default click-to-advance; user must // press Next →. Pulse dot is also hidden for no-op overrides below. 8: () => {}, }; // Step definitions — action() runs BEFORE the tooltip shows (after page navigation) // Data injection happens progressively: account+today at step 3, historical at step 6 const LAB_STEPS = [ { title: '👋 Welcome to PayoutLab', desc: 'This is your command center for funded trading. Every account, every payout, every trade — tracked in one place. Let\'s set up a demo account so you can see it in action.', target: null, position: 'center', page: 'dashboard', }, { title: '🔗 Add Your First Connection', desc: 'See the green "+ Add Connection" button in the top left? That\'s how you connect your broker account. Click Next and we\'ll walk you through selecting Tradovate.', target: '#import-btn-top', position: 'right', page: 'dashboard', action: async () => { // Retry until #import-btn-top is visible with a valid bounding rect let attempts = 0; while (attempts < 10) { const btn = document.getElementById('import-btn-top'); if (btn) { const rect = btn.getBoundingClientRect(); if (rect.width > 0 && rect.height > 0) break; } await new Promise(r => setTimeout(r, 150)); attempts++; } }, }, { title: '🔗 Select Tradovate', desc: 'The connection modal shows your broker options. Click Tradovate — it\'s used by Lucid, Tradeify, and all Tradovate-connected prop firms.', target: '#wizard-step-1 div[onclick*="tradovate"]', targetFallback: null, position: 'right', page: 'dashboard', action: async () => { if (typeof openAddConnectionModal === 'function') openAddConnectionModal(); await new Promise(r => setTimeout(r, 300)); }, // Mirror the click override for the Next path so the Tradovate // connecting animation plays whether the user clicks the card OR // the Next button. Without this, Next bypasses the animation // (Step 4's `page: 'settings'` navigates first, hiding the modal). nextOverride: (nextIdx) => simulateLabTradovateSync(nextIdx), }, { title: '✅ Account Connected!', desc: 'Your Lucid Direct account is now connected and synced. PayoutLab pulled today\'s 3 MNQ trades automatically. Every day at market close, your trades sync without any action from you.', target: '[data-account-id="lucid_LUCIDDIRECT_DEMO_LAB"]', targetFallback: '#unified-accounts-table', position: 'below', page: 'settings', action: async () => { // If the user clicked Next → instead of the Tradovate card on the // previous step, the sync animation never ran. Play it now — // simulateLabTradovateSync calls runLabStep(3) at the end which // re-enters this step. On re-entry #lab-sync-screen exists, so // this guard falls through to the rest of the action. if (!document.getElementById('lab-sync-screen')) { simulateLabTradovateSync(3); return; } cleanupLabSyncScreen(); if (typeof closeAddConnectionModal === 'function') closeAddConnectionModal(); await new Promise(r => setTimeout(r, 300)); await injectLabDemoAccount(); await injectLabDemoTodayTrades(); // Manually push demo account into accounts array since onSnapshot is suppressed during labActive if (typeof accounts !== 'undefined') { const demoAcct = { id: LAB_DEMO_ACCOUNT_ID, propFirm: 'lucid', plan: 'luciddirect', stage: 'funded', connectionType: 'tradovate', startingBalance: 25000, currentBalance: 27500, name: 'Lucid Direct Demo', accountName: 'Lucid Direct Demo', accountStatus: 'active', isLabTraining: true, }; if (!accounts.find(a => a.id === LAB_DEMO_ACCOUNT_ID)) { accounts.push(demoAcct); } } // Also push today's trades into trades array directly if (typeof trades !== 'undefined') { const today = new Date(); const dow = today.getDay(); if (dow === 0) today.setDate(today.getDate() - 2); if (dow === 6) today.setDate(today.getDate() - 1); const makeTime = (h, m) => { const d = new Date(today); d.setHours(h, m, 0, 0); return d.toISOString(); }; const todayTrades = [ { id: 'lab_today_trade_0', accountId: LAB_DEMO_ACCOUNT_ID, symbol: 'MNQ', side: 'Long', qty: 2, entryPrice: 19850.5, exitPrice: 19872.25, entryTime: makeTime(9,31), exitTime: makeTime(10,15), pnl: 174.00, netPnl: 174.00, commission: 3.80, isLabTraining: true }, { id: 'lab_today_trade_1', accountId: LAB_DEMO_ACCOUNT_ID, symbol: 'MNQ', side: 'Short', qty: 1, entryPrice: 19875.0, exitPrice: 19862.5, entryTime: makeTime(10,45), exitTime: makeTime(11,2), pnl: 50.00, netPnl: 50.00, commission: 1.90, isLabTraining: true }, { id: 'lab_today_trade_2', accountId: LAB_DEMO_ACCOUNT_ID, symbol: 'MNQ', side: 'Long', qty: 2, entryPrice: 19840.0, exitPrice: 19856.0, entryTime: makeTime(11,30), exitTime: makeTime(12,5), pnl: 128.00, netPnl: 128.00, commission: 3.80, isLabTraining: true }, ]; todayTrades.forEach(t => { if (!trades.find(x => x.id === t.id)) trades.push(t); }); } // Switch to the Accounts/Connections tab within settings if (typeof switchSettingsSection === 'function') switchSettingsSection('accounts'); if (typeof renderAll === 'function') { try { await renderAll(); } catch(e) {} } // Give the accounts onSnapshot listener + renderConnectionsPage a moment to show the new row await new Promise(r => setTimeout(r, 600)); }, }, { title: '⚙️ Configure Your Account', desc: 'Every account needs to be configured with your prop firm, plan, and starting balance. PayoutLab then pulls the drawdown rules, buffer, profit split, and consistency requirements automatically from its prop firm database — no manually tracking 18 different rule sets.', target: '#edit-account-modal .modal-content, #edit-account-modal', targetFallback: '#edit-account-modal', position: 'left', page: 'settings', action: async () => { if (typeof switchSettingsSection === 'function') switchSettingsSection('accounts'); await new Promise(r => setTimeout(r, 200)); if (typeof editAccount === 'function') { try { await editAccount(LAB_DEMO_ACCOUNT_ID); } catch(e) { console.log('[Lab] editAccount error:', e); } } await new Promise(r => setTimeout(r, 600)); // Pulse the three required fields so the eye knows where to look const requiredFields = [ 'edit-account-prop-firm', 'edit-account-balance', 'edit-account-plan' ]; requiredFields.forEach(id => { const el = document.getElementById(id); if (el) { el.classList.add('lab-tab-glow'); el.style.outline = '2px solid var(--cyan)'; el.style.outlineOffset = '2px'; } }); }, }, { title: '📅 Today\'s Trades Are In', desc: 'You can see today\'s 3 MNQ trades on the dashboard. But your history only goes back to today — to see your full track record, you need to import a historical CSV.', target: null, targetFallback: null, position: 'right', page: 'dashboard', action: async () => { // Clear the cyan-glow highlight applied to the three required fields // by the previous step before the modal disappears const requiredFields = [ 'edit-account-prop-firm', 'edit-account-balance', 'edit-account-plan' ]; requiredFields.forEach(id => { const el = document.getElementById(id); if (el) { el.classList.remove('lab-tab-glow'); el.style.outline = ''; el.style.outlineOffset = ''; } }); // Close the account edit modal if it's still open from the previous step if (typeof closeEditAccountModal === 'function') closeEditAccountModal(); await new Promise(r => setTimeout(r, 200)); // Find today's cell or the most recent trading day (walk back up to 7 days) const today = new Date(); const dow = today.getDay(); if (dow === 0) today.setDate(today.getDate() - 2); if (dow === 6) today.setDate(today.getDate() - 1); let todayCell = null; for (let i = 0; i < 7; i++) { const d = new Date(today); d.setDate(d.getDate() - i); const dateStr = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); const cell = document.querySelector(`[data-date="${dateStr}"]`); if (cell && cell.classList.contains('has-trades')) { todayCell = cell; break; } // Also accept today's cell even without trades (early morning case) if (i === 0 && cell) { todayCell = cell; } } if (todayCell) { todayCell.scrollIntoView({ behavior: 'smooth', block: 'center' }); await new Promise(r => setTimeout(r, 400)); // Position spotlight on the cell manually (runLabStep honors this when target is null) const rect = todayCell.getBoundingClientRect(); const pad = 4; const spotlight = document.getElementById('lab-spotlight'); if (spotlight) { spotlight.style.cssText = `display:block; top:${rect.top-pad}px; left:${rect.left-pad}px; width:${rect.width+pad*2}px; height:${rect.height+pad*2}px; border-radius:8px; opacity:1;`; } // Also position the tooltip to the right of the cell (runLabStep honors this when target is null) const tooltipEl = document.getElementById('lab-tooltip'); if (tooltipEl) { positionLabTooltip(tooltipEl, rect, 'right'); } } }, }, { title: '📥 Import Historical Trades', desc: 'Your live sync only pulls today\'s trades. For your full history, export an Orders CSV from trader.tradovate.com and import it here. We\'ve pre-loaded a demo file — click Next and we\'ll confirm it together.', target: '.nav-item[onclick*="openAddConnectionModal"][onclick*="csv"]', targetFallback: '#import-btn-top', position: 'right', page: 'dashboard', action: null, }, { title: '✅ Importing Demo CSV', desc: 'This is where you drop your CSV file to import trades. For Tradovate, export your Orders CSV from trader.tradovate.com and drop it here. We\'ve pre-loaded a demo file for you — click Next to confirm and load 14 days of MNQ history into your dashboard.', target: '#drop-zone', targetFallback: '#add-connection-modal', position: 'right', page: 'dashboard', action: async () => { simulateLabCSVImport(); await new Promise(r => setTimeout(r, 800)); // Scroll drop zone back into view after modal auto-scrolls to preview const dropZone = document.getElementById('drop-zone'); if (dropZone) { dropZone.scrollIntoView({ behavior: 'smooth', block: 'center' }); await new Promise(r => setTimeout(r, 400)); } }, }, { title: '✅ Trading History Loaded', desc: 'Your full trading history is now in PayoutLab. The P&L calendar fills in, your win rate calculates, and payout eligibility updates automatically.', target: '#calendar', targetFallback: '.calendar', position: 'above', page: 'dashboard', action: async () => { if (typeof closeAddConnectionModal === 'function') closeAddConnectionModal(); await new Promise(r => setTimeout(r, 300)); await injectLabDemoHistoricalTrades(); // Push historical trades into trades array directly since onSnapshot is suppressed during labActive if (typeof trades !== 'undefined') { LAB_DEMO_TRADES.forEach((trade, i) => { const t = { ...trade, id: 'lab_demo_trade_' + i, accountId: LAB_DEMO_ACCOUNT_ID, isLabTraining: true }; if (!trades.find(x => x.id === t.id)) trades.push(t); }); } if (typeof renderAll === 'function') { try { await renderAll(); } catch(e) {} } await new Promise(r => setTimeout(r, 400)); // Scroll calendar into view const cal = document.getElementById('calendar'); if (cal) { cal.scrollIntoView({ behavior: 'smooth', block: 'center' }); await new Promise(r => setTimeout(r, 400)); } }, }, { title: '🔍 Filter by Account', desc: 'Use these filters to focus on one firm or account. As you scale to multiple funded accounts across different prop firms, filters keep everything organized.', target: '#filter-bar-content', targetFallback: '.filter-bar, #sticky-filter-wrapper', position: 'below', page: 'dashboard', action: async () => { const el = document.getElementById('filter-bar-content') || document.querySelector('.filter-bar, #sticky-filter-wrapper'); if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'start' }); await new Promise(r => setTimeout(r, 300)); } }, }, { title: '📈 Reports - KPIs', desc: 'The Charts view shows your equity curve, win rate, profit factor, and best/worst days — all calculated from your real trade data.', target: '.nav-item[data-page="reports"]', targetFallback: '#rpt-tab-charts', position: 'right', page: 'reports', action: async () => { if (typeof switchReportsTab === 'function') { try { switchReportsTab('charts'); } catch(e) {} } const el = document.getElementById('rpt-tab-charts'); if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); await new Promise(r => setTimeout(r, 300)); } }, }, { title: '📊 Key Stats', desc: 'Key Stats gives you a text-based summary of your performance metrics — total trades, win rate, profit factor, average trade, and more. A quick snapshot of how you are trading.', target: '.nav-item[data-page="reports"]', targetFallback: '#rpt-tab-keystats', position: 'right', page: 'reports', action: async () => { if (typeof switchReportsTab === 'function') { try { switchReportsTab('keystats'); } catch(e) {} } const el = document.getElementById('rpt-tab-keystats'); if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); await new Promise(r => setTimeout(r, 300)); } }, }, { title: '🔬 LabBuilder', desc: 'LabBuilder is your deep dive analytics engine — fully customizable. Slice your trades by any dimension: time of day, day of week, instrument, session, and more. For example, this time-of-day heatmap reveals your peak performance hours so you can stop trading during your worst windows.', target: '.nav-item[data-page="labbuilder"]', targetFallback: '#lb-heatmap-container', position: 'right', page: 'labbuilder', action: async () => { // Configure the heatmap: Hour × Day of Week, colored by Total P&L const setSelect = (id, val) => { const el = document.getElementById(id); if (el) { el.value = val; el.dispatchEvent(new Event('change')); } }; setSelect('lb-group-primary', 'hour'); setSelect('lb-group-secondary', 'dayOfWeek'); setSelect('lb-metric', 'totalPnl'); setSelect('lb-viz', 'heatmap'); if (typeof lbRun === 'function') { try { lbRun(); } catch(e) { console.log('[Lab] lbRun error:', e); } } await new Promise(r => setTimeout(r, 500)); const el = document.getElementById('lb-heatmap-container') || document.getElementById('lb-results-card'); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); await new Promise(r => setTimeout(r, 300)); }, }, { title: '💰 Payout Eligible!', desc: 'The Objectives panel shows exactly what your prop firm requires for a payout — Trading Days, Profit Days, and Consistency. When all objectives are met and your buffer is satisfied, PayoutLab marks the account ✓ Payout Eligible automatically. No guesswork.', target: '[data-account-id="lucid_LUCIDDIRECT_DEMO_LAB"]', targetFallback: '#firm-body-lucid', position: 'left', page: 'dashboard', action: async () => { // runLabStep already navigated to dashboard. A second showPage() // here would re-render the dashboard (collapsing all firms) and // reset scroll to top — exactly the state we are about to fix. // .main is the scroll container (overflow-y: auto, height: 100vh) — window never scrolls const mainEl = document.querySelector('.main'); if (mainEl) { mainEl.scrollTo({ top: mainEl.scrollHeight, behavior: 'instant' }); await new Promise(r => setTimeout(r, 150)); } // Expand the Lucid firm group if collapsed (all firms default to collapsed after renderAll) if (typeof toggleFirmCollapse === 'function') { const firmBody = document.getElementById('firm-body-lucid'); if (firmBody && firmBody.style.display === 'none') { toggleFirmCollapse('lucid'); } } await new Promise(r => setTimeout(r, 200)); // Expand the demo account row if (typeof toggleAllFirmsDetail === 'function') { const detail = document.getElementById('all-firms-detail-lucid_LUCIDDIRECT_DEMO_LAB'); if (detail && detail.style.display === 'none') { toggleAllFirmsDetail('lucid_LUCIDDIRECT_DEMO_LAB'); } } await new Promise(r => setTimeout(r, 200)); // Then anchor on the specific row — instant scroll so the rect is // settled by the time runLabStep computes the spotlight position. const row = document.querySelector('[data-account-id="lucid_LUCIDDIRECT_DEMO_LAB"]'); if (row) { row.scrollIntoView({ behavior: 'instant', block: 'center' }); await new Promise(r => setTimeout(r, 150)); } }, }, { title: '💸 Payout Calculator', desc: 'When an account is payout eligible, PayoutLab opens the calculator automatically. It shows your available balance, applies your profit split, and calculates exactly what you receive. Enter your withdrawal amount and click Record Withdrawal to log it.', target: '#payout-calc-grid', targetFallback: '#account-availability-breakdown', position: 'left', page: 'payouts', action: async () => { const el = document.querySelector('#payout-calc-grid'); if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); await new Promise(r => setTimeout(r, 400)); } }, }, { title: '💸 Record Your Payout', desc: 'Your Lucid Direct Demo account is payout eligible. The calculator shows your max available after buffer, applies your 90/10 profit split automatically, and shows exactly what you receive. In real life you\'d enter your withdrawal amount and click Record Withdrawal. We\'re recording a demo payout now.', target: '#record-withdrawal-btn', targetFallback: '#payout-calc-grid', position: 'left', page: 'payouts', action: async () => { // Pre-select the demo account in the Ready to Payout panel so the // calculator on the right populates and #record-withdrawal-btn // becomes visible before runLabStep computes the spotlight rect. if (typeof selectPayoutAccount === 'function') { try { selectPayoutAccount(LAB_DEMO_ACCOUNT_ID); } catch(e) { console.log('[Lab] selectPayoutAccount error:', e); } } await new Promise(r => setTimeout(r, 300)); const btn = document.getElementById('record-withdrawal-btn'); if (btn) { btn.scrollIntoView({ behavior: 'instant', block: 'center' }); await new Promise(r => setTimeout(r, 150)); } }, }, { title: '✅ Payout Recorded!', desc: 'Your $500 payout has been recorded. PayoutLab tracks your full payout history, available balance, and net income over time.', target: '#payout-calendar', targetFallback: '.main', position: 'above', page: 'payouts', action: async () => { await injectLabDemoPayout(); if (typeof renderAll === 'function') { try { await renderAll(); } catch(e) {} } // Explicitly re-render the payout calendar in case renderAll didn't reach it if (typeof renderPayoutCalendar === 'function') { try { renderPayoutCalendar(); } catch(e) {} } await new Promise(r => setTimeout(r, 600)); const cal = document.getElementById('payout-calendar'); if (cal) cal.scrollIntoView({ behavior: 'smooth', block: 'center' }); await new Promise(r => setTimeout(r, 400)); }, }, { title: '🃏 LabCard — Share Your Win', desc: 'LabCard generates a shareable image of your payout milestone. Traders post these on Twitter and Discord to celebrate verified payouts.', target: '.nav-item[data-page="share-card"]', position: 'right', page: 'share-card', action: async () => { // Force Payout Cards type so tour copy matches the visible page // (user may have previously toggled to Day Cards — localStorage persisted) if (typeof setLabCardType === 'function') setLabCardType('payout'); await new Promise(r => setTimeout(r, 200)); }, }, { title: '🃏 Your Generated LabCard', desc: 'Every payout auto-generates a verified card like the one here. Pick a style, download the image, and post it to Discord or Twitter to celebrate your milestone.', target: '#updated-labcard-gallery', targetFallback: '.updated-labcard-grid, #page-share-card', position: 'above', page: 'share-card', action: async () => { const el = document.getElementById('updated-labcard-gallery'); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); await new Promise(r => setTimeout(r, 400)); }, }, { title: '📊 ROI Tracker', desc: 'ROI Tracker shows your real net income — payout amount minus all eval and reset fees. This is what you actually keep.', target: '.nav-item[data-page="roi"]', position: 'right', page: 'roi', action: async () => { // Snapshot the user's real widget set, then activate all 6 for the tour. // Restored on tour exit/finish via restoreRealData(). if (typeof _roiActiveWidgets !== 'undefined' && labRealRoiWidgets === null) { labRealRoiWidgets = [..._roiActiveWidgets]; const allWidgets = ['pnl_by_firm', 'monthly', 'expense_donut', 'cumulative', 'payout_chart', 'consistency']; _roiActiveWidgets.length = 0; allWidgets.forEach(id => _roiActiveWidgets.push(id)); try { localStorage.setItem('roiWidgets', JSON.stringify(_roiActiveWidgets)); } catch(e) {} if (typeof renderROIWidgets === 'function') { try { renderROIWidgets(); } catch(e) {} } } await new Promise(r => setTimeout(r, 200)); }, }, { title: '📊 Your Net Income', desc: 'Every eval fee, reset, and activation is subtracted from your payouts here. The cyan "Net Profit" number is what you actually keep — the only one that matters.', target: '#roi-net-card', targetFallback: '#roi-hero, #roi-net-profit', position: 'below', page: 'roi', action: async () => { const el = document.getElementById('roi-net-card') || document.getElementById('roi-hero'); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); await new Promise(r => setTimeout(r, 400)); }, }, { title: '🎯 Scorecard', desc: 'The Scorecard grades your trading discipline automatically — consistency, risk management, and process adherence. It tells you not just how much you made, but how well you traded.', target: '.nav-item[data-page="scorecard"]', position: 'right', page: 'scorecard', }, { title: '📝 Daily Journal', desc: 'Log your mindset and trade rationale each day. Traders who journal consistently identify emotional patterns that cost them money.', target: '.nav-item[data-page="journal"]', position: 'right', page: 'journal', }, { title: '🎉 You\'re a PayoutLab Pro!', desc: 'You now have everything you need to track your funded trading like a professional. Your payout eligibility updates automatically, your consistency is monitored in real time, and every trade is accounted for. Welcome to the Lab. Let\'s get you paid. 💰 You can revisit this walkthrough anytime by clicking the Lab Training button in your sidebar.', target: '#lab-training-btn', targetFallback: null, position: 'right', page: 'dashboard', action: null, }, ]; let labCurrentStep = 0; let labActive = false; // Lab Training — in-memory mask/restore of real user data let labRealAccounts = null; let labRealTrades = null; let labRealPayouts = null; let labRealExpenses = null; let labRealScorecards = null; let labRealJournal = null; let labRealDayInsightsCache = null; let labRealRoiWidgets = null; // Lab Training — filter state snapshot (preserved across the tour) let labRealSelectedAccounts = null; let labRealStageFilter = null; let labRealPropFirmFilter = null; let labRealAccountFilterLabel = null; // ===== LAB TRAINING DATA ISOLATION CONTRACT ===== // During Lab Training (labActive === true): // 1. Real data arrays (accounts, trades, payouts, // expenses) are saved in labReal* variables and // cleared in memory // 2. Demo data is pushed ONLY to in-memory arrays // — NEVER written to Firestore user doc arrays // 3. Demo accounts/trades go to subcollections // with known IDs, safe to delete by ID // 4. On exit, restoreRealData() writes real // payouts/expenses back to Firestore first, // then restores in-memory arrays // 5. ANY Firestore write to the user doc's // payouts/expenses fields during labActive // is a bug — labSafeFirestoreSet() blocks it // ================================================ // Defensive guard. If any future code path tries to write the user doc's // payouts/expenses arrays during a lab tour, this returns a resolved // promise without touching Firestore. The original incident overwrote // a real user's arrays with a single demo entry; this prevents recurrence // even if the inject path is reintroduced by mistake. function labSafeFirestoreSet(docRef, data, options) { if (labActive) { const uid = currentUser && currentUser.uid; const path = (docRef && docRef.path) || (docRef && docRef._key && docRef._key.path && docRef._key.path.string); const isUserDoc = uid && path === `users/${uid}`; if (isUserDoc) { const dangerous = ['payouts', 'expenses']; const attempted = data && typeof data === 'object' ? Object.keys(data) : []; const blocked = attempted.filter(k => dangerous.includes(k)); if (blocked.length > 0) { console.error( '[Lab] BLOCKED Firestore write to user doc ' + 'during Lab Training. Fields:', blocked, '— this would have corrupted real user data.' ); return Promise.resolve(); } } } return docRef.set(data, options || {}); } function maskRealData() { // Save current filter state so it can be restored on tour exit labRealSelectedAccounts = (typeof selectedAllFirmsAccounts !== 'undefined') ? [...selectedAllFirmsAccounts] : []; labRealStageFilter = (typeof globalStageFilter !== 'undefined') ? globalStageFilter : 'all'; labRealPropFirmFilter = document.getElementById( 'global-prop-firm-filter')?.value || 'all'; labRealAccountFilterLabel = document.getElementById( 'account-filter-label')?.textContent || 'All Accounts'; // Save real data arrays labRealAccounts = typeof accounts !== 'undefined' ? [...accounts] : []; labRealTrades = typeof trades !== 'undefined' ? [...trades] : []; labRealPayouts = typeof payouts !== 'undefined' ? [...payouts] : []; labRealExpenses = typeof expenses !== 'undefined' ? [...expenses] : []; // Clear arrays in place so the dashboard shows an empty state if (typeof accounts !== 'undefined') accounts.length = 0; if (typeof trades !== 'undefined') trades.length = 0; if (typeof payouts !== 'undefined') payouts.length = 0; if (typeof expenses !== 'undefined') expenses.length = 0; // Snapshot and clear scorecard/journal/insights labRealScorecards = { ...scorecards }; Object.keys(scorecards).forEach(k => delete scorecards[k]); labRealJournal = { ...journal }; Object.keys(journal).forEach(k => delete journal[k]); labRealDayInsightsCache = { ...dayInsightsCache }; Object.keys(dayInsightsCache).forEach(k => delete dayInsightsCache[k]); // Immediately render empty state before any background render can fire if (typeof renderAll === 'function') { try { renderAll(); } catch(e) {} } } async function restoreRealData() { // Restore in place (preserves the array reference Firestore listeners hold) if (labRealAccounts !== null && typeof accounts !== 'undefined') { accounts.length = 0; labRealAccounts.forEach(a => accounts.push(a)); } if (labRealTrades !== null && typeof trades !== 'undefined') { trades.length = 0; labRealTrades.forEach(t => trades.push(t)); } if (labRealPayouts !== null && typeof payouts !== 'undefined') { payouts.length = 0; labRealPayouts.forEach(p => payouts.push(p)); } if (labRealExpenses !== null && typeof expenses !== 'undefined') { expenses.length = 0; labRealExpenses.forEach(e => expenses.push(e)); } // Authoritative Firestore restore — undo any demo writes that may have // corrupted the user doc's payouts/expenses arrays during the tour. // This runs unconditionally so that even if a future inject path writes // to Firestore by mistake, the real arrays are guaranteed to be put back. if (currentUser && labRealPayouts !== null && labRealExpenses !== null) { try { await db.collection('users').doc(currentUser.uid).set({ payouts: labRealPayouts, expenses: labRealExpenses, }, { merge: true }); } catch(e) { console.error('[Lab] Firestore restore error:', e); } } labRealAccounts = null; labRealTrades = null; labRealPayouts = null; labRealExpenses = null; // Restore ROI Tracker widget set (was overridden to all 6 by ROI step action) if (labRealRoiWidgets !== null && typeof _roiActiveWidgets !== 'undefined') { _roiActiveWidgets.length = 0; labRealRoiWidgets.forEach(id => _roiActiveWidgets.push(id)); try { localStorage.setItem('roiWidgets', JSON.stringify(_roiActiveWidgets)); } catch(e) {} if (typeof renderROIWidgets === 'function') { try { renderROIWidgets(); } catch(e) {} } labRealRoiWidgets = null; } // Restore scorecard/journal/insights if (labRealScorecards !== null) { Object.keys(scorecards).forEach(k => delete scorecards[k]); Object.assign(scorecards, labRealScorecards); labRealScorecards = null; } if (labRealJournal !== null) { Object.keys(journal).forEach(k => delete journal[k]); Object.assign(journal, labRealJournal); labRealJournal = null; } if (labRealDayInsightsCache !== null) { Object.keys(dayInsightsCache).forEach(k => delete dayInsightsCache[k]); Object.assign(dayInsightsCache, labRealDayInsightsCache); labRealDayInsightsCache = null; } // Restore filter globals if (typeof selectedAllFirmsAccounts !== 'undefined') { selectedAllFirmsAccounts = labRealSelectedAccounts || []; } if (typeof globalStageFilter !== 'undefined') { globalStageFilter = labRealStageFilter || 'all'; } // Restore DOM widget values const firmEl = document.getElementById('global-prop-firm-filter'); if (firmEl && labRealPropFirmFilter !== null) { firmEl.value = labRealPropFirmFilter; } const labelEl = document.getElementById('account-filter-label'); if (labelEl && labRealAccountFilterLabel !== null) { labelEl.textContent = labRealAccountFilterLabel; } // Restore stage button active state document.querySelectorAll('.stage-filter-btn').forEach(btn => { btn.classList.toggle('active', btn.dataset.stage === (labRealStageFilter || 'all')); }); // Repopulate dropdown and re-render with restored filter if (typeof populateGlobalAccountFilter === 'function') { try { populateGlobalAccountFilter(); } catch(e) {} } if (typeof renderActiveFilterPills === 'function') { try { renderActiveFilterPills(); } catch(e) {} } if (typeof updateGlobalFilterIndicator === 'function') { try { updateGlobalFilterIndicator(); } catch(e) {} } if (typeof renderAll === 'function') { try { await renderAll(); } catch(e) {} } // Clear filter snapshot vars labRealSelectedAccounts = null; labRealStageFilter = null; labRealPropFirmFilter = null; labRealAccountFilterLabel = null; } async function showLabWelcome() { document.getElementById('lab-welcome-modal').style.display = 'flex'; // Write "seen" immediately so closing the browser doesn't cause re-prompt if (currentUser) { try { await db.collection('users').doc(currentUser.uid).set({ labTraining: { seen: true, seenAt: new Date().toISOString() } }, { merge: true }); } catch(e) {} } } async function skipLabTraining() { document.getElementById('lab-welcome-modal').style.display = 'none'; if (currentUser) { await db.collection('users').doc(currentUser.uid).set({ labTraining: { skipped: true, skippedAt: new Date().toISOString() } }, { merge: true }); } } async function startLabTraining() { document.getElementById('lab-welcome-modal').style.display = 'none'; labActive = true; maskRealData(); labCurrentStep = 0; document.getElementById('lab-progress').style.display = 'block'; document.getElementById('lab-progress-bar').style.width = '0%'; // No pre-injection — dashboard starts empty; data is injected progressively via step actions await runLabStep(0); } // Next-button dispatcher. Detaches any pending click handler, then either // finishes the tour, runs the step's nextOverride (mirroring click overrides // for the Next path), or advances to the next step. function labGoNext(stepIndex) { if (window._labClickHandler && window._labClickTarget) { try { window._labClickTarget.removeEventListener('click', window._labClickHandler); } catch(e) {} } const total = LAB_STEPS.length; if (stepIndex >= total - 1) { finishLabTraining(); return; } const step = LAB_STEPS[stepIndex]; if (step && typeof step.nextOverride === 'function') { step.nextOverride(stepIndex + 1); } else { runLabStep(stepIndex + 1); } } async function runLabStep(stepIndex) { // Close any open modals before switching steps if (typeof closeEditAccountModal === 'function') { try { closeEditAccountModal(); } catch(e) {} } if (typeof closeAddConnectionModal === 'function') { try { closeAddConnectionModal(); } catch(e) {} } if (typeof closeAIInsightsModal === 'function') { try { closeAIInsightsModal(); } catch(e) {} } if (stepIndex >= LAB_STEPS.length) { await endLabTraining(); return; } // Fade out existing tooltip and spotlight before transition const tooltip = document.getElementById('lab-tooltip'); const spotlight = document.getElementById('lab-spotlight'); const pulse = document.getElementById('lab-pulse'); if (tooltip.style.display !== 'none') { tooltip.style.opacity = '0'; spotlight.style.opacity = '0'; if (pulse) pulse.style.opacity = '0'; await new Promise(r => setTimeout(r, 250)); } labCurrentStep = stepIndex; const step = LAB_STEPS[stepIndex]; const total = LAB_STEPS.length; // Update progress document.getElementById('lab-progress-bar').style.width = (((stepIndex + 1) / total) * 100) + '%'; // Navigate to the right page FIRST — app renders before tooltip shows if (step.page) { window._labInternalNav = true; showPage(step.page); window._labInternalNav = false; await new Promise(r => setTimeout(r, 600)); // wait for page render } // Reset spotlight + tooltip positioning state before action runs. // Without this, a leftover spotlight/tooltip position from the previous // step persists when this step has no target — visible bug on the // completion step. Action can still pre-position both after this reset. spotlight.style.display = 'none'; pulse.style.display = 'none'; tooltip.style.top = ''; tooltip.style.left = ''; tooltip.style.right = ''; tooltip.style.transform = ''; tooltip.style.zIndex = ''; tooltip.style.maxWidth = ''; tooltip.style.minWidth = ''; // Run step action (inject data, open modals, etc.) before spotlight/tooltip render if (typeof step.action === 'function') { try { await step.action(); } catch(e) { console.log('[Lab] Step action error:', e); } } // Find target element let targetEl = null; if (step.target) { const selectors = step.target.split(',').map(s => s.trim()); for (const sel of selectors) { try { targetEl = document.querySelector(sel); } catch(e) {} if (targetEl && targetEl.offsetParent !== null) break; // must be visible } } if (!targetEl && step.targetFallback) { try { targetEl = document.querySelector(step.targetFallback); } catch(e) {} } // Spotlight if (targetEl) { const rect = targetEl.getBoundingClientRect(); const pad = 8; spotlight.style.cssText = `display:block; top:${rect.top-pad}px; left:${rect.left-pad}px; width:${rect.width+pad*2}px; height:${rect.height+pad*2}px; border-radius:10px;`; pulse.style.cssText = `display:block; top:${rect.bottom - 12}px; left:${rect.right - 12}px;`; // Hide the pulse dot for no-op overrides — clicking does nothing on // those steps, so the pulse would mislead the user into clicking. const isNoOp = LAB_STEP_CLICK_OVERRIDES[stepIndex] && LAB_STEP_CLICK_OVERRIDES[stepIndex].toString() === '() => {}'; if (isNoOp) pulse.style.display = 'none'; } else if (spotlight.style.display !== 'block') { // No selector target — only hide if the step's action didn't already position the spotlight spotlight.style.display = 'none'; pulse.style.display = 'none'; } else { // Action positioned the spotlight manually; hide the pulse indicator (no click-advance target) pulse.style.display = 'none'; } // Auto-advance when user clicks the spotlighted element if (targetEl) { const nextIdx = stepIndex + 1; const clickAction = LAB_STEP_CLICK_OVERRIDES[stepIndex]; const handler = () => { console.log('[Lab] click handler fired, stepIndex:', stepIndex, 'has override:', !!clickAction); targetEl.removeEventListener('click', handler); window._labClickHandler = null; window._labClickTarget = null; if (clickAction) { clickAction(nextIdx); } else { setTimeout(() => runLabStep(nextIdx), 400); } }; targetEl.addEventListener('click', handler); window._labClickHandler = handler; window._labClickTarget = targetEl; } // Tooltip tooltip.style.transform = 'none'; tooltip.innerHTML = `
Step ${stepIndex + 1} of ${total}
${step.title}
${step.desc}
${stepIndex > 0 ? `` : ''}
`; // Position tooltip if (targetEl) { const rect = targetEl.getBoundingClientRect(); positionLabTooltip(tooltip, rect, step.position || 'below'); } else if (tooltip.style.top && tooltip.style.top.endsWith('px')) { // Action already positioned the tooltip in px; leave it alone } else { tooltip.style.top = '50%'; tooltip.style.left = '50%'; tooltip.style.transform = 'translate(-50%, -50%)'; } // Scroll target into view if needed if (targetEl) { targetEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); } // Fade everything back in tooltip.style.opacity = '0'; tooltip.style.display = 'block'; // small tick to let display:block paint before transition await new Promise(r => setTimeout(r, 20)); tooltip.style.opacity = '1'; spotlight.style.opacity = '1'; if (pulse) pulse.style.opacity = '0.7'; } function positionLabTooltip(tooltip, targetRect, position) { const tw = 320; const th = 180; const gap = 16; const vw = window.innerWidth; const vh = window.innerHeight; let top, left; if (position === 'right') { top = Math.min(targetRect.top, vh - th - gap); left = targetRect.right + gap; if (left + tw > vw - gap) left = targetRect.left - tw - gap; // flip left } else if (position === 'left') { top = Math.min(targetRect.top, vh - th - gap); left = targetRect.left - tw - gap; if (left < gap) left = targetRect.right + gap; // flip right if no room } else if (position === 'below') { top = targetRect.bottom + gap; left = Math.max(gap, Math.min(targetRect.left, vw - tw - gap)); } else if (position === 'above') { top = targetRect.top - th - gap; left = Math.max(gap, Math.min(targetRect.left, vw - tw - gap)); } else { // center top = vh / 2 - th / 2; left = vw / 2 - tw / 2; } top = Math.max(gap, Math.min(top, vh - th - gap)); left = Math.max(gap, Math.min(left, vw - tw - gap)); tooltip.style.top = top + 'px'; tooltip.style.left = left + 'px'; tooltip.style.transform = 'none'; } async function injectLabDemoAccount() { if (!currentUser) return; await db.collection('users').doc(currentUser.uid).collection('accounts').doc(LAB_DEMO_ACCOUNT_ID).set({ propFirm: 'lucid', plan: 'luciddirect', stage: 'funded', connectionType: 'tradovate', startingBalance: 25000, currentBalance: 27500, drawdown: 1000, buffer: 200, profitSplit: '90/10', name: 'Lucid Direct Demo', accountName: 'Lucid Direct Demo', needsSetup: false, isEvaluation: false, archived: false, accountStatus: 'active', cost: 150, isLabTraining: true, minTradingDays: 10, createdAt: new Date().toISOString(), }); } // TODAY ONLY — 3 trades for current date (simulates live sync) async function injectLabDemoTodayTrades() { if (!currentUser) return; const uid = currentUser.uid; // If today is a weekend, use the most recent Friday const today = new Date(); const day = today.getDay(); if (day === 0) today.setDate(today.getDate() - 2); // Sunday → Friday if (day === 6) today.setDate(today.getDate() - 1); // Saturday → Friday const makeTime = (h, m) => { const d = new Date(today); d.setHours(h, m, 0, 0); return d.toISOString(); }; const todayTrades = [ { symbol: 'MNQ', side: 'Long', qty: 2, entryPrice: 19850.5, exitPrice: 19872.25, entryTime: makeTime(9, 31), exitTime: makeTime(10, 15), pnl: 174.00, netPnl: 174.00, commission: 3.80, source: 'tradovate' }, { symbol: 'MNQ', side: 'Short', qty: 1, entryPrice: 19875.0, exitPrice: 19862.5, entryTime: makeTime(10, 45), exitTime: makeTime(11, 2), pnl: 50.00, netPnl: 50.00, commission: 1.90, source: 'tradovate' }, { symbol: 'MNQ', side: 'Long', qty: 2, entryPrice: 19840.0, exitPrice: 19856.0, entryTime: makeTime(11, 30), exitTime: makeTime(12, 5), pnl: 128.00, netPnl: 128.00, commission: 3.80, source: 'tradovate' }, ]; const batch = db.batch(); todayTrades.forEach((trade, i) => { const ref = db.collection('users').doc(uid).collection('trades').doc('lab_today_trade_' + i); batch.set(ref, { ...trade, accountId: LAB_DEMO_ACCOUNT_ID, userId: uid, isLabTraining: true, id: 'lab_today_trade_' + i }); }); await batch.commit(); } // PAYOUT + EXPENSE — $500 withdrawal and $150 eval fee for ROI/LabCard demo // Writes to the array fields on the user doc (matches app's existing storage pattern, // NOT a subcollection — the app reads payouts/expenses as fields, not subcollection docs). async function injectLabDemoPayout() { if (!currentUser) return; const nowIso = new Date().toISOString(); const todayDate = nowIso.split('T')[0]; // Deterministic statsSnapshot — computed inline from known LAB_DEMO_TRADES (15 hist) + 3 today. // All wins: 15 hist sum to $2,400, today's 3 sum to $352. 9 distinct trading days. // NOT computed from live trades array — order of step injection means trades may not be present // when this runs; fixed values keep the LabCard render stable regardless of tour timing. const newPayout = { id: 'lab_demo_payout_1', type: 'withdrawal', accountId: LAB_DEMO_ACCOUNT_ID, accountName: 'Lucid Direct Demo', accountSize: 25000, amount: 1000, grossAmount: 1000, date: nowIso, firmKey: 'lucid', propFirm: 'lucid', note: 'Demo payout', isLabTraining: true, createdAt: nowIso, wasOverride: false, statsSnapshot: { totalPnl: 2752, dailyPnl: 352, avgDailyPnl: 305.78, tradingDays: 9, winRate: 100, winStreak: 18, ytdPayouts: 1000, balance: 27500, accountSize: 25000, profitFactor: '∞', }, }; // Realistic varied expense set — 6 entries across multiple firms and types. // Uses real `type` enum values (eval_fee/reset_fee/etc), not the legacy // `category` field — the ROI Tracker table reads `exp.type` and renders // an empty row when it's missing. const daysAgoIso = (n) => { const d = new Date(); d.setDate(d.getDate() - n); return d.toISOString().slice(0, 10); }; const demoExpenses = [ { id: 'lab_demo_exp_1', type: 'eval_fee', propFirm: 'lucid', accountId: LAB_DEMO_ACCOUNT_ID, amount: 150, description: 'Lucid Direct evaluation', date: daysAgoIso(14) }, { id: 'lab_demo_exp_2', type: 'eval_fee', propFirm: 'apex', accountId: null, amount: 137, description: 'Apex 50K eval', date: daysAgoIso(30) }, { id: 'lab_demo_exp_3', type: 'reset_fee', propFirm: 'apex', accountId: null, amount: 80, description: 'Apex reset', date: daysAgoIso(22) }, { id: 'lab_demo_exp_4', type: 'activation_fee', propFirm: 'topstep', accountId: null, amount: 149, description: 'TopStep PA activation', date: daysAgoIso(45) }, { id: 'lab_demo_exp_5', type: 'data_fee', propFirm: null, accountId: null, amount: 12, description: 'CME Level 1 data', date: todayDate }, { id: 'lab_demo_exp_6', type: 'platform_fee', propFirm: null, accountId: null, amount: 85, description: 'Tradovate Lifetime', date: daysAgoIso(30) }, ].map(e => ({ ...e, expenseCurrency: 'USD', isLabTraining: true, createdAt: nowIso, updatedAt: nowIso, })); // Push to in-memory arrays only — NEVER write to Firestore. // The masking system keeps demo data isolated to memory; writing to the // user doc would replace the real payouts/expenses arrays (Firestore // merge:true does not deep-merge array fields). if (typeof payouts !== 'undefined' && !payouts.some(p => p.id === 'lab_demo_payout_1')) { payouts.push(newPayout); } if (typeof expenses !== 'undefined') { demoExpenses.forEach(exp => { if (!expenses.some(e => e.id === exp.id)) expenses.push(exp); }); } } // HISTORICAL — 14 days of trades (simulates CSV import result) async function injectLabDemoHistoricalTrades() { if (!currentUser) return; const uid = currentUser.uid; const batch = db.batch(); LAB_DEMO_TRADES.forEach((trade, i) => { const ref = db.collection('users').doc(uid).collection('trades').doc('lab_demo_trade_' + i); batch.set(ref, { ...trade, accountId: LAB_DEMO_ACCOUNT_ID, userId: uid, isLabTraining: true, id: 'lab_demo_trade_' + i }); }); await batch.commit(); } // CSV IMPORT SIMULATION — opens the import modal pre-filled with demo CSV, // stops at the Confirm Import preview (user does not need to click it — // the NEXT step injects historical trades directly via Firestore) function simulateLabCSVImport() { if (typeof openAddConnectionModal === 'function') openAddConnectionModal(); if (typeof showConnWizardStep === 'function') showConnWizardStep(2, 'csv'); setTimeout(() => { // Pre-select demo account const accountSelect = document.querySelector('#import-account'); if (accountSelect) { const opts = accountSelect.querySelectorAll('option'); opts.forEach(opt => { if (opt.value === LAB_DEMO_ACCOUNT_ID || opt.textContent.includes('Lucid')) { accountSelect.value = opt.value; accountSelect.dispatchEvent(new Event('change')); } }); } // Pre-select Tradovate format const formatSelect = document.querySelector('#import-format'); if (formatSelect) { formatSelect.value = 'tradovate_auto'; formatSelect.dispatchEvent(new Event('change')); } // Build valid Tradovate Orders CSV and simulate file drop const demoCSV = [ // All wins, each day $80-120 profit, 14 days → total ~$1,360 // Max day ≤ $110 → ~8% of total → passes Lucid Direct 20% consistency rule (also 15%) 'B/S,Contract,Fill Time,Status,Filled Qty,Avg Fill Price', 'B,MNQH5,04/23/2026 09:31:00 CDT,Filled,2,19850.5', 'S,MNQH5,04/23/2026 10:15:00 CDT,Filled,2,19872.25', 'B,MNQH5,04/22/2026 09:45:00 CDT,Filled,2,19860.0', 'S,MNQH5,04/22/2026 11:15:00 CDT,Filled,2,19885.0', 'B,MNQH5,04/21/2026 09:30:00 CDT,Filled,2,19820.25', 'S,MNQH5,04/21/2026 10:00:00 CDT,Filled,2,19845.0', 'B,MNQH5,04/17/2026 09:32:00 CDT,Filled,2,19795.0', 'S,MNQH5,04/17/2026 10:15:00 CDT,Filled,2,19822.5', 'B,MNQH5,04/16/2026 09:45:00 CDT,Filled,1,19760.0', 'S,MNQH5,04/16/2026 10:30:00 CDT,Filled,1,19810.0', 'B,MNQH5,04/15/2026 09:30:00 CDT,Filled,2,19750.25', 'S,MNQH5,04/15/2026 10:00:00 CDT,Filled,2,19771.0', 'B,MNQH5,04/14/2026 09:31:00 CDT,Filled,2,19830.0', 'S,MNQH5,04/14/2026 10:20:00 CDT,Filled,2,19851.5', 'B,MNQH5,04/11/2026 09:45:00 CDT,Filled,2,19810.0', 'S,MNQH5,04/11/2026 11:00:00 CDT,Filled,2,19835.0', 'B,MNQH5,04/10/2026 09:30:00 CDT,Filled,1,19775.0', 'S,MNQH5,04/10/2026 10:15:00 CDT,Filled,1,19825.0', 'B,MNQH5,04/09/2026 09:31:00 CDT,Filled,2,19850.5', 'S,MNQH5,04/09/2026 10:15:00 CDT,Filled,2,19872.25', 'B,MNQH5,04/08/2026 09:45:00 CDT,Filled,2,19860.0', 'S,MNQH5,04/08/2026 11:15:00 CDT,Filled,2,19885.0', 'B,MNQH5,04/07/2026 09:30:00 CDT,Filled,2,19820.25', 'S,MNQH5,04/07/2026 10:00:00 CDT,Filled,2,19845.0', 'B,MNQH5,04/03/2026 09:32:00 CDT,Filled,2,19795.0', 'S,MNQH5,04/03/2026 10:15:00 CDT,Filled,2,19822.5', 'B,MNQH5,04/02/2026 09:45:00 CDT,Filled,1,19750.0', 'S,MNQH5,04/02/2026 10:30:00 CDT,Filled,1,19800.0', ].join('\n'); const blob = new Blob([demoCSV], { type: 'text/csv' }); const fakeFile = new File([blob], 'tradovate-demo-orders.csv', { type: 'text/csv' }); if (typeof processCSV === 'function') { try { processCSV(fakeFile); } catch(e) { console.log('[Lab] processCSV error:', e); } } }, 700); } function simulateLabTradovateSync(nextIdx) { console.log('[Lab] simulateLabTradovateSync called, nextIdx:', nextIdx); // Hide the lab spotlight + pulse during the sync screen — the sync UI // is its own modal-content panel and should be the only thing visible. const spotlight = document.getElementById('lab-spotlight'); const pulse = document.getElementById('lab-pulse'); if (spotlight) spotlight.style.display = 'none'; if (pulse) pulse.style.display = 'none'; // Find the actual modal content area const step1 = document.querySelector('#wizard-step-1'); const step2 = document.querySelector('#wizard-step-2'); // Hide wizard steps and show fake sync UI if (step1) step1.style.display = 'none'; if (step2) step2.style.display = 'none'; // Insert fake sync screen into the modal // Selector corrected: appends to .modal-content (stacks after .modal-body) const modal = document.querySelector('#add-connection-modal .modal-content'); const syncDiv = document.createElement('div'); syncDiv.id = 'lab-sync-screen'; syncDiv.style.cssText = 'padding: 40px; text-align: center;'; syncDiv.innerHTML = `
🔄
Connecting to Tradovate
Demo credentials accepted. Syncing your accounts...
CONNECTED ACCOUNT
Lucid Direct — $25,000 Funded
✓ Authentication successful
Fetching today's trades...
`; if (modal) modal.appendChild(syncDiv); // Animate the sync progress bar — delays bumped so animation starts AFTER tooltip fade-in completes setTimeout(() => { const bar = document.getElementById('lab-sync-bar'); const status = document.getElementById('lab-sync-status'); if (bar) bar.style.width = '40%'; if (status) status.textContent = 'Syncing fills...'; }, 1200); setTimeout(() => { const bar = document.getElementById('lab-sync-bar'); const status = document.getElementById('lab-sync-status'); if (bar) bar.style.width = '75%'; if (status) status.textContent = 'Applying FIFO matching...'; }, 1700); setTimeout(() => { const bar = document.getElementById('lab-sync-bar'); const status = document.getElementById('lab-sync-status'); if (bar) bar.style.width = '100%'; if (status) status.textContent = '3 trades synced ✓'; }, 2200); // Auto-advance to next step after the animation completes setTimeout(() => { if (window._labClickHandler && window._labClickTarget) { window._labClickTarget.removeEventListener('click', window._labClickHandler); } runLabStep(nextIdx); }, 2800); } function cleanupLabSyncScreen() { const syncDiv = document.getElementById('lab-sync-screen'); if (syncDiv) syncDiv.remove(); const step1 = document.querySelector('#wizard-step-1'); const step2 = document.querySelector('#wizard-step-2'); if (step1) step1.style.display = ''; if (step2) step2.style.display = ''; } async function cleanupLabDemoData() { if (!currentUser) return; const uid = currentUser.uid; try { await db.collection('users').doc(uid).collection('accounts').doc(LAB_DEMO_ACCOUNT_ID).delete(); const batch = db.batch(); for (let i = 0; i < 3; i++) { batch.delete(db.collection('users').doc(uid).collection('trades').doc('lab_today_trade_' + i)); } for (let i = 0; i < LAB_DEMO_TRADES.length; i++) { batch.delete(db.collection('users').doc(uid).collection('trades').doc('lab_demo_trade_' + i)); } await batch.commit(); // Catch CSV-imported demo trades. simulateLabCSVImport() routes through // processCSV → saveTradesToStaging which generates IDs like // csv__ — not in the known-ID list above. // They all carry accountId === LAB_DEMO_ACCOUNT_ID, so a query delete // by accountId catches them on both trades and tradingStage subcollections. const tradesSnap = await db.collection('users') .doc(uid).collection('trades') .where('accountId', '==', LAB_DEMO_ACCOUNT_ID) .get(); const stageSnap = await db.collection('users') .doc(uid).collection('tradingStage') .where('accountId', '==', LAB_DEMO_ACCOUNT_ID) .get(); if (!tradesSnap.empty || !stageSnap.empty) { const cleanup = db.batch(); tradesSnap.forEach(d => cleanup.delete(d.ref)); stageSnap.forEach(d => cleanup.delete(d.ref)); await cleanup.commit(); } // Note: payouts/expenses cleanup is no longer needed here. // restoreRealData() now writes the real arrays back to Firestore // unconditionally before this runs, so any demo entries that may // have leaked to the user doc are already overwritten with the // real pre-tour snapshot. Splicing in-memory here would also be // a no-op because restoreRealData has already replaced the arrays. if (typeof renderAll === 'function') { try { await renderAll(); } catch(e) {} } } catch(e) { console.log('[LabTraining] Cleanup error:', e); } } async function cleanupOrphanedLabData() { if (!currentUser) return; try { const uid = currentUser.uid; const tradesSnap = await db.collection('users') .doc(uid).collection('trades') .where('isLabTraining', '==', true).get(); const stageSnap = await db.collection('users') .doc(uid).collection('tradingStage') .where('isLabTraining', '==', true).get(); const accountSnap = await db.collection('users') .doc(uid).collection('accounts') .doc('lucid_LUCIDDIRECT_DEMO_LAB').get(); if (!tradesSnap.empty || !stageSnap.empty || accountSnap.exists) { console.log('[Lab] Cleaning up orphaned demo data...'); const batch = db.batch(); tradesSnap.forEach(d => batch.delete(d.ref)); stageSnap.forEach(d => batch.delete(d.ref)); if (accountSnap.exists) batch.delete(accountSnap.ref); await batch.commit(); console.log('[Lab] Orphaned demo data cleaned.'); } } catch(e) { console.log('[Lab] Orphan cleanup error:', e); } } async function endLabTraining() { labActive = false; document.getElementById('lab-spotlight').style.display = 'none'; document.getElementById('lab-pulse').style.display = 'none'; document.getElementById('lab-tooltip').style.display = 'none'; document.getElementById('lab-progress').style.display = 'none'; // Close any tour-opened modal that the user may have left behind by exiting if (typeof closeAIInsightsModal === 'function') { try { closeAIInsightsModal(); } catch(e) {} } await restoreRealData(); await cleanupLabDemoData(); if (currentUser) { try { await db.collection('users').doc(currentUser.uid).set({ labTraining: { skipped: true, skippedAt: new Date().toISOString() } }, { merge: true }); } catch(e) {} } showPage('dashboard'); } async function finishLabTraining() { labActive = false; document.getElementById('lab-spotlight').style.display = 'none'; document.getElementById('lab-pulse').style.display = 'none'; document.getElementById('lab-tooltip').style.display = 'none'; document.getElementById('lab-progress').style.display = 'none'; document.getElementById('lab-finish-modal').style.display = 'none'; if (typeof closeAIInsightsModal === 'function') { try { closeAIInsightsModal(); } catch(e) {} } await restoreRealData(); await cleanupLabDemoData(); if (currentUser) { await db.collection('users').doc(currentUser.uid).set({ labTraining: { completed: true, completedAt: new Date().toISOString() } }, { merge: true }); } showPage('dashboard'); } async function checkLabTraining() { if (!currentUser) return; try { const userDoc = await db.collection('users').doc(currentUser.uid).get(); const data = userDoc.data() || {}; const lt = data.labTraining || {}; if (!lt.completed && !lt.skipped && !lt.seen) { setTimeout(() => showLabWelcome(), 2500); } } catch(e) {} } // Cleanup on tab close to prevent orphaned demo data window.addEventListener('beforeunload', () => { if (labActive && currentUser) { restoreRealData(); cleanupLabDemoData(); } }); // Dev helper — Ctrl+Shift+G during an active tour prompts for a step number window.addEventListener('keydown', (e) => { if (e.ctrlKey && e.shiftKey && e.key === 'G' && labActive) { const input = prompt(`Jump to step (1-${LAB_STEPS.length}):`); const num = parseInt(input); if (!isNaN(num) && num >= 1 && num <= LAB_STEPS.length) { runLabStep(num - 1); } } }); // Dev helper — console access: labGoTo(14) jumps to step 14 window.labGoTo = (stepNum) => { if (!labActive) { console.log('[Lab] Tour not active'); return; } runLabStep(stepNum - 1); }; // ===== POST-CHECKOUT WELCOME ===== // Thin wrapper around the claimOrLookupSubscription Cloud Function. // Single source of truth for subscription resolution — tries pending_subscriptions // first, then falls back to a direct Stripe lookup by email. The CF uses the // Admin SDK so the pending-doc delete actually succeeds (Firestore rules block // client deletes). Returns true on found, false otherwise. async function attemptPendingSubscriptionClaim() { if (!currentUser?.email) return false; try { const token = await currentUser.getIdToken(); const r = await fetch('https://us-central1-trade-journal-fc3ba.cloudfunctions.net/claimOrLookupSubscription', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }, body: '{}' }); if (!r.ok) return false; const result = await r.json(); if (result.found) { console.log('[Checkout] Subscription resolved via', result.source); try { trackSubscriptionStart('early_supporter', result.subscription?.amount || 0, currentUser.uid); } catch(_) {} return true; } return false; } catch (e) { console.log('[Checkout] Claim CF error:', e); return false; } } function startCheckoutWelcomeFlow() { document.getElementById('checkout-success-modal').style.display = 'none'; // Mark lab training as not skipped so welcome fires showLabWelcome(); } function dismissCheckoutModal() { document.getElementById('checkout-success-modal').style.display = 'none'; // Mark skipped so lab training doesn't also pop up if (currentUser) { db.collection('users').doc(currentUser.uid).set({ labTraining: { skipped: true, skippedAt: new Date().toISOString() } }, { merge: true }).catch(() => {}); } }