This commit is contained in:
@@ -36,10 +36,10 @@ function App() {
|
|||||||
<TrainSchedule {...{ state, dispatch }} />
|
<TrainSchedule {...{ state, dispatch }} />
|
||||||
</Pane>
|
</Pane>
|
||||||
<Pane>
|
<Pane>
|
||||||
<h1>News</h1>
|
|
||||||
<NewsWidget {...{ state, dispatch }} />
|
|
||||||
<h1>Weather</h1>
|
<h1>Weather</h1>
|
||||||
<WeatherWidget {...{ state, dispatch }} />
|
<WeatherWidget {...{ state, dispatch }} />
|
||||||
|
<h1>News</h1>
|
||||||
|
<NewsWidget {...{ state, dispatch }} />
|
||||||
</Pane>
|
</Pane>
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
|
|||||||
93
src/components/Indicators.tsx
Normal file
93
src/components/Indicators.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
@@ -108,6 +108,7 @@ export const NewsArticle = ({ article }: NewsArticleProps) => {
|
|||||||
</NewsHeader>
|
</NewsHeader>
|
||||||
|
|
||||||
<Description>{article.description}</Description>
|
<Description>{article.description}</Description>
|
||||||
|
<img src={article.image_url} />
|
||||||
|
|
||||||
<Footer>
|
<Footer>
|
||||||
<CreatorSource>
|
<CreatorSource>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export const TimeStyled = styled.div`
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding-right: 1em;
|
padding-right: 1em;
|
||||||
|
width: 40vh;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const NowTime = () => {
|
export const NowTime = () => {
|
||||||
|
|||||||
64
src/components/Pager.tsx
Normal file
64
src/components/Pager.tsx
Normal 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;
|
||||||
|
|
||||||
@@ -1,12 +1,46 @@
|
|||||||
import type { Dispatch } from "react"
|
import { useEffect, useState, type Dispatch } from "react"
|
||||||
import type { Action, State } from "../state"
|
import type { Action, State } from "../state"
|
||||||
|
|
||||||
import { NewsArticle } from "../components"
|
import { NewsArticle } from "../components"
|
||||||
|
import {SpinnerDiamond} from "spinners-react"
|
||||||
|
import Pager from "../components/Pager";
|
||||||
|
|
||||||
|
const pageSpeed = 10000;
|
||||||
|
const perPage = 2;
|
||||||
|
|
||||||
type NewsDispatchProps = {
|
type NewsDispatchProps = {
|
||||||
state: State,
|
state: State,
|
||||||
dispatch: Dispatch<Action>
|
dispatch: Dispatch<Action>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const NewsWidget = ({ state }: NewsDispatchProps ) => {
|
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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import styled from 'styled-components';
|
|
||||||
|
|
||||||
import { type Dispatch } from 'react';
|
import { type Dispatch } from 'react';
|
||||||
import {
|
import {
|
||||||
@@ -8,17 +7,40 @@ import {
|
|||||||
|
|
||||||
import { Departure } from '../components';
|
import { Departure } from '../components';
|
||||||
import { NowTime } from '../components/NowTime';
|
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 = {
|
type TrainScheduleProps = {
|
||||||
dispatch: Dispatch<Action>,
|
dispatch: Dispatch<Action>,
|
||||||
state: State,
|
state: State,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const TrainSchedule = ({ state } : TrainScheduleProps) => {
|
export const TrainSchedule = ({ state } : TrainScheduleProps) => {
|
||||||
|
|
||||||
|
const filteredDepartures = state.departures?.filter(filterTrainHour(showTrainsMinutes))
|
||||||
|
|
||||||
return <>
|
return <>
|
||||||
{state.liveboard?.departures.departure.map(departure => <Departure {...{departure}} />)}
|
<NowTime />
|
||||||
<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}} />)}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { Action, State } from "../state"
|
|||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import { WiStrongWind, WiRaindrops, WiThermometer } from "react-icons/wi";
|
import { WiStrongWind, WiRaindrops, WiThermometer } from "react-icons/wi";
|
||||||
import {accentColor} from "../styles";
|
import {accentColor} from "../styles";
|
||||||
|
import {SpinnerDiamond} from "spinners-react";
|
||||||
|
|
||||||
|
|
||||||
export const WeatherContainer = styled.div`
|
export const WeatherContainer = styled.div`
|
||||||
@@ -73,27 +74,31 @@ export const WeatherWidget = ({ state }: WeatherDispatchProps) => {
|
|||||||
const weather = state.weather?.current;
|
const weather = state.weather?.current;
|
||||||
if (!weather) return null;
|
if (!weather) return null;
|
||||||
|
|
||||||
return (
|
return state.weatherLoading
|
||||||
<WeatherContainer>
|
? <SpinnerDiamond />
|
||||||
<WeatherHeader>
|
: state.weatherError
|
||||||
<img src={weather.condition.icon} alt={weather.condition.text} />
|
? <em>{state.weatherError?.message}</em>
|
||||||
<h2>{weather.condition.text}</h2>
|
: (
|
||||||
</WeatherHeader>
|
<WeatherContainer>
|
||||||
|
<WeatherHeader>
|
||||||
|
<img src={weather.condition.icon} alt={weather.condition.text} />
|
||||||
|
<h2>{weather.condition.text}</h2>
|
||||||
|
</WeatherHeader>
|
||||||
|
|
||||||
<WeatherDetails>
|
<WeatherDetails>
|
||||||
<div className="weather-item">
|
<div className="weather-item">
|
||||||
<WiThermometer />
|
<WiThermometer />
|
||||||
<span>{weather.temp_c}°C</span>
|
<span>{weather.temp_c}°C</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="weather-item">
|
<div className="weather-item">
|
||||||
<WiRaindrops />
|
<WiRaindrops />
|
||||||
<span>{weather.precip_mm} mm</span>
|
<span>{weather.precip_mm} mm</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="weather-item">
|
<div className="weather-item">
|
||||||
<WiStrongWind />
|
<WiStrongWind />
|
||||||
<span>{weather.gust_kph} kph</span>
|
<span>{weather.gust_kph} kph</span>
|
||||||
</div>
|
</div>
|
||||||
</WeatherDetails>
|
</WeatherDetails>
|
||||||
</WeatherContainer>
|
</WeatherContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { useEffect, type Dispatch } from "react"
|
import { useEffect, type Dispatch } from "react"
|
||||||
import { actions, type Action, type State } from "../state"
|
import { actions, type Action, type State } from "../state"
|
||||||
|
import { flatten, range } from 'lodash';
|
||||||
|
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import type {LiveBoard} from "../types/liveboard";
|
import type { LiveBoard } from "../types/liveboard";
|
||||||
|
|
||||||
const irailApiUrl = 'https://api.irail.be';
|
const irailApiUrl = 'https://api.irail.be';
|
||||||
|
const lookahead = 3;
|
||||||
|
|
||||||
export const useLoadTrainSchedule = (_: State, dispatch: Dispatch<Action>) => {
|
export const useLoadTrainSchedule = (_: State, dispatch: Dispatch<Action>) => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -12,23 +14,31 @@ export const useLoadTrainSchedule = (_: State, dispatch: Dispatch<Action>) => {
|
|||||||
try {
|
try {
|
||||||
dispatch(actions.loadTrainSchedule({}))
|
dispatch(actions.loadTrainSchedule({}))
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
const pad = (num: number) => num.toString().padStart(2, '0');
|
const pad = (num: number) => num.toString().padStart(2, '0');
|
||||||
|
|
||||||
const day = pad(now.getDate());
|
const departures =
|
||||||
const month = pad(now.getMonth() + 1);
|
flatten(await
|
||||||
const year = now.getFullYear().toString().slice(-2);
|
Promise.all(range(0, lookahead).map(async hourPadding => {
|
||||||
const date = `${day}${month}${year}`
|
|
||||||
|
|
||||||
const hours = pad(now.getHours());
|
const day = pad(now.getDate());
|
||||||
const minutes = pad(now.getMinutes());
|
const month = pad(now.getMonth() + 1);
|
||||||
const time = `${hours}${minutes}`
|
const year = now.getFullYear().toString().slice(-2);
|
||||||
|
const date = `${day}${month}${year}`
|
||||||
|
|
||||||
const answer = await axios.get(`${irailApiUrl}/liveboard?station=Nivelles&date=${date}&time=${time}&format=json&lang=en&alerts=true`)
|
const hours = pad(now.getHours() + hourPadding) ;
|
||||||
|
const minutes = pad(now.getMinutes());
|
||||||
|
const time = `${hours}${minutes}`
|
||||||
|
|
||||||
const liveboard = answer.data as LiveBoard;
|
const answer = await axios.get(`${irailApiUrl}/liveboard?station=Nivelles&date=${date}&time=${time}&format=json&lang=en&alerts=true`)
|
||||||
|
|
||||||
dispatch(actions.loadTrainScheduleSuccess({ liveboard }))
|
const liveboard = answer.data as LiveBoard;
|
||||||
|
|
||||||
|
return liveboard.departures.departure;
|
||||||
|
})))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
dispatch(actions.loadTrainScheduleSuccess({ departures }))
|
||||||
} catch(error) {
|
} catch(error) {
|
||||||
dispatch(actions.loadTrainScheduleError({ error: error as Error}))
|
dispatch(actions.loadTrainScheduleError({ error: error as Error}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export type ActionType = string;
|
export type ActionType = string;
|
||||||
|
|
||||||
import type {Article} from '../../types/article';
|
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 type {WeatherData} from '../../types/weather';
|
||||||
import {
|
import {
|
||||||
loadTrainSchedule,
|
loadTrainSchedule,
|
||||||
@@ -25,7 +25,7 @@ export type LoadTrainSchedule = {
|
|||||||
|
|
||||||
export type LoadTrainScheduleSuccess = {
|
export type LoadTrainScheduleSuccess = {
|
||||||
type: typeof loadTrainScheduleSuccess,
|
type: typeof loadTrainScheduleSuccess,
|
||||||
liveboard: LiveBoard
|
departures: DepartureType[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LoadTrainScheduleError = {
|
export type LoadTrainScheduleError = {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { type State } from "./state";
|
import { type State } from "./state";
|
||||||
|
|
||||||
export const initialState: State = {
|
export const initialState: State = {
|
||||||
liveboard: undefined,
|
departures: undefined,
|
||||||
trainScheduleError: undefined,
|
trainScheduleError: undefined,
|
||||||
trainScheduleLoading: false,
|
trainScheduleLoading: false,
|
||||||
news: undefined,
|
news: undefined,
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export const reducerInner = (state: State, action: Action): State => {
|
|||||||
else if(action.type === loadTrainScheduleSuccess) {
|
else if(action.type === loadTrainScheduleSuccess) {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
liveboard: (action as LoadTrainScheduleSuccess).liveboard,
|
departures: (action as LoadTrainScheduleSuccess).departures,
|
||||||
trainScheduleLoading: false,
|
trainScheduleLoading: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import type { Article } from "../types/article";
|
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 type { WeatherData } from "../types/weather";
|
||||||
|
|
||||||
export type State = {
|
export type State = {
|
||||||
trainScheduleLoading: boolean,
|
trainScheduleLoading: boolean,
|
||||||
liveboard: LiveBoard | undefined,
|
departures: DepartureType[] | undefined,
|
||||||
trainScheduleError: Error | undefined,
|
trainScheduleError: Error | undefined,
|
||||||
|
|
||||||
weather: WeatherData | undefined,
|
weather: WeatherData | undefined,
|
||||||
|
|||||||
Reference in New Issue
Block a user