TfL API TypeScript Client

A fully-typed TypeScript client for the Transport for London (TfL) API with auto-generated types, real-time data support, and comprehensive coverage of all TfL endpoints. Built with modern TypeScript practices and zero dependencies.

v0.0.1
🍰
🏃
🎓
Beginner

TfL API TypeScript Client

A fully-typed TypeScript client for the Transport for London (TfL) API with auto-generated types, real-time data support, and comprehensive coverage of all TfL endpoints. Built with modern TypeScript practices and zero dependencies.

Description

Please visit the npm package for more details.

Basic usage:

import TfLClients from "tfl-ts";

const tfl = new TfLClients();

const tubeStatus = await tfl.line.getStatus({ modes: ['tube'] });

Example:

Line Status

Bakerloo

Minor DelaysNo service between Harrow & Wealdstone and Queen's Park while Network Rail fix a signal failure in the Willesden Junction area. Tickets are being accepted on London Buses. MINOR DELAYS on the rest of the line.
Part SuspendedNo service between Harrow & Wealdstone and Queen's Park while Network Rail fix a signal failure in the Willesden Junction area. Tickets are being accepted on London Buses. MINOR DELAYS on the rest of the line.

Jubilee

Severe DelaysSevere delays due to an earlier signal failure at Stratford. Tickets are being accepted on London Buses, London Overground, the DLR, the Elizabeth line, C2C, Southeastern and Thameslink.

Lioness

Severe DelaysSevere delays while Network Rail fix a signal failure in the Willesden Junction area. Tickets are being accepted on London Buses.

Northern

Minor DelaysMinor delays due to an earlier points failure at Golders Green.

Piccadilly

Minor DelaysMinor delays due to an earlier signal failure at Arnos Grove.

District

Minor DelaysMinor delays due to an earlier fire alert at Barking and Network Rail signal failure in the Parsons Green area.

DLR

Minor DelaysMinor delays between Stratford International and Canning Town due to an earlier faulty train at West Ham. GOOD SERVICE on the rest of the line.

Good Service on all other lines

Central

Victoria

Circle

Hammersmith & City

Metropolitan

Waterloo & City

Tram

Elizabeth line

Liberty

Mildmay

Suffragette

Weaver

Windrush

import { type ReactNode } from "react";
import Link from "next/link";
import TfLClients from "tfl-ts";
import { ExternalLink, TrainFrontTunnel } from 'lucide-react';
import { cn } from '@workspace/ui/lib/utils';

interface Props {
  children?: ReactNode;
}

const TFLLogo = ({ className }: { className?: string }) => (
  <div
    style={{
      display: 'flex',
      alignItems: 'center',
      justifyContent: 'center',
    }}
    className={cn(className, "dark:brightness-200")}
  >
    <svg
      style={{
        width: '100%',
        height: '100%'
      }}
      viewBox="0 0 615.322 500"
    >
      <g>
        <path
          style={{
            fill: '#000F9F'
          }}
          d="M469.453,249.986c0,89.078-72.26,161.308-161.337,161.308c-89.1,0-161.294-72.23-161.294-161.308 c0-89.063,72.194-161.286,161.294-161.286C397.194,88.699,469.453,160.922,469.453,249.986 M308.116,0 C170.027,0,58.094,111.925,58.094,249.986C58.094,388.06,170.027,500,308.116,500c138.06,0,249.985-111.94,249.985-250.014 C558.101,111.925,446.176,0,308.116,0"
        />
        <rect
          style={{
            fill: '#000F9F'
          }}
          y="199.516"
          width="615.322"
          height="101.129"
        />
      </g>
    </svg>
  </div>
);

const lineColorMap = {
  'bakerloo': { text: 'text-[#B36305]', bg: 'bg-[#B36305]', badWithDarkBg: false },
  'central': { text: 'text-[#E32017]', bg: 'bg-[#E32017]', badWithDarkBg: false },
  'circle': { text: 'text-[#FFD300]', bg: 'bg-[#FFD300]', badWithDarkBg: false },
  'district': { text: 'text-[#00782A]', bg: 'bg-[#00782A]', badWithDarkBg: false },
  'hammersmith-city': { text: 'text-[#F3A9BB]', bg: 'bg-[#F3A9BB]', badWithDarkBg: false },
  'jubilee': { text: 'text-[#A0A5A9]', bg: 'bg-[#A0A5A9]', badWithDarkBg: false },
  'metropolitan': { text: 'text-[#9B0056]', bg: 'bg-[#9B0056]', badWithDarkBg: false },
  'northern': { text: 'text-[#000000]', bg: 'bg-[#000000]', badWithDarkBg: true },
  'piccadilly': { text: 'text-[#003688]', bg: 'bg-[#003688]', badWithDarkBg: false },
  'victoria': { text: 'text-[#0098D4]', bg: 'bg-[#0098D4]', badWithDarkBg: false },
  'waterloo-city': { text: 'text-[#95CDBA]', bg: 'bg-[#95CDBA]', badWithDarkBg: false },
  'dlr': { text: 'text-[#00A4A7]', bg: 'bg-[#00A4A7]', badWithDarkBg: false },
  'elizabeth': { text: 'text-[#6950A1]', bg: 'bg-[#6950A1]', badWithDarkBg: false },
  'tram': { text: 'text-[#5fb526]', bg: 'bg-[#5fb526]', badWithDarkBg: false },
  'liberty': { text: 'text-[#0071FD]', bg: 'bg-[#0071FD]', badWithDarkBg: false },
  'lioness': { text: 'text-[#FC9D9A]', bg: 'bg-[#FC9D9A]', badWithDarkBg: false },
  'mildmay': { text: 'text-[#0071FD]', bg: 'bg-[#0071FD]', badWithDarkBg: false },
  'suffragette': { text: 'text-[#76B82A]', bg: 'bg-[#76B82A]', badWithDarkBg: false },
  'weaver': { text: 'text-[#A45A2A]', bg: 'bg-[#A45A2A]', badWithDarkBg: false },
  'windrush': { text: 'text-[#EE2E24]', bg: 'bg-[#EE2E24]', badWithDarkBg: false }
} as const;


const tfl = new TfLClients();

// Get severity data from TfL API
const severity = tfl.line.ALL_SEVERITY.filter(mode => mode.modeName === 'tube');

// Create dynamic severity mappings based on TfL data
const createSeverityMapping = () => {
  const critical: number[] = [];
  const severe: number[] = [];
  const minor: number[] = [];
  const special: number[] = [];
  const good: number[] = [];

  severity.forEach(item => {
    const level = item.severityLevel;
    const description = item.description.toLowerCase();

    if (description.includes('closed') || description.includes('suspended') || description.includes('not running')) {
      critical.push(level);
    } else if (description.includes('severe') || description.includes('part closure') || description.includes('exit only')) {
      severe.push(level);
    } else if (description.includes('minor') || description.includes('reduced') || description.includes('bus service') || description.includes('diverted') || description.includes('issues')) {
      minor.push(level);
    } else if (description.includes('special') || description.includes('no step free') || description.includes('information')) {
      special.push(level);
    } else if (description.includes('good') || description.includes('no issues') || description.includes('closed for the night')) {
      good.push(level);
    }
  });

  return {
    critical,
    severe,
    minor,
    special,
    good
  };
};

const severityMapping = createSeverityMapping();

// Create a union type from all severity levels
type SeverityNumber = number;

const isNormalSeverity = (statuses: Array<{ statusSeverity?: number }>) => {
  return statuses.every(status => {
    const severity = status.statusSeverity;
    if (severity === undefined) return false;
    return severityMapping.good.includes(severity) ||
      severityMapping.special.includes(severity);
  });
};

const getSeverityClass = (severity: SeverityNumber) => {
  if (severityMapping.critical.includes(severity)) return 'text-red-700 animate-[pulse_1s_ease-in-out_infinite]';
  if (severityMapping.severe.includes(severity)) return 'text-orange-700 animate-[pulse_1.5s_ease-in-out_infinite]';
  if (severityMapping.minor.includes(severity)) return 'text-yellow-700 animate-[pulse_2s_ease-in-out_infinite]';
  if (severityMapping.special.includes(severity)) return 'text-blue-700';
  return 'text-green-700';
};

const getSeverityAnimation = (severity: SeverityNumber) => {
  if (severityMapping.critical.includes(severity)) return "animate-[pulse_1s_ease-in-out_infinite]";
  if (severityMapping.severe.includes(severity)) return "animate-[pulse_1.5s_ease-in-out_infinite]";
  if (severityMapping.minor.includes(severity)) return "animate-[pulse_2s_ease-in-out_infinite]";
  return "";
};

const hasNightClosure = (statuses: Array<{ statusSeverity?: number }>) => {
  return statuses.some(status => status.statusSeverity === 20);
};

const lineOrder = [
  // Tube lines by passenger volume (busiest first)
  'central',
  'northern',
  'jubilee',
  'piccadilly',
  'district',
  'victoria',
  'circle',
  'hammersmith-city',
  'bakerloo',
  'metropolitan',
  'waterloo-city',
  
  // Other transport modes
  'dlr',
  'tram',
  'elizabeth',
  
  // Overground group
  'liberty',
  'lioness',
  'mildmay',
  'suffragette',
  'weaver',
  'windrush'
] as const;

const getLineOrder = (lineId: string) => {
  const index = lineOrder.indexOf(lineId as typeof lineOrder[number]);
  return index === -1 ? lineOrder.length : index;
};

export async function TflTs(props: Props) {

  // Get status for multiple modes
  const lineStatuses = await tfl.line.getStatus({ modes: ['tube', 'elizabeth-line', 'dlr', 'tram', 'overground'] });

  // Enhanced sorting logic
  const sortedLineStatuses = lineStatuses.sort((a, b) => {
    const aMinSeverity = Math.min(...(a.lineStatuses?.map(s => s.statusSeverity || 0) || []));
    const bMinSeverity = Math.min(...(b.lineStatuses?.map(s => s.statusSeverity || 0) || []));

    // If both lines have normal service, sort by predefined order
    if (isNormalSeverity(a.lineStatuses || []) && isNormalSeverity(b.lineStatuses || [])) {
      return getLineOrder(a.id || '') - getLineOrder(b.id || '');
    }

    // If severities are different, sort by severity
    if (aMinSeverity !== bMinSeverity) {
      return aMinSeverity - bMinSeverity;
    }

    // If both lines have the same severity level (but not normal),
    // still sort by predefined order as a fallback
    return getLineOrder(a.id || '') - getLineOrder(b.id || '');
  });

  return (
    <div className="w-full mt-4 flex flex-col gap-2">
      <div className="flex items-center gap-2 justify-between flex-wrap">

        <div className="flex items-center gap-2">
          <TFLLogo className="size-[var(--text-4xl)] lg:size-[var(--text-5xl)]" />
          <h1 className="scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl">
            Line Status
          </h1>
        </div>
        <div className="flex items-center gap-2"><Link href="https://tfl.gov.uk/tube-dlr-overground/status/" className="text-blue-500 hover:underline">TfL website <ExternalLink className="size-4 inline-block" /></Link></div>
      </div>

      {/* Lines with issues */}
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
        {sortedLineStatuses.filter(line => !isNormalSeverity(line.lineStatuses || [])).map((line) => (
          <div
            key={line.id}
            className="flex flex-col gap-0"
          >
            <h2 className="text-lg font-semibold">{line.name}</h2>
            <div className="w-full h-[6px] relative">
              {(line.modeName === 'overground' || line.modeName === 'elizabeth-line') ? (
                <>
                  <div className={`w-full h-[6px] ${line.id && line.id in lineColorMap
                    ? `${lineColorMap[line.id as keyof typeof lineColorMap].bg}`
                    : 'bg-gray-400'
                    } ${line.id && line.id in lineColorMap && lineColorMap[line.id as keyof typeof lineColorMap].badWithDarkBg ? 'dark:shadow-[0_0_8px_rgba(255,255,255,0.3)]' : ''}`} />
                  <div className="absolute top-[2px] left-0 w-full h-[2px] bg-white" />
                </>
              ) : (
                <div className={`w-full h-[6px] ${line.id && line.id in lineColorMap
                  ? `${lineColorMap[line.id as keyof typeof lineColorMap].bg}`
                  : 'bg-gray-400'
                  } ${line.id && line.id in lineColorMap && lineColorMap[line.id as keyof typeof lineColorMap].badWithDarkBg ? 'dark:shadow-[0_0_8px_rgba(255,255,255,0.3)]' : ''}`} />
              )}
            </div>

            {line.lineStatuses?.map((status, index) => {
              const severityTitleClass = getSeverityClass(status.statusSeverity as SeverityNumber);
              const statusDescription = status.statusSeverityDescription;

              return (
                <div
                  key={index}
                  className={`mt-2`}
                >
                  <div className="">
                    <span className={`font-medium ${severityTitleClass} mr-2`}>
                      {statusDescription}
                    </span>
                    {status.reason && (
                      <span className={`text-sm text-foreground/80 mt-1 text-pretty`}>
                        {status.reason?.replace(new RegExp(`^${line.name?.toUpperCase()}( LINE)?: `, 'i'), '').replace(/^(Hammersmith and City Line: )|(London Overground: )|(Docklands Light Railway: )\s*/, '')}
                      </span>
                    )}
                  </div>
                </div>
              );
            })}
          </div>
        ))}
      </div>

      {/* Lines with good service */}
      <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4 justify-items-stretch">
        <div className="flex flex-col justify-end col-span-2 md:col-span-3 lg:col-span-1">
          <h2 className="text-xl prose font-semibold leading-none">Good Service on all {sortedLineStatuses.filter(line => !isNormalSeverity(line.lineStatuses || [])).length > 0 && `other `}lines</h2>
        </div>
        {sortedLineStatuses.filter(line => isNormalSeverity(line.lineStatuses || [])).map((line) => (
          <div
            key={line.id}
            className="flex flex-col"
          >
            <div className="flex justify-between">
              <h2 className="text-lg font-semibold">
                {line.name}
              </h2>
              {hasNightClosure(line.lineStatuses || []) && (
                <div className="flex items-center gap-1 text-xs text-muted-foreground">
                  <TrainFrontTunnel className={`w-3 h-3 ${line.id && line.id in lineColorMap ? `${lineColorMap[line.id as keyof typeof lineColorMap].text}` : 'text-gray-400'}`} />
                  <span>
                    {line.lineStatuses?.find(status => status.statusSeverity === 20)?.statusSeverityDescription}
                  </span>
                </div>
              )}
            </div>
            <div className="w-full h-[6px] relative">
              {(line.modeName === 'overground' || line.modeName === 'elizabeth-line') ? (
                <>
                  <div className={`w-full h-[6px] ${line.id && line.id in lineColorMap
                    ? `${lineColorMap[line.id as keyof typeof lineColorMap].bg}`
                    : 'bg-gray-400'
                    } ${line.id && line.id in lineColorMap && lineColorMap[line.id as keyof typeof lineColorMap].badWithDarkBg ? 'dark:shadow-[0_0_8px_rgba(255,255,255,0.3)]' : ''}`} />
                  <div className="absolute top-[2px] left-0 w-full h-[2px] bg-white" />
                </>
              ) : (
                <div className={`w-full h-[6px] ${line.id && line.id in lineColorMap
                  ? `${lineColorMap[line.id as keyof typeof lineColorMap].bg}`
                  : 'bg-gray-400'
                  } ${line.id && line.id in lineColorMap && lineColorMap[line.id as keyof typeof lineColorMap].badWithDarkBg ? 'dark:shadow-[0_0_8px_rgba(255,255,255,0.3)]' : ''}`} />
              )}
            </div>
          </div>
        ))}
      </div>

      {props.children}
    </div>
  );
}