Skip to content

Instantly share code, notes, and snippets.

@say4n
Last active April 7, 2026 13:06
Show Gist options
  • Select an option

  • Save say4n/98f9effce1fcf27d8eba8448e24b5dfd to your computer and use it in GitHub Desktop.

Select an option

Save say4n/98f9effce1fcf27d8eba8448e24b5dfd to your computer and use it in GitHub Desktop.
Computes total fare for a month with TFL.
// ==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">&nbsp; ${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