Compare commits
5 Commits
bf75062760
...
f884982766
| Author | SHA1 | Date | |
|---|---|---|---|
|
f884982766
|
|||
|
9b828bd7cf
|
|||
|
b5a9e308e8
|
|||
|
37b61e105d
|
|||
|
81660c2d7e
|
@@ -2,6 +2,7 @@ import { Hono } from "hono"
|
|||||||
import { cors } from "hono/cors"
|
import { cors } from "hono/cors"
|
||||||
import { logger } from "hono/logger"
|
import { logger } from "hono/logger"
|
||||||
import weather from "./weather"
|
import weather from "./weather"
|
||||||
|
import tfl from "./tfl"
|
||||||
|
|
||||||
const app = new Hono()
|
const app = new Hono()
|
||||||
|
|
||||||
@@ -19,6 +20,9 @@ app.get("/api/health", (c) => {
|
|||||||
// Mount weather routes
|
// Mount weather routes
|
||||||
app.route("/api/weather", weather)
|
app.route("/api/weather", weather)
|
||||||
|
|
||||||
|
// Mount TfL routes
|
||||||
|
app.route("/api/tfl", tfl)
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
port: 8000,
|
port: 8000,
|
||||||
fetch: app.fetch,
|
fetch: app.fetch,
|
||||||
|
|||||||
57
apps/backend/src/tfl/cache.ts
Normal file
57
apps/backend/src/tfl/cache.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* Simple in-memory cache for shortened disruption descriptions
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface CacheEntry {
|
||||||
|
shortened: string
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = new Map<string, CacheEntry>()
|
||||||
|
|
||||||
|
// Cache for 1 hour
|
||||||
|
const CACHE_DURATION = 60 * 60 * 1000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cached shortened description
|
||||||
|
*/
|
||||||
|
export function getCachedShortened(originalReason: string): string | null {
|
||||||
|
const entry = cache.get(originalReason)
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if expired
|
||||||
|
if (Date.now() - entry.timestamp > CACHE_DURATION) {
|
||||||
|
cache.delete(originalReason)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.shortened
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache a shortened description
|
||||||
|
*/
|
||||||
|
export function setCachedShortened(originalReason: string, shortened: string): void {
|
||||||
|
cache.set(originalReason, {
|
||||||
|
shortened,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear expired cache entries
|
||||||
|
*/
|
||||||
|
export function clearExpiredCache(): void {
|
||||||
|
const now = Date.now()
|
||||||
|
for (const [key, entry] of cache.entries()) {
|
||||||
|
if (now - entry.timestamp > CACHE_DURATION) {
|
||||||
|
cache.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear expired entries every 10 minutes
|
||||||
|
setInterval(clearExpiredCache, 10 * 60 * 1000)
|
||||||
204
apps/backend/src/tfl/gemini.ts
Normal file
204
apps/backend/src/tfl/gemini.ts
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
/**
|
||||||
|
* Gemini AI integration for shortening TfL disruption descriptions
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getCachedShortened, setCachedShortened } from "./cache"
|
||||||
|
|
||||||
|
interface DisruptionToShorten {
|
||||||
|
lineName: string
|
||||||
|
status: string
|
||||||
|
reason: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShortenedResult {
|
||||||
|
lineName: string
|
||||||
|
shortened: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip line name prefix from description
|
||||||
|
*/
|
||||||
|
function stripLineName(text: string, lineName: string): string {
|
||||||
|
// Escape special regex characters in line name
|
||||||
|
const escapedName = lineName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
|
||||||
|
// Remove patterns like "Central Line:", "CENTRAL LINE:", "Mildmay Line:", etc.
|
||||||
|
const patterns = [
|
||||||
|
new RegExp(`^${escapedName}\\s*Line:\\s*`, "i"),
|
||||||
|
new RegExp(`^${escapedName}:\\s*`, "i"),
|
||||||
|
new RegExp(`^${escapedName.toUpperCase()}\\s*LINE:\\s*`),
|
||||||
|
]
|
||||||
|
|
||||||
|
let result = text
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
result = result.replace(pattern, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shorten multiple disruption reasons in a single Gemini API call
|
||||||
|
*/
|
||||||
|
export async function shortenMultipleDisruptions(
|
||||||
|
disruptions: DisruptionToShorten[]
|
||||||
|
): Promise<Map<string, string>> {
|
||||||
|
const apiKey = process.env.GEMINI_API_KEY
|
||||||
|
const results = new Map<string, string>()
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
console.warn("GEMINI_API_KEY not set, returning stripped versions")
|
||||||
|
for (const disruption of disruptions) {
|
||||||
|
results.set(disruption.lineName, stripLineName(disruption.reason, disruption.lineName))
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter disruptions that need shortening
|
||||||
|
const toShorten: DisruptionToShorten[] = []
|
||||||
|
|
||||||
|
for (const disruption of disruptions) {
|
||||||
|
const stripped = stripLineName(disruption.reason, disruption.lineName)
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
const cached = getCachedShortened(disruption.reason)
|
||||||
|
if (cached) {
|
||||||
|
results.set(disruption.lineName, cached)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// If already short after stripping, use that
|
||||||
|
if (stripped.length < 80) {
|
||||||
|
results.set(disruption.lineName, stripped)
|
||||||
|
setCachedShortened(disruption.reason, stripped)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Needs shortening
|
||||||
|
toShorten.push({ ...disruption, reason: stripped })
|
||||||
|
}
|
||||||
|
|
||||||
|
// If nothing needs shortening, return early
|
||||||
|
if (toShorten.length === 0) {
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build batch prompt
|
||||||
|
const prompt = buildBatchShorteningPrompt(toShorten)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent?key=${apiKey}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
contents: [
|
||||||
|
{
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
text: prompt,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
generationConfig: {
|
||||||
|
temperature: 0.3,
|
||||||
|
maxOutputTokens: 2000, // Higher limit to account for thinking tokens in Gemini 2.5 Flash
|
||||||
|
topP: 0.9,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error(`Gemini API error: ${response.status}`)
|
||||||
|
// Fallback to stripped versions
|
||||||
|
for (const disruption of toShorten) {
|
||||||
|
results.set(disruption.lineName, disruption.reason)
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as any
|
||||||
|
const responseText = data.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || ""
|
||||||
|
|
||||||
|
// Parse JSON response
|
||||||
|
try {
|
||||||
|
// Extract JSON from markdown code blocks if present
|
||||||
|
let jsonText = responseText
|
||||||
|
const jsonMatch = responseText.match(/```json\s*([\s\S]*?)\s*```/)
|
||||||
|
if (jsonMatch) {
|
||||||
|
jsonText = jsonMatch[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
const shortened = JSON.parse(jsonText) as ShortenedResult[]
|
||||||
|
|
||||||
|
// Map results
|
||||||
|
for (const item of shortened) {
|
||||||
|
results.set(item.lineName, item.shortened)
|
||||||
|
// Cache the result
|
||||||
|
const original = toShorten.find(d => d.lineName === item.lineName)
|
||||||
|
if (original) {
|
||||||
|
setCachedShortened(original.reason, item.shortened)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (parseError) {
|
||||||
|
console.error("Failed to parse Gemini JSON response:", parseError)
|
||||||
|
console.error("Response was:", responseText)
|
||||||
|
// Fallback to stripped versions
|
||||||
|
for (const disruption of toShorten) {
|
||||||
|
results.set(disruption.lineName, disruption.reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to shorten disruptions:", error)
|
||||||
|
// Fallback to stripped versions
|
||||||
|
for (const disruption of toShorten) {
|
||||||
|
results.set(disruption.lineName, disruption.reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a batch prompt for Gemini to shorten multiple disruptions at once
|
||||||
|
*/
|
||||||
|
function buildBatchShorteningPrompt(disruptions: DisruptionToShorten[]): string {
|
||||||
|
const disruptionsList = disruptions.map((d, i) =>
|
||||||
|
`${i + 1}. Line: ${d.lineName}\n Status: ${d.status}\n Message: "${d.reason}"`
|
||||||
|
).join('\n\n')
|
||||||
|
|
||||||
|
return `Shorten these London transport disruption messages for a dashboard display. Return your response as a JSON array.
|
||||||
|
|
||||||
|
Disruptions to shorten:
|
||||||
|
${disruptionsList}
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
- Keep each shortened message under 80 characters
|
||||||
|
- Be concise but keep essential information (reason, locations, alternatives, time info)
|
||||||
|
- DO NOT include line names in the shortened text (they're displayed separately)
|
||||||
|
- Use natural, clear language
|
||||||
|
- NO emojis
|
||||||
|
|
||||||
|
Return ONLY a JSON array in this exact format:
|
||||||
|
[
|
||||||
|
{"lineName": "Piccadilly", "shortened": "Suspended Rayners Lane-Uxbridge until Fri due to Storm Benjamin. Use Metropolitan line."},
|
||||||
|
{"lineName": "Central", "shortened": "Minor delays due to train cancellations"},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
|
||||||
|
Good examples of shortened messages:
|
||||||
|
- "Suspended Rayners Lane-Uxbridge until Fri due to Storm Benjamin. Use Metropolitan line."
|
||||||
|
- "Minor delays due to train cancellations"
|
||||||
|
- "Minor delays due to earlier incidents at Gospel Oak & Highbury"
|
||||||
|
- "Severe delays - signal failure at King's Cross. Use buses/Elizabeth line."
|
||||||
|
- "No service Earls Court-Wimbledon until Sun 27 Oct (engineering)"
|
||||||
|
|
||||||
|
Generate JSON array:`
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
195
apps/backend/src/tfl/index.ts
Normal file
195
apps/backend/src/tfl/index.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import { Hono } from "hono"
|
||||||
|
import { shortenMultipleDisruptions } from "./gemini"
|
||||||
|
|
||||||
|
const tfl = new Hono()
|
||||||
|
|
||||||
|
interface TflLineStatus {
|
||||||
|
$type: string
|
||||||
|
id: number
|
||||||
|
lineId?: string
|
||||||
|
statusSeverity: number
|
||||||
|
statusSeverityDescription: string
|
||||||
|
reason?: string
|
||||||
|
created: string
|
||||||
|
validityPeriods: {
|
||||||
|
$type: string
|
||||||
|
fromDate: string
|
||||||
|
toDate: string
|
||||||
|
isNow: boolean
|
||||||
|
}[]
|
||||||
|
disruption?: {
|
||||||
|
$type: string
|
||||||
|
category: string
|
||||||
|
categoryDescription: string
|
||||||
|
description: string
|
||||||
|
affectedRoutes: unknown[]
|
||||||
|
affectedStops: unknown[]
|
||||||
|
closureText: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TflLine {
|
||||||
|
$type: string
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
modeName: string
|
||||||
|
disruptions: unknown[]
|
||||||
|
created: string
|
||||||
|
modified: string
|
||||||
|
lineStatuses: TflLineStatus[]
|
||||||
|
routeSections: unknown[]
|
||||||
|
serviceTypes: {
|
||||||
|
$type: string
|
||||||
|
name: string
|
||||||
|
uri: string
|
||||||
|
}[]
|
||||||
|
crowding: {
|
||||||
|
$type: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DisruptionSummary {
|
||||||
|
lineId: string
|
||||||
|
lineName: string
|
||||||
|
mode: string
|
||||||
|
status: string
|
||||||
|
statusSeverity: number
|
||||||
|
reason?: string
|
||||||
|
validFrom?: string
|
||||||
|
validTo?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DisruptionsResponse {
|
||||||
|
lastUpdated: string
|
||||||
|
disruptions: DisruptionSummary[]
|
||||||
|
goodService: string[]
|
||||||
|
totalLines: number
|
||||||
|
disruptedLines: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current disruptions across all London transport modes
|
||||||
|
tfl.get("/disruptions", async (c) => {
|
||||||
|
try {
|
||||||
|
// Fetch status for all major transport modes
|
||||||
|
const modes = ["tube", "overground", "dlr", "elizabeth-line", "tram"]
|
||||||
|
const url = `https://api.tfl.gov.uk/Line/Mode/${modes.join(",")}/Status`
|
||||||
|
|
||||||
|
const response = await fetch(url)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Failed to fetch TfL data",
|
||||||
|
status: response.status,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: response.status,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as TflLine[]
|
||||||
|
|
||||||
|
const disruptions: DisruptionSummary[] = []
|
||||||
|
const goodService: string[] = []
|
||||||
|
|
||||||
|
for (const line of data) {
|
||||||
|
// Get the most severe status for this line
|
||||||
|
const status = line.lineStatuses[0]
|
||||||
|
|
||||||
|
if (!status) continue
|
||||||
|
|
||||||
|
// statusSeverity: 10 = Good Service, anything less is a disruption
|
||||||
|
if (status.statusSeverity === 10) {
|
||||||
|
goodService.push(line.name)
|
||||||
|
} else {
|
||||||
|
const validPeriod = status.validityPeriods.find((p) => p.isNow)
|
||||||
|
|
||||||
|
disruptions.push({
|
||||||
|
lineId: line.id,
|
||||||
|
lineName: line.name,
|
||||||
|
mode: line.modeName,
|
||||||
|
status: status.statusSeverityDescription,
|
||||||
|
statusSeverity: status.statusSeverity,
|
||||||
|
reason: status.reason,
|
||||||
|
validFrom: validPeriod?.fromDate,
|
||||||
|
validTo: validPeriod?.toDate,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort disruptions by severity (lower number = more severe)
|
||||||
|
disruptions.sort((a, b) => a.statusSeverity - b.statusSeverity)
|
||||||
|
|
||||||
|
// Shorten all disruption reasons in a single Gemini API call
|
||||||
|
const disruptionsToShorten = disruptions
|
||||||
|
.filter(d => d.reason)
|
||||||
|
.map(d => ({
|
||||||
|
lineName: d.lineName,
|
||||||
|
status: d.status,
|
||||||
|
reason: d.reason!,
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (disruptionsToShorten.length > 0) {
|
||||||
|
const shortenedMap = await shortenMultipleDisruptions(disruptionsToShorten)
|
||||||
|
|
||||||
|
// Apply shortened reasons back to disruptions
|
||||||
|
for (const disruption of disruptions) {
|
||||||
|
const shortened = shortenedMap.get(disruption.lineName)
|
||||||
|
if (shortened) {
|
||||||
|
disruption.reason = shortened
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary: DisruptionsResponse = {
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
disruptions,
|
||||||
|
goodService: goodService.sort(),
|
||||||
|
totalLines: data.length,
|
||||||
|
disruptedLines: disruptions.length,
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json(summary)
|
||||||
|
} catch (error) {
|
||||||
|
return c.json(
|
||||||
|
{ error: "Internal server error", message: String(error) },
|
||||||
|
500
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get status for specific line(s)
|
||||||
|
tfl.get("/line/:lineIds", async (c) => {
|
||||||
|
try {
|
||||||
|
const lineIds = c.req.param("lineIds")
|
||||||
|
|
||||||
|
const url = `https://api.tfl.gov.uk/Line/${lineIds}/Status`
|
||||||
|
|
||||||
|
const response = await fetch(url)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Failed to fetch TfL line data",
|
||||||
|
status: response.status,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: response.status,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
return c.json(data)
|
||||||
|
} catch (error) {
|
||||||
|
return c.json(
|
||||||
|
{ error: "Internal server error", message: String(error) },
|
||||||
|
500
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default tfl
|
||||||
@@ -35,7 +35,7 @@ export async function generateWeatherDescription(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-exp:generateContent?key=${apiKey}`,
|
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent?key=${apiKey}`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -53,7 +53,7 @@ export async function generateWeatherDescription(
|
|||||||
],
|
],
|
||||||
generationConfig: {
|
generationConfig: {
|
||||||
temperature: 0.7,
|
temperature: 0.7,
|
||||||
maxOutputTokens: 120,
|
maxOutputTokens: 1000,
|
||||||
topP: 0.95,
|
topP: 0.95,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useQuery } from "@tanstack/react-query"
|
import { useQuery } from "@tanstack/react-query"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import cn from "./components/lib/cn"
|
import cn from "./components/lib/cn"
|
||||||
|
import { StatusSeverity, getLineColor, getStatusBorderColor, tflDisruptionsQuery } from "./tfl"
|
||||||
import {
|
import {
|
||||||
DEFAULT_LATITUDE,
|
DEFAULT_LATITUDE,
|
||||||
DEFAULT_LONGITUDE,
|
DEFAULT_LONGITUDE,
|
||||||
@@ -15,17 +16,26 @@ function App() {
|
|||||||
<div className="h-screen bg-black gap-4 text-neutral-200 grid grid-cols-4 grid-rows-5 p-4">
|
<div className="h-screen bg-black gap-4 text-neutral-200 grid grid-cols-4 grid-rows-5 p-4">
|
||||||
<DateTimeTile />
|
<DateTimeTile />
|
||||||
<WeatherTile />
|
<WeatherTile />
|
||||||
|
<TFLTile />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Tile({ children, className }: { children: React.ReactNode; className?: string }) {
|
function Tile({
|
||||||
|
decorations = true,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: { decorations?: boolean; children: React.ReactNode; className?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("relative bg-neutral-900 flex flex-col justify-end items-start", className)}>
|
<div className={cn("relative bg-neutral-900 flex flex-col justify-end items-start", className)}>
|
||||||
|
{decorations && (
|
||||||
|
<>
|
||||||
<div className="absolute top-0 left-0 w-4 h-[1px] bg-neutral-200" />
|
<div className="absolute top-0 left-0 w-4 h-[1px] bg-neutral-200" />
|
||||||
<div className="absolute top-0 left-0 w-[1px] h-4 bg-neutral-200" />
|
<div className="absolute top-0 left-0 w-[1px] h-4 bg-neutral-200" />
|
||||||
<div className="absolute bottom-0 right-0 w-4 h-[1px] bg-neutral-200" />
|
<div className="absolute bottom-0 right-0 w-4 h-[1px] bg-neutral-200" />
|
||||||
<div className="absolute bottom-0 right-0 w-[1px] h-4 bg-neutral-200" />
|
<div className="absolute bottom-0 right-0 w-[1px] h-4 bg-neutral-200" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -195,4 +205,90 @@ function WeatherTile() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TFLTile() {
|
||||||
|
const {
|
||||||
|
data: tflData,
|
||||||
|
isLoading: isLoadingTFL,
|
||||||
|
error: errorTFL,
|
||||||
|
} = useQuery({
|
||||||
|
...tflDisruptionsQuery(),
|
||||||
|
select: (data) => {
|
||||||
|
data.disruptions.sort((a, b) => {
|
||||||
|
if (a.lineName.match(/northern/i)) return -1
|
||||||
|
return a.statusSeverity - b.statusSeverity
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
refetchInterval: 5 * 60 * 1000, // 5 minutes
|
||||||
|
refetchIntervalInBackground: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isLoadingTFL) {
|
||||||
|
return (
|
||||||
|
<Tile className="col-start-3 h-full row-start-1 col-span-2 row-span-2 flex flex-row justify-start items-center p-8">
|
||||||
|
<p className="text-2xl font-light animate-pulse">Loading TfL</p>
|
||||||
|
</Tile>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tile
|
||||||
|
decorations={false}
|
||||||
|
className="gap-x-1 col-start-3 h-full row-start-1 col-span-2 row-span-1 grid grid-cols-[min-content_1fr] auto-rows-min overflow-y-auto"
|
||||||
|
>
|
||||||
|
{tflData?.goodService.includes("Northern") && (
|
||||||
|
<TFLDistruptionItem
|
||||||
|
lineId="northern"
|
||||||
|
lineName="Northern"
|
||||||
|
reason="Good service"
|
||||||
|
severity={StatusSeverity.GoodService}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{tflData?.disruptions.map((disruption) => (
|
||||||
|
<>
|
||||||
|
<TFLDistruptionItem
|
||||||
|
key={disruption.lineId}
|
||||||
|
lineId={disruption.lineId}
|
||||||
|
lineName={disruption.lineName}
|
||||||
|
reason={disruption.reason ?? "Unknown reason"}
|
||||||
|
severity={disruption.statusSeverity}
|
||||||
|
/>
|
||||||
|
<hr className="col-span-2 border-neutral-700" />
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</Tile>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TFLDistruptionItem({
|
||||||
|
lineId,
|
||||||
|
lineName,
|
||||||
|
reason,
|
||||||
|
severity,
|
||||||
|
}: { lineId: string; lineName: string; reason: string; severity: number }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="h-full flex items-center justify-center px-2 py-0.5">
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"text-xl uppercase font-bold bg-blue-500 w-full text-center px-1 rounded-sm",
|
||||||
|
getLineColor(lineId),
|
||||||
|
getStatusBorderColor(severity),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{lineName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"text-xl text-wrap text-neutral-300 leading-tight self-center pr-2 py-1 font-light border-r-4",
|
||||||
|
getStatusBorderColor(severity),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{reason}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default App
|
export default App
|
||||||
|
|||||||
217
apps/dashboard/src/tfl.ts
Normal file
217
apps/dashboard/src/tfl.ts
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
/**
|
||||||
|
* TfL (Transport for London) API TypeScript Types
|
||||||
|
* For London transport status and disruptions
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { queryOptions } from "@tanstack/react-query"
|
||||||
|
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:8000"
|
||||||
|
|
||||||
|
// Disruption Summary
|
||||||
|
export interface DisruptionSummary {
|
||||||
|
lineId: string
|
||||||
|
lineName: string
|
||||||
|
mode: string
|
||||||
|
status: string
|
||||||
|
statusSeverity: number
|
||||||
|
reason?: string
|
||||||
|
validFrom?: string
|
||||||
|
validTo?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disruptions Response
|
||||||
|
export interface DisruptionsResponse {
|
||||||
|
lastUpdated: string
|
||||||
|
disruptions: DisruptionSummary[]
|
||||||
|
goodService: string[]
|
||||||
|
totalLines: number
|
||||||
|
disruptedLines: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status severity levels
|
||||||
|
export enum StatusSeverity {
|
||||||
|
SpecialService = 0,
|
||||||
|
Closed = 1,
|
||||||
|
Suspended = 2,
|
||||||
|
PartSuspended = 3,
|
||||||
|
PlannedClosure = 4,
|
||||||
|
PartClosure = 5,
|
||||||
|
SevereDelays = 6,
|
||||||
|
ReducedService = 7,
|
||||||
|
BusService = 8,
|
||||||
|
MinorDelays = 9,
|
||||||
|
GoodService = 10,
|
||||||
|
PartClosed = 11,
|
||||||
|
ExitOnly = 12,
|
||||||
|
NoStepFreeAccess = 13,
|
||||||
|
ChangeOfFrequency = 14,
|
||||||
|
Diverted = 15,
|
||||||
|
NotRunning = 16,
|
||||||
|
IssuesReported = 17,
|
||||||
|
NoIssues = 18,
|
||||||
|
Information = 19,
|
||||||
|
ServiceClosed = 20,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get severity color
|
||||||
|
export function getSeverityColor(severity: number): string {
|
||||||
|
if (severity >= 10) return "green" // Good Service
|
||||||
|
if (severity >= 9) return "orange" // Minor Delays
|
||||||
|
if (severity >= 6) return "red" // Severe Delays or worse
|
||||||
|
return "darkred" // Suspended/Closed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get severity label
|
||||||
|
export function getSeverityLabel(severity: number): string {
|
||||||
|
switch (severity) {
|
||||||
|
case 10:
|
||||||
|
return "Good Service"
|
||||||
|
case 9:
|
||||||
|
return "Minor Delays"
|
||||||
|
case 8:
|
||||||
|
return "Bus Service"
|
||||||
|
case 7:
|
||||||
|
return "Reduced Service"
|
||||||
|
case 6:
|
||||||
|
return "Severe Delays"
|
||||||
|
case 5:
|
||||||
|
return "Part Closure"
|
||||||
|
case 4:
|
||||||
|
return "Planned Closure"
|
||||||
|
case 3:
|
||||||
|
return "Part Suspended"
|
||||||
|
case 2:
|
||||||
|
return "Suspended"
|
||||||
|
case 1:
|
||||||
|
return "Closed"
|
||||||
|
default:
|
||||||
|
return "Special Service"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to format line name for display
|
||||||
|
export function formatLineName(lineId: string): string {
|
||||||
|
const lineNames: Record<string, string> = {
|
||||||
|
bakerloo: "Bakerloo",
|
||||||
|
central: "Central",
|
||||||
|
circle: "Circle",
|
||||||
|
district: "District",
|
||||||
|
"hammersmith-city": "Hammersmith & City",
|
||||||
|
jubilee: "Jubilee",
|
||||||
|
metropolitan: "Metropolitan",
|
||||||
|
northern: "Northern",
|
||||||
|
piccadilly: "Piccadilly",
|
||||||
|
victoria: "Victoria",
|
||||||
|
"waterloo-city": "Waterloo & City",
|
||||||
|
"london-overground": "London Overground",
|
||||||
|
dlr: "DLR",
|
||||||
|
"elizabeth-line": "Elizabeth Line",
|
||||||
|
tram: "Tram",
|
||||||
|
}
|
||||||
|
return lineNames[lineId] || lineId
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map of tube lines to their official TfL colors (as Tailwind classes)
|
||||||
|
export function getLineColor(lineId: string): string {
|
||||||
|
const lineColors: Record<string, string> = {
|
||||||
|
bakerloo: "bg-amber-700",
|
||||||
|
central: "bg-red-600",
|
||||||
|
circle: "bg-yellow-400",
|
||||||
|
district: "bg-green-600",
|
||||||
|
"hammersmith-city": "bg-pink-400",
|
||||||
|
jubilee: "bg-slate-500",
|
||||||
|
metropolitan: "bg-purple-800",
|
||||||
|
northern: "bg-black",
|
||||||
|
piccadilly: "bg-blue-900",
|
||||||
|
victoria: "bg-sky-500",
|
||||||
|
"waterloo-city": "bg-teal-500",
|
||||||
|
"london-overground": "bg-orange-500",
|
||||||
|
dlr: "bg-teal-600",
|
||||||
|
"elizabeth-line": "bg-purple-600",
|
||||||
|
tram: "bg-green-500",
|
||||||
|
}
|
||||||
|
return lineColors[lineId] || "bg-gray-500"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map of status severity to border colors
|
||||||
|
export function getStatusBorderColor(severity: number): string {
|
||||||
|
const borderColors: Record<number, string> = {
|
||||||
|
[StatusSeverity.SpecialService]: "border-gray-500",
|
||||||
|
[StatusSeverity.Closed]: "border-red-700",
|
||||||
|
[StatusSeverity.Suspended]: "border-red-600",
|
||||||
|
[StatusSeverity.PartSuspended]: "border-red-500",
|
||||||
|
[StatusSeverity.PlannedClosure]: "border-orange-600",
|
||||||
|
[StatusSeverity.PartClosure]: "border-yellow-500",
|
||||||
|
[StatusSeverity.SevereDelays]: "border-red-500",
|
||||||
|
[StatusSeverity.ReducedService]: "border-orange-500",
|
||||||
|
[StatusSeverity.BusService]: "border-blue-500",
|
||||||
|
[StatusSeverity.MinorDelays]: "border-yellow-500",
|
||||||
|
[StatusSeverity.GoodService]: "border-green-500",
|
||||||
|
[StatusSeverity.PartClosed]: "border-orange-600",
|
||||||
|
[StatusSeverity.ExitOnly]: "border-gray-600",
|
||||||
|
[StatusSeverity.NoStepFreeAccess]: "border-gray-500",
|
||||||
|
[StatusSeverity.ChangeOfFrequency]: "border-blue-400",
|
||||||
|
[StatusSeverity.Diverted]: "border-purple-500",
|
||||||
|
[StatusSeverity.NotRunning]: "border-red-600",
|
||||||
|
[StatusSeverity.IssuesReported]: "border-yellow-400",
|
||||||
|
[StatusSeverity.NoIssues]: "border-green-500",
|
||||||
|
[StatusSeverity.Information]: "border-blue-400",
|
||||||
|
[StatusSeverity.ServiceClosed]: "border-red-700",
|
||||||
|
}
|
||||||
|
return borderColors[severity] || "border-gray-400"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to check if there are any disruptions
|
||||||
|
export function hasDisruptions(data: DisruptionsResponse): boolean {
|
||||||
|
return data.disruptedLines > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get critical disruptions (severe or worse)
|
||||||
|
export function getCriticalDisruptions(data: DisruptionsResponse): DisruptionSummary[] {
|
||||||
|
return data.disruptions.filter((d) => d.statusSeverity <= 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TanStack Query Options
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query options for fetching current TfL disruptions
|
||||||
|
* Returns disruptions across Tube, Overground, DLR, Elizabeth Line, and Tram
|
||||||
|
*/
|
||||||
|
export function tflDisruptionsQuery() {
|
||||||
|
return queryOptions({
|
||||||
|
queryKey: ["tfl", "disruptions"],
|
||||||
|
queryFn: async (): Promise<DisruptionsResponse> => {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/tfl/disruptions`)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch TfL disruptions")
|
||||||
|
}
|
||||||
|
return response.json()
|
||||||
|
},
|
||||||
|
select: (data) =>
|
||||||
|
data.disruptions.sort((a, b) => {
|
||||||
|
if (a.lineName.match(/northern/i)) return -1
|
||||||
|
return a.statusSeverity - b.statusSeverity
|
||||||
|
}),
|
||||||
|
staleTime: 2 * 60 * 1000, // 2 minutes (TfL updates frequently)
|
||||||
|
gcTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query options for fetching status of specific line(s)
|
||||||
|
* @param lineIds - Comma-separated line IDs (e.g., "central,northern")
|
||||||
|
*/
|
||||||
|
export function tflLineStatusQuery(lineIds: string) {
|
||||||
|
return queryOptions({
|
||||||
|
queryKey: ["tfl", "line", lineIds],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/tfl/line/${lineIds}`)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch TfL line status")
|
||||||
|
}
|
||||||
|
return response.json()
|
||||||
|
},
|
||||||
|
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||||
|
gcTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user