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

Mildmay

Severe DelaysNo service between Camden Road and Stratford while we fix a track fault at Canonbury. SEVERE DELAYS on the rest of the line.
Part SuspendedNo service between Camden Road and Stratford while we fix a track fault at Canonbury. SEVERE DELAYS on the rest of the line.

Tram

Part ClosureLONDON TRAMS: Wednesday 23 until Sunday 27 July, no service between Wandle Park and East Croydon. Replacement bus service T1 operates between Waddon Marsh and East Croydon (Dingwall Road) via Wandle Park (Waddon Road), Reeves Corner (eastbound only, Tamworth Road) or Church Street (westbound only, Reeves Corner), Centrale, West Croydon (Bus Station) and George Street (westbound only, Wellesley Road).
Part ClosureLONDON TRAMS: From 2230 Sunday 13 until Sunday 27 July, no service between Arena and Elmers End. This is due to planned engineering work.

Good Service on all other lines

Central

Northern

Jubilee

Piccadilly

District

Victoria

Circle

Hammersmith & City

Bakerloo

Metropolitan

Waterloo & City

DLR

Elizabeth line

Liberty

Lioness

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>
  );
}