Last active
April 7, 2026 13:06
-
-
Save say4n/98f9effce1fcf27d8eba8448e24b5dfd to your computer and use it in GitHub Desktop.
Computes total fare for a month with TFL.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name TFL Fare Total | |
| // @namespace https://contactless.tfl.gov.uk | |
| // @match https://contactless.tfl.gov.uk/NewStatements/Billing* | |
| // @match https://contactless.tfl.gov.uk/NewStatements/Journey* | |
| // @version 2.0 | |
| // @description Computes total fares and statistics on TFL payment history pages | |
| // @grant none | |
| // ==/UserScript== | |
| (function () { | |
| 'use strict'; | |
| function parseFare(el) { | |
| const text = el.textContent.trim().replace('£', ''); | |
| const val = parseFloat(text); | |
| return isNaN(val) ? 0 : val; | |
| } | |
| function parseTime(timeStr) { | |
| // "09:50 - 10:33" or "19:42" (bus, single time) | |
| const match = timeStr.match(/^(\d{2}):(\d{2})/); | |
| if (!match) return null; | |
| return { hours: parseInt(match[1]), minutes: parseInt(match[2]) }; | |
| } | |
| function isPeak(time) { | |
| if (!time) return false; | |
| const mins = time.hours * 60 + time.minutes; | |
| // Morning peak: 06:30-09:30, Evening peak: 16:00-19:00 | |
| return (mins >= 390 && mins < 570) || (mins >= 960 && mins < 1140); | |
| } | |
| function computeStats() { | |
| const daySections = document.querySelectorAll('[data-pageobject="travelstatement-paymentsummary"]'); | |
| let totalCharged = 0; | |
| let totalJourneyFares = 0; | |
| let journeyCount = 0; | |
| let peakCount = 0; | |
| let offPeakCount = 0; | |
| let peakSpend = 0; | |
| let offPeakSpend = 0; | |
| let cappedDays = 0; | |
| let dayCount = daySections.length; | |
| const routeCounts = {}; | |
| daySections.forEach((section) => { | |
| // Daily total | |
| const priceEl = section.querySelector('[data-pageobject="travelstatement-billingdetail-priceheading"]'); | |
| if (priceEl) totalCharged += parseFare(priceEl); | |
| // Check if day was capped | |
| const cappedIcon = section.querySelector('.capped-icon-day, [alt="fares capped"]'); | |
| if (cappedIcon) cappedDays++; | |
| // Individual journeys | |
| const journeyTiles = section.querySelectorAll('[data-pageobject="statement-detaillink"]'); | |
| journeyTiles.forEach((tile) => { | |
| journeyCount++; | |
| const fareEl = tile.querySelector('[data-pageobject="journey-fare"]'); | |
| const fare = fareEl ? parseFare(fareEl) : 0; | |
| totalJourneyFares += fare; | |
| // Time & peak detection | |
| const timeEl = tile.querySelector('[data-pageobject="journey-time"]'); | |
| const timeStr = timeEl ? timeEl.textContent.trim() : ''; | |
| const startTime = parseTime(timeStr); | |
| if (isPeak(startTime)) { | |
| peakCount++; | |
| peakSpend += fare; | |
| } else { | |
| offPeakCount++; | |
| offPeakSpend += fare; | |
| } | |
| // Route tracking | |
| const fromEl = tile.querySelector('[data-pageobject="journey-from"]'); | |
| const toEl = tile.querySelector('[data-pageobject="journey-to"]'); | |
| if (fromEl) { | |
| const from = fromEl.textContent.trim(); | |
| const to = toEl ? toEl.textContent.trim() : ''; | |
| const route = to ? `${from} → ${to}` : from; | |
| routeCounts[route] = (routeCounts[route] || 0) + 1; | |
| } | |
| }); | |
| }); | |
| // Most frequent route | |
| let topRoute = ''; | |
| let topRouteCount = 0; | |
| for (const [route, count] of Object.entries(routeCounts)) { | |
| if (count > topRouteCount) { | |
| topRoute = route; | |
| topRouteCount = count; | |
| } | |
| } | |
| const cappingSavings = totalJourneyFares - totalCharged; | |
| return { | |
| totalCharged, | |
| totalJourneyFares, | |
| dayCount, | |
| journeyCount, | |
| avgPerDay: dayCount > 0 ? totalCharged / dayCount : 0, | |
| avgPerJourney: journeyCount > 0 ? totalCharged / journeyCount : 0, | |
| peakCount, | |
| offPeakCount, | |
| peakSpend, | |
| offPeakSpend, | |
| cappedDays, | |
| cappingSavings, | |
| topRoute, | |
| topRouteCount, | |
| }; | |
| } | |
| function createPanel() { | |
| const existing = document.getElementById('tfl-fare-total-panel'); | |
| if (existing) existing.remove(); | |
| const s = computeStats(); | |
| const panel = document.createElement('div'); | |
| panel.id = 'tfl-fare-total-panel'; | |
| const css = ` | |
| #tfl-fare-total-panel { | |
| font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; | |
| position: relative; | |
| background: #003688; color: #fff; | |
| border-bottom: 3px solid #0019A8; | |
| box-shadow: 0 2px 8px rgba(0,0,0,0.2); | |
| } | |
| #tfl-total-header { | |
| display: flex; justify-content: space-between; align-items: center; | |
| padding: 10px 20px; cursor: pointer; user-select: none; | |
| } | |
| #tfl-total-header:hover { background: #002D73; } | |
| #tfl-total-header .tfl-total-amount { | |
| font-size: 20px; font-weight: 700; | |
| } | |
| #tfl-total-header .tfl-total-summary { | |
| font-size: 13px; opacity: 0.85; | |
| } | |
| #tfl-total-header .tfl-toggle { | |
| font-size: 12px; background: rgba(255,255,255,0.15); | |
| border: none; color: #fff; padding: 4px 12px; border-radius: 3px; | |
| cursor: pointer; | |
| } | |
| #tfl-total-header .tfl-toggle:hover { background: rgba(255,255,255,0.25); } | |
| #tfl-total-details { | |
| display: none; padding: 0 20px 14px; | |
| } | |
| #tfl-total-details.open { display: block; } | |
| .tfl-stats-grid { | |
| display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); | |
| gap: 10px; | |
| } | |
| .tfl-stat-card { | |
| background: rgba(255,255,255,0.1); border-radius: 6px; padding: 10px 14px; | |
| } | |
| .tfl-stat-label { font-size: 11px; text-transform: uppercase; opacity: 0.7; letter-spacing: 0.5px; } | |
| .tfl-stat-value { font-size: 18px; font-weight: 600; margin-top: 2px; } | |
| .tfl-stat-sub { font-size: 11px; opacity: 0.7; margin-top: 2px; } | |
| .tfl-savings { color: #7FE084; } | |
| .tfl-route-info { margin-top: 10px; font-size: 12px; opacity: 0.8; } | |
| `; | |
| const style = document.createElement('style'); | |
| style.textContent = css; | |
| panel.appendChild(style); | |
| // Header (always visible) | |
| const header = document.createElement('div'); | |
| header.id = 'tfl-total-header'; | |
| header.innerHTML = ` | |
| <div> | |
| <span class="tfl-total-amount" id="tfl-copy-amount" title="Click to copy" style="cursor:copy;">£${s.totalCharged.toFixed(2)}</span> | |
| <span class="tfl-total-summary"> ${s.dayCount} days, ${s.journeyCount} journeys</span> | |
| </div> | |
| <button class="tfl-toggle" id="tfl-toggle-btn">Details ▾</button> | |
| `; | |
| panel.appendChild(header); | |
| // Details (collapsible) | |
| const details = document.createElement('div'); | |
| details.id = 'tfl-total-details'; | |
| details.innerHTML = ` | |
| <div class="tfl-stats-grid"> | |
| <div class="tfl-stat-card"> | |
| <div class="tfl-stat-label">Avg per day</div> | |
| <div class="tfl-stat-value">£${s.avgPerDay.toFixed(2)}</div> | |
| </div> | |
| <div class="tfl-stat-card"> | |
| <div class="tfl-stat-label">Avg per journey</div> | |
| <div class="tfl-stat-value">£${s.avgPerJourney.toFixed(2)}</div> | |
| </div> | |
| <div class="tfl-stat-card"> | |
| <div class="tfl-stat-label">Peak journeys</div> | |
| <div class="tfl-stat-value">${s.peakCount}</div> | |
| <div class="tfl-stat-sub">£${s.peakSpend.toFixed(2)} total</div> | |
| </div> | |
| <div class="tfl-stat-card"> | |
| <div class="tfl-stat-label">Off-peak journeys</div> | |
| <div class="tfl-stat-value">${s.offPeakCount}</div> | |
| <div class="tfl-stat-sub">£${s.offPeakSpend.toFixed(2)} total</div> | |
| </div> | |
| <div class="tfl-stat-card"> | |
| <div class="tfl-stat-label">Days capped</div> | |
| <div class="tfl-stat-value">${s.cappedDays} / ${s.dayCount}</div> | |
| ${s.cappingSavings > 0.01 ? `<div class="tfl-stat-sub tfl-savings">Saved £${s.cappingSavings.toFixed(2)} from caps</div>` : ''} | |
| </div> | |
| <div class="tfl-stat-card"> | |
| <div class="tfl-stat-label">Top route</div> | |
| <div class="tfl-stat-value" style="font-size:13px; margin-top:4px;">${s.topRoute || 'N/A'}</div> | |
| <div class="tfl-stat-sub">${s.topRouteCount} time${s.topRouteCount !== 1 ? 's' : ''}</div> | |
| </div> | |
| </div> | |
| `; | |
| panel.appendChild(details); | |
| const container = document.getElementById('main-content'); | |
| if (container) { | |
| container.insertBefore(panel, container.firstChild); | |
| } else { | |
| document.body.insertBefore(panel, document.body.firstChild); | |
| } | |
| // Copy amount to clipboard | |
| document.getElementById('tfl-copy-amount').addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| const amount = s.totalCharged.toFixed(2); | |
| navigator.clipboard.writeText(amount).then(() => { | |
| const el = e.target; | |
| const orig = el.textContent; | |
| el.textContent = 'Copied!'; | |
| setTimeout(() => { el.textContent = orig; }, 1000); | |
| }); | |
| }); | |
| // Toggle | |
| header.addEventListener('click', () => { | |
| const d = document.getElementById('tfl-total-details'); | |
| const btn = document.getElementById('tfl-toggle-btn'); | |
| d.classList.toggle('open'); | |
| btn.textContent = d.classList.contains('open') ? 'Details ▴' : 'Details ▾'; | |
| }); | |
| } | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', createPanel); | |
| } else { | |
| createPanel(); | |
| } | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment