This commit is contained in:
@@ -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>
|
||||
)
|
||||
|
||||
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>
|
||||
|
||||
<Description>{article.description}</Description>
|
||||
<img src={article.image_url} />
|
||||
|
||||
<Footer>
|
||||
<CreatorSource>
|
||||
|
||||
@@ -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
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 { 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>
|
||||
}
|
||||
|
||||
@@ -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}} />)}
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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";
|
||||
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}))
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type State } from "./state";
|
||||
|
||||
export const initialState: State = {
|
||||
liveboard: undefined,
|
||||
departures: undefined,
|
||||
trainScheduleError: undefined,
|
||||
trainScheduleLoading: false,
|
||||
news: undefined,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user