1// ========================================================================== 2// UK U-TURN TRACKER v2 - Main Application 3// Seamless auto-updating - no user setup required 4// ========================================================================== 5 6let uturnsData = null; 7const GOVERNMENT_START = new Date('2024-07-05'); 8 9// Initialize on page load 10document.addEventListener('DOMContentLoaded', async () => { 11 await loadData(); 12 setupEventListeners(); 13 updateStats(); 14 updateLastChecked(); 15 renderMinisterLeaderboard(); 16 renderUturns(); 17 renderSpeedRuns(); 18 initLiveSocialFeed(); 19 renderTimeline(); 20}); 21 22// Load U-turn data 23async function loadData() { 24 try { 25 const response = await fetch('data/uturns.json'); 26 uturnsData = await response.json(); 27 document.getElementById('lastUpdate').textContent = 28 new Date(uturnsData.meta.lastUpdated).toLocaleDateString('en-GB', { 29 day: 'numeric', 30 month: 'long', 31 year: 'numeric', 32 hour: '2-digit', 33 minute: '2-digit' 34 }); 35 } catch (error) { 36 console.error('Failed to load U-turn data:', error); 37 } 38} 39 40// Setup event listeners 41function setupEventListeners() { 42 // Filter buttons 43 document.querySelectorAll('.filter-btn').forEach(btn => { 44 btn.addEventListener('click', (e) => { 45 document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active')); 46 e.target.classList.add('active'); 47 renderUturns(e.target.dataset.filter); 48 }); 49 }); 50 51 // Sort select 52 document.getElementById('sortSelect').addEventListener('change', (e) => { 53 const activeFilter = document.querySelector('.filter-btn.active').dataset.filter; 54 renderUturns(activeFilter, e.target.value); 55 }); 56 57 // Scan buttons - now just show info modal (no API key needed) 58 document.getElementById('scanNowBtn').addEventListener('click', showScanInfo); 59 document.getElementById('footerScanNews').addEventListener('click', (e) => { 60 e.preventDefault(); 61 showScanInfo(); 62 }); 63 64 // Modal close 65 document.getElementById('modalClose').addEventListener('click', closeScanModal); 66} 67 68// Update last checked timestamp 69function updateLastChecked() { 70 const el = document.getElementById('lastChecked'); 71 if (uturnsData && uturnsData.meta.lastUpdated) { 72 const lastUpdate = new Date(uturnsData.meta.lastUpdated); 73 const now = new Date(); 74 const diffHours = Math.floor((now - lastUpdate) / 3600000); 75 76 if (diffHours < 1) { 77 el.textContent = 'Less than 1 hour ago'; 78 } else if (diffHours < 24) { 79 el.textContent = diffHours + ' hours ago'; 80 } else { 81 el.textContent = lastUpdate.toLocaleDateString('en-GB', { 82 day: 'numeric', 83 month: 'short', 84 hour: '2-digit', 85 minute: '2-digit' 86 }); 87 } 88 } else { 89 el.textContent = 'Daily at 9am UK'; 90 } 91} 92 93// Update statistics 94function updateStats() { 95 if (!uturnsData) return; 96 97 const today = new Date(); 98 const daysInPower = Math.floor((today - GOVERNMENT_START) / (1000 * 60 * 60 * 24)); 99 const monthsInPower = daysInPower / 30.44; 100 const uturnsPerMonth = (uturnsData.meta.totalCount / monthsInPower).toFixed(1); 101 102 const fastestUturn = Math.min(...uturnsData.uturns.map(u =>
102 u.daysToUturn)); 103 104 document.getElementById('totalCount').textContent = uturnsData.meta.totalCount; 105 document.getElementById('daysInPower').textContent = daysInPower; 106 document.getElementById('uturnsPerMonth').textContent = uturnsPerMonth; 107 document.getElementById('fastestUturn').textContent = fastestUturn; 108 109 animateCounter('totalCount', uturnsData.meta.totalCount); 110} 111 112// Animate counter number 113function animateCounter(elementId, target) { 114 const element = document.getElementById(elementId); 115 const duration = 1500; 116 const start = 0; 117 const startTime = performance.now(); 118 119 function update(currentTime) { 120 const elapsed = currentTime - startTime; 121 const progress = Math.min(elapsed / duration, 1); 122 const eased = 1 - Math.pow(1 - progress, 3); 123 const current = Math.floor(start + (target - start) * eased); 124 125 element.textContent = current; 126 127 if (progress < 1) { 128 requestAnimationFrame(update); 129 } 130 } 131 132 requestAnimationFrame(update); 133} 134 135// Render minister leaderboard 136function renderMinisterLeaderboard() { 137 if (!uturnsData) return; 138 139 const container = document.getElementById('ministerLeaderboard'); 140 const ministers = Object.entries(uturnsData.ministers) 141 .sort((a, b) => b[1].uturns - a[1].uturns); 142 143 container.innerHTML = ministers.map(([name, data], index) => ` 144 <div class="leader-card"> 145 <div class="leader-rank">${index === 0 ? 'ð¥' : index === 1 ? 'ð¥' : index === 2 ? 'ð¥' : index + 1}</div> 146 <div class="leader-info"> 147 <div class="leader-name">${name}</div> 148 <div class="leader-role">${data.role}</div> 149 </div> 150 <div class="leader-count"> 151 ${data.uturns} 152 <span class="leader-count-label">U-turns</span> 153 </div> 154 </div> 155 `).join(''); 156} 157 158// Render U-turn cards 159function renderUturns(filter = 'all', sort = 'recent') { 160 if (!uturnsData) return; 161 162 let uturns = [...uturnsData.uturns]; 163 164 if (filter === 'major') { 165 uturns = uturns.filter(u => u.severity === 'major'); 166 } else if (filter !== 'all') { 167 uturns = uturns.filter(u => u.category === filter); 168 } 169 170 switch (sort) { 171 case 'recent': 172 uturns.sort((a, b) => new Date(b.dateReversed) - new Date(a.dateReversed)); 173 break; 174 case 'fastest': 175 uturns.sort((a, b) => a.daysToUturn - b.daysToUturn); 176 break; 177 case 'slowest': 178 uturns.sort((a, b) => b.daysToUturn - a.daysToUturn); 179 break; 180 } 181 182 const container = document.getElementById('uturnsGrid'); 183 container.innerHTML = uturns.map(uturn => { 184 const category = uturnsData.categories[uturn.category]; 185 return ` 186 <article class="uturn-card ${uturn.severity}"> 187 <div class="card-header"> 188 <sp
188an class="card-category">${category.emoji} ${category.name}</span> 189 <span class="card-days">${uturn.daysToUturn} days</span> 190 </div> 191 <div class="card-body"> 192 <h3 class="card-title">${uturn.title}</h3> 193 <div class="card-policy"> 194 <span class="policy-label">Original Position</span> 195 <p class="policy-original">${uturn.originalPolicy}</p> 196 <span class="policy-label">U-Turn</span> 197 <p class="policy-reversal">â© ${uturn.reversal}</p> 198 </div> 199 <p class="card-summary">"${uturn.summary}"</p> 200 </div> 201 <div class="card-footer"> 202 <span class="card-minister">ð ${uturn.minister}</span> 203 <span class="card-date">${formatDate(uturn.dateReversed)}</span> 204 </div> 205 </article> 206 `; 207 }).join(''); 208} 209 210// Render speed run hall of fame
211function renderSpeedRuns() { 212 if (!uturnsData) return; 213 214 const container = document.getElementById('speedRuns'); 215 const fastest = [...uturnsData.uturns] 216 .sort((a, b) => a.daysToUturn - b.daysToUturn) 217 .slice(0, 5); 218 219 const medals = ['ð¥', 'ð¥', 'ð¥', '4ï¸â£', '5ï¸â£']; 220 221 container.innerHTML = fastest.map((uturn, index) => ` 222 <div class="speed-card"> 223 <div class="speed-medal">${medals[index]}</div> 224 <div class="speed-days"> 225 ${uturn.daysToUturn} 226 <span>days</span> 227 </div> 228 <div class="speed-info"> 229 <div class="speed-title">${uturn.title}</div> 230 <div class="speed-detail">${formatDate(uturn.dateAnnounced)} â ${formatDate(uturn.dateReversed)}</div> 231 </div> 232 </div> 233 `).join(''); 234} 235 236// Render timeline 237function renderTimeline() { 238 if (!uturnsData) return; 239 240 const container = document.getElementById('timeline'); 241 const sorted = [...uturnsData.uturns] 242 .sort((a, b) => new Date(a.dateReversed) - new Date(b.dateReversed)); 243 244 container.innerHTML = sorted.map(uturn => ` 245 <div class="timeline-item"> 246 <div class="timeline-date">${formatDate(uturn.dateReversed)}</div> 247 <div class="timeline-marker"></div> 248 <div class="timeline-content"> 249 <div class="timeline-title">${uturn.title}</div> 250 </div> 251 </div> 252 `).join(''); 253} 254 255// Format date helper 256function formatDate(dateStr) { 257 return new Date(dateStr).toLocaleDateString('en-GB', { 258 day: 'numeric', 259 month: 'short', 260 year: 'numeric' 261 }); 262} 263 264// ========================================================================== 265// SCAN INFO MODAL - No API key required! 266// ========================================================================== 267 268function showScanInfo() { 269 const modal = document.getElementById('scanModal'); 270 const statusEl = document.getElementById('scanStatus'); 271 const resultsEl = document.getElementById('scanResults'); 272 const barEl = document.getElementById('scanBar'); 273 274 modal.classList.add('active'); 275 barEl.style.width = '100%'; 276 277 const lastUpdate = uturnsData ? new Date(uturnsData.meta.lastUpdated) : null; 278 const lastUpdateStr = lastUpdate ? lastUpdate.toLocaleDateString('en-GB', { 279 weekday: 'long', 280 day: 'numeric', 281 month: 'long', 282 year: 'numeric', 283 hour: '2-digit', 284 minute: '2-digit' 285 }) : 'Unknown'; 286 287 statusEl.textContent = 'â Auto-scanning enabled'; 288 289 resultsEl.innerHTML = ` 290 <div style="text-align: center; padding: 1rem;"> 291 <p style="font-size: 1.1rem; margin-bottom: 1rem;"> 292 <strong>ð¤ This site updates automatically!</strong> 293 </p> 294 <p style="margin-bottom: 1rem;"> 295 Our AI scans UK news sources <strong>every day at 9am</strong> for new government U-turns. 296 </p> 297 <p style="margin-bottom: 1rem; padding: 0.75rem; background: var(--paper); border: 2px solid var(--ink);"> 298 ð <strong>Last scan:</strong><br> 299 ${lastUpdateStr} 300 </p> 301 <p style="margin-bottom: 1rem;"> 302 <strong>Current count: ${uturnsData ? uturnsData.meta.totalCount : '?'} U-turns</strong> 303 </p> 304 <hr style="margin: 1.5rem 0; border: none; border-top: 2px dashed var(--ink);"> 305 <p style="font-size: 0.9rem; color: var(--ink-light);"> 306 ð¢ Want to be notified of new U-turns? 307 </p> 308 <div style="margin-top: 1rem; display: flex; gap: 0.5rem; justify-content: center; flex-wrap: wrap;"> 309 <a href="https://github.com/matthewdavidrussell/uk-uturn-tracker" target="_blank" 310 style="padding: 0.5rem 1rem; background: var(--ink); color: white; text-decoration: none; font-family: var(--font-mono); font-size: 0.8rem;"> 311 Watch on GitHub 312 </a> 313 </div> 314 </div> 315 `; 316} 317 318function closeScanModal() { 319 320 // ========================================================================== 321 // LIVE SOCIAL FEED - Dynamic X/Twitter-style feed 322 // ========================================================================== 323 324 const SOCIAL_CONFIG = { 325 accounts: [ 326 { handle: 'PippaCrerar', name: 'Pippa Crerar', outlet: 'The Guardian' }, 327 { handle: 'BethRigby', name: 'Beth Rigby', outlet: 'Sky News' }, 328 { handle: 'Peston', name: 'Robert Peston', outlet: 'ITV News' }, 329 { handle: 'Steven_Swinford', name: 'Steven Swinford', outlet: 'The Times' }, 330 { handle: 'SamCoatesSky', name: 'Sam Coates', outlet: 'Sky News' }, 331 { handle: 'tnewtondunn', name: 'Tom Newton Dunn', outlet: 'Times Radio' } 332 ], 333 keywords: ['u-turn', 'uturn', 'reversal', 'climbdown', 'backtrack', 'backs down', 'u turn', 'policy change'], 334 rssProxies: ['https://api.rss2json.com/v1/api.json?rss_url='], 335 nitterInstances: ['nitter.poast.org', 'nitter.privacydev.net'], 336 refreshInterval: 300000, 337 maxPosts: 20 338 }; 339 340 let socialFeedData = []; 341 let currentNitterIndex = 0; 342 343 async function initLiveSocialFeed() { 344 const container = document.getElementById('liveFeed'); 345 if (!container) return; 346 347 container.innerHTML = '<div class="feed-loading"><div class="feed-spinner"></div><p>Loading live political commentary...</p></div>'; 348 349 await fetchSocialFeed(); 350 setInterval(fetchSocialFeed, SOCIAL_CONFIG.refreshInterval); 351 352 const refreshBtn = document.getElementById('refreshFeed'); 353 if (refreshBtn) { 354 refreshBtn.addEventListener('click', async () => { 355 refreshBtn.classList.add('spinning'); 356 await fetchSocialFeed(); 357 refreshBtn.classList.remove('spinning'); 358 }); 359 } 360 } 361 362 async function fetchSocialFeed() { 363 const container = document.getElementById('liveFeed'); 364 if (!container) return; 365 366 socialFeedData = []; 367 368 const fetchPromises = SOCIAL_CONFIG.accounts.slice(0, 6).map(account => 369 fetchAccountFeed(account).catch(() => []) 370 ); 371 372 const results = await Promise.allSettled(fetchPromises); 373
374 results.forEach(result => { 375 if (result.status === 'fulfilled' && result.value) { 376 socialFeedData = socialFeedData.concat(result.value); 377 } 378 }); 379 380 socialFeedData.sort((a, b) => { 381 const aHasKeyword = hasUturnKeyword(a.content); 382 const bHasKeyword = hasUturnKeyword(b.content); 383 if (aHasKeyword && !bHasKeyword) return -1; 384 if (!aHasKeyword && bHasKeyword) return 1; 385 return new Date(b.date) - new Date(a.date); 386 }); 387 388 socialFeedData = socialFeedData.slice(0, SOCIAL_CONFIG.maxPosts); 389 renderSocialFeed(); 390 } 391 392 async function fetchAccountFeed(account) { 393 const posts = []; 394 395 for (let i = 0; i < SOCIAL_CONFIG.nitterInstances.length; i++) { 396 const instanceIndex = (currentNitterIndex + i) % SOCIAL_CONFIG.nitterInstances.length; 397 const nitter = SOCIAL_CONFIG.nitterInstances[instanceIndex]; 398 const rssUrl = `https://${nitter}/${account.handle}/rss`; 399 400 try { 401 const proxyUrl = SOCIAL_CONFIG.rssProxies[0] + encodeURIComponent(rssUrl); 402 const response = await fetch(proxyUrl, { signal: AbortSignal.timeout(5000) }); 403 404 if (!response.ok) continue; 405 406 const data = await response.json(); 407 408 if (data.status === 'ok' && data.items) { 409 data.items.slice(0, 5).forEac
409h(item => { 410 posts.push({ 411 handle: account.handle, 412 name: account.name, 413 outlet: account.outlet, 414 content: cleanHtml(item.title || item.description || ''), 415 date: new Date(item.pubDate), 416 link: item.link || `https://x.com/${account.handle}` 417 }); 418 }); 419 currentNitterIndex = instanceIndex; 420 return posts; 421 } 422 } catch (err) { 423 continue; 424 } 425 } 426 return posts; 427 } 428 429 function hasUturnKeyword(text) { 430 const lowerText = text.toLowerCase(); 431 return SOCIAL_CONFIG.keywords.some(keyword => lowerText.includes(keyword.toLowerCase())); 432 } 433 434 function cleanHtml(html) { 435 const div = document.createElement('div'); 436 div.innerHTML = html; 437 return div.textContent || div.innerText || ''; 438 } 439 440 function highlightKeywords(text) { 441 let highlighted = text; 442 SOCIAL_CONFIG.keywords.forEach(keyword => { 443 const regex = new RegExp(`(${keyword})`, 'gi'); 444 highlighted = highlighted.replace(regex, '<mark class="keyword-highlight">$1</mark>'); 445 }); 446 return highlighted; 447 } 448 449 function formatTimeAgo(date) { 450 const now = new Date(); 451 const diff = now - date; 452 const minutes = Math.floor(diff / 60000); 453 const hours = Math.floor(diff / 3600000); 454 const days = Math.floor(diff / 86400000); 455 456 if (minutes < 1) return 'Just now'; 457 if (minutes < 60) return `${minutes}m ago`; 458 if (hours < 24) return `${hours}h ago`; 459 if (days < 7) return `${days}d ago`; 460 return date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }); 461 } 462
463 function renderSocialFeed() { 464 const container = document.getElementById('liveFeed'); 465 if (!container) return; 466 467 if (socialFeedData.length === 0) { 468 container.innerHTML = '<div class="feed-empty"><p>ð¡ Unable to fetch live posts right now.</p><p class="feed-empty-sub">The journalists listed below are great follows for U-turn news!</p></div>'; 469 return; 470 } 471 472 const uturnPosts = socialFeedData.filter(post => hasUturnKeyword(post.content)); 473 const otherPosts = socialFeedData.filter(post => !hasUturnKeyword(post.content)); 474 475 let html = ''; 476 477 if (uturnPosts.length > 0) { 478 html += '<div class="feed-section-label">ð¥ U-Turn Related</div>'; 479 html += uturnPosts.map(post => renderPost(post, true)).join(''); 480 } 481 482 if (otherPosts.length > 0) { 483 html += '<div class="feed-section-label">ð° Latest Political Commentary</div>'; 484 html += otherPosts.slice(0, 10).map(post => renderPost(post, false)).join(''); 485 } 486 487 container.innerHTML = html; 488 489 const timestampEl = document.getElementById('feedLastUpdate'); 490 if (timestampEl) { 491 timestampEl.textContent = new Date().toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }); 492 } 493 } 494 495 function renderPost(post, isUturn) { 496 const content = isUturn ? highlightKeywords(post.content) : post.content; 497 const truncated = content.length > 280 ? content.substring(0, 277) + '...' : content; 498 499 return ` 500 <a href="${post.link}" target="_blank" rel="noopener" class="feed-post ${isUturn ? 'uturn-mention' : ''}"> 501 <div class="post-header"> 502 <div class="post-author"> 503 <span class="author-name">${post.name}</span> 504 <span class="author-handle">@${post.handle}</span> 505 </div> 506 <span class="post-outlet">${post.outlet}</span> 507 </div> 508 <div class="post-content">${truncated}</div> 509 <div class="post-footer"> 510 <span class="post-time">${formatTimeAgo(post.date)}</span> 511 <span class="post-platform">via X</span> 512 </div> 513 </a> 514 `; 515 } 516 document.getElementById('scanModal').classList.remove('active'); 517}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.