PageSourceSearch

https://uturn.to/app.js

js uturn.to collected 2026-10-02 06:59:34 UTC 36,611 bytes, 517 lines download raw bytes

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.