feat: indicators
Some checks failed
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Loic Coenen
2025-10-31 14:29:46 +01:00
parent b2a5031056
commit 0ee7532a30
13 changed files with 278 additions and 48 deletions

View File

@@ -36,10 +36,10 @@ function App() {
<TrainSchedule {...{ state, dispatch }} />
</Pane>
<Pane>
<h1>News</h1>
<NewsWidget {...{ state, dispatch }} />
<h1>Weather</h1>
<WeatherWidget {...{ state, dispatch }} />
<h1>News</h1>
<NewsWidget {...{ state, dispatch }} />
</Pane>
</Container>
)

View File

@@ -0,0 +1,93 @@
import type {State} from "../state";
import {filterTrainHour} from "../containers";
import {mean} from "lodash";
import styled from "styled-components";
import { Clock, AlertTriangle, XCircle } from "lucide-react";
import {accentColor} from "../styles";
export const IndicatorStyled = styled.div`
display: flex;
justify-content: space-around;
align-items: center;
background: #0b0c10;
color: #fff;
padding: 1.5rem 2rem;
border-radius: 16px;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35);
font-family: 'Inter', sans-serif;
`;
const StatBlock = styled.div`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
& svg {
margin-bottom: 0.3em;
color: ${accentColor};
}
.value {
font-weight: 600;
line-height: 1.1;
}
.label {
font-size: 1em;
opacity: 0.7;
margin-top: 0.3em;
}
`;
const formatTime = (seconds?: number) => {
if (seconds == null || isNaN(seconds)) return "";
const mins = Math.floor(seconds / 60);
const secs = Math.round(seconds % 60);
return `${mins}m ${secs}s`;
};
const formatPercent = (num?: number) => {
if (num == null || isNaN(num)) return "";
return `${(num * 100).toFixed(1)}%`;
};
export const Indicator = ({ state }: { state: State }) => {
const inOneHour = state.departures?.filter(filterTrainHour(60));
const inThreeHour = state.departures?.filter(filterTrainHour(-60 * 3));
const averageDelayGeneral = mean(inOneHour?.map(d => parseInt(d.delay)));
const averageDelayedOnly = mean(
inOneHour?.filter(d => parseInt(d.delay) !== 0)?.map(d => parseInt(d.delay))
);
const cancelled =
(inThreeHour?.filter(d => d?.canceled === '1')?.length || 0) /
(inThreeHour?.length || 1);
return (
<IndicatorStyled>
<StatBlock>
<Clock size={40} />
<div className="value">{formatTime(averageDelayGeneral)}</div>
<div className="label">Avg Delay (all)</div>
</StatBlock>
<StatBlock>
<AlertTriangle size={40} />
<div className="value">{formatTime(averageDelayedOnly)}</div>
<div className="label">Avg Delay (delayed only)</div>
</StatBlock>
<StatBlock>
<XCircle size={40} />
<div className="value">{formatPercent(cancelled)}</div>
<div className="label">Cancelled</div>
</StatBlock>
</IndicatorStyled>
);
};

View File

@@ -108,6 +108,7 @@ export const NewsArticle = ({ article }: NewsArticleProps) => {
</NewsHeader>
<Description>{article.description}</Description>
<img src={article.image_url} />
<Footer>
<CreatorSource>

View File

@@ -20,6 +20,7 @@ export const TimeStyled = styled.div`
align-items: center;
justify-content: center;
padding-right: 1em;
width: 40vh;
`;
export const NowTime = () => {

64
src/components/Pager.tsx Normal file
View File

@@ -0,0 +1,64 @@
import React from "react";
import styled from "styled-components";
import {accentColor} from "../styles";
const Container = styled.div`
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
padding: 12px 0;
`;
const Dot = styled.button<{ $active: boolean }>`
width: 12px;
height: 12px;
padding: 0;
margin: 0;
border: none;
border-radius: 50%;
background-color: ${({ $active }) => ($active ? accentColor : "#ccc")};
cursor: pointer;
outline: none;
box-sizing: border-box;
display: block;
transform: scale(${({ $active }) => ($active ? 1.5 : 1)});
transition: transform 0.3s ease, background-color 0.3s ease;
&:hover {
transform: scale(${({ $active }) => ($active ? 1.6 : 1.3)});
background-color: ${({ $active }) => ($active ? accentColor : "#999")};
}
`;
interface PagerProps {
currentPage: number;
total: number;
onChange: (page: number) => void;
}
const Pager: React.FC<PagerProps> = ({ currentPage, total, onChange }) => {
return (
<Container>
{Array.from({ length: total }, (_, i) => {
const page = i + 1;
const isActive = page === currentPage;
return (
<Dot
key={page}
$active={isActive}
onClick={() => onChange(page)}
aria-label={`Page ${page}`}
/>
);
})}
</Container>
);
};
export default Pager;

View File

@@ -1,12 +1,46 @@
import type { Dispatch } from "react"
import { useEffect, useState, type Dispatch } from "react"
import type { Action, State } from "../state"
import { NewsArticle } from "../components"
import {SpinnerDiamond} from "spinners-react"
import Pager from "../components/Pager";
const pageSpeed = 10000;
const perPage = 2;
type NewsDispatchProps = {
state: State,
dispatch: Dispatch<Action>
}
export const NewsWidget = ({ state }: NewsDispatchProps ) => {
return <p>{state.news?.slice(0,2).map(article => <NewsArticle {...{article}} />)}</p>
const [newsCurrentPage, setNewsCurrentPage] = useState(1);
const numberOfPages = Math.floor((state.news?.length || 1) / perPage)
useEffect( () => {
const id = setInterval(() => {
if(newsCurrentPage === numberOfPages) {
setNewsCurrentPage(1)
} else {
setNewsCurrentPage(newsCurrentPage + 1)
}
}, pageSpeed);
return () => clearInterval(id)
}, [newsCurrentPage, state])
return <div>
{state.newsLoading
? <SpinnerDiamond />
: state.newsError
? <em>{state.newsError?.message}</em>
: <>
<Pager
currentPage={newsCurrentPage}
total={numberOfPages}
onChange={setNewsCurrentPage}
/>
{state.news?.slice((newsCurrentPage - 1) * perPage, newsCurrentPage * perPage)
.map(article => <NewsArticle {...{article}} />)}
</>}
</div>
}

View File

@@ -1,4 +1,3 @@
import styled from 'styled-components';
import { type Dispatch } from 'react';
import {
@@ -8,17 +7,40 @@ import {
import { Departure } from '../components';
import { NowTime } from '../components/NowTime';
import { SpinnerDiamond } from 'spinners-react';
import type { DepartureType } from '../types/liveboard';
import { Indicator } from '../components/Indicators';
const showTrainsMinutes = 120;
export const filterTrainHour = (delay: number) => (departure: DepartureType) => {
const time = new Date(parseInt(departure.time) * 1000);
const now = new Date();
const h = time.getHours();
const m = time.getMinutes();
const nowInMinutes = (now.getHours() * 60) + now.getMinutes();
const minutes = h * 60 + m;
return (nowInMinutes + delay) > minutes;
}
type TrainScheduleProps = {
dispatch: Dispatch<Action>,
state: State,
}
export const TrainSchedule = ({ state } : TrainScheduleProps) => {
const filteredDepartures = state.departures?.filter(filterTrainHour(showTrainsMinutes))
return <>
{state.liveboard?.departures.departure.map(departure => <Departure {...{departure}} />)}
<NowTime />
<Indicator {...{state}} />
{state.trainScheduleLoading
? <SpinnerDiamond />
: state.trainScheduleError
? <em>{state.trainScheduleError?.message}</em>
: state.departures?.length === 0
? <em>No more trains for today.</em>
: filteredDepartures?.map(departure => <Departure {...{departure}} />)}
</>
}

View File

@@ -3,6 +3,7 @@ import type { Action, State } from "../state"
import styled from "styled-components";
import { WiStrongWind, WiRaindrops, WiThermometer } from "react-icons/wi";
import {accentColor} from "../styles";
import {SpinnerDiamond} from "spinners-react";
export const WeatherContainer = styled.div`
@@ -73,7 +74,11 @@ export const WeatherWidget = ({ state }: WeatherDispatchProps) => {
const weather = state.weather?.current;
if (!weather) return null;
return (
return state.weatherLoading
? <SpinnerDiamond />
: state.weatherError
? <em>{state.weatherError?.message}</em>
: (
<WeatherContainer>
<WeatherHeader>
<img src={weather.condition.icon} alt={weather.condition.text} />

View File

@@ -1,10 +1,12 @@
import { useEffect, type Dispatch } from "react"
import { actions, type Action, type State } from "../state"
import { flatten, range } from 'lodash';
import axios from 'axios';
import type { LiveBoard } from "../types/liveboard";
const irailApiUrl = 'https://api.irail.be';
const lookahead = 3;
export const useLoadTrainSchedule = (_: State, dispatch: Dispatch<Action>) => {
useEffect(() => {
@@ -12,15 +14,18 @@ export const useLoadTrainSchedule = (_: State, dispatch: Dispatch<Action>) => {
try {
dispatch(actions.loadTrainSchedule({}))
const now = new Date();
const pad = (num: number) => num.toString().padStart(2, '0');
const departures =
flatten(await
Promise.all(range(0, lookahead).map(async hourPadding => {
const day = pad(now.getDate());
const month = pad(now.getMonth() + 1);
const year = now.getFullYear().toString().slice(-2);
const date = `${day}${month}${year}`
const hours = pad(now.getHours());
const hours = pad(now.getHours() + hourPadding) ;
const minutes = pad(now.getMinutes());
const time = `${hours}${minutes}`
@@ -28,7 +33,12 @@ export const useLoadTrainSchedule = (_: State, dispatch: Dispatch<Action>) => {
const liveboard = answer.data as LiveBoard;
dispatch(actions.loadTrainScheduleSuccess({ liveboard }))
return liveboard.departures.departure;
})))
dispatch(actions.loadTrainScheduleSuccess({ departures }))
} catch(error) {
dispatch(actions.loadTrainScheduleError({ error: error as Error}))
}

View File

@@ -1,7 +1,7 @@
export type ActionType = string;
import type {Article} from '../../types/article';
import type { LiveBoard } from '../../types/liveboard';
import type { DepartureType } from '../../types/liveboard';
import type {WeatherData} from '../../types/weather';
import {
loadTrainSchedule,
@@ -25,7 +25,7 @@ export type LoadTrainSchedule = {
export type LoadTrainScheduleSuccess = {
type: typeof loadTrainScheduleSuccess,
liveboard: LiveBoard
departures: DepartureType[]
}
export type LoadTrainScheduleError = {

View File

@@ -1,7 +1,7 @@
import { type State } from "./state";
export const initialState: State = {
liveboard: undefined,
departures: undefined,
trainScheduleError: undefined,
trainScheduleLoading: false,
news: undefined,

View File

@@ -33,7 +33,7 @@ export const reducerInner = (state: State, action: Action): State => {
else if(action.type === loadTrainScheduleSuccess) {
return {
...state,
liveboard: (action as LoadTrainScheduleSuccess).liveboard,
departures: (action as LoadTrainScheduleSuccess).departures,
trainScheduleLoading: false,
}
}

View File

@@ -1,10 +1,10 @@
import type { Article } from "../types/article";
import type { LiveBoard } from "../types/liveboard";
import type { DepartureType } from "../types/liveboard";
import type { WeatherData } from "../types/weather";
export type State = {
trainScheduleLoading: boolean,
liveboard: LiveBoard | undefined,
departures: DepartureType[] | undefined,
trainScheduleError: Error | undefined,
weather: WeatherData | undefined,