Let's set up your campaign
📋
Add Your First Policy
Load your platform into the Policy Hub so volunteers have answers at every door
👥
Invite Your First Volunteer
Send a link to your team, they sign in and start logging immediately
🚪
Log Your First Door Contact
Head out and log your first knock, see how fast the dashboard updates
💳
Set Up Billing
14-day free trial, full access, no credit card needed
⚡ Election Day Mode
0
Voted ✓
0
Not Yet
0
Needs Ride
0
Doors Today
📍
Question 1 of 2
Have they voted today?
Question 2 of 2
Do they need a ride to the polls?
Recent Logs
Loading…
Campaign
?
⚡ Quick Log
Roads Housing Safety Taxes Transit Seniors Parks Schools Other
--°
Loading…
💧 --
💨 --
▼ Hourly
24-Hour Forecast
✅ Contact logged and saved!
Address & Volunteer
Door Status
Resident Information
Support Level
Key Issues, select all that apply
Housing Affordability Roads & Potholes Public Safety Transit & Bus Property Taxes Seniors Services Youth Programs Parks & Green Space Homelessness Local Business Environment Development/Zoning Schools Other
Notes, type or use voice 🎙️
Tap mic
--°
Loading weather…
Detecting location…
FEELS LIKE
--°
WIND
--
💧 Humidity: --
🌅 Sunrise: --
🌇 Sunset: --
👁️ Visibility: --
▼ Tap for hourly forecast
24-Hour Forecast, Plan Your Canvass
0
Total Contacts
0
Homes Visited
0
Support/Leaning
0
Undecided
0
Follow-ups
Top Issues
Door Outcomes
Strong Support
0
Leaning
0
Undecided
0
Not Supporting
0
All Submissions
0
Doors Knocked
0
Answered
0
Not Home
0
DND
0%
Answer Rate
By Door Outcome
By Volunteer
VolunteerDoorsAnsweredRate
🗺️

Map activates once your Google Maps API key is added in Settings.

Strong Support
Leaning
Undecided
Not Supporting
Not Home / DND
⚡ Quick Reference
0
Stops
0 km
Est. Distance
0 min
Est. Time
0
Unvisited
🗺️

Click Generate Route to create an optimized walking route for your volunteers based on logged addresses.

Google Maps API key required in Settings to display the route map.

✨ AI Campaign Strategist
Click "Generate Briefing" to get an AI-powered strategic analysis of your field data, top issues, support picture, and a specific recommendation for this week.
🤖 AI Conversation Coach
Generates personalized approach tips for undecided voters based on their logged concerns. Available after generating a briefing.
How Access Works

Team members log in at myturfrunner.com/login with their email or Google account. Invitations expire after 7 days. Each role controls what they can see and do.

0
Team Members
0
Admins
0
Volunteers
0
Pending Invites
0
Total Signs
0
Placed
0
Damaged
0
Missing/Stolen
0
Retrieved
Sign Coverage Map
🪧

Add your Google Maps API key in Settings to see signs plotted on the map

By Volunteer
VolunteerPlacedDamagedRetrieved
Election Day Stats
0
Confirmed Voted
0
Need Ride
Today's Rides Needed
No ride requests yet.
🗳️
Ready to Go?

GOTV mode gives your volunteers a stripped-down, easy-to-use interface, just two questions per door. Perfect for phones with small screens.

Runs full-screen. Press ✕ to exit back to the platform.

GOTV Log, All Contacts Today
No GOTV contacts logged yet.
1. Choose Your Segment
2. Write Your Message
0 / 160
Use {name} to personalize with the resident's first name
3. Preview & Send
Preview
⚠️ Only contacts with phone numbers logged will receive SMS. Estimated cost: ~$0.008 per message (Twilio).
SMS History
No SMS blasts sent yet.
0
Unique Addresses
0
Multi-Visit
0
Have Phone
0
Have Email
Upload Spreadsheet
📄
Drop your file here
Supports .csv and .xlsx / .xls files
or click to browse
Import Guidelines

Your file can have any column names, you'll map them to TurfRunner fields after upload.

Recognized Fields:
Address *
Required
First Name
Optional
Last Name
Optional
Phone
Optional
Email
Optional
Support Level
strong/lean/undecided/against
Door Status
Answered/Not Home/DND
Notes
Optional
Volunteer
Optional
⚡ Duplicate detection: rows with the same address are flagged and you choose what to keep.
Import History
No imports yet. Upload a file above to get started.
Campaign Info
Integrations
Leave blank to use TurfRunner's shared map. Enter your own Google Maps key to bill map usage to your own Google account — applies to your whole team.
Door-Step Donations
Billing
Current plan: · Status:
Fundraising Goal
Sets the progress bar in the Fundraising tracker
Win Condition Target
Sets the progress bar across dashboards and scoreboard
Data
Database entries: 0
0
Turfs Created
0
Assigned
0
Completed
0%
Overall Coverage
No turfs created yet. Click "Create Turf" to assign streets to volunteers.
Coverage By Volunteer
Coverage By Street
Priority Heatmap, Undecided Voter Concentration
Hot (4+ undecided)
Warm (2-3 undecided)
Lukewarm (1 undecided)
Covered (all support)
🎯 Win Condition Progress
Supporters identified vs. target needed to win
0%
0 supporters identified Target: - - to go
0
Upcoming
0
Confirmed Volunteers
0
Doors Targeted
No shifts scheduled. Create your first canvass shift!
How it works

These volunteers haven't logged a door in 5 or more days. Tap WhatsApp to send a personal nudge. One message at the right time brings most volunteers back.

All volunteers are active! No nudges needed.
📢 Team Broadcast

Send one message to your whole team via WhatsApp

🚨 Spike Alerts, Issues Rising Fast
Run a refresh to check for emerging issues.
📊 Issue Trend (Last 7 Days vs Prior)
🗺️ Opponent Activity Log
0
Opponent Signs
0
Resident Mentions
0
Streets Flagged
No opponent activity logged yet. Volunteers can flag signs and mentions from the field.
🎯
Click Generate Prep Card to get an AI briefing built from your team's field data.
Top issues, common objections, and suggested talking points.
0
Calls Today
0
Answered
0
Support Pledges
📋 Call List
No call list loaded yet. Load your imported contacts, or import a list first.
Next Contact
Script
Opening: "Hi, may I speak with [name]? My name is [your name] and I'm volunteering for [candidate]'s campaign for [office]."

If asked why: "We're reaching out to residents in your neighbourhood because [candidate] wants to hear what matters most to you before the October 26th election."

Key question: "Are there any issues in the ward that have been on your mind, like roads, housing, or safety?"

Closing: "Based on what you've told me, [candidate] cares about the same things. Can we count on your support on October 26th?"
Call Outcome
Today's Call Log
No calls logged yet. Start dialling!
$0
Total Raised
$0
Pledged
0
Donors
$0
Avg Donation
Progress to Goal
Set a goal in Settings 0%
No donations logged yet. Log your first donor!
Export Format
🇨🇦
Elections Canada
Municipal & Federal reporting
🇺🇸
FEC Format
US Federal Election Commission
📊
Generic CSV
Universal spreadsheet format
Canvassing Data

All door contacts, support levels, issues, volunteer activity, and follow-ups in the selected format.

Financial Data

Donor names, amounts, dates, and addresses in the format required by your election authority.

📅 Reporting Calendar
⚡ Open Action Items
All caught up — no open action items.
Days to Election Day
Campaign Progress
0%
0 done 0 remaining
Ask me anything about your race, the incumbent's record, local issues, or strategy. I'll research and give you a direct answer.
Quick questions:
0
Total Posts
0
Drafts
0
Scheduled
0
Posted
|
No posts yet. Click "+ New Post" to plan your first piece of content.
`; const w = window.open('','_blank'); w.document.write(html); w.document.close(); } // ═══ MAPS ═══ function loadMaps(){ const key=(currentCampaign && currentCampaign.maps_api_key)||localStorage.getItem('tr_maps_key')||MAPS_API_KEY; if(!key||key.startsWith('YOUR_'))return; const s=document.createElement('script'); s.src=`https://maps.googleapis.com/maps/api/js?key=${key}&callback=initMap`; s.async=true;s.defer=true;document.head.appendChild(s); } window.initMap=function(){ document.getElementById('map-placeholder').style.display='none'; document.getElementById('map').style.display='block'; gmap=new google.maps.Map(document.getElementById('map'),{center:{lat:42.3149,lng:-83.0364},zoom:13,styles:[{featureType:'all',elementType:'geometry',stylers:[{color:'#1a2436'}]},{featureType:'water',stylers:[{color:'#0d1f3c'}]},{featureType:'road',elementType:'geometry',stylers:[{color:'#2a3f6f'}]},{featureType:'road',elementType:'labels.text.fill',stylers:[{color:'#94a3b8'}]},{featureType:'administrative',elementType:'labels.text.fill',stylers:[{color:'#f5a623'}]}]}); plotMarkers(); }; function plotMarkers(){ mapMarkers.forEach(m=>m.setMap(null));mapMarkers=[]; const cm={strong:'#22c55e',lean:'#1d6ef5',undecided:'#f5a623',against:'#e63946'}; entries.filter(e=>e.lat&&e.lng).forEach(e=>{ const name=[e.first_name,e.last_name].filter(Boolean).join(' ')||'Unknown'; const marker=new google.maps.Marker({position:{lat:e.lat,lng:e.lng},map:gmap,title:name+', '+e.address,icon:{path:google.maps.SymbolPath.CIRCLE,scale:9,fillColor:cm[e.support]||'#94a3b8',fillOpacity:0.9,strokeColor:'#fff',strokeWeight:2}}); const info=new google.maps.InfoWindow({content:`
${esc(name)}
${esc(e.address)}${e.issues?.length?`
${esc(e.issues.join(', '))}`:''}
`}); marker.addListener('click',()=>info.open(gmap,marker)); mapMarkers.push(marker); }); } // ═══ AI BRIEFING ═══ // Routes all AI calls through the Supabase edge function (keeps API key server-side) async function callAIEdgeFunction(type, payload) { const { data: { session } } = await supabase.auth.getSession(); const res = await fetch(`${SUPABASE_URL}/functions/v1/ai-briefing`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${session.access_token}`, 'apikey': SUPABASE_ANON, }, body: JSON.stringify({ type, campaign_id: currentCampaign.id, payload }), }); const data = await res.json(); if (data.error) throw new Error(data.error); return data.text; } async function loadAIBriefing(){ const el=document.getElementById('ai-briefing'); if(!entries.length){el.textContent='No field data yet, start knocking doors first!';return;} el.innerHTML='Analyzing field data'; const summary=entries.slice(0,80).map(e=>{const n=[e.first_name,e.last_name].filter(Boolean).join(' ')||'Unknown';return `Address: ${e.address} | Resident: ${n} | Support: ${e.support||'unknown'} | Issues: ${(e.issues||[]).join(', ')} | Notes: ${e.notes||'none'}`;}).join('\n'); const sc={strong:entries.filter(e=>e.support==='strong').length,lean:entries.filter(e=>e.support==='lean').length,undecided:entries.filter(e=>e.support==='undecided').length,against:entries.filter(e=>e.support==='against').length}; try{ const text = await callAIEdgeFunction('briefing', { candidate: currentCampaign.candidate, level: currentCampaign.level, ward: currentCampaign.ward || currentCampaign.name, summary, support: sc, }); el.textContent = text || 'Unable to generate briefing.'; // Coach const coachEl=document.getElementById('ai-coach'); const undecided=entries.filter(e=>e.support==='undecided').slice(0,3); if(undecided.length){ const contacts=undecided.map(e=>`Address: ${e.address} | Issues: ${(e.issues||[]).join(', ')} | Notes: ${e.notes||'none'}`).join('\n'); const coachText = await callAIEdgeFunction('coach', { contacts }); coachEl.textContent = coachText || ''; } }catch(e){el.textContent='Could not connect to AI. Check your connection.';} } // ═══ QUICK LOG (mobile bottom sheet) ═══ let qlSelectedDoor = ''; let qlSelectedSupport = ''; let qlSelectedIssues = []; function openQuickLog() { // Reset document.getElementById('ql-address').value = ''; document.getElementById('ql-notes').value = ''; document.querySelectorAll('.ql-door-btn').forEach(b => b.className = 'ql-door-btn'); document.querySelectorAll('.ql-sup-btn').forEach(b => b.className = 'ql-sup-btn'); document.querySelectorAll('.ql-issue').forEach(t => t.classList.remove('sel')); document.getElementById('ql-resident-section').style.display = 'block'; qlSelectedDoor = ''; qlSelectedSupport = ''; qlSelectedIssues = []; document.getElementById('quick-log-overlay').classList.add('show'); setTimeout(() => document.getElementById('ql-address').focus(), 350); } function closeQuickLog() { document.getElementById('quick-log-overlay').classList.remove('show'); } function qlSelectDoor(btn, status, colorClass) { document.querySelectorAll('.ql-door-btn').forEach(b => b.className = 'ql-door-btn'); btn.classList.add('sel', colorClass); qlSelectedDoor = status; document.getElementById('ql-resident-section').style.display = (status === 'Answered') ? 'block' : 'none'; } function qlSelectSupport(btn, level) { document.querySelectorAll('.ql-sup-btn').forEach(b => b.className = 'ql-sup-btn'); btn.classList.add('sel-' + level); qlSelectedSupport = level; } function qlToggleIssue(el) { const issue = el.textContent; if (el.classList.contains('sel')) { el.classList.remove('sel'); qlSelectedIssues = qlSelectedIssues.filter(i => i !== issue); } else { el.classList.add('sel'); qlSelectedIssues.push(issue); } } async function submitQuickLog() { const address = document.getElementById('ql-address').value.trim(); if (!address) { alert('Please enter the street address.'); return; } if (!qlSelectedDoor) { alert('Please tap a door status.'); return; } const btn = document.querySelector('.ql-submit'); btn.disabled = true; btn.textContent = '⏳ Saving…'; const entry = { campaign_id: currentCampaign.id, logged_by: currentUser.id, volunteer_name: currentProfile?.full_name || 'Anonymous', address, door_status: qlSelectedDoor, support: qlSelectedSupport, issues: [...qlSelectedIssues], notes: document.getElementById('ql-notes').value.trim(), follow_up: 'No', volunteer_interest: 'No', }; const { data, error } = await supabase.from('entries').insert(entry).select().single(); btn.disabled = false; btn.textContent = '📋 Log This Door →'; if (error) { alert('Save failed. Check your connection.'); return; } entries.unshift(data); closeQuickLog(); // Flash success const flash = document.createElement('div'); flash.style.cssText = 'position:fixed;top:70px;left:50%;transform:translateX(-50%);background:var(--green);color:#fff;padding:12px 24px;border-radius:10px;font-weight:700;font-size:14px;z-index:500;animation:pop 0.3s ease;'; flash.textContent = '✅ Door logged!'; document.body.appendChild(flash); setTimeout(() => flash.remove(), 2500); renderDashboard(); renderVisits(); document.getElementById('data-count').textContent = entries.length; } // ═══ ONBOARDING WIZARD ═══ let onboardingData = null; async function loadOnboarding() { const { data } = await supabase .from('onboarding') .select('*') .eq('campaign_id', currentCampaign.id) .single(); if (!data || data.dismissed || data.completed) return; onboardingData = data; updateOnboardingUI(); document.getElementById('onboarding-overlay').classList.add('show'); } function updateOnboardingUI() { if (!onboardingData) return; const steps = ['policy','volunteer','contact','billing']; const done = steps.filter(s => onboardingData['step_' + s]).length; const pct = Math.round((done / steps.length) * 100); document.getElementById('ob-progress-fill').style.width = pct + '%'; steps.forEach(step => { const el = document.getElementById('ob-step-' + step); if (el) el.classList.toggle('done', !!onboardingData['step_' + step]); }); if (done === steps.length) { markOnboardingComplete(); } } async function markOnboardingStep(step) { if (!onboardingData) return; const update = { ['step_' + step]: true }; await supabase.from('onboarding').update(update).eq('campaign_id', currentCampaign.id); if (onboardingData) onboardingData['step_' + step] = true; updateOnboardingUI(); } async function markOnboardingComplete() { await supabase.from('onboarding').update({ completed: true, dismissed: true }) .eq('campaign_id', currentCampaign.id); setTimeout(() => dismissOnboarding(), 1500); } async function dismissOnboarding() { document.getElementById('onboarding-overlay').classList.remove('show'); await supabase.from('onboarding').update({ dismissed: true }) .eq('campaign_id', currentCampaign.id); } function obGoTo(view) { dismissOnboarding(); const btn = document.querySelector(`.sb-item[onclick*="'${view}'"]`); switchView(view, btn); } // ═══ SMS BLAST ═══ let selectedSegment = 'undecided'; function initSMSView() { // Populate segment counts const segments = { undecided: entries.filter(e => e.support === 'undecided' && e.phone), followup: entries.filter(e => e.follow_up && e.follow_up !== 'No' && e.phone), support: entries.filter(e => (e.support === 'strong' || e.support === 'lean') && e.phone), all: entries.filter(e => e.phone), }; Object.entries(segments).forEach(([seg, list]) => { const el = document.getElementById(`seg-${seg}-count`); if (el) el.textContent = `${list.length} contacts with phone`; }); loadSMSHistory(); } function selectSegment(seg, btn) { selectedSegment = seg; document.querySelectorAll('.sms-seg-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); document.getElementById('sms-preview-box').classList.remove('show'); } function updateSmsCharCount() { const msg = document.getElementById('sms-message').value; const el = document.getElementById('sms-char-count'); el.textContent = `${msg.length} / 160`; el.className = 'sms-char-count' + (msg.length > 160 ? ' over' : ''); } async function previewSMS() { const message = document.getElementById('sms-message').value.trim(); if (!message) { alert('Please write a message first.'); return; } if (message.length > 160) { alert('Message must be 160 characters or less.'); return; } const { data: { session } } = await supabase.auth.getSession(); const res = await fetch(`${SUPABASE_URL}/functions/v1/send-sms`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${session.access_token}`, 'apikey': SUPABASE_ANON }, body: JSON.stringify({ campaign_id: currentCampaign.id, segment: selectedSegment, message, preview_only: true }), }); const data = await res.json(); if (data.error) { alert('Preview failed: ' + data.error); return; } const box = document.getElementById('sms-preview-box'); const content = document.getElementById('sms-preview-content'); const dc = { strong:'var(--green)', lean:'var(--blue)', undecided:'var(--gold)', against:'var(--red)' }; content.innerHTML = `
${data.count} recipients · Est. cost: $${data.estimated_cost_usd}
"${data.message_preview}"
${data.count > 0 ? `
Sample Recipients
${(data.sample || []).map(r => `
${r.name}
${r.address}
${r.phone}
`).join('')}` : `
No contacts in this segment have phone numbers.
`}`; box.classList.add('show'); } async function sendSMSBlast() { const message = document.getElementById('sms-message').value.trim(); if (!message) { alert('Please write a message first.'); return; } if (message.length > 160) { alert('Message must be 160 characters or less.'); return; } const segmentLabel = { undecided:'undecided voters', followup:'follow-up contacts', support:'supporters', all:'all contacts' }[selectedSegment]; if (!confirm(`Send this SMS to all ${segmentLabel} with phone numbers? This will charge your Twilio account approximately $0.008 per message. Message: "${message}"`)) return; const btn = document.getElementById('sms-send-btn'); btn.disabled = true; btn.textContent = '⏳ Sending…'; const { data: { session } } = await supabase.auth.getSession(); const res = await fetch(`${SUPABASE_URL}/functions/v1/send-sms`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${session.access_token}`, 'apikey': SUPABASE_ANON }, body: JSON.stringify({ campaign_id: currentCampaign.id, segment: selectedSegment, message, preview_only: false }), }); const data = await res.json(); btn.disabled = false; btn.textContent = '📱 Send SMS Blast'; if (data.error) { alert('SMS failed: ' + data.error); return; } alert(`✅ SMS blast complete! ${data.sent} sent successfully ${data.failed} failed`); loadSMSHistory(); } async function loadSMSHistory() { const { data } = await supabase .from('sms_blasts') .select('*') .eq('campaign_id', currentCampaign.id) .order('created_at', { ascending: false }) .limit(10); const el = document.getElementById('sms-history-list'); if (!data?.length) { el.innerHTML = '
No SMS blasts sent yet.
'; return; } el.innerHTML = data.map(b => `
"${esc(b.message.slice(0, 60))}${b.message.length > 60 ? '…' : ''}"
${new Date(b.created_at).toLocaleDateString('en-CA', {month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'})} · Segment: ${esc(b.segment)}
${b.sent_count} sent
${b.failed_count > 0 ? `
${b.failed_count} failed
` : ''}
`).join(''); } // ═══ VOTER CRM ═══ let crmFilter = 'all'; let crmData = []; function buildCRMData() { const scoped = getCRMEntries(); const grouped = {}; scoped.forEach(e => { const key = e.address?.toLowerCase().trim(); if (!key) return; if (!grouped[key]) grouped[key] = { address: e.address, visits: [], phone: '', email: '' }; grouped[key].visits.push(e); if (e.phone && !grouped[key].phone) grouped[key].phone = e.phone; if (e.email && !grouped[key].email) grouped[key].email = e.email; }); crmData = Object.values(grouped).map(g => ({ ...g, visits: g.visits.sort((a,b) => new Date(b.created_at) - new Date(a.created_at)), name: [g.visits[0]?.first_name, g.visits[0]?.last_name].filter(Boolean).join(' '), currentSupport: g.visits[0]?.support || '', latestFollowUp: g.visits[0]?.follow_up || 'No', visitCount: g.visits.length, })); // Update stats document.getElementById('crm-total').textContent = crmData.length; document.getElementById('crm-multi').textContent = crmData.filter(c => c.visitCount > 1).length; document.getElementById('crm-phones').textContent = crmData.filter(c => c.phone).length; document.getElementById('crm-emails').textContent = crmData.filter(c => c.email).length; } function filterCRM(filter, btn) { crmFilter = filter; document.querySelectorAll('#view-crm .filter-chip').forEach(c => c.classList.remove('active')); btn.classList.add('active'); renderCRM(); } function renderCRM() { const q = (document.getElementById('crm-search')?.value || '').toLowerCase(); let list = [...crmData]; if (crmFilter === 'multi') list = list.filter(c => c.visitCount > 1); if (crmFilter === 'undecided') list = list.filter(c => c.currentSupport === 'undecided'); if (crmFilter === 'followup') list = list.filter(c => c.latestFollowUp && c.latestFollowUp !== 'No'); if (crmFilter === 'phone') list = list.filter(c => c.phone); if (q) list = list.filter(c => c.address.toLowerCase().includes(q) || c.name.toLowerCase().includes(q) || (c.phone || '').includes(q) ); const el = document.getElementById('crm-list'); if (!list.length) { el.innerHTML = '
No contacts match this filter.
'; return; } const dc = { strong:'var(--green)', lean:'var(--blue)', undecided:'var(--gold)', against:'var(--red)' }; const pc = { 'Answered':'✅', 'Not Home':'🔔', 'Do Not Disturb':'🚫' }; el.innerHTML = list.slice(0, 50).map(c => `
${esc(c.name || 'Unknown Resident')}
📍 ${esc(c.address)}
${c.currentSupport ? `${esc(c.currentSupport)}` : ''} ${c.phone ? `📞 ${esc(c.phone)}` : ''} ${c.email ? `✉️ ${esc(c.email)}` : ''} ${c.latestFollowUp !== 'No' ? `⚡ ${esc(c.latestFollowUp)}` : ''}
${c.visitCount}
visit${c.visitCount===1?'':'s'}
${c.visitCount > 1 ? `
${c.visits.slice(0, 3).map(v => `
${pc[v.door_status] || '🚪'} ${v.door_status} ${v.support ? `· ${v.support}` : ''} ${v.notes ? `· "${v.notes.slice(0,60)}${v.notes.length>60?'…':''}"` : ''}
${new Date(v.created_at).toLocaleDateString('en-CA',{month:'short',day:'numeric',year:'numeric'})}
`).join('')} ${c.visitCount > 3 ? `
+${c.visitCount-3} more visits
` : ''}
` : ''}
`).join(''); if (list.length > 50) { el.innerHTML += `
Showing 50 of ${list.length} contacts. Use search to narrow results.
`; } } // ═══ CRM SCOPE (canvasser vs admin) ═══ let crmScope = 'all'; // 'all' or 'mine' function initCRMScope() { const isAdmin = currentUser?.role === 'admin' || currentCampaign?.owner_id === currentUser?.id; const toggle = document.getElementById('crm-view-toggle'); if (isAdmin) { toggle.style.display = 'block'; } else { // Non-admins only see their own visits, auto-set mine crmScope = 'mine'; document.getElementById('crm-scope-label') && (document.getElementById('crm-scope-label').textContent = 'Showing your visits only'); } } function setCRMScope(scope, btn) { crmScope = scope; document.querySelectorAll('#crm-view-toggle .filter-chip').forEach(c => c.classList.remove('active')); btn.classList.add('active'); const label = document.getElementById('crm-scope-label'); if (label) label.textContent = scope === 'all' ? 'Showing all team data' : 'Showing your visits only'; buildCRMData(); renderCRM(); } function getCRMEntries() { if (crmScope === 'mine' && currentUser) { const myName = currentUser.user_metadata?.name || currentUser.email; return entries.filter(e => e.volunteer_name === myName || e.user_id === currentUser.id); } return entries; } // ═══ CRM EXPORT / SHARE / PRINT ═══ function getCRMRows() { return crmData.map(c => ({ Name: c.name || 'Unknown', Address: c.address, Phone: c.phone || '', Email: c.email || '', Support: c.currentSupport || '', Visits: c.visitCount, 'Follow-up': c.latestFollowUp || 'No', 'Last Visit': c.visits[0] ? new Date(c.visits[0].created_at).toLocaleDateString('en-CA') : '', 'Last Notes': c.visits[0]?.notes || '', Volunteer: c.visits[0]?.volunteer_name || '', })); } function crmExportCSV() { const rows = getCRMRows(); if (!rows.length) { alert('No contacts to export.'); return; } const keys = Object.keys(rows[0]); const csv = [keys.join(','), ...rows.map(r => keys.map(k => `"${(r[k]||'').toString().replace(/"/g,'""')}"`).join(','))].join('\n'); const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([csv], {type:'text/csv'})); a.download = `crm_${currentCampaign?.name?.replace(/\s+/g,'_') || 'contacts'}_${new Date().toISOString().slice(0,10)}.csv`; a.click(); } function crmExportExcel() { // Build a simple XLSX-compatible CSV with BOM for Excel to auto-detect const rows = getCRMRows(); if (!rows.length) { alert('No contacts to export.'); return; } const keys = Object.keys(rows[0]); const csv = '' + [keys.join('\t'), ...rows.map(r => keys.map(k => (r[k]||'').toString()).join('\t'))].join('\n'); const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([csv], {type:'application/vnd.ms-excel;charset=utf-8'})); a.download = `crm_${currentCampaign?.name?.replace(/\s+/g,'_') || 'contacts'}_${new Date().toISOString().slice(0,10)}.xls`; a.click(); } function crmPrintPDF() { const rows = getCRMRows(); if (!rows.length) { alert('No contacts to print.'); return; } const keys = Object.keys(rows[0]); const campName = currentCampaign?.name || 'Campaign'; const date = new Date().toLocaleDateString('en-CA',{year:'numeric',month:'long',day:'numeric'}); const tableRows = rows.map(r => `${keys.map(k => `${r[k] || ''}`).join('')}` ).join(''); const html = `CRM, ${campName}

📋 Voter CRM, ${campName}

Exported ${date} · ${rows.length} contacts
${keys.map(k=>``).join('')}${tableRows}
${k}
`; const w = window.open('', '_blank'); w.document.write(html); w.document.close(); } function crmShareWhatsApp() { const rows = getCRMRows(); const campName = currentCampaign?.name || 'Campaign'; const summary = rows.slice(0, 20).map(r => `• ${r.Name} | ${r.Address} | ${r.Support || 'unknown'} | ${r.Phone || 'no phone'}` ).join('\n'); const text = encodeURIComponent(`*${campName}, Voter Contacts*\n\n${summary}${rows.length > 20 ? `\n\n…and ${rows.length - 20} more contacts. Full list exported separately.` : ''}`); window.open(`https://wa.me/?text=${text}`, '_blank'); } function crmShareEmail() { const rows = getCRMRows(); const campName = currentCampaign?.name || 'Campaign'; const date = new Date().toLocaleDateString('en-CA'); const body = rows.slice(0, 30).map(r => `${r.Name} | ${r.Address} | Support: ${r.Support || 'n/a'} | Phone: ${r.Phone || 'n/a'} | Visits: ${r.Visits}` ).join('\n'); const subject = encodeURIComponent(`${campName}, Voter CRM Export (${date})`); const bodyEnc = encodeURIComponent(`${campName} Voter Contacts, ${date}\n\n${body}${rows.length > 30 ? `\n\n…and ${rows.length - 30} more. Download the full CSV/Excel from TurfRunner.` : ''}`); window.location.href = `mailto:?subject=${subject}&body=${bodyEnc}`; } // ═══ LAWN SIGN TRACKER ═══ let signs = JSON.parse(localStorage.getItem('tr_signs_' + (currentCampaign?.id||'')) || '[]'); let editingSignId = null; let signFilter = 'all'; let signMapInstance = null; let signMarkers = []; function saveSigns() { localStorage.setItem('tr_signs_' + currentCampaign.id, JSON.stringify(signs)); } function initSignsView() { // Reload from local storage scoped to this campaign signs = JSON.parse(localStorage.getItem('tr_signs_' + currentCampaign.id) || '[]'); renderSignStats(); renderSigns(); renderSignVolunteers(); if (gmap && !signMapInstance) { document.getElementById('sign-map-placeholder').style.display = 'none'; document.getElementById('sign-map').style.display = 'block'; signMapInstance = new google.maps.Map(document.getElementById('sign-map'), { center: { lat: 42.3149, lng: -83.0364 }, zoom: 13, styles: [{featureType:'all',elementType:'geometry',stylers:[{color:'#1a2436'}]}, {featureType:'road',elementType:'geometry',stylers:[{color:'#2a3f6f'}]}, {featureType:'water',stylers:[{color:'#0d1f3c'}]}] }); plotSignMarkers(); } } function renderSignStats() { document.getElementById('sign-total').textContent = signs.length; document.getElementById('sign-placed').textContent = signs.filter(s=>s.status==='placed').length; document.getElementById('sign-damaged').textContent = signs.filter(s=>s.status==='damaged').length; document.getElementById('sign-missing').textContent = signs.filter(s=>s.status==='missing').length; document.getElementById('sign-retrieved').textContent = signs.filter(s=>s.status==='retrieved').length; } function filterSigns(f, btn) { signFilter = f; document.querySelectorAll('#view-signs .filter-chip').forEach(c=>c.classList.remove('active')); btn.classList.add('active'); renderSigns(); } function renderSigns() { const q = (document.getElementById('sign-search')?.value||'').toLowerCase(); let list = [...signs]; if (signFilter !== 'all') list = list.filter(s=>s.status===signFilter); if (q) list = list.filter(s=>(s.address||'').toLowerCase().includes(q)||(s.volunteer||'').toLowerCase().includes(q)); list.sort((a,b) => new Date(b.date) - new Date(a.date)); const el = document.getElementById('signs-list'); if (!list.length) { el.innerHTML='
No signs logged yet. Place your first sign!
'; return; } const statusColors = { placed:'placed', damaged:'damaged', missing:'missing', retrieved:'retrieved' }; const statusIcons = { placed:'✅', damaged:'⚠️', missing:'🚨', retrieved:'📦' }; const dotColors = { placed:'dot-placed', damaged:'dot-damaged', missing:'dot-missing', retrieved:'dot-retrieved' }; el.innerHTML = list.map(s => `
${s.address}
${s.size ? s.size.charAt(0).toUpperCase()+s.size.slice(1)+' sign · ' : ''} ${s.volunteer ? s.volunteer : ''} ${s.resident ? ' · ' + s.resident : ''}
${s.notes ? `
"${s.notes}"
` : ''} ${statusIcons[s.status]} ${s.status.charAt(0).toUpperCase()+s.status.slice(1)}
${new Date(s.date).toLocaleDateString('en-CA',{month:'short',day:'numeric'})}
`).join(''); } function renderSignVolunteers() { const vm = {}; signs.forEach(s => { const v = s.volunteer || 'Unknown'; if (!vm[v]) vm[v] = { placed:0, damaged:0, retrieved:0 }; vm[v][s.status] = (vm[v][s.status]||0) + 1; }); const tbody = document.getElementById('sign-vol-tbody'); const vols = Object.entries(vm).sort((a,b) => b[1].placed - a[1].placed); if (!vols.length) { tbody.innerHTML='No data yet'; return; } tbody.innerHTML = vols.map(([name,d]) => ` ${name} ${d.placed||0} ${d.damaged||0} ${d.retrieved||0} `).join(''); } function plotSignMarkers() { if (!signMapInstance) return; signMarkers.forEach(m => m.setMap(null)); signMarkers = []; const colors = { placed:'#22c55e', damaged:'#f97316', missing:'#e63946', retrieved:'#94a3b8' }; signs.filter(s => s.lat && s.lng).forEach(s => { const marker = new google.maps.Marker({ position: { lat: s.lat, lng: s.lng }, map: signMapInstance, title: s.address, icon: { path: google.maps.SymbolPath.CIRCLE, scale: 10, fillColor: colors[s.status]||'#22c55e', fillOpacity: 0.9, strokeColor: '#fff', strokeWeight: 2 } }); const info = new google.maps.InfoWindow({ content: `
${s.address}
${s.status.charAt(0).toUpperCase()+s.status.slice(1)} ${s.size ? ' · ' + s.size : ''} ${s.volunteer ? '
' + s.volunteer : ''}
` }); marker.addListener('click', () => info.open(signMapInstance, marker)); signMarkers.push(marker); }); } // Sign Modal let selectedSignStatus = 'placed'; function openSignModal(id) { editingSignId = id; const isEdit = !!id; document.getElementById('sign-modal-title').textContent = isEdit ? '✏️ Edit Lawn Sign' : '🪧 Log Lawn Sign'; document.getElementById('sm-delete-btn').style.display = isEdit ? 'block' : 'none'; selectedSignStatus = 'placed'; if (isEdit) { const s = signs.find(x => x.id === id); if (!s) return; document.getElementById('sm-address').value = s.address||''; document.getElementById('sm-size').value = s.size||'small'; document.getElementById('sm-volunteer').value = s.volunteer||''; document.getElementById('sm-resident').value = s.resident||''; document.getElementById('sm-notes').value = s.notes||''; selectedSignStatus = s.status||'placed'; } else { document.getElementById('sm-address').value = ''; document.getElementById('sm-size').value = 'small'; document.getElementById('sm-volunteer').value = currentProfile?.full_name||''; document.getElementById('sm-resident').value = ''; document.getElementById('sm-notes').value = ''; } // Update button states ['placed','damaged','missing','retrieved'].forEach(st => { const btn = document.getElementById('ss-'+st); if (btn) btn.className = 'sign-size-btn' + (st===selectedSignStatus?' sel':''); }); document.getElementById('sign-modal').classList.add('show'); } function selectSignStatus(status, btn) { selectedSignStatus = status; ['placed','damaged','missing','retrieved'].forEach(st => { const b = document.getElementById('ss-'+st); if (b) b.className = 'sign-size-btn'; }); btn.className = 'sign-size-btn sel'; } function closeSignModal() { document.getElementById('sign-modal').classList.remove('show'); editingSignId = null; } async function saveSign() { const address = document.getElementById('sm-address').value.trim(); if (!address) { alert('Please enter the street address.'); return; } const sign = { id: editingSignId || Date.now().toString(), address, status: selectedSignStatus, size: document.getElementById('sm-size').value, volunteer: document.getElementById('sm-volunteer').value.trim(), resident: document.getElementById('sm-resident').value.trim(), notes: document.getElementById('sm-notes').value.trim(), date: new Date().toISOString(), lat: null, lng: null, }; // Geocode if Maps available if (gmap) { const coords = await geocodeAddr(address); if (coords) { sign.lat = coords.lat; sign.lng = coords.lng; } } if (editingSignId) { const idx = signs.findIndex(s => s.id === editingSignId); if (idx > -1) signs[idx] = sign; } else { signs.unshift(sign); // Mark onboarding } saveSigns(); closeSignModal(); renderSignStats(); renderSigns(); renderSignVolunteers(); if (signMapInstance) plotSignMarkers(); const flash = document.createElement('div'); flash.style.cssText='position:fixed;top:70px;left:50%;transform:translateX(-50%);background:var(--green);color:#fff;padding:12px 24px;border-radius:10px;font-weight:700;font-size:14px;z-index:500;animation:pop 0.3s ease;'; flash.textContent = editingSignId ? '✅ Sign updated!' : '✅ Sign logged!'; document.body.appendChild(flash); setTimeout(() => flash.remove(), 2500); } function deleteSign() { if (!editingSignId) return; const sign = signs.find(s => s.id === editingSignId); if (!confirm(`Delete sign at ${sign?.address}?`)) return; signs = signs.filter(s => s.id !== editingSignId); saveSigns(); closeSignModal(); renderSignStats(); renderSigns(); renderSignVolunteers(); if (signMapInstance) plotSignMarkers(); } // ═══ GOTV ELECTION DAY MODE ═══ let gotvLogs = JSON.parse(localStorage.getItem('tr_gotv_' + (currentCampaign?.id||'')) || '[]'); let gotvVotedStatus = null; let gotvRideStatus = null; function saveGotvLogs() { localStorage.setItem('tr_gotv_' + currentCampaign.id, JSON.stringify(gotvLogs)); } function launchGOTV() { gotvLogs = JSON.parse(localStorage.getItem('tr_gotv_' + currentCampaign.id) || '[]'); resetGOTVForm(); updateGOTVStats(); renderGOTVRecent(); document.getElementById('gotv-overlay').classList.add('show'); document.body.style.overflow = 'hidden'; setTimeout(() => document.getElementById('gotv-address').focus(), 300); } function exitGOTV() { document.getElementById('gotv-overlay').classList.remove('show'); document.body.style.overflow = ''; renderGOTVFullLog(); updateGOTVDBStats(); } function resetGOTVForm() { document.getElementById('gotv-address').value = ''; gotvVotedStatus = null; gotvRideStatus = null; // Reset all buttons document.querySelectorAll('.gotv-ans-btn').forEach(b => { b.style.opacity='1'; b.style.transform=''; }); document.getElementById('gotv-submit').disabled = true; // Lock Q2 until Q1 answered const q2 = document.getElementById('gotv-q2'); q2.style.opacity = '0.4'; q2.style.pointerEvents = 'none'; } function gotvSelect(answer, btn) { const isVotedQuestion = answer === 'voted' || answer === 'not_voted'; if (isVotedQuestion) { // Deselect Q1 buttons document.querySelectorAll('.gotv-ans-btn.voted, .gotv-ans-btn.not-voted').forEach(b => { b.style.opacity='0.5'; b.style.transform='scale(0.97)'; }); btn.style.opacity = '1'; btn.style.transform = 'scale(1.02)'; gotvVotedStatus = answer; // Unlock Q2 const q2 = document.getElementById('gotv-q2'); q2.style.opacity = '1'; q2.style.pointerEvents = 'auto'; } else { // Q2 buttons document.querySelectorAll('.gotv-ans-btn.needs-ride, .gotv-ans-btn.refused').forEach(b => { b.style.opacity='0.5'; b.style.transform='scale(0.97)'; }); btn.style.opacity = '1'; btn.style.transform = 'scale(1.02)'; gotvRideStatus = answer; } document.getElementById('gotv-submit').disabled = !(gotvVotedStatus && gotvRideStatus); } async function submitGOTV() { const address = document.getElementById('gotv-address').value.trim(); if (!address) { document.getElementById('gotv-address').focus(); return; } const log = { id: Date.now().toString(), address, voted: gotvVotedStatus === 'voted', needs_ride: gotvRideStatus === 'needs_ride', timestamp: new Date().toISOString(), volunteer: currentProfile?.full_name || 'Unknown', }; gotvLogs.unshift(log); saveGotvLogs(); // Also save to Supabase entries table as a GOTV contact supabase.from('entries').insert({ campaign_id: currentCampaign.id, logged_by: currentUser.id, volunteer_name: currentProfile?.full_name || 'GOTV Volunteer', address, door_status: 'Answered', notes: `GOTV: ${log.voted ? 'Confirmed voted' : 'Not yet voted'}${log.needs_ride ? ' · Needs ride to polls' : ''}`, follow_up: log.needs_ride ? 'Yes - Call' : 'No', }).then(() => {}).catch(() => {}); updateGOTVStats(); renderGOTVRecent(); // Flash feedback const submit = document.getElementById('gotv-submit'); submit.textContent = '✅ Logged!'; submit.style.background = '#22c55e'; setTimeout(() => { submit.textContent = 'Log This Door →'; submit.style.background = '#ffd700'; resetGOTVForm(); document.getElementById('gotv-address').focus(); }, 700); } function updateGOTVStats() { const voted = gotvLogs.filter(l => l.voted).length; const notVoted = gotvLogs.filter(l => !l.voted).length; const rides = gotvLogs.filter(l => l.needs_ride).length; document.getElementById('gotv-voted').textContent = voted; document.getElementById('gotv-not-voted').textContent = notVoted; document.getElementById('gotv-needs-ride').textContent= rides; document.getElementById('gotv-total').textContent = gotvLogs.length; } function renderGOTVRecent() { const el = document.getElementById('gotv-recent-list'); const recent = gotvLogs.slice(0, 8); if (!recent.length) { el.innerHTML='
No logs yet
'; return; } el.innerHTML = recent.map(l => `
${l.address}
${l.voted ? '✅ Voted' : '⏰ Not Yet'} ${l.needs_ride ? ' 🚗' : ''}
`).join(''); } function updateGOTVDBStats() { const el1 = document.getElementById('gotv-db-voted'); const el2 = document.getElementById('gotv-db-ride'); if (el1) el1.textContent = gotvLogs.filter(l=>l.voted).length; if (el2) el2.textContent = gotvLogs.filter(l=>l.needs_ride).length; } function renderGOTVFullLog() { gotvLogs = JSON.parse(localStorage.getItem('tr_gotv_' + currentCampaign.id) || '[]'); updateGOTVDBStats(); // Rides list const ridesEl = document.getElementById('gotv-rides-list'); const rides = gotvLogs.filter(l => l.needs_ride); if (!rides.length) { ridesEl.innerHTML = '
No ride requests yet.
'; } else { ridesEl.innerHTML = rides.map(l => `
🚗 Ride
${l.address}
${l.volunteer}
${new Date(l.timestamp).toLocaleTimeString('en-CA',{hour:'2-digit',minute:'2-digit'})}
`).join(''); } // Full log const logEl = document.getElementById('gotv-full-log'); if (!gotvLogs.length) { logEl.innerHTML = '
No GOTV contacts logged yet.
'; return; } logEl.innerHTML = gotvLogs.map(l => `
${l.voted?'✅ Voted':'⏰ Not Yet'}
${l.address}
${l.volunteer}${l.needs_ride?' · 🚗 Needs ride':''}
${new Date(l.timestamp).toLocaleTimeString('en-CA',{hour:'2-digit',minute:'2-digit'})}
`).join(''); } // ═══ ROUTE OPTIMIZATION ═══ let routeMap = null; let routeMarkers = []; let routeDirections = null; let optimizedRoute = []; function initRouteView() { // Populate address dropdown from unvisited entries const select = document.getElementById('route-area-input'); const streets = [...new Set(entries.map(e => { const parts = e.address.split(' '); return parts.slice(1).join(' '); // street name without number }))].filter(Boolean).sort(); select.innerHTML = '' + streets.slice(0, 30).map(s => ``).join(''); // Update unvisited count const visitedAddrs = new Set(entries.map(e => e.address?.toLowerCase().trim())); document.getElementById('r-unvisited').textContent = visitedAddrs.size; document.getElementById('r-stops').textContent = entries.length; } async function generateRoute() { const priority = document.getElementById('route-priority').value; // Filter entries based on priority let candidates = [...entries]; if (priority === 'unvisited') { // Show all logged addresses (in a real app you'd diff against a voter list) candidates = entries.filter(e => e.door_status !== 'Answered'); } else if (priority === 'undecided') { candidates = entries.filter(e => e.support === 'undecided'); } else if (priority === 'followup') { candidates = entries.filter(e => e.follow_up && e.follow_up !== 'No'); } if (candidates.length === 0) { alert('No addresses match this filter. Try "All logged addresses" or log some contacts first.'); return; } // Use nearest-neighbor algorithm to create optimized route const withCoords = candidates.filter(e => e.lat && e.lng); const withoutCoords = candidates.filter(e => !e.lat || !e.lng); let routeEntries = []; if (withCoords.length > 1) { // Sort by proximity (nearest neighbor greedy algorithm) routeEntries = nearestNeighborSort(withCoords); } else { // Fallback: sort alphabetically by address (groups by street) routeEntries = candidates.sort((a, b) => { const streetA = a.address.replace(/^\d+\s*/, ''); const streetB = b.address.replace(/^\d+\s*/, ''); if (streetA !== streetB) return streetA.localeCompare(streetB); return parseInt(a.address) - parseInt(b.address); }); } optimizedRoute = routeEntries.slice(0, 25); // Cap at 25 for display // Calculate estimated stats const stopCount = optimizedRoute.length; const estDistance = (stopCount * 0.15).toFixed(1); // ~150m per stop const estTime = Math.round(stopCount * 3.5); // ~3.5 min per door document.getElementById('r-stops').textContent = stopCount; document.getElementById('r-distance').textContent = estDistance + ' km'; document.getElementById('r-time').textContent = estTime + ' min'; // Show route list renderRouteList(optimizedRoute); document.getElementById('route-list').style.display = 'block'; // Plot on map if available if (routeMap && withCoords.length > 0) { plotRouteOnMap(optimizedRoute.filter(e => e.lat && e.lng)); } else if (!routeMap) { document.getElementById('route-map-placeholder').innerHTML = `

Route optimized! ${stopCount} stops in order below. Add your Google Maps API key in Settings to see the visual route map.

`; } } function nearestNeighborSort(points) { if (points.length <= 1) return points; const remaining = [...points]; const route = [remaining.splice(0, 1)[0]]; while (remaining.length > 0) { const last = route[route.length - 1]; let nearestIdx = 0; let nearestDist = Infinity; remaining.forEach((point, i) => { const dist = haversineDistance( parseFloat(last.lat), parseFloat(last.lng), parseFloat(point.lat), parseFloat(point.lng) ); if (dist < nearestDist) { nearestDist = dist; nearestIdx = i; } }); route.push(remaining.splice(nearestIdx, 1)[0]); } return route; } function haversineDistance(lat1, lon1, lat2, lon2) { const R = 6371; const dLat = (lat2 - lat1) * Math.PI / 180; const dLon = (lon2 - lon1) * Math.PI / 180; const a = Math.sin(dLat/2)**2 + Math.cos(lat1 * Math.PI/180) * Math.cos(lat2 * Math.PI/180) * Math.sin(dLon/2)**2; return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); } function renderRouteList(route) { const dc = {strong:'var(--green)',lean:'var(--blue)',undecided:'var(--gold)',against:'var(--red)'}; const pc = {'Answered':'✅','Not Home':'🔔','Do Not Disturb':'🚫'}; document.getElementById('route-stops-list').innerHTML = route.map((e, i) => { const name = [e.first_name, e.last_name].filter(Boolean).join(' ') || ''; const statusIcon = pc[e.door_status] || '🚪'; const supportColor = dc[e.support] || 'var(--slate)'; const issues = (e.issues || []).slice(0, 2).join(', '); return `
${i+1}
${e.address}
${name ? name + ' · ' : ''} ${statusIcon} ${e.door_status || 'Not visited'} ${e.support ? `· ${e.support}` : ''} ${issues ? `· ${issues}` : ''}
${e.follow_up && e.follow_up !== 'No' ? `
⚡ Follow-up
` : ''}
`; }).join(''); } function plotRouteOnMap(routePoints) { if (!routeMap || routePoints.length === 0) return; routeMarkers.forEach(m => m.setMap(null)); routeMarkers = []; const bounds = new google.maps.LatLngBounds(); const waypoints = routePoints.map((e, i) => { const pos = { lat: parseFloat(e.lat), lng: parseFloat(e.lng) }; bounds.extend(pos); const marker = new google.maps.Marker({ position: pos, map: routeMap, label: { text: String(i+1), color: '#fff', fontWeight: 'bold', fontSize: '11px' }, icon: { path: google.maps.SymbolPath.CIRCLE, scale: 14, fillColor: i === 0 ? '#f5a623' : '#1d6ef5', fillOpacity: 1, strokeColor: '#fff', strokeWeight: 2 } }); routeMarkers.push(marker); return pos; }); routeMap.fitBounds(bounds); // Draw the route line const routeLine = new google.maps.Polyline({ path: waypoints, geodesic: true, strokeColor: '#00d4ff', strokeOpacity: 0.7, strokeWeight: 3, map: routeMap }); routeMarkers.push({ setMap: (m) => routeLine.setMap(m) }); document.getElementById('route-map-placeholder').style.display = 'none'; document.getElementById('route-map').style.display = 'block'; } function clearRoute() { optimizedRoute = []; routeMarkers.forEach(m => m.setMap && m.setMap(null)); routeMarkers = []; document.getElementById('route-list').style.display = 'none'; document.getElementById('route-map').style.display = 'none'; document.getElementById('route-map-placeholder').style.display = 'flex'; ['r-stops','r-distance','r-time'].forEach(id => { document.getElementById(id).textContent = id === 'r-distance' ? '0 km' : id === 'r-time' ? '0 min' : '0'; }); } function shareRoute() { if (!optimizedRoute.length) return; const text = optimizedRoute.map((e, i) => `${i+1}. ${e.address}`).join('\n'); if (navigator.share) { navigator.share({ title: 'TurfRunner Route', text: `Today's canvassing route: ${text}` }); } else { navigator.clipboard.writeText(text).then(() => alert('Route copied to clipboard!')); } } function exportRouteCSV() { const rows = optimizedRoute.map((e, i) => [ i+1, e.address, e.door_status||'', e.support||'', (e.issues||[]).join('; '), e.follow_up||'No' ].map(v => `"${v}"`).join(',')); const csv = ['Stop,Address,Status,Support,Issues,FollowUp', ...rows].join('\n'); const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([csv], {type:'text/csv'})); a.download = 'turfrunner_route.csv'; a.click(); } // ═══ MOBILE HELPERS ═══ function setBottomNav(activeId) { document.querySelectorAll('.bn-item').forEach(b => b.classList.remove('active')); const el = document.getElementById(activeId); if (el) el.classList.add('active'); } function showStickySubmit() { const isMobile = window.innerWidth <= 768; const wrap = document.getElementById('sticky-submit-wrap'); if (wrap) wrap.style.display = isMobile ? 'block' : 'none'; } // Prevent screen sleep while canvassing (where supported) let wakeLock = null; async function requestWakeLock() { try { if ('wakeLock' in navigator) { wakeLock = await navigator.wakeLock.request('screen'); } } catch (e) { /* Not supported, ignore */ } } // Re-acquire wake lock when tab becomes visible document.addEventListener('visibilitychange', async () => { if (document.visibilityState === 'visible' && wakeLock !== null) { await requestWakeLock(); } }); // PWA install prompt let deferredInstallPrompt = null; window.addEventListener('beforeinstallprompt', (e) => { e.preventDefault(); deferredInstallPrompt = e; // Wait 60 seconds before showing, don't interrupt the user immediately setTimeout(showInstallBanner, 60000); }); function showInstallBanner() { if (localStorage.getItem('tr_install_dismissed')) return; const banner = document.createElement('div'); banner.id = 'install-banner'; banner.style.cssText = 'position:fixed;bottom:72px;left:12px;right:12px;background:var(--card);border:1px solid rgba(245,166,35,0.4);border-radius:12px;padding:14px 16px;display:flex;align-items:center;gap:12px;z-index:150;box-shadow:0 8px 32px rgba(0,0,0,0.4);'; banner.innerHTML = `
📲
Add to Home Screen
Install TurfRunner for faster access in the field
`; document.body.appendChild(banner); // Auto-dismiss after 10 seconds setTimeout(() => document.getElementById('install-banner')?.remove(), 10000); } async function installPWA() { if (!deferredInstallPrompt) return; deferredInstallPrompt.prompt(); const { outcome } = await deferredInstallPrompt.userChoice; if (outcome === 'accepted') { document.getElementById('install-banner')?.remove(); localStorage.setItem('tr_install_dismissed', '1'); } deferredInstallPrompt = null; } function dismissInstall() { document.getElementById('install-banner')?.remove(); localStorage.setItem('tr_install_dismissed', '1'); } // ═══ NAV ═══ function switchView(view,btn){ if (isPremiumView(view) && isFreeTier()) { showUpgradeModal(view); return; } document.querySelectorAll('.view').forEach(v=>v.classList.remove('active')); document.querySelectorAll('.sb-item').forEach(t=>t.classList.remove('active')); const el = document.getElementById('view-'+view); if (el) el.classList.add('active'); if(btn) btn.classList.add('active'); const _content = document.querySelector('.content'); if (_content) _content.scrollTop = 0; if(view==='field') { showStickySubmit(); requestWakeLock(); if (!sessionStartTime) startSession(); } else { const w = document.getElementById('sticky-submit-wrap'); if(w) w.style.display='none'; if (sessionStartTime && view === 'dashboard') { setTimeout(() => showSessionRecap(), 400); } } if(view==='dashboard') { renderDashboard(); renderWinBars(); } if(view==='visits') renderVisits(); if(view==='map'&&gmap) plotMarkers(); if(view==='policy') renderPolicies(); if(view==='users') { renderTeam(); setTimeout(renderTeamWithCaptains, 100); } if(view==='route') { initRouteView(); if(gmap&&!routeMap){routeMap=gmap;} } if(view==='sms') { initSMSView(); } if(view==='signs') { initSignsView(); } if(view==='gotv') { renderGOTVFullLog(); updateGOTVDBStats(); } if(view==='crm') { initCRMScope(); buildCRMData(); renderCRM(); } if(view==='import') { renderImportHistory(); } if(view==='meetings') { renderMeetings(); } if(view==='timeline') { renderTimeline(); } if(view==='social') { renderSocialCalendar(); } if(view==='research') {} if(view==='settings') { document.getElementById('data-count').textContent=entries.length; renderWinBars(); } if(view==='turf') renderTurfs(); if(view==='coverage') renderCoverage(); if(view==='scores') renderScoreboard(); if(view==='shifts') renderShifts(); if(view==='nudge') { renderNudgeCenter(); } if(view==='radar') { renderIssueRadar(); } if(view==='prep') {} // triggered by button if(view==='phone') { renderCallLog(); } if(view==='fundraise') { renderFundraising(); } if(view==='compliance'){ renderComplianceCalendar(); } } // ═══ MODAL CLOSE ON BACKDROP ═══ ['policy-modal','invite-modal','edit-modal','sign-modal','turf-modal','shift-modal','opp-modal','donor-modal'].forEach(id=>{ document.getElementById(id)?.addEventListener('click',function(e){ if(e.target===this) this.classList.remove('show'); }); }); // ═══════════════════════════════════════════════════════════════ // TURFRUNNER v4, TIER 5 & 6 FEATURES // Intelligence, Phone Banking, Fundraising, Compliance // ═══════════════════════════════════════════════════════════════ // ─── STATE T5/T6 ─── let oppSightings = JSON.parse(localStorage.getItem('tr_opp_' + (currentCampaign?.id||'')) || '[]'); let donors = []; // loaded from Supabase via loadDonors() let callLogs = JSON.parse(localStorage.getItem('tr_calls_' + (currentCampaign?.id||'')) || '[]'); let donorFilter = 'all'; let compFormat = 'elections_canada'; let editingDonorId = null; let pbOutcome = ''; // ─── ISSUE RADAR (T5) ─── function renderIssueRadar() { const now = Date.now(); const week = 7 * 24 * 3600000; // Split entries into last 7 days vs prior period const recent = entries.filter(e => now - new Date(e.created_at).getTime() < week); const prior = entries.filter(e => { const age = now - new Date(e.created_at).getTime(); return age >= week && age < week * 2; }); // Count issues per period const countIssues = (list) => { const c = {}; list.forEach(e => (e.issues||[]).forEach(i => { c[i] = (c[i]||0) + 1; })); return c; }; const recentCounts = countIssues(recent); const priorCounts = countIssues(prior); // Find spikes: issues that grew >50% or appeared fresh const spikes = Object.entries(recentCounts) .map(([issue, count]) => { const prev = priorCounts[issue] || 0; const spike = prev === 0 ? 999 : Math.round((count - prev) / prev * 100); return { issue, count, prev, spike }; }) .filter(x => x.spike > 40) .sort((a,b) => b.spike - a.spike); // Render alerts const alertEl = document.getElementById('radar-alerts'); if (alertEl) { if (!spikes.length && !recent.length) { alertEl.innerHTML = '
No data yet, log more field contacts to detect emerging issues.
'; } else if (!spikes.length) { alertEl.innerHTML = '
✅ No unusual spikes detected. Issues are tracking normally.
'; } else { alertEl.innerHTML = spikes.slice(0,5).map(s => { const isNew = s.prev === 0; return `
${isNew ? '🆕' : '📈'}
${s.issue}
${isNew ? `First time appearing, ${s.count} mentions in last 7 days` : `Up ${s.spike}%, ${s.count} this week vs ${s.prev} prior week`}
${isNew ? 'NEW' : '+' + s.spike + '%'}
`; }).join(''); } } // Trend bars const trendEl = document.getElementById('radar-trend'); if (trendEl) { const allIssues = [...new Set([...Object.keys(recentCounts), ...Object.keys(priorCounts)])]; const sorted = allIssues.sort((a,b) => (recentCounts[b]||0) - (recentCounts[a]||0)).slice(0,8); const max = Math.max(...sorted.map(i => Math.max(recentCounts[i]||0, priorCounts[i]||0)), 1); if (!sorted.length) { trendEl.innerHTML = '
No issues logged yet.
'; } else { trendEl.innerHTML = sorted.map(issue => { const r = recentCounts[issue] || 0; const p = priorCounts[issue] || 0; return `
${issue}${r} this week${p ? ' vs ' + p : ''}
${p ? `
` : ''}
`; }).join('') + '
Cyan = this week · Grey = prior week
'; } } // Render opponent sightings renderOppList(); } function renderOppList() { oppSightings = JSON.parse(localStorage.getItem('tr_opp_' + (currentCampaign?.id||'')) || '[]'); const signs = oppSightings.filter(o => o.type === 'sign').length; const contacts = oppSightings.filter(o => o.type === 'contact').length; const streets = new Set(oppSightings.map(o => o.address)).size; const safeSet = (id, v) => { const el = document.getElementById(id); if(el) el.textContent = v; }; safeSet('opp-signs', signs); safeSet('opp-contacts', contacts); safeSet('opp-streets', streets); const el = document.getElementById('opp-list'); if (!el) return; if (!oppSightings.length) { el.innerHTML = '
No opponent activity logged yet.
'; return; } const typeIcons = { sign:'🪧', contact:'💬', flyer:'📄', event:'📣' }; const typeLabels = { sign:'Sign Spotted', contact:'Resident Mention', flyer:'Literature Found', event:'Nearby Event' }; el.innerHTML = [...oppSightings].reverse().map(o => `
${esc(o.address)}
${esc(o.notes || '')}
${typeIcons[o.type]||'📍'} ${esc(typeLabels[o.type]||o.type)}
${new Date(o.date).toLocaleDateString('en-CA',{month:'short',day:'numeric'})}
${esc(o.loggedBy||'')}
`).join(''); } function openOppModal() { document.getElementById('opp-modal').classList.add('show'); } function closeOppModal() { document.getElementById('opp-modal').classList.remove('show'); } function saveOppSighting() { const address = document.getElementById('opp-address').value.trim(); if (!address) { alert('Please enter the address.'); return; } const sighting = { id: Date.now().toString(), address, type: document.getElementById('opp-type').value, notes: document.getElementById('opp-notes').value.trim(), date: new Date().toISOString(), loggedBy: currentProfile?.full_name || 'Unknown', }; oppSightings.push(sighting); localStorage.setItem('tr_opp_' + currentCampaign.id, JSON.stringify(oppSightings)); closeOppModal(); renderOppList(); showFlash('🚨 Opponent sighting logged!', 'var(--orange)'); document.getElementById('opp-address').value = ''; document.getElementById('opp-notes').value = ''; } // ─── CANDIDATE PREP CARD (T5) ─── async function generatePrepCard() { const container = document.getElementById('prep-card-container'); if (!container) return; if (!entries.length) { container.innerHTML = '
No field data yet. Log door contacts first to generate a meaningful prep card.
'; return; } container.innerHTML = '
Generating prep card
Analyzing ' + entries.length + ' field contacts…
'; // Build context const issueCounts = {}; entries.forEach(e => (e.issues||[]).forEach(i => { issueCounts[i] = (issueCounts[i]||0)+1; })); const topIssues = Object.entries(issueCounts).sort((a,b)=>b[1]-a[1]).slice(0,5); const sc = { strong: entries.filter(e=>e.support==='strong').length, lean: entries.filter(e=>e.support==='lean').length, undecided:entries.filter(e=>e.support==='undecided').length, against: entries.filter(e=>e.support==='against').length, }; const recentNotes = entries.filter(e=>e.notes).slice(0,30).map(e=>`"${e.notes}"`).join('\n'); const candidate = currentCampaign?.candidate || 'the candidate'; const ward = currentCampaign?.ward || 'the ward'; try { // Route through edge function so the Anthropic API key stays server-side const raw = await callAIEdgeFunction('debate', { candidate, ward, entries_count: entries.length, support: sc, top_issues: topIssues.map(([i,c])=>i+' ('+c+')').join(', '), recent_notes: recentNotes.slice(0, 800), }); const clean = raw.replace(/```json|```/g,'').trim(); const prep = JSON.parse(clean); window.lastPrepData = prep; // Store for sharePrepWhatsApp const maxCount = Math.max(...(prep.topIssues||[]).map(i=>i.count), 1); container.innerHTML = `
✨ AI Prep Card, ${new Date().toLocaleDateString('en-CA',{month:'short',day:'numeric'})}
🏆 This Week's Message
"${prep.messageOfWeek||''}"
📊 Top Issues Residents Are Raising
${(prep.topIssues||[]).map(i=>`
${i.issue}
${i.count} doors
${i.talking_point||''}
`).join('')}
🎯 Objections, What Residents Are Pushing Back With
${(prep.objections||[]).map(o=>`
❓ "${o.question}"
💬 ${o.answer}
`).join('')}
${prep.watchOut ? `
⚠️ Watch Out This Week
${prep.watchOut}
` : ''}
`; } catch(e) { container.innerHTML = '
Could not generate prep card. Check your connection and try again.
'; } } function sharePrepWhatsApp() { try { const prep = window.lastPrepData; if (!prep) { showFlash('Generate a prep card first', 'var(--orange)'); return; } const candidate = currentCampaign?.candidate || 'Campaign'; const topIssues = (prep.topIssues||[]).slice(0,3).map(i=>'• '+i.issue).join('\n'); const msg = `📋 *${candidate}, Campaign Prep Update* 🏆 This week's message: "${prep.messageOfWeek}" 🔥 Top issues residents care about: ${topIssues} ⚠️ Watch: ${prep.watchOut||''}`; window.open('https://wa.me/?text=' + encodeURIComponent(msg), '_blank'); } catch(e) { showFlash('Could not share prep card', 'var(--red)'); } } // ─── PHONE BANKING (T6) ─── function saveCalls() { localStorage.setItem('tr_calls_' + currentCampaign.id, JSON.stringify(callLogs)); } function pbSelect(outcome, btn) { pbOutcome = outcome; document.querySelectorAll('.call-outcome-btn').forEach(b => { b.className = 'call-outcome-btn'; }); const classMap = { answered:'sel-answer', voicemail:'sel-vm', no_answer:'sel-noans', do_not_call:'sel-dnd' }; btn.classList.add(classMap[outcome]||''); const showDetails = outcome === 'answered'; document.getElementById('pb-support-section').style.display = showDetails ? 'block' : 'none'; document.getElementById('pb-notes-section').style.display = showDetails ? 'block' : 'none'; // Reset support buttons if (!showDetails) document.querySelectorAll('.support-btn').forEach(b=>b.className='support-btn'); } // PHONE BANK CALL-THROUGH QUEUE let pbQueue = []; let pbQueueIndex = 0; function pbNormPhone(p){ return (p||'').toString().replace(/\D/g,''); } async function pbBuildQueue() { try { if (typeof loadEntries === 'function') await loadEntries(); } catch(e){} const calls = JSON.parse(localStorage.getItem('tr_calls_'+(currentCampaign&¤tCampaign.id||''))||'[]'); const called = new Set(calls.map(c=>pbNormPhone(c.phone)).filter(Boolean)); const seen = new Set(); pbQueue = (entries||[]).filter(e=>{ const ph = pbNormPhone(e.phone); if(!ph || ph.length < 7) return false; if(called.has(ph)) return false; if(seen.has(ph)) return false; seen.add(ph); return true; }); pbQueueIndex = 0; const status = document.getElementById('pb-queue-status'); const skip = document.getElementById('pb-skip-btn'); const wrap = document.getElementById('pb-queue-progress-wrap'); if(!pbQueue.length){ if(status) status.textContent = 'No uncalled contacts with phone numbers found. Import a list (or log door contacts with phones) first.'; if(wrap) wrap.style.display='none'; if(skip) skip.style.display='none'; return; } if(wrap) wrap.style.display='block'; if(skip) skip.style.display='inline-flex'; pbLoadCurrent(); } function pbLoadCurrent() { const status = document.getElementById('pb-queue-status'); const prog = document.getElementById('pb-queue-progress'); if(pbQueueIndex >= pbQueue.length){ if(status) status.textContent = '🎉 Call list complete! '+pbQueue.length+' contacts worked through.'; if(prog) prog.style.width='100%'; const skip=document.getElementById('pb-skip-btn'); if(skip) skip.style.display='none'; ['pb-name','pb-phone','pb-address'].forEach(id=>{const el=document.getElementById(id); if(el) el.value='';}); return; } const c = pbQueue[pbQueueIndex]; const name = [c.first_name,c.last_name].filter(Boolean).join(' ').trim() || c.name || ''; const setV=(id,v)=>{const el=document.getElementById(id); if(el) el.value=v||'';}; setV('pb-name',name); setV('pb-phone',c.phone); setV('pb-address',c.address); const priorEl=document.getElementById('pb-prior'); if(priorEl) priorEl.value = ({strong:'strong',lean:'lean',undecided:'undecided'})[c.support] || 'none'; const total=pbQueue.length, pos=pbQueueIndex+1; if(status) status.textContent = 'Contact '+pos+' of '+total+' · '+(total-pos)+' remaining'; if(prog) prog.style.width = ((pos-1)/total*100)+'%'; pbOutcome=''; selectedSupport=''; document.querySelectorAll('.call-outcome-btn').forEach(b=>b.className='call-outcome-btn'); document.querySelectorAll('.support-btn').forEach(b=>b.className='support-btn'); const ss=document.getElementById('pb-support-section'); if(ss) ss.style.display='none'; const ns=document.getElementById('pb-notes-section'); if(ns) ns.style.display='none'; const nt=document.getElementById('pb-notes'); if(nt) nt.value=''; } function pbSkip(){ if(pbQueue.length){ pbQueueIndex++; pbLoadCurrent(); } } function pbAdvance(){ if(pbQueue.length){ pbQueueIndex++; pbLoadCurrent(); } } async function logCall() { const phone = document.getElementById('pb-phone').value.trim(); const name = document.getElementById('pb-name').value.trim(); if (!phone && !name) { alert('Enter a name or phone number.'); return; } if (!pbOutcome) { alert('Select a call outcome first.'); return; } const btn = document.getElementById('pb-log-btn'); btn.disabled = true; btn.textContent = 'Saving…'; const call = { id: Date.now().toString(), name, phone, address: document.getElementById('pb-address').value.trim(), outcome: pbOutcome, support: selectedSupport, notes: document.getElementById('pb-notes')?.value.trim() || '', volunteer: currentProfile?.full_name || 'Unknown', timestamp: new Date().toISOString(), }; callLogs.unshift(call); saveCalls(); // If answered, also log to entries table if (pbOutcome === 'answered' && call.address) { await supabase.from('entries').insert({ campaign_id: currentCampaign.id, logged_by: currentUser.id, volunteer_name: currentProfile?.full_name || 'Phone Banker', address: call.address, door_status: 'Answered', first_name: name.split(' ')[0] || '', last_name: name.split(' ').slice(1).join(' ') || '', phone: call.phone, support: call.support, notes: call.notes ? '[Phone] ' + call.notes : '[Phone call]', follow_up: 'No', }).then(()=>{}).catch(()=>{}); } // Reset form ['pb-name','pb-phone','pb-address','pb-notes'].forEach(id => { const el = document.getElementById(id); if(el) el.value = ''; }); document.getElementById('pb-prior').value = 'none'; document.querySelectorAll('.call-outcome-btn').forEach(b=>b.className='call-outcome-btn'); document.querySelectorAll('.support-btn').forEach(b=>b.className='support-btn'); document.getElementById('pb-support-section').style.display = 'none'; document.getElementById('pb-notes-section').style.display = 'none'; pbOutcome = ''; selectedSupport = ''; btn.disabled = false; btn.textContent = '📋 Log This Call →'; renderCallLog(); showFlash('✅ Call logged!', 'var(--purple)'); pbAdvance(); } function renderCallLog() { callLogs = JSON.parse(localStorage.getItem('tr_calls_' + (currentCampaign?.id||'')) || '[]'); const outcomeColors = { answered:'var(--green)', voicemail:'var(--blue)', no_answer:'var(--orange)', do_not_call:'var(--red)' }; const outcomeLabels = { answered:'✅ Answered', voicemail:'📬 Voicemail', no_answer:'📵 No Answer', do_not_call:'🚫 DNC' }; const today = callLogs.filter(c => new Date(c.timestamp).toDateString() === new Date().toDateString()); const safeSet = (id, v) => { const el = document.getElementById(id); if(el) el.textContent = v; }; safeSet('pb-calls', today.length); safeSet('pb-answers', today.filter(c=>c.outcome==='answered').length); safeSet('pb-pledges', today.filter(c=>c.support==='strong'||c.support==='lean').length); const el = document.getElementById('pb-log-list'); if (!el) return; if (!callLogs.length) { el.innerHTML = '
No calls logged yet. Start dialling!
'; return; } el.innerHTML = callLogs.slice(0,30).map(c => `
${c.outcome==='answered'?'✅':c.outcome==='voicemail'?'📬':c.outcome==='no_answer'?'📵':'🚫'}
${esc(c.name||c.phone||'Unknown')}
${esc(c.address||'No address')} ${c.support?'· '+esc(c.support):''}
${c.notes?`
"${esc(c.notes.slice(0,60))}"
`:''}
${new Date(c.timestamp).toLocaleTimeString('en-CA',{hour:'2-digit',minute:'2-digit'})}
${c.volunteer||''}
`).join(''); } function exportPhoneLog() { if (!callLogs.length) { alert('No call logs to export.'); return; } const h = ['Timestamp','Volunteer','Name','Phone','Address','Outcome','Support','Notes']; const rows = callLogs.map(c => [c.timestamp,c.volunteer,c.name,c.phone,c.address,c.outcome,c.support,c.notes].map(v=>`"${(v||'').toString().replace(/"/g,'""')}"`)); const csv = [h.join(','), ...rows.map(r=>r.join(','))].join('\n'); const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([csv],{type:'text/csv'})); a.download = 'turfrunner_calls_' + new Date().toISOString().slice(0,10) + '.csv'; a.click(); } // ─── FUNDRAISING (T6) ─── // ─── DONORS: Supabase-backed (migrated from localStorage) ─── function donorFromRow(r){ return { id:r.id, firstName:r.first_name||'', lastName:r.last_name||'', amount:r.amount, date:r.donation_date||'', status:r.status||'pledged', method:r.method||'', address:r.address||'', phone:r.phone||'', email:r.email||'', notes:r.notes||'', loggedBy:r.logged_by||'' }; } function donorToRow(d){ return { campaign_id:currentCampaign.id, first_name:d.firstName||'', last_name:d.lastName||'', amount:parseFloat(d.amount)||0, donation_date:d.date||null, status:d.status||'pledged', method:d.method||'', address:d.address||'', phone:d.phone||'', email:d.email||'', notes:d.notes||'', logged_by:d.loggedBy||(currentProfile&¤tProfile.full_name)||'Unknown' }; } async function loadDonors(){ const { data, error } = await supabase.from('donors').select('*') .eq('campaign_id', currentCampaign.id).order('created_at',{ascending:false}); donors = error ? [] : (data||[]).map(donorFromRow); } function getFundGoal() { return parseFloat(localStorage.getItem('tr_fund_goal_' + (currentCampaign?.id||'')) || '0'); } async function renderFundraising() { await loadDonors(); const received = donors.filter(d=>d.status==='received').reduce((s,d)=>s+parseFloat(d.amount||0), 0); const pledged = donors.filter(d=>d.status==='pledged').reduce((s,d)=>s+parseFloat(d.amount||0), 0); const avg = donors.length ? received / donors.filter(d=>d.status==='received').length : 0; const goal = getFundGoal(); const fmt = n => '$' + n.toLocaleString('en-CA',{minimumFractionDigits:0,maximumFractionDigits:0}); const safeSet = (id, v) => { const el = document.getElementById(id); if(el) el.textContent = v; }; safeSet('fund-total', fmt(received)); safeSet('fund-pledged', fmt(pledged)); safeSet('fund-donors', donors.filter(d=>d.status==='received').length); safeSet('fund-avg', isFinite(avg) && avg > 0 ? fmt(avg) : '$0'); // Goal bar const goalLabel = document.getElementById('fund-goal-label'); const goalPct = document.getElementById('fund-goal-pct'); const goalFill = document.getElementById('fund-goal-fill'); if (goal > 0) { const pct = Math.min(100, Math.round(received / goal * 100)); if (goalLabel) goalLabel.textContent = fmt(received) + ' of ' + fmt(goal) + ' goal'; if (goalPct) goalPct.textContent = pct + '%'; if (goalFill) goalFill.style.width = pct + '%'; } else { if (goalLabel) goalLabel.textContent = 'Set a fundraising goal in Settings'; } renderDonorList(); } function filterDonors(f, btn) { donorFilter = f; document.querySelectorAll('#view-fundraise .filter-chip').forEach(c=>c.classList.remove('active')); btn.classList.add('active'); renderDonorList(); } function renderDonorList() { let list = [...donors]; if (donorFilter !== 'all') list = list.filter(d=>d.status===donorFilter); list.sort((a,b) => new Date(b.date) - new Date(a.date)); const el = document.getElementById('donor-list'); if (!el) return; if (!list.length) { el.innerHTML = '
No donations match this filter.
'; return; } const statusColors = { received:'status-received', pledged:'status-pledged', overdue:'status-overdue' }; const statusLabels = { received:'✅ Received', pledged:'🤝 Pledged', overdue:'⚠️ Overdue' }; el.innerHTML = list.map(d => `
${esc((d.firstName||'?')[0].toUpperCase())}
${esc(d.firstName)} ${esc(d.lastName)}
${esc(d.address||'')} ${d.method?' · '+esc(d.method):''}
${d.notes?`
"${esc(d.notes.slice(0,60))}"
`:''}
$${parseFloat(d.amount||0).toLocaleString()}
${esc(statusLabels[d.status]||d.status)}
`).join(''); } function openDonorModal(id) { editingDonorId = id; const today = new Date().toISOString().slice(0,10); document.getElementById('donor-modal-title').textContent = id ? 'Edit Donation' : 'Log Donation'; document.getElementById('dm-delete-btn').style.display = id ? 'block' : 'none'; if (id) { const d = donors.find(x=>x.id===id); if (d) { document.getElementById('dm-first').value = d.firstName||''; document.getElementById('dm-last').value = d.lastName||''; document.getElementById('dm-amount').value = d.amount||''; document.getElementById('dm-date').value = d.date||today; document.getElementById('dm-status').value = d.status||'received'; document.getElementById('dm-method').value = d.method||'cash'; document.getElementById('dm-address').value = d.address||''; document.getElementById('dm-phone').value = d.phone||''; document.getElementById('dm-email').value = d.email||''; document.getElementById('dm-notes').value = d.notes||''; } } else { ['dm-first','dm-last','dm-amount','dm-address','dm-phone','dm-email','dm-notes'].forEach(id=>document.getElementById(id).value=''); document.getElementById('dm-date').value = today; document.getElementById('dm-status').value = 'received'; document.getElementById('dm-method').value = 'cash'; } document.getElementById('donor-modal').classList.add('show'); } function closeDonorModal() { document.getElementById('donor-modal').classList.remove('show'); editingDonorId = null; } async function saveDonor() { const firstName = document.getElementById('dm-first').value.trim(); const amount = parseFloat(document.getElementById('dm-amount').value); if (!firstName) { alert('Donor first name is required.'); return; } if (!amount||amount<=0) { alert('Enter a valid donation amount.'); return; } const donor = { firstName, lastName: document.getElementById('dm-last').value.trim(), amount, date: document.getElementById('dm-date').value, status: document.getElementById('dm-status').value, method: document.getElementById('dm-method').value, address: document.getElementById('dm-address').value.trim(), phone: document.getElementById('dm-phone').value.trim(), email: document.getElementById('dm-email').value.trim(), notes: document.getElementById('dm-notes').value.trim(), }; let error; if (editingDonorId) { ({ error } = await supabase.from('donors').update(donorToRow(donor)).eq('id', editingDonorId)); } else { ({ error } = await supabase.from('donors').insert(donorToRow(donor))); } if (error) { alert('Save failed: ' + error.message); return; } closeDonorModal(); await renderFundraising(); showFlash('💰 Donation saved!', 'var(--green)'); } async function deleteDonor() { if (!editingDonorId) return; const d = donors.find(x=>x.id===editingDonorId); if (!confirm('Delete donation from ' + (d?.firstName||'this donor') + '?')) return; const { error } = await supabase.from('donors').delete().eq('id', editingDonorId); if (error) { alert('Delete failed: ' + error.message); return; } closeDonorModal(); await renderFundraising(); } function exportDonorCSV() { if (!donors.length) { alert('No donors to export.'); return; } const h = ['Date','FirstName','LastName','Amount','Status','Method','Address','Phone','Email','Notes']; const rows = donors.map(d=>[d.date,d.firstName,d.lastName,d.amount,d.status,d.method,d.address,d.phone,d.email,d.notes].map(v=>`"${(v||'').toString().replace(/"/g,'""')}"`)); const csv = [h.join(','), ...rows.map(r=>r.join(','))].join('\n'); const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([csv],{type:'text/csv'})); a.download = 'turfrunner_donors_' + new Date().toISOString().slice(0,10) + '.csv'; a.click(); } // ─── COMPLIANCE REPORTS (T6) ─── function selectCompFormat(fmt, btn) { compFormat = fmt; document.querySelectorAll('.compliance-btn').forEach(b=>b.classList.remove('active')); btn.classList.add('active'); } function renderComplianceCalendar() { const el = document.getElementById('compliance-calendar'); if (!el) return; const country = currentCampaign?.country || 'Canada'; const isCanada = country === 'Canada'; const dates = isCanada ? [ { date: 'Aug 24, 2026', event: 'Nomination Certification Deadline', type: 'deadline' }, { date: 'Oct 26, 2026', event: 'Election Day', type: 'election' }, { date: 'Nov 25, 2026', event: 'Financial Disclosure Filing Due', type: 'filing' }, { date: 'Dec 31, 2026', event: 'Audited Financial Statement Due', type: 'audit' }, ] : [ { date: 'Oct 15, 2026', event: 'Pre-Election FEC Report Due', type: 'deadline' }, { date: 'Nov 3, 2026', event: 'Election Day', type: 'election' }, { date: 'Dec 5, 2026', event: 'Post-Election FEC Report Due', type: 'filing' }, ]; const typeColors = { deadline:'var(--orange)', election:'var(--red)', filing:'var(--blue)', audit:'var(--gold)' }; el.innerHTML = dates.map(d => `
${d.date}
${d.event}
${d.type}
`).join(''); } function exportCompliance(type) { const campaign = currentCampaign?.name || 'Campaign'; const candidate = currentCampaign?.candidate || ''; const ward = currentCampaign?.ward || ''; const now = new Date().toISOString().slice(0,10); if (type === 'canvassing') { if (!entries.length) { alert('No canvassing data to export.'); return; } let h, rows; if (compFormat === 'elections_canada') { h = ['Contact Date','Volunteer Name','Street Address','City','Door Status','Support Level','Issues Raised','Notes','Follow Up Required','Volunteer Interest']; rows = entries.map(e => [ e.created_at ? new Date(e.created_at).toLocaleDateString('en-CA') : '', e.volunteer_name, e.address, ward, e.door_status, e.support, (e.issues||[]).join('; '), e.notes, e.follow_up, e.volunteer_interest ].map(v=>`"${(v||'').toString().replace(/"/g,'""')}"`)); } else { h = ['Date','Volunteer','Address','Status','Support','Issues','Notes']; rows = entries.map(e => [ e.created_at ? new Date(e.created_at).toLocaleDateString() : '', e.volunteer_name, e.address, e.door_status, e.support, (e.issues||[]).join('; '), e.notes ].map(v=>`"${(v||'').toString().replace(/"/g,'""')}"`)); } const csv = [h.join(','), ...rows.map(r=>r.join(','))].join('\n'); downloadCSV(csv, `${campaign}_canvassing_${now}.csv`); } else if (type === 'volunteers') { const vm = {}; entries.forEach(e => { const v = e.volunteer_name || 'Unknown'; if (!vm[v]) vm[v] = { doors:0, first:'', last:'' }; vm[v].doors++; if (!vm[v].first || e.created_at < vm[v].first) vm[v].first = e.created_at; if (!vm[v].last || e.created_at > vm[v].last) vm[v].last = e.created_at; }); const h = ['Volunteer Name','Total Doors Knocked','First Activity','Last Activity']; const rows = Object.entries(vm).map(([name,d]) => [ name, d.doors, d.first ? new Date(d.first).toLocaleDateString('en-CA') : '', d.last ? new Date(d.last).toLocaleDateString('en-CA') : '' ].map(v=>`"${v}"`)); const csv = [h.join(','), ...rows.map(r=>r.join(','))].join('\n'); downloadCSV(csv, `${campaign}_volunteers_${now}.csv`); } else if (type === 'donations') { if (!donors.length) { alert('No donation data to export.'); return; } let h, rows; if (compFormat === 'elections_canada') { h = ['Receipt Date','First Name','Last Name','Residential Address','Amount (CAD)','Payment Method','Status','Occupation (if known)']; rows = donors.map(d => [ d.date, d.firstName, d.lastName, d.address, parseFloat(d.amount||0).toFixed(2), d.method, d.status, '' ].map(v=>`"${(v||'').toString().replace(/"/g,'""')}"`)); } else { h = ['Date','First Name','Last Name','Address','Amount','Method','Status','Email','Phone']; rows = donors.map(d => [ d.date, d.firstName, d.lastName, d.address, parseFloat(d.amount||0).toFixed(2), d.method, d.status, d.email, d.phone ].map(v=>`"${(v||'').toString().replace(/"/g,'""')}"`)); } const csv = [h.join(','), ...rows.map(r=>r.join(','))].join('\n'); downloadCSV(csv, `${campaign}_donations_${now}.csv`); } else if (type === 'full_report') { // Combined report with multiple sections const sections = [ '# ' + campaign + ', Full Compliance Report', '# Generated: ' + new Date().toLocaleString('en-CA'), '# Candidate: ' + candidate + ' | Ward: ' + ward, '', '## CAMPAIGN SUMMARY', 'Total Contacts,' + entries.length, 'Supporters (Strong + Leaning),' + entries.filter(e=>e.support==='strong'||e.support==='lean').length, 'Total Donors,' + donors.length, 'Total Raised,$' + donors.filter(d=>d.status==='received').reduce((s,d)=>s+parseFloat(d.amount||0),0).toFixed(2), '', '## CONTACT DATA', 'Date,Volunteer,Address,Status,Support', ...entries.map(e=>[ e.created_at ? new Date(e.created_at).toLocaleDateString('en-CA') : '', e.volunteer_name, e.address, e.door_status, e.support ].map(v=>`"${(v||'').replace(/"/g,'""')}"`).join(',')) ].join('\n'); downloadCSV(sections, `${campaign}_full_report_${now}.csv`); } } function downloadCSV(csv, filename) { const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([csv], {type:'text/csv;charset=utf-8;'})); a.download = filename; a.click(); showFlash('⬇️ Report downloaded!', 'var(--cyan)'); } // ─── PATCH: Add fund goal to Settings ─── function saveFundGoal() { const val = parseFloat(document.getElementById('set-fund-goal')?.value); if (!val || val <= 0) { alert('Enter a valid fundraising goal.'); return; } localStorage.setItem('tr_fund_goal_' + currentCampaign.id, val); renderFundraising(); showFlash('✅ Fundraising goal saved!', 'var(--green)'); } // ═══════════════════════════════════════════════════════════════ // TURFRUNNER v3, TIER 2, 3 & 4 FEATURES // ═══════════════════════════════════════════════════════════════ // ─── STATE ─── let turfs = JSON.parse(localStorage.getItem(('tr_turfs_v3_' + (currentCampaign?.id||''))) || '[]'); let shifts = JSON.parse(localStorage.getItem(('tr_shifts_v3_' + (currentCampaign?.id||''))) || '[]'); let sessionStartEntryCount = 0; let sessionStartTime = null; let editingTurfId = null; let editingShiftId = null; // ─── WIN CONDITION ─── function getWinTarget() { return parseInt(localStorage.getItem('tr_win_target_' + (currentCampaign?.id||'')) || '0'); } function saveWinTarget() { const val = parseInt(document.getElementById('set-win-target').value); if (!val || val < 1) { alert('Enter a valid target number.'); return; } localStorage.setItem('tr_win_target_' + currentCampaign.id, val); renderWinBars(); alert('✅ Win target saved! Your progress bars will now show ' + val + ' supporters as the goal.'); } function renderWinBars() { const target = getWinTarget(); const current = entries.filter(e => e.support === 'strong' || e.support === 'lean').length; const pct = target > 0 ? Math.min(100, Math.round(current / target * 100)) : 0; const remaining = target > 0 ? Math.max(0, target - current) : null; // Settings prefill const setEl = document.getElementById('set-win-target'); if (setEl && target) setEl.value = target; // Dashboard bar const dashBar = document.getElementById('dash-win-bar'); if (dashBar) { dashBar.style.display = target > 0 ? 'block' : 'none'; if (target > 0) { document.getElementById('dash-win-pct').textContent = pct + '%'; document.getElementById('dash-win-fill').style.width = pct + '%'; document.getElementById('dash-win-current').textContent = current; document.getElementById('dash-win-target').textContent = target.toLocaleString(); document.getElementById('dash-win-remaining').textContent = remaining !== null ? remaining.toLocaleString() : '-'; } } // Field view mini bar const fieldBar = document.getElementById('field-win-bar'); if (fieldBar) { fieldBar.style.display = target > 0 ? 'flex' : 'none'; const fFill = document.getElementById('field-win-fill'); const fPct = document.getElementById('field-win-pct'); if (fFill) fFill.style.width = pct + '%'; if (fPct) fPct.textContent = pct + '%'; } // Scores page bar const wpct = document.getElementById('win-pct-display'); if (wpct) { wpct.textContent = target > 0 ? pct + '%' : '-'; const fill = document.getElementById('win-bar-fill'); if (fill) fill.style.width = pct + '%'; const wc = document.getElementById('win-current'); if (wc) wc.textContent = current; const wt = document.getElementById('win-target-display'); if (wt) wt.textContent = target > 0 ? target.toLocaleString() : 'Not set'; const wr = document.getElementById('win-remaining'); if (wr) wr.textContent = target > 0 ? remaining.toLocaleString() + ' to go' : 'Set a target in Settings'; } } // ─── TURF ASSIGNMENT ─── function saveTurfsLocal() { localStorage.setItem(('tr_turfs_v3_' + (currentCampaign?.id||'')), JSON.stringify(turfs)); } function renderTurfs() { turfs = JSON.parse(localStorage.getItem(('tr_turfs_v3_' + (currentCampaign?.id||''))) || '[]'); const total = turfs.length; const assigned = turfs.filter(t => t.assignee).length; const complete = turfs.filter(t => t.completed).length; const covered = total > 0 ? Math.round(turfs.reduce((s,t) => s + (t.doorsKnocked||0), 0) / Math.max(1, turfs.reduce((s,t) => s + (t.targetDoors||0), 0)) * 100) : 0; const safeSet = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; }; safeSet('turf-total', total); safeSet('turf-assigned', assigned); safeSet('turf-complete', complete); safeSet('turf-coverage', Math.min(100, covered) + '%'); const el = document.getElementById('turf-list'); if (!el) return; if (!turfs.length) { el.innerHTML = '
No turfs yet. Click "Create Turf" to get started.
'; return; } const pColors = { normal:'var(--slate)', high:'var(--orange)', urgent:'var(--red)' }; el.innerHTML = turfs.map(t => { const knocked = t.doorsKnocked || entries.filter(e => { const streets = (t.streets||'').toLowerCase(); return streets && e.address && e.address.toLowerCase().includes(streets.split(',')[0].trim().replace(/[()]/g,'')); }).length; const target = t.targetDoors || 40; const pct = Math.min(100, Math.round(knocked / target * 100)); const pColor = pct >= 80 ? 'var(--green)' : pct >= 40 ? 'var(--cyan)' : 'var(--blue)'; return `
${esc(t.name)}
${t.priority && t.priority !== 'normal' ? `${t.priority === 'high' ? '🔥 HIGH PRIORITY' : '🚨 URGENT'}` : ''}
${esc(t.streets?.slice(0,60))}${t.streets?.length > 60 ? '…' : ''} ${pct}%
🚪 ${knocked} / ${target} doors ${t.assignee ? `👤 ${esc(t.assignee)}` : '⚠️ Unassigned'} ${t.completed ? '✅ Complete' : ''}
${t.assignee ? `` : ''}
`; }).join(''); } function openTurfModal(id) { editingTurfId = id; document.getElementById('turf-modal-title').textContent = id ? 'Edit Turf' : 'Create Turf'; document.getElementById('tm-delete-btn').style.display = id ? 'block' : 'none'; if (id) { const t = turfs.find(x => x.id === id); if (t) { document.getElementById('tm-name').value = t.name || ''; document.getElementById('tm-streets').value = t.streets || ''; document.getElementById('tm-assignee').value= t.assignee || ''; document.getElementById('tm-target').value = t.targetDoors || ''; document.getElementById('tm-priority').value= t.priority || 'normal'; } } else { ['tm-name','tm-streets','tm-assignee','tm-target'].forEach(id => document.getElementById(id).value = ''); document.getElementById('tm-priority').value = 'normal'; } document.getElementById('turf-modal').classList.add('show'); } function closeTurfModal() { document.getElementById('turf-modal').classList.remove('show'); editingTurfId = null; } function saveTurf() { const name = document.getElementById('tm-name').value.trim(); const streets = document.getElementById('tm-streets').value.trim(); if (!name || !streets) { alert('Name and streets are required.'); return; } const turf = { id: editingTurfId || Date.now().toString(), name, streets, assignee: document.getElementById('tm-assignee').value.trim(), targetDoors: parseInt(document.getElementById('tm-target').value) || 40, priority: document.getElementById('tm-priority').value, completed: editingTurfId ? (turfs.find(t=>t.id===editingTurfId)?.completed||false) : false, createdAt: editingTurfId ? (turfs.find(t=>t.id===editingTurfId)?.createdAt||new Date().toISOString()) : new Date().toISOString(), }; if (editingTurfId) { const idx = turfs.findIndex(t => t.id === editingTurfId); if (idx > -1) turfs[idx] = turf; } else { turfs.unshift(turf); } saveTurfsLocal(); closeTurfModal(); renderTurfs(); showFlash('✅ Turf saved!', 'var(--green)'); } function deleteTurf() { if (!editingTurfId) return; const t = turfs.find(x => x.id === editingTurfId); if (!confirm('Delete turf "' + t?.name + '"? This cannot be undone.')) return; turfs = turfs.filter(x => x.id !== editingTurfId); saveTurfsLocal(); closeTurfModal(); renderTurfs(); } function toggleTurfComplete(id) { const t = turfs.find(x => x.id === id); if (t) { t.completed = !t.completed; saveTurfsLocal(); renderTurfs(); } } function shareTurfWhatsApp(id) { const t = turfs.find(x => x.id === id); if (!t) return; const msg = `Hi ${t.assignee}! Your canvass turf for today: *${t.name}* Streets: ${t.streets} Target: ${t.targetDoors} doors. Log every contact in TurfRunner as you go. Good luck! 🚪`; window.open('https://wa.me/?text=' + encodeURIComponent(msg), '_blank'); } // ─── COVERAGE DASHBOARD ─── function renderCoverage() { // By volunteer const vm = {}; entries.forEach(e => { const v = e.volunteer_name || 'Unknown'; if (!vm[v]) vm[v] = { doors: 0, supporters: 0, undecided: 0 }; vm[v].doors++; if (e.support === 'strong' || e.support === 'lean') vm[v].supporters++; if (e.support === 'undecided') vm[v].undecided++; }); const vols = Object.entries(vm).sort((a,b) => b[1].doors - a[1].doors); const maxDoors = vols[0]?.[1].doors || 1; const volEl = document.getElementById('coverage-by-vol'); if (volEl) { if (!vols.length) { volEl.innerHTML = '
No data yet.
'; } else volEl.innerHTML = vols.slice(0,12).map(([name,d]) => { const pct = Math.round(d.doors / maxDoors * 100); return `
${name}
${d.doors}
`; }).join(''); } // By street (heatmap) const sm = {}; entries.forEach(e => { if (!e.address) return; const parts = e.address.split(' '); const street = parts.slice(1).join(' ').trim() || e.address; if (!sm[street]) sm[street] = { total:0, undecided:0, support:0 }; sm[street].total++; if (e.support === 'undecided') sm[street].undecided++; if (e.support === 'strong' || e.support === 'lean') sm[street].support++; }); const streets = Object.entries(sm).sort((a,b) => b[1].undecided - a[1].undecided); const streetEl = document.getElementById('coverage-by-street'); if (streetEl) { if (!streets.length) { streetEl.innerHTML = '
No street data yet.
'; } else streetEl.innerHTML = streets.slice(0,15).map(([street, d]) => { const heatColor = d.undecided >= 4 ? 'var(--red)' : d.undecided >= 2 ? 'var(--orange)' : d.undecided >= 1 ? 'var(--gold)' : 'var(--green)'; return `
${street}
${d.total} doors · ${d.undecided} undecided
${d.undecided >= 4 ? '🔥' : d.undecided >= 2 ? '⚠️' : d.undecided >= 1 ? '👀' : '✅'}
`; }).join(''); } // Heatmap summary const hmEl = document.getElementById('heatmap-list'); if (hmEl) { const hot = streets.filter(([,d]) => d.undecided >= 4); const warm = streets.filter(([,d]) => d.undecided >= 2 && d.undecided < 4); const luke = streets.filter(([,d]) => d.undecided === 1); hmEl.innerHTML = `
${hot.length}
Hot Streets
${warm.length}
Warm Streets
${luke.length}
Lukewarm
${hot.length ? '
🔥 Prioritize these streets now:
' + hot.slice(0,5).map(([s,d]) => `
${s}, ${d.undecided} undecided voters logged
`).join('') : ''}`; } } // ─── VOLUNTEER SCOREBOARD ─── function renderScoreboard() { renderWinBars(); const vm = {}; entries.forEach(e => { const v = e.volunteer_name || 'Unknown'; if (!vm[v]) vm[v] = { doors:0, supporters:0, undecided:0, followups:0, firstSeen: e.created_at }; vm[v].doors++; if (e.support==='strong'||e.support==='lean') vm[v].supporters++; if (e.support==='undecided') vm[v].undecided++; if (e.follow_up && e.follow_up!=='No') vm[v].followups++; }); const vols = Object.entries(vm).sort((a,b) => b[1].doors - a[1].doors); const el = document.getElementById('scoreboard-list'); if (!el) return; if (!vols.length) { el.innerHTML = '
No volunteer data yet. Once your team starts logging doors, their scores will appear here.
'; return; } el.innerHTML = vols.map(([name, d], i) => { const badges = getBadges(d.doors, d.supporters); const isCurrentUser = name === currentProfile?.full_name; return `
${i===0?'🥇':i===1?'🥈':i===2?'🥉':i+1}
${name[0].toUpperCase()}
${name}${isCurrentUser?' (you)':''}
${d.supporters} supporters · ${d.undecided} undecided · ${d.followups} follow-ups
${badges.map(b => `${b.icon}`).join('')}
${d.doors}
Doors
`; }).join(''); } function getBadges(doors, supporters) { const target = getWinTarget(); const badges = []; if (doors >= 5) badges.push({icon:'🚀', label:'First 5 Doors'}); if (doors >= 25) badges.push({icon:'🌟', label:'25 Doors Knocked'}); if (doors >= 50) badges.push({icon:'💪', label:'50 Doors, Power Canvasser'}); if (doors >= 100) badges.push({icon:'🏆', label:'100 Doors, Legend'}); if (supporters >= 5) badges.push({icon:'💚', label:'5 Supporters Found'}); if (supporters >= 10) badges.push({icon:'⚡', label:'10 Votes Secured'}); if (supporters >= 25) badges.push({icon:'🔥', label:'25 Votes Secured'}); if (target > 0 && supporters >= target * 0.01) badges.push({icon:'🎯', label:'1% of Win Goal'}); return badges; } // Check and show milestone toast let shownMilestones = JSON.parse(localStorage.getItem('tr_milestones_' + (currentCampaign?.id||'')) || '[]'); function checkMilestones() { if (!currentProfile?.full_name) return; const myEntries = entries.filter(e => e.volunteer_name === currentProfile.full_name); const myDoors = myEntries.length; const mySupporters = myEntries.filter(e => e.support==='strong'||e.support==='lean').length; const milestones = [ {key:'d5', check: myDoors >= 5, msg: '🚀 First 5 doors knocked!'}, {key:'d25', check: myDoors >= 25, msg: "🌟 25 doors! You're making a difference."}, {key:'d50', check: myDoors >= 50, msg: '💪 50 doors knocked, Power Canvasser!'}, {key:'d100', check: myDoors >= 100, msg: "🏆 100 doors! You're a legend."}, {key:'s10', check: mySupporters >= 10, msg: '⚡ 10 votes secured for the campaign!'}, {key:'s25', check: mySupporters >= 25, msg: '🔥 25 supporters found, incredible work!'}, ]; for (const m of milestones) { if (m.check && !shownMilestones.includes(m.key)) { shownMilestones.push(m.key); localStorage.setItem('tr_milestones_' + currentCampaign.id, JSON.stringify(shownMilestones)); showMilestoneToast(m.msg); break; // One at a time } } } function showMilestoneToast(msg) { const t = document.createElement('div'); t.className = 'milestone-toast'; t.innerHTML = `${msg.split(' ')[0]}${msg.slice(msg.indexOf(' ')+1)}`; document.body.appendChild(t); setTimeout(() => t.style.opacity = '0', 3500); setTimeout(() => t.remove(), 4000); } // ─── SESSION RECAP ─── function startSession() { sessionStartEntryCount = entries.length; sessionStartTime = Date.now(); } function showSessionRecap() { if (sessionStartTime === null) return; const sessionEntries = entries.slice(0, entries.length - sessionStartEntryCount); if (sessionEntries.length < 1) return; // Show recap after the first door const doors = sessionEntries.length; const supporters = sessionEntries.filter(e => e.support==='strong'||e.support==='lean').length; const undecided = sessionEntries.filter(e => e.support==='undecided').length; const topIssue = getTopIssue(sessionEntries); let emojis, titles; if (doors >= 50) { emojis = '🏆'; titles = 'Legendary Run!'; } else if (doors >= 30) { emojis = '🚀'; titles = 'Unstoppable!'; } else if (doors >= 20) { emojis = '🔥'; titles = 'On Fire!'; } else if (doors >= 10) { emojis = '🎉'; titles = 'Great Session!'; } else if (doors >= 5) { emojis = '💪'; titles = 'Crushing It!'; } else if (doors >= 2) { emojis = '⚡'; titles = 'Momentum Building!'; } else { emojis = '🎯'; titles = "You're On The Board!"; } const mins = Math.round((Date.now() - sessionStartTime) / 60000); document.getElementById('recap-emoji').textContent = emojis; document.getElementById('recap-title').textContent = titles; document.getElementById('recap-sub').textContent = `${mins} min · ${doors} door${doors===1?'':'s'} knocked`; document.getElementById('recap-doors').textContent = doors; document.getElementById('recap-supporters').textContent= supporters; document.getElementById('recap-undecided').textContent = undecided; document.getElementById('recap-issue-text').textContent = topIssue ? `Top concern heard today: "${topIssue}", ${sessionEntries.filter(e=>(e.issues||[]).includes(topIssue)).length} residents mentioned it.` : ''; document.getElementById('recap-overlay').classList.add('show'); sessionStartTime = null; } function getTopIssue(list) { const counts = {}; list.forEach(e => (e.issues||[]).forEach(i => { counts[i] = (counts[i]||0)+1; })); const sorted = Object.entries(counts).sort((a,b) => b[1]-a[1]); return sorted[0]?.[0] || null; } function closeRecap() { document.getElementById('recap-overlay').classList.remove('show'); switchView('dashboard', document.querySelector('.sb-item[onclick*="dashboard"]')); } function shareRecapWhatsApp() { const doors = document.getElementById('recap-doors').textContent; const supporters = document.getElementById('recap-supporters').textContent; const issue = document.getElementById('recap-issue-text').textContent; const campaign = currentCampaign?.name || 'the campaign'; const msg = `Just finished canvassing for ${campaign}! 🚪 ${doors} doors knocked · ${supporters} supporters found ${issue} Want to join us next time? Every door makes a difference! 💪`; window.open('https://wa.me/?text=' + encodeURIComponent(msg), '_blank'); } // ─── SHIFT SCHEDULER ─── function saveShiftsLocal() { localStorage.setItem(('tr_shifts_v3_' + (currentCampaign?.id||'')), JSON.stringify(shifts)); } function renderShifts() { shifts = JSON.parse(localStorage.getItem(('tr_shifts_v3_' + (currentCampaign?.id||''))) || '[]'); const now = Date.now(); const upcoming = shifts.filter(s => new Date(s.date).getTime() >= now - 86400000); const totalConfirmed = upcoming.reduce((s, sh) => s + (sh.attendees||[]).length, 0); const totalDoors = upcoming.reduce((s, sh) => s + (sh.targetDoors||0), 0); const safeSet = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; }; safeSet('shift-upcoming', upcoming.length); safeSet('shift-confirmed', totalConfirmed); safeSet('shift-doors', totalDoors); const el = document.getElementById('shifts-list'); if (!el) return; if (!shifts.length) { el.innerHTML = '
No shifts yet. Create your first canvass shift!
'; return; } const sorted = [...shifts].sort((a,b) => new Date(a.date) - new Date(b.date)); el.innerHTML = sorted.map(sh => { const isPast = new Date(sh.date).getTime() < now - 86400000; const isJoined = (sh.attendees||[]).includes(currentProfile?.full_name); const attendees = sh.attendees || []; return `
${esc(sh.name)}
📅 ${new Date(sh.date).toLocaleDateString('en-CA',{weekday:'long',month:'long',day:'numeric'})} · ⏰ ${sh.time||'10:00'}
${!isPast ? `` : ''}
${sh.location ? `📍 ${esc(sh.location)}` : ''} ${sh.targetDoors ? `🚪 ${sh.targetDoors} doors targeted` : ''} 👥 ${attendees.length} confirmed
${sh.notes ? `
${esc(sh.notes)}
` : ''} ${attendees.length > 0 ? `
${attendees.map(a => `✓ ${esc(a)}`).join('')}
` : ''} ${!isPast ? `
` : ''}
`; }).join(''); } function openShiftModal(id) { editingShiftId = id; document.getElementById('shift-modal-title').textContent = id ? 'Edit Shift' : 'Create Canvass Shift'; if (id) { const sh = shifts.find(x => x.id === id); if (sh) { document.getElementById('shm-name').value = sh.name||''; document.getElementById('shm-date').value = sh.date||''; document.getElementById('shm-time').value = sh.time||'10:00'; document.getElementById('shm-location').value = sh.location||''; document.getElementById('shm-doors').value = sh.targetDoors||''; document.getElementById('shm-notes').value = sh.notes||''; } } else { ['shm-name','shm-date','shm-location','shm-doors','shm-notes'].forEach(i => document.getElementById(i).value = ''); document.getElementById('shm-time').value = '10:00'; } document.getElementById('shift-modal').classList.add('show'); } function closeShiftModal() { document.getElementById('shift-modal').classList.remove('show'); editingShiftId = null; } function saveShift() { const name = document.getElementById('shm-name').value.trim(); const date = document.getElementById('shm-date').value; if (!name || !date) { alert('Name and date are required.'); return; } const shift = { id: editingShiftId || Date.now().toString(), name, date, time: document.getElementById('shm-time').value, location: document.getElementById('shm-location').value.trim(), targetDoors: parseInt(document.getElementById('shm-doors').value)||40, notes: document.getElementById('shm-notes').value.trim(), attendees: editingShiftId ? (shifts.find(s=>s.id===editingShiftId)?.attendees||[]) : [], }; if (editingShiftId) { const idx = shifts.findIndex(s => s.id === editingShiftId); if (idx > -1) shifts[idx] = shift; } else { shifts.unshift(shift); } saveShiftsLocal(); closeShiftModal(); renderShifts(); showFlash('✅ Shift saved!', 'var(--green)'); } function joinShift(id) { const sh = shifts.find(x => x.id === id); if (!sh || !currentProfile?.full_name) return; const name = currentProfile.full_name; if (!sh.attendees) sh.attendees = []; if (sh.attendees.includes(name)) { sh.attendees = sh.attendees.filter(a => a !== name); } else { sh.attendees.push(name); showFlash(`✅ You're confirmed for ${sh.name}!`, 'var(--green)'); } saveShiftsLocal(); renderShifts(); } function shareShiftWhatsApp(id) { const sh = shifts.find(x => x.id === id); if (!sh) return; const campaign = currentCampaign?.name || 'the campaign'; const date = new Date(sh.date).toLocaleDateString('en-CA',{weekday:'long',month:'long',day:'numeric'}); const msg = `📅 *Canvass Shift, ${campaign}*\n\n📆 ${date} at ${sh.time||'10:00'}\n📍 ${sh.location || 'TBD'}\n🚪 Goal: ${sh.targetDoors} doors\n\n${sh.notes ? sh.notes + '\n\n' : ''}Can you make it? Reply here or confirm in TurfRunner. Every volunteer makes a difference! 💪`; window.open('https://wa.me/?text=' + encodeURIComponent(msg), '_blank'); } // ─── NUDGE CENTER ─── function renderNudgeCenter() { const INACTIVE_DAYS = 5; const now = Date.now(); const lastSeen = {}; entries.forEach(e => { const v = e.volunteer_name || 'Unknown'; const ts = new Date(e.created_at).getTime(); if (!lastSeen[v] || ts > lastSeen[v]) lastSeen[v] = ts; }); const inactive = Object.entries(lastSeen) .filter(([name, ts]) => { const days = Math.round((now - ts) / 86400000); return days >= INACTIVE_DAYS && name !== (currentProfile?.full_name||''); }) .sort((a,b) => a[1] - b[1]) .map(([name, ts]) => ({ name, days: Math.round((now - ts) / 86400000) })); const el = document.getElementById('nudge-list'); if (!el) return; if (!inactive.length) { el.innerHTML = '
🎉 All volunteers are active! No nudges needed.
'; return; } el.innerHTML = inactive.map(v => { const campaign = currentCampaign?.name || 'the campaign'; const msg = `Hi ${v.name}! Just checking in on ${campaign}. We'd love to have you back out knocking doors, every contact matters this close to election day! 🚪 Are you free this week?`; return `
${v.name[0].toUpperCase()}
${v.name}
⏰ Inactive for ${v.days} days
`; }).join(''); } function broadcastToTeam() { const msg = document.getElementById('broadcast-msg')?.value.trim(); if (!msg) { alert('Write a message first.'); return; } const campaign = currentCampaign?.name || 'the campaign'; const full = `📣 *${campaign} Team Update* ${msg}`; window.open('https://wa.me/?text=' + encodeURIComponent(full), '_blank'); } // ─── CAPTAIN FLAG IN TEAM VIEW ─── function getCaptains() { return JSON.parse(localStorage.getItem('tr_captains_' + (currentCampaign?.id||'')) || '[]'); } function toggleCaptain(name) { const caps = getCaptains(); const idx = caps.indexOf(name); if (idx > -1) caps.splice(idx, 1); else caps.push(name); localStorage.setItem('tr_captains_' + currentCampaign.id, JSON.stringify(caps)); renderTeamWithCaptains(); } function renderTeamWithCaptains() { const caps = getCaptains(); const roleClass = {admin:'role-admin',hq_staff:'role-hq',team_lead:'role-lead',volunteer:'role-volunteer'}; const roleLabel = {admin:'Admin',hq_staff:'HQ Staff',team_lead:'Team Lead',volunteer:'Volunteer'}; const el = document.getElementById('team-list'); if (!el || !teamMembers.length) return; el.innerHTML = teamMembers.map(m => { const isCaptain = caps.includes(m.full_name || m.email); return `
${(m.full_name||m.email)[0].toUpperCase()}
${m.full_name||m.email.split('@')[0]}${isCaptain?' ⭐ Captain':''}
${m.email}
${roleLabel[m.role]||m.role} Joined ${new Date(m.joined_at).toLocaleDateString('en-CA')}
${m.user_id!==currentUser.id ? ` ` : 'You'}
`; }).join(''); } // ─── SHARED UTILITY ─── function showFlash(msg, color) { const f = document.createElement('div'); f.style.cssText = `position:fixed;top:70px;left:50%;transform:translateX(-50%);background:${color||'var(--green)'};color:#fff;padding:12px 24px;border-radius:10px;font-weight:700;font-size:14px;z-index:600;animation:pop 0.3s ease;`; f.textContent = msg; document.body.appendChild(f); setTimeout(() => f.remove(), 2500); } // ─── PATCH: showOnboarding (was missing from original) ─── function showOnboarding() { document.getElementById('onboarding-overlay').classList.add('show'); } // ─── PATCH: geocodeAddr (used by sign tracker but wasn't defined) ─── async function geocodeAddr(address) { const key = (currentCampaign && currentCampaign.maps_api_key) || localStorage.getItem('tr_maps_key') || MAPS_API_KEY; if (!key || key.startsWith('YOUR_') || !window.google) return null; try { return new Promise(resolve => { new google.maps.Geocoder().geocode( { address: address + ', ' + (currentCampaign?.city || '') + ', ' + (currentCampaign?.country || 'Canada') }, (results, status) => { if (status === 'OK') resolve({ lat: results[0].geometry.location.lat(), lng: results[0].geometry.location.lng() }); else resolve(null); } ); }); } catch(e) { return null; } } // ═══ IMPORT & MERGE ═══ let importedRows = []; let importColMap = {}; let importDupeAction = 'skip'; let importHistory = JSON.parse(localStorage.getItem('tr_import_history') || '[]'); const IMPORT_FIELD_OPTIONS = ['address','first_name','last_name','phone','email','support','door_status','notes','volunteer_name','skip']; function handleImportDrop(e) { e.preventDefault(); document.getElementById('import-drop-zone').style.borderColor = 'rgba(29,110,245,0.35)'; const file = e.dataTransfer.files[0]; if (file) handleImportFile(file); } async function handleImportFile(file) { if (!file) return; const ext = file.name.split('.').pop().toLowerCase(); let rows = []; if (ext === 'csv') { const text = await file.text(); rows = parseCSVToObjects(text); } else if (ext === 'xlsx' || ext === 'xls') { rows = await parseExcelToObjects(file); } else { alert('Please upload a .csv or .xlsx file.'); return; } if (!rows.length) { alert('No data found in this file.'); return; } importedRows = rows; showColMapper(Object.keys(rows[0])); } function parseCSVToObjects(text) { const lines = text.trim().split(/\r?\n/); if (lines.length < 2) return []; const headers = lines[0].split(',').map(h => h.trim().replace(/^"|"$/g,'')); return lines.slice(1).map(line => { const vals = line.match(/(".*?"|[^,]+|(?<=,)(?=,))/g) || []; const obj = {}; headers.forEach((h, i) => { obj[h] = (vals[i] || '').replace(/^"|"$/g,'').trim(); }); return obj; }).filter(r => Object.values(r).some(v => v)); } async function parseExcelToObjects(file) { // Load SheetJS dynamically if (!window.XLSX) { await new Promise((res, rej) => { const s = document.createElement('script'); s.src = 'https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js'; s.onload = res; s.onerror = rej; document.head.appendChild(s); }); } const buf = await file.arrayBuffer(); const wb = XLSX.read(buf, {type:'array'}); const ws = wb.Sheets[wb.SheetNames[0]]; return XLSX.utils.sheet_to_json(ws, {defval:''}); } function showColMapper(headers) { const section = document.getElementById('import-col-map'); const rows = document.getElementById('import-col-map-rows'); importColMap = {}; // Auto-guess mappings const guesses = { address: ['address','addr','street','location','house','home'], first_name: ['first','firstname','first_name','fname','given'], last_name: ['last','lastname','last_name','lname','surname','family'], phone: ['phone','tel','telephone','mobile','cell'], email: ['email','e-mail','mail'], support: ['support','stance','level','position'], door_status: ['door','status','outcome','result','knocked'], notes: ['notes','note','comments','comment','remarks'], volunteer_name: ['volunteer','canvasser','worker','by','rep'], }; rows.innerHTML = headers.map(h => { let guess = 'skip'; for (const [field, keys] of Object.entries(guesses)) { if (keys.some(k => h.toLowerCase().includes(k))) { guess = field; break; } } importColMap[h] = guess; return `
${h}
`; }).join(''); section.style.display = 'block'; } function previewImport() { const addrField = Object.entries(importColMap).find(([,v]) => v === 'address')?.[0]; if (!addrField) { alert('Please map the Address column, it is required.'); return; } const existingAddrs = new Set(entries.map(e => e.address?.toLowerCase().trim()).filter(Boolean)); const newRows = [], dupeRows = [], skipRows = []; importedRows.forEach(row => { const addr = row[addrField]?.trim(); if (!addr) { skipRows.push(row); return; } if (existingAddrs.has(addr.toLowerCase())) dupeRows.push(row); else newRows.push(row); }); document.getElementById('imp-total').textContent = importedRows.length; document.getElementById('imp-new').textContent = newRows.length; document.getElementById('imp-dupe').textContent = dupeRows.length; document.getElementById('imp-skip').textContent = skipRows.length; const dupeSection = document.getElementById('import-dupe-section'); dupeSection.style.display = dupeRows.length ? 'block' : 'none'; if (dupeRows.length) { document.getElementById('import-dupe-list').innerHTML = dupeRows.slice(0,10).map(r => `
⚠️ ${r[addrField]}
` ).join('') + (dupeRows.length > 10 ? `
…and ${dupeRows.length - 10} more
` : ''); } const previewList = document.getElementById('import-preview-list'); previewList.innerHTML = newRows.slice(0, 20).map(row => { const mapped = mapImportRow(row); return `
${mapped.address}
${[mapped.first_name, mapped.last_name].filter(Boolean).join(' ')||''} ${mapped.phone ? `· 📞 ${mapped.phone}` : ''} ${mapped.support ? `· ${mapped.support}` : ''} ${mapped.door_status ? `· ${mapped.door_status}` : ''}
`; }).join('') + (newRows.length > 20 ? `
…and ${newRows.length - 20} more new contacts
` : ''); document.getElementById('import-preview-section').style.display = 'block'; document.getElementById('import-preview-section').scrollIntoView({behavior:'smooth'}); } function setDupeAction(action) { importDupeAction = action; document.querySelectorAll('#import-dupe-section .btn').forEach(b => b.classList.remove('btn-primary')); event.target.classList.add('btn-primary'); } function mapImportRow(row) { const mapped = { campaign_id: currentCampaign?.id, door_status: 'Answered', created_at: new Date().toISOString() }; for (const [col, field] of Object.entries(importColMap)) { if (field === 'skip' || !field) continue; mapped[field] = row[col]?.toString().trim() || ''; } // Normalize support if (mapped.support) { const s = mapped.support.toLowerCase(); if (s.includes('strong')) mapped.support = 'strong'; else if (s.includes('lean')) mapped.support = 'lean'; else if (s.includes('undecided') || s.includes('unsure')) mapped.support = 'undecided'; else if (s.includes('against') || s.includes('no') || s.includes('oppose')) mapped.support = 'against'; } if (!mapped.volunteer_name) mapped.volunteer_name = currentUser?.user_metadata?.name || currentUser?.email || 'Imported'; return mapped; } async function executeImport() { const btn = document.getElementById('import-execute-btn'); btn.disabled = true; btn.textContent = '⏳ Importing…'; const addrField = Object.entries(importColMap).find(([,v]) => v === 'address')?.[0]; const existingAddrs = new Set(entries.map(e => e.address?.toLowerCase().trim()).filter(Boolean)); const toInsert = []; importedRows.forEach(row => { const addr = row[addrField]?.trim(); if (!addr) return; const isDupe = existingAddrs.has(addr.toLowerCase()); if (isDupe && importDupeAction === 'skip') return; toInsert.push(mapImportRow(row)); }); if (!toInsert.length) { alert('Nothing to import after applying duplicate rules.'); btn.disabled = false; btn.textContent = '✅ Merge Into Campaign'; return; } try { // Insert in batches of 100 for (let i = 0; i < toInsert.length; i += 100) { const batch = toInsert.slice(i, i + 100); const { error } = await supabase.from('contacts').insert(batch); if (error) throw error; } // Record history const histEntry = { file: 'Imported file', count: toInsert.length, date: new Date().toISOString() }; importHistory.unshift(histEntry); localStorage.setItem('tr_import_history', JSON.stringify(importHistory.slice(0, 10))); renderImportHistory(); // Refresh data await loadEntries(); buildCRMData(); renderCRM(); alert(`✅ Successfully imported ${toInsert.length} contacts!`); cancelImport(); } catch(e) { console.error('Import error:', e); alert('Import failed: ' + e.message); } btn.disabled = false; btn.textContent = '✅ Merge Into Campaign'; } function cancelImport() { document.getElementById('import-preview-section').style.display = 'none'; document.getElementById('import-col-map').style.display = 'none'; document.getElementById('import-file-input').value = ''; importedRows = []; importColMap = {}; } function renderImportHistory() { const el = document.getElementById('import-history-list'); if (!el) return; if (!importHistory.length) { el.innerHTML = '
No imports yet.
'; return; } el.innerHTML = importHistory.map(h => `
📥
${h.count} contacts imported
${new Date(h.date).toLocaleDateString('en-CA',{year:'numeric',month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'})}
+${h.count}
` ).join(''); } // ═══ START ═══ window.addEventListener('DOMContentLoaded', () => { init().catch(err => { console.error('TurfRunner init error:', err); // Show a helpful error instead of blank screen const ls = document.getElementById('loading-screen'); if (ls) ls.innerHTML = '

TurfRunner

Connect to your Supabase project to get started.

Error: ' + err.message + '

'; }); }); // ===== MERGED FROM app1.html: donations, meetings, timeline, social, research, role ===== // ═══ DONATION INFO (SETTINGS) ═══ function saveDonationInfo() { const zelle = document.getElementById('set-zelle')?.value.trim(); const etransfer = document.getElementById('set-etransfer')?.value.trim(); localStorage.setItem('tr_donate_zelle_' + currentCampaign.id, zelle || ''); localStorage.setItem('tr_donate_etransfer_' + currentCampaign.id, etransfer || ''); alert('Payment info saved!'); } function getDonationInfo() { return { zelle: localStorage.getItem('tr_donate_zelle_' + currentCampaign.id) || '', etransfer: localStorage.getItem('tr_donate_etransfer_' + currentCampaign.id) || '', }; } function prefillDonationSettings() { const info = getDonationInfo(); const z = document.getElementById('set-zelle'); const e = document.getElementById('set-etransfer'); if (z) z.value = info.zelle; if (e) e.value = info.etransfer; } // ═══ DONATION MODAL ═══ let activeDonateTab = 'zelle'; let donationContactName = ''; function showDonationModal(contactName) { const info = getDonationInfo(); if (!info.zelle && !info.etransfer) return; // No payment info set up — skip silently donationContactName = contactName || ''; const modal = document.getElementById('donation-modal'); modal.style.display = 'flex'; // Default to whichever tab has info const startTab = info.zelle ? 'zelle' : 'etransfer'; switchDonateTab(startTab); // Hide tabs that have no info document.getElementById('tab-zelle').style.display = info.zelle ? '' : 'none'; document.getElementById('tab-etransfer').style.display = info.etransfer ? '' : 'none'; } function closeDonationModal() { document.getElementById('donation-modal').style.display = 'none'; } function switchDonateTab(tab) { activeDonateTab = tab; const info = getDonationInfo(); const value = tab === 'zelle' ? info.zelle : info.etransfer; // Tab styles ['zelle','etransfer'].forEach(t => { const btn = document.getElementById('tab-' + t); if (t === tab) { btn.style.background = 'rgba(29,110,245,0.15)'; btn.style.borderColor = 'rgba(29,110,245,0.4)'; btn.style.color = '#93c5fd'; } else { btn.style.background = 'rgba(255,255,255,0.03)'; btn.style.borderColor = 'rgba(255,255,255,0.1)'; btn.style.color = '#94a3b8'; } }); // Generate QR const qrWrap = document.getElementById('donate-qr'); qrWrap.innerHTML = ''; if (value) { new QRCode(qrWrap, { text: value, width: 160, height: 160, colorDark: '#000', colorLight: '#fff', correctLevel: QRCode.CorrectLevel.H }); } // Text info document.getElementById('donate-info-text').textContent = value || '—'; document.getElementById('donate-info-sub').textContent = tab === 'zelle' ? 'Donor opens Zelle app → Send → paste this email or phone' : 'Donor opens banking app → Interac e-Transfer → send to this email'; } function confirmDoorDonation() { closeDonationModal(); const modal = document.getElementById('door-donation-modal'); document.getElementById('door-donation-name').value = donationContactName; document.getElementById('door-donation-amount').value = ''; modal.style.display = 'flex'; } function closeDoorDonationModal() { document.getElementById('door-donation-modal').style.display = 'none'; } async function saveDoorDonation() { const amount = parseFloat(document.getElementById('door-donation-amount').value); const name = document.getElementById('door-donation-name').value.trim(); if (!amount || amount <= 0) { alert('Please enter an amount.'); return; } // Add to donors array and save (reuses existing fundraising tracker) const method = activeDonateTab === 'zelle' ? 'Zelle' : 'e-Transfer'; const donor = { id: Date.now().toString(), date: new Date().toISOString().slice(0, 10), firstName: name.split(' ')[0] || 'Door', lastName: name.split(' ').slice(1).join(' ') || 'Donor', amount, status: 'received', method, address: '', phone: '', email: '', notes: 'Collected at door via ' + method, }; const { error } = await supabase.from('donors').insert(donorToRow(donor)); if (error) { alert('Save failed: ' + error.message); return; } await renderFundraising(); closeDoorDonationModal(); // Quick confirmation banner const banner = document.createElement('div'); banner.style.cssText = 'position:fixed;top:72px;left:50%;transform:translateX(-50%);background:linear-gradient(135deg,#166534,#15803d);color:#fff;padding:12px 24px;border-radius:10px;font-weight:700;font-size:13px;z-index:700;'; banner.textContent = '💰 $' + amount.toFixed(2) + ' donation logged!'; document.body.appendChild(banner); setTimeout(() => banner.remove(), 3000); } // ═══ MEETINGS & MINUTES ═══ let meetings = JSON.parse(localStorage.getItem('tr_meetings_v1') || 'null') || defaultMeetings(); let meetingFilter = 'all'; let editMeetingId = null; function defaultMeetings() { return []; } function saveMeetingsToStorage() { localStorage.setItem('tr_meetings_v1', JSON.stringify(meetings)); } function getAllOpenActions() { const all = []; meetings.forEach(m => { (m.actions || []).filter(a => !a.done).forEach(a => { all.push({ ...a, meetingId: m.id, meetingTitle: m.title }); }); }); return all; } function renderMeetings() { // Open actions summary const openActions = getAllOpenActions(); const oa = document.getElementById('all-open-actions'); if (oa) { if (!openActions.length) { oa.innerHTML = '
All caught up — no open action items.
'; } else { oa.innerHTML = openActions.map(a => `
${a.text}
${a.owner || 'Unassigned'} · ${a.meetingTitle}
${a.due || ''}
`).join(''); } } const today = new Date(); let list = [...meetings].sort((a, b) => new Date(b.date) - new Date(a.date)); if (meetingFilter === 'upcoming') list = list.filter(m => new Date(m.date) >= today); else if (meetingFilter === 'past') list = list.filter(m => new Date(m.date) < today); else if (meetingFilter === 'open-actions') list = list.filter(m => (m.actions || []).some(a => !a.done)); const el = document.getElementById('meetings-list'); if (!el) return; if (!list.length) { el.innerHTML = '
No meetings yet. Create your first one above!
'; return; } el.innerHTML = list.map(m => { const d = new Date(m.date); const isPast = d < today; const openActs = (m.actions || []).filter(a => !a.done).length; const dateStr = d.toLocaleDateString('en-CA', { weekday: 'short', month: 'short', day: 'numeric' }); const agendaHTML = (m.agenda || []).map((item, i) => `
${String(i + 1).padStart(2, '0')}
${item}
${isPast ? `` : ''}
`).join(''); const actionsHTML = (m.actions || []).map(a => `
${a.done ? '✓' : ''}
${a.text}
${a.owner || ''}
${a.due || ''}
`).join(''); return `
${m.title}
${dateStr}${m.time ? ' · ' + m.time : ''}${m.location ? ' · ' + m.location : ''}
${openActs ? `${openActs} open` : ''}
`; }).join(''); } function filterMeetings(f, btn) { meetingFilter = f; document.querySelectorAll('#view-meetings .filter-chip').forEach(c => c.classList.remove('active')); btn.classList.add('active'); renderMeetings(); } function openMeetingModal(id) { editMeetingId = id; if (id) { const m = meetings.find(x => x.id === id); if (m) { document.getElementById('mm-title').value = m.title; document.getElementById('mm-date').value = m.date; document.getElementById('mm-time').value = m.time || ''; document.getElementById('mm-location').value = m.location || ''; document.getElementById('mm-attendees').value = m.attendees || ''; document.getElementById('mm-agenda').value = (m.agenda || []).join('\n'); } document.getElementById('meeting-modal-h').textContent = 'Edit Meeting'; } else { ['mm-title','mm-time','mm-location','mm-attendees'].forEach(id => document.getElementById(id).value = ''); document.getElementById('mm-date').value = ''; document.getElementById('mm-agenda').value = ''; document.getElementById('meeting-modal-h').textContent = 'New Meeting'; } document.getElementById('meeting-modal').classList.add('show'); } function closeMeetingModal() { document.getElementById('meeting-modal').classList.remove('show'); editMeetingId = null; } function saveMeeting() { const title = document.getElementById('mm-title').value.trim(); const date = document.getElementById('mm-date').value; if (!title || !date) { alert('Title and date are required.'); return; } const agenda = document.getElementById('mm-agenda').value.split('\n').map(l => l.replace(/^\d+\.\s*/, '').trim()).filter(Boolean); if (editMeetingId) { const i = meetings.findIndex(m => m.id === editMeetingId); if (i > -1) meetings[i] = { ...meetings[i], title, date, time: document.getElementById('mm-time').value, location: document.getElementById('mm-location').value.trim(), attendees: document.getElementById('mm-attendees').value.trim(), agenda }; } else { meetings.unshift({ id: Date.now(), title, date, time: document.getElementById('mm-time').value, location: document.getElementById('mm-location').value.trim(), attendees: document.getElementById('mm-attendees').value.trim(), agenda, notes: [], actions: [] }); } saveMeetingsToStorage(); closeMeetingModal(); renderMeetings(); } function deleteMeeting(id) { if (!confirm('Delete this meeting?')) return; meetings = meetings.filter(m => m.id !== id); saveMeetingsToStorage(); renderMeetings(); } function saveMeetingNote(meetingId, idx, val) { const m = meetings.find(x => x.id === meetingId); if (!m) return; if (!m.notes) m.notes = []; m.notes[idx] = val; saveMeetingsToStorage(); } function openActionModal(meetingId) { document.getElementById('aim-text').value = ''; document.getElementById('aim-owner').value = ''; document.getElementById('aim-due').value = ''; document.getElementById('aim-meeting-id').value = meetingId; document.getElementById('action-item-modal').classList.add('show'); } function closeActionModal() { document.getElementById('action-item-modal').classList.remove('show'); } function saveActionItem() { const text = document.getElementById('aim-text').value.trim(); if (!text) { alert('Task description is required.'); return; } const mid = parseInt(document.getElementById('aim-meeting-id').value); const m = meetings.find(x => x.id === mid); if (!m) return; if (!m.actions) m.actions = []; m.actions.push({ id: Date.now(), text, owner: document.getElementById('aim-owner').value.trim(), due: document.getElementById('aim-due').value, done: false }); saveMeetingsToStorage(); closeActionModal(); renderMeetings(); } function toggleMeetingAction(meetingId, actionId) { const m = meetings.find(x => x.id == meetingId); if (!m) return; const a = (m.actions || []).find(x => x.id == actionId); if (!a) return; a.done = !a.done; saveMeetingsToStorage(); renderMeetings(); } async function generateMinutes(id) { const m = meetings.find(x => x.id === id); if (!m) return; const btn = event.target; btn.textContent = 'Generating...'; btn.disabled = true; const agendaWithNotes = (m.agenda || []).map((item, i) => `${i + 1}. ${item}${m.notes && m.notes[i] ? ' — Notes: ' + m.notes[i] : ''}`).join('\n'); const actionsStr = (m.actions || []).map(a => `- ${a.text} (Owner: ${a.owner || 'TBD'}, Due: ${a.due || 'TBD'}, ${a.done ? 'Done' : 'Open'})`).join('\n'); try { const text = await callAIEdgeFunction('research', { messages: [{ role: 'user', content: `Draft professional meeting minutes for a political campaign team meeting.\n\nMeeting: ${m.title}\nDate: ${m.date}\nAttendees: ${m.attendees || 'Campaign team'}\n\nAgenda and notes:\n${agendaWithNotes}\n\nAction items:\n${actionsStr || 'None recorded'}\n\nWrite concise but professional minutes. Cover each agenda item briefly, note key decisions, and list action items with owners and dates.` }] }); const overlay = document.createElement('div'); overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.85);z-index:700;display:flex;align-items:center;justify-content:center;padding:20px;'; overlay.innerHTML = `
Meeting Minutes
${text}
`; document.body.appendChild(overlay); } catch (e) { alert('Could not generate minutes. Make sure the Anthropic API key is configured in your Supabase project.'); } btn.textContent = '✨ Generate Minutes'; btn.disabled = false; } // ═══ CAMPAIGN TIMELINE ═══ let milestones = JSON.parse(localStorage.getItem('tr_milestones_v1') || 'null') || defaultMilestones(); let editMilestoneId = null; function defaultMilestones() { return [ { id: 1, title: 'Nomination Filed', date: '2026-07-24', cat: 'admin', desc: 'Official nomination papers submitted.', done: false }, { id: 2, title: 'Campaign Website Live', date: '2026-08-01', cat: 'media', desc: 'Website and social media fully operational.', done: false }, { id: 3, title: 'Canvassing Launch Weekend', date: '2026-08-15', cat: 'canvassing', desc: 'First large-scale door-to-door push.', done: false }, { id: 4, title: 'Lawn Signs Go Up', date: '2026-09-01', cat: 'outreach', desc: 'First wave of signs deployed.', done: false }, { id: 5, title: 'Debate Season', date: '2026-09-15', cat: 'media', desc: 'Candidate debates and community forums.', done: false }, { id: 6, title: 'GOTV Push Begins', date: '2026-10-12', cat: 'canvassing', desc: 'All-hands canvassing and phone banking.', done: false }, { id: 7, title: 'Election Day', date: '2026-10-26', cat: 'election', desc: 'Polls open 10 AM – 8 PM.', done: false }, ]; } function saveMilestonesToStorage() { localStorage.setItem('tr_milestones_v1', JSON.stringify(milestones)); } const MILESTONE_CAT_COLORS = { admin: '#94a3b8', outreach: '#1d6ef5', canvassing: '#22c55e', media: '#8b5cf6', fundraising: '#f5a623', election: '#e63946' }; function renderTimeline() { const today = new Date(); const electionDate = milestones.find(m => m.cat === 'election'); const elDate = electionDate ? new Date(electionDate.date) : new Date('2026-10-26'); const days = Math.ceil((elDate - today) / (1000 * 60 * 60 * 24)); const daysEl = document.getElementById('tl-days'); if (daysEl) daysEl.textContent = Math.max(0, days); const dateEl = document.getElementById('tl-election-date'); if (dateEl) dateEl.textContent = 'Election: ' + elDate.toLocaleDateString('en-CA', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' }); const done = milestones.filter(m => m.done).length; const total = milestones.length; const pct = total ? Math.round(done / total * 100) : 0; const pb = document.getElementById('tl-progress-bar'); const ppct = document.getElementById('tl-progress-pct'); const doneCount = document.getElementById('tl-done-count'); const remCount = document.getElementById('tl-remaining-count'); if (pb) pb.style.width = pct + '%'; if (ppct) ppct.textContent = pct + '%'; if (doneCount) doneCount.textContent = done + ' done'; if (remCount) remCount.textContent = (total - done) + ' remaining'; const sorted = [...milestones].sort((a, b) => new Date(a.date) - new Date(b.date)); const el = document.getElementById('timeline-list'); if (!el) return; if (!sorted.length) { el.innerHTML = '
No milestones yet. Add your first one above!
'; return; } el.innerHTML = sorted.map(m => { const d = new Date(m.date); const diff = Math.ceil((d - today) / (1000 * 60 * 60 * 24)); const color = MILESTONE_CAT_COLORS[m.cat] || '#94a3b8'; const isUrgent = !m.done && diff > 0 && diff <= 14; const isOverdue = !m.done && diff < 0; const dateStr = d.toLocaleDateString('en-CA', { month: 'long', day: 'numeric', year: 'numeric' }); const diffLabel = m.done ? '✓ Done' : isOverdue ? 'Overdue' : diff === 0 ? 'Today!' : `${diff}d away`; const diffColor = m.done ? 'var(--green)' : isOverdue ? 'var(--red)' : isUrgent ? 'var(--gold)' : 'var(--slate)'; return `
${dateStr} · ${m.cat}
${m.title}
${m.desc ? `
${m.desc}
` : ''}
${diffLabel}
`; }).join(''); } function openMilestoneModal(id) { editMilestoneId = id; if (id) { const m = milestones.find(x => x.id === id); if (m) { document.getElementById('msm-title').value = m.title; document.getElementById('msm-date').value = m.date; document.getElementById('msm-cat').value = m.cat || 'admin'; document.getElementById('msm-desc').value = m.desc || ''; } document.getElementById('milestone-modal-h').textContent = 'Edit Milestone'; } else { document.getElementById('msm-title').value = ''; document.getElementById('msm-date').value = ''; document.getElementById('msm-cat').value = 'admin'; document.getElementById('msm-desc').value = ''; document.getElementById('milestone-modal-h').textContent = 'Add Milestone'; } document.getElementById('milestone-modal').classList.add('show'); } function closeMilestoneModal() { document.getElementById('milestone-modal').classList.remove('show'); editMilestoneId = null; } function saveMilestone() { const title = document.getElementById('msm-title').value.trim(); const date = document.getElementById('msm-date').value; if (!title || !date) { alert('Title and date are required.'); return; } const ms = { title, date, cat: document.getElementById('msm-cat').value, desc: document.getElementById('msm-desc').value.trim() }; if (editMilestoneId) { const i = milestones.findIndex(m => m.id === editMilestoneId); if (i > -1) milestones[i] = { ...milestones[i], ...ms }; } else { milestones.push({ id: Date.now(), ...ms, done: false }); } saveMilestonesToStorage(); closeMilestoneModal(); renderTimeline(); } function toggleMilestoneDone(id) { const m = milestones.find(x => x.id === id); if (m) { m.done = !m.done; saveMilestonesToStorage(); renderTimeline(); } } function deleteMilestone(id) { if (!confirm('Delete this milestone?')) return; milestones = milestones.filter(m => m.id !== id); saveMilestonesToStorage(); renderTimeline(); } // ═══ AI RESEARCH CHAT ═══ let researchHistory = []; async function sendResearchChat() { const input = document.getElementById('research-input'); const q = input.value.trim(); if (!q) return; input.value = ''; const chat = document.getElementById('research-chat'); // User bubble chat.innerHTML += `
${q}
`; chat.innerHTML += `
Researching...
`; chat.scrollTop = chat.scrollHeight; researchHistory.push({ role: 'user', content: q }); const campaign = (typeof currentCampaign !== 'undefined' && currentCampaign) ? currentCampaign : {}; const systemCtx = `You are a political research assistant for ${campaign.candidate || 'the candidate'}'s ${campaign.level || 'municipal'} campaign in ${campaign.ward || campaign.name || 'their ward'}. Be direct, specific, and mobile-friendly. Short paragraphs.`; try { const text = await callAIEdgeFunction('research', { system: systemCtx, messages: researchHistory }); researchHistory.push({ role: 'assistant', content: text }); document.getElementById('thinking-bubble')?.remove(); chat.innerHTML += `
${text.replace(/\n/g, '
')}
`; chat.scrollTop = chat.scrollHeight; } catch (e) { document.getElementById('thinking-bubble')?.remove(); chat.innerHTML += `
Could not connect to AI. Make sure your Anthropic API key is set in Supabase.
`; chat.scrollTop = chat.scrollHeight; } } // ═══ ROLE SWITCHER ═══ function switchRole(role) { localStorage.setItem('tr_role', role); document.getElementById('nav-candidate').style.display = role === 'candidate' ? 'block' : 'none'; document.getElementById('nav-hq').style.display = role === 'hq' ? 'block' : 'none'; document.getElementById('role-candidate').classList.toggle('active', role === 'candidate'); document.getElementById('role-hq').classList.toggle('active', role === 'hq'); // Reset active item highlights document.querySelectorAll('.sb-item').forEach(i => i.classList.remove('active')); // Highlight default item for the mode const defaultView = role === 'candidate' ? 'dashboard' : 'field'; const firstItem = document.querySelector(`#nav-${role} .sb-item`); if (firstItem) firstItem.classList.add('active'); switchView(defaultView, firstItem); } // ═══ SOCIAL MEDIA CALENDAR ═══ let socialPosts = JSON.parse(localStorage.getItem('tr_social_posts') || '[]'); let editSocialId = null; let socialFilter = 'all'; const PLATFORM_EMOJI = { facebook:'📘', instagram:'📸', x:'🐦', linkedin:'💼' }; const PLATFORM_CLASS = { facebook:'plat-facebook', instagram:'plat-instagram', x:'plat-x', linkedin:'plat-linkedin' }; function saveSocialToStorage() { localStorage.setItem('tr_social_posts', JSON.stringify(socialPosts)); } function filterSocial(f, btn) { socialFilter = f; document.querySelectorAll('#view-social .filter-chip').forEach(c => c.classList.remove('active')); if (btn) btn.classList.add('active'); renderSocialCalendar(); } function renderSocialCalendar() { const stats = { draft:0, scheduled:0, posted:0 }; socialPosts.forEach(p => { if (stats[p.status] !== undefined) stats[p.status]++; }); const total = document.getElementById('soc-total'); const draft = document.getElementById('soc-draft'); const sched = document.getElementById('soc-sched'); const post = document.getElementById('soc-posted'); if (total) total.textContent = socialPosts.length; if (draft) draft.textContent = stats.draft; if (sched) sched.textContent = stats.scheduled; if (post) post.textContent = stats.posted; let filtered = [...socialPosts]; if (['facebook','instagram','x','linkedin'].includes(socialFilter)) { filtered = filtered.filter(p => p.platform === socialFilter); } else if (['draft','scheduled','posted'].includes(socialFilter)) { filtered = filtered.filter(p => p.status === socialFilter); } filtered.sort((a,b) => { const da = a.date || '9999', db = b.date || '9999'; return da < db ? -1 : da > db ? 1 : 0; }); const list = document.getElementById('social-posts-list'); if (!list) return; if (!filtered.length) { list.innerHTML = '
No posts match this filter.
'; return; } list.innerHTML = filtered.map(p => { const emoji = PLATFORM_EMOJI[p.platform] || '📲'; const cls = PLATFORM_CLASS[p.platform] || ''; const statusCls = { draft:'status-draft', scheduled:'status-scheduled', posted:'status-posted' }[p.status] || 'status-draft'; const statusLabel = { draft:'Draft', scheduled:'Scheduled', posted:'Posted' }[p.status] || p.status; const dateStr = p.date ? new Date(p.date + 'T12:00:00').toLocaleDateString('en-CA',{month:'short',day:'numeric',year:'numeric'}) + (p.time ? ' · ' + p.time : '') : 'No date set'; const preview = (p.content || '').slice(0, 140) + ((p.content||'').length > 140 ? '…' : ''); return `
${emoji}
${p.platform} 📅 ${dateStr} ${p.tag ? `${p.tag}` : ''}
${preview}
${p.notes ? `
📝 ${p.notes}
` : ''}
`; }).join(''); } function openSocialModal(id) { editSocialId = id; const post = id ? socialPosts.find(p => p.id === id) : null; document.getElementById('social-modal-title').textContent = id ? '✏️ Edit Post' : '📲 New Social Post'; document.getElementById('soc-platform').value = post?.platform || 'facebook'; document.getElementById('soc-status').value = post?.status || 'draft'; document.getElementById('soc-date').value = post?.date || ''; document.getElementById('soc-time').value = post?.time || '10:00'; document.getElementById('soc-content').value = post?.content || ''; document.getElementById('soc-notes').value = post?.notes || ''; document.getElementById('soc-tag').value = post?.tag || ''; document.getElementById('soc-char-count').textContent = (post?.content||'').length + ' chars'; document.getElementById('soc-delete-btn').style.display = id ? 'inline-flex' : 'none'; document.getElementById('social-modal').classList.add('show'); document.getElementById('soc-content').oninput = function() { document.getElementById('soc-char-count').textContent = this.value.length + ' chars'; }; } function closeSocialModal() { document.getElementById('social-modal').classList.remove('show'); editSocialId = null; } function saveSocialPost() { const content = document.getElementById('soc-content').value.trim(); if (!content) { alert('Post content is required.'); return; } const post = { id: editSocialId || Date.now(), platform: document.getElementById('soc-platform').value, status: document.getElementById('soc-status').value, date: document.getElementById('soc-date').value, time: document.getElementById('soc-time').value, content, notes: document.getElementById('soc-notes').value.trim(), tag: document.getElementById('soc-tag').value.trim(), created: editSocialId ? (socialPosts.find(p=>p.id===editSocialId)?.created || new Date().toISOString()) : new Date().toISOString(), }; if (editSocialId) { const idx = socialPosts.findIndex(p => p.id === editSocialId); if (idx > -1) socialPosts[idx] = post; } else { socialPosts.unshift(post); } saveSocialToStorage(); closeSocialModal(); renderSocialCalendar(); } function deleteSocialPost() { if (!editSocialId || !confirm('Delete this post?')) return; socialPosts = socialPosts.filter(p => p.id !== editSocialId); saveSocialToStorage(); closeSocialModal(); renderSocialCalendar(); } function markSocialPosted(id) { const p = socialPosts.find(x => x.id === id); if (p) { p.status = 'posted'; saveSocialToStorage(); renderSocialCalendar(); } } // ═══ AI RESEARCH CHAT 2 (standalone view) ═══ let researchHistory2 = []; async function sendResearchChat2() { const input = document.getElementById('research-input2'); const chat = document.getElementById('research-chat2'); const q = input.value.trim(); if (!q) return; input.value = ''; chat.innerHTML += `
${q}
`; chat.innerHTML += `
Researching…
`; chat.scrollTop = chat.scrollHeight; researchHistory2.push({ role: 'user', content: q }); const campaign = (typeof currentCampaign !== 'undefined' && currentCampaign) ? currentCampaign : {}; const systemCtx = `You are a political research assistant for ${campaign.candidate || 'the candidate'}'s ${campaign.level || 'municipal'} campaign in ${campaign.ward || campaign.name || 'their ward'}. Be direct, specific, and mobile-friendly. Short paragraphs.`; try { const text = await callAIEdgeFunction('research', { system: systemCtx, messages: researchHistory2 }); researchHistory2.push({ role: 'assistant', content: text }); document.getElementById('thinking-bubble2')?.remove(); chat.innerHTML += `
${text.replace(/\n/g,'
')}
`; chat.scrollTop = chat.scrollHeight; } catch(e) { document.getElementById('thinking-bubble2')?.remove(); chat.innerHTML += `
Could not connect to AI. Check your Anthropic API key in Supabase.
`; chat.scrollTop = chat.scrollHeight; } }
🎉
Great Session!
Here's what you accomplished today
0
Doors
0
Supporters
0
Undecided
How it works