feat: train, news and weather

This commit is contained in:
Loic Coenen
2025-10-30 23:19:03 +01:00
parent 283b3e6885
commit b2a5031056
24 changed files with 796 additions and 41 deletions

View File

@@ -5,18 +5,43 @@ import { useLoadTrainSchedule } from './hooks/useLoadTrainSchedule'
import { initialState, reducer } from './state'
import './App.css'
import { NewsWidget, TrainSchedule, WeatherWidget } from './containers';
import {useNewsApi, useWeatherApi} from './hooks';
import styled from 'styled-components';
const Container = styled.div`
display: flex;
height: 100vh;
width: 100vw;
gap: 2rem;
`;
const Pane = styled.div`
max-width: 45vw;
`;
function App() {
const [state, dispatch] = useReducer( reducer, initialState,);
useLoadTrainSchedule(state, dispatch);
useNewsApi({state, dispatch});
useWeatherApi({state, dispatch});
return (
<>
<textarea value={JSON.stringify(state)}></textarea>)
</>
<Container>
<Pane>
<h1>Next trains</h1>
<TrainSchedule {...{ state, dispatch }} />
</Pane>
<Pane>
<h1>News</h1>
<NewsWidget {...{ state, dispatch }} />
<h1>Weather</h1>
<WeatherWidget {...{ state, dispatch }} />
</Pane>
</Container>
)
}

View File

@@ -0,0 +1,48 @@
import styled from "styled-components";
export const DepartureStyled = styled.div`
display: flex;
justify-content: space-between;
align-items: stretch;
background: #0b0c10;
color: #fff;
padding: 1rem 1.5rem;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
font-family: "Inter", sans-serif;
`;
export const TimeStyled = styled.div`
font-size: 2rem;
font-weight: 200;
flex: 0 0 auto;
display: flex;
align-items: center;
padding-right: 1em;
`;
export const InfoStyled = styled.div`
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
text-align: right;
padding-right: 1em;
`;
export const DestinationStyled = styled.div`
font-size: 1.2rem;
font-weight: 600;
`;
export const DelayStyled = styled.div`
font-size: 1rem;
color: #ff4444;
margin-right: 8px;
`;
export const PlatformStyled = styled.div`
font-size: 0.9rem;
opacity: 0.8;
`;

View File

@@ -0,0 +1,30 @@
import type { DepartureType } from "../types/liveboard"
import {
DelayStyled,
DepartureStyled,
DestinationStyled,
InfoStyled,
PlatformStyled,
TimeStyled
} from './Departure.spec';
export const Departure = ({ departure }: { departure: DepartureType }) => {
const date = new Date(parseInt(departure.time)*1000);
const delayInt = Math.round(parseInt(departure.delay) / 60);
const delay = delayInt > 0? `+${delayInt}`: ``
const pad = (n:number) => n <= 9? `0${n}`: n;
const timeStr = `${pad(date.getHours())}:${pad(date.getMinutes())}`;
return <DepartureStyled>
<TimeStyled>{timeStr}</TimeStyled>
<DestinationStyled>{departure.station}</DestinationStyled>
<DelayStyled>{departure.canceled === '1'? 'CANCELED': ''}</DelayStyled>
<DelayStyled>{delay}</DelayStyled>
<InfoStyled>{departure.left === '1'? "LEFT" : ""}</InfoStyled>
<PlatformStyled>Platform: {departure.platform}</PlatformStyled>
</DepartureStyled>
}

View File

@@ -0,0 +1,120 @@
import styled from 'styled-components';
import type {Article} from '../types/article';
import {accentColor} from '../styles';
export const NewsContainer = styled.div`
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: stretch;
background: #0b0c10;
color: #fff;
padding: 1rem 1.5rem;
border-radius: 12px;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
font-family: "Inter", sans-serif;
max-width: 600px;
margin: 1rem auto;
`;
export const NewsHeader = styled.div`
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.75rem;
`;
export const TitleLink = styled.a`
font-size: 1.25rem;
font-weight: 700;
color: ${accentColor};
text-decoration: none;
margin-right: 1rem;
&:hover {
text-decoration: underline;
color: ${accentColor};
}
`;
export const PubDate = styled.span`
font-size: 0.8rem;
color: #c5c6c7;
white-space: nowrap;
`;
export const Description = styled.p`
font-size: 1rem;
color: #a7a7a7;
line-height: 1.4;
margin-bottom: 1rem;
`;
export const Footer = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.85rem;
padding-top: 0.5rem;
border-top: 1px solid #1f2833;
`;
export const CreatorSource = styled.span`
color: ${accentColor};
strong {
font-weight: 600;
color: #fff;
}
`;
export const Keywords = styled.span`
color: ${accentColor};
font-style: italic;
`;
type NewsArticleProps = {
article: Article
}
export const NewsArticle = ({ article }: NewsArticleProps) => {
if (!article) return null;
// Function to format the date
const formatDate = (isoString: string) => {
try {
const date = new Date(isoString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
} catch {
return 'Unknown Date';
}
};
const formattedDate = formatDate(article.pubDate);
const keywordString = Array.isArray(article.keywords) ? article.keywords.join(', ') : article.keywords;
return (
<NewsContainer>
<NewsHeader>
<TitleLink href={article.link} target="_blank" rel="noopener noreferrer">
{article.title}
</TitleLink>
<PubDate>{formattedDate}</PubDate>
</NewsHeader>
<Description>{article.description}</Description>
<Footer>
<CreatorSource>
By: <strong>{article.creator || 'N/A'}</strong> from <strong>{article.source_name || 'N/A'}</strong>
</CreatorSource>
<Keywords>Tags: {keywordString || 'none'}</Keywords>
</Footer>
</NewsContainer>
);
};

View File

@@ -0,0 +1,38 @@
import { useEffect, useState } from "react";
import styled from "styled-components";
export const TimeStyled = styled.div`
display: flex;
justify-content: space-between;
align-items: stretch;
background: #0b0c10;
color: #fff;
padding: 1rem 1.5rem;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
font-size: 4rem;
font-weight: 200;
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
padding-right: 1em;
`;
export const NowTime = () => {
const [time, setTime] = useState(new Date().toLocaleTimeString("fr-FR"));
useEffect(() => {
const timer = setInterval(() => {
setTime(new Date().toLocaleTimeString("fr-FR"));
}, 1000);
return () => clearInterval(timer);
}, []);
return <TimeStyled>{time}</TimeStyled>;
};

3
src/components/index.tsx Normal file
View File

@@ -0,0 +1,3 @@
export * from './Departure';
export * from './NewsArticle';
export * from './NowTime';

View File

@@ -0,0 +1,12 @@
import type { Dispatch } from "react"
import type { Action, State } from "../state"
import { NewsArticle } from "../components"
type NewsDispatchProps = {
state: State,
dispatch: Dispatch<Action>
}
export const NewsWidget = ({ state }: NewsDispatchProps ) => {
return <p>{state.news?.slice(0,2).map(article => <NewsArticle {...{article}} />)}</p>
}

View File

@@ -0,0 +1,24 @@
import styled from 'styled-components';
import { type Dispatch } from 'react';
import {
type State,
type Action
} from '../state';
import { Departure } from '../components';
import { NowTime } from '../components/NowTime';
type TrainScheduleProps = {
dispatch: Dispatch<Action>,
state: State,
}
export const TrainSchedule = ({ state } : TrainScheduleProps) => {
return <>
{state.liveboard?.departures.departure.map(departure => <Departure {...{departure}} />)}
<NowTime />
</>
}

View File

@@ -0,0 +1,99 @@
import type { Dispatch } from "react"
import type { Action, State } from "../state"
import styled from "styled-components";
import { WiStrongWind, WiRaindrops, WiThermometer } from "react-icons/wi";
import {accentColor} from "../styles";
export const WeatherContainer = styled.div`
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: stretch;
background: #0b0c10;
color: #fff;
padding: 1rem 1.5rem;
border-radius: 12px;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
font-family: "Inter", sans-serif;
max-width: 600px;
margin: 1rem auto;
`;
const WeatherHeader = styled.div`
display: flex;
align-items: center;
gap: 1rem;
img {
width: 64px;
height: 64px;
}
h2 {
font-size: 1.5rem;
font-weight: 600;
margin: 0;
}
`;
const WeatherDetails = styled.div`
display: flex;
justify-content: space-around;
align-items: center;
margin-top: 1rem;
font-size: 4rm;
.weather-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
font-size: 1rem;
svg {
font-size: 2rem;
color: ${accentColor};
}
span {
font-size: 0.9rem;
opacity: 0.8;
}
}
`;
type WeatherDispatchProps = {
state: State;
dispatch: Dispatch<Action>;
};
export const WeatherWidget = ({ state }: WeatherDispatchProps) => {
const weather = state.weather?.current;
if (!weather) return null;
return (
<WeatherContainer>
<WeatherHeader>
<img src={weather.condition.icon} alt={weather.condition.text} />
<h2>{weather.condition.text}</h2>
</WeatherHeader>
<WeatherDetails>
<div className="weather-item">
<WiThermometer />
<span>{weather.temp_c}°C</span>
</div>
<div className="weather-item">
<WiRaindrops />
<span>{weather.precip_mm} mm</span>
</div>
<div className="weather-item">
<WiStrongWind />
<span>{weather.gust_kph} kph</span>
</div>
</WeatherDetails>
</WeatherContainer>
);
};

3
src/containers/index.tsx Normal file
View File

@@ -0,0 +1,3 @@
export * from './TrainSchedule';
export * from './WeatherWidget';
export * from './NewsWidget';

View File

@@ -1 +1,3 @@
export * from './useLoadTrainSchedule';
export * from './useNewsApi';
export * from './useWeatherApi';

View File

@@ -1,9 +1,8 @@
import { useEffect, type Dispatch } from "react"
import { actions, type Action, type State } from "../state"
import type { Data } from "../types";
import axios from 'axios';
import type {LiveBoard} from "../types/liveboard";
const irailApiUrl = 'https://api.irail.be';
@@ -12,9 +11,24 @@ export const useLoadTrainSchedule = (_: State, dispatch: Dispatch<Action>) => {
(async () => {
try {
dispatch(actions.loadTrainSchedule({}))
const answer = await axios.get(`${irailApiUrl}/stations?format=json&lang=en`)
const { station } = answer.data as Data;
dispatch(actions.loadTrainScheduleSuccess({ stations: station }))
const now = new Date();
const pad = (num: number) => num.toString().padStart(2, '0');
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 minutes = pad(now.getMinutes());
const time = `${hours}${minutes}`
const answer = await axios.get(`${irailApiUrl}/liveboard?station=Nivelles&date=${date}&time=${time}&format=json&lang=en&alerts=true`)
const liveboard = answer.data as LiveBoard;
dispatch(actions.loadTrainScheduleSuccess({ liveboard }))
} catch(error) {
dispatch(actions.loadTrainScheduleError({ error: error as Error}))
}

25
src/hooks/useNewsApi.tsx Normal file
View File

@@ -0,0 +1,25 @@
import { useEffect, type Dispatch } from "react"
import { actions, type Action, type State } from "../state"
import type {Article} from "../types/article"
const newsUrl = 'https://newsdata.io/api/1/latest?apikey=pub_26997f21bb174c7cbab59b3651533429&q=nivelle &country=be'
export type UseNewsApiProps = {
dispatch: Dispatch<Action>,
state: State
}
export const useNewsApi = ({dispatch}: UseNewsApiProps) => {
useEffect(() => {
(async () => {
try {
dispatch(actions.loadNews({}));
const answer = await fetch(newsUrl);
const { results: news } = await answer.json() as { results: Article[] }
dispatch(actions.loadNewsSuccess({ news }));
} catch(error) {
dispatch(actions.loadNewsError({ error: error as Error}));
}
})()
}, [])
}

View File

@@ -0,0 +1,25 @@
import { useEffect, type Dispatch } from "react"
import { actions, type Action, type State } from "../state"
import type {WeatherData} from "../types/weather";
const weatherApiUrl = `http://api.weatherapi.com/v1/current.json?key=176d6e98c8894466aa6205455253010&q=Nivelles&aqi=no`;
export type UseWeatherApiProps = {
dispatch: Dispatch<Action>,
state: State
}
export const useWeatherApi = ({ dispatch}: UseWeatherApiProps) => {
useEffect(() => {
(async () => {
try {
dispatch(actions.loadWeather({}));
const answer = await fetch(weatherApiUrl);
const weather = await answer.json() as WeatherData;
dispatch(actions.loadWeatherSuccess({ weather }));
} catch(error) {
dispatch(actions.loadWeatherError({ error: error as Error }));
}
})()
}, [])
}

View File

@@ -2,12 +2,24 @@ import {
loadTrainSchedule,
loadTrainScheduleSuccess,
loadTrainScheduleError,
loadWeatherError,
loadWeatherSuccess,
loadWeather,
loadNewsError,
loadNewsSuccess,
loadNews,
} from './consts'
import {
type LoadTrainSchedule,
type LoadTrainScheduleSuccess,
type LoadTrainScheduleError,
type LoadWeatherError,
type LoadWeatherSuccess,
type LoadWeather,
type LoadNewsError,
type LoadNewsSuccess,
type LoadNews,
} from './types';
export const actions = {
@@ -23,4 +35,28 @@ export const actions = {
type: loadTrainScheduleError,
...args
} as LoadTrainScheduleError),
loadNews: (args: Omit<LoadNews, "type">) => ({
type: loadNews,
...args
} as LoadNews),
loadNewsSuccess: (args: Omit<LoadNewsSuccess, "type">) => ({
type: loadNewsSuccess,
...args
} as LoadNewsSuccess),
loadNewsError: (args: Omit<LoadNewsError, "type">) => ({
type: loadNewsError,
...args
} as LoadNewsError),
loadWeather: (args: Omit<LoadWeather, "type">) => ({
type: loadWeather,
...args
} as LoadWeather),
loadWeatherSuccess: (args: Omit<LoadWeatherSuccess, "type">) => ({
type: loadWeatherSuccess,
...args
} as LoadWeatherSuccess),
loadWeatherError: (args: Omit<LoadWeatherError, "type">) => ({
type: loadWeatherError,
...args
} as LoadWeatherError),
}

View File

@@ -4,3 +4,10 @@ export const loadTrainSchedule: ActionType = 'loadTrainSchedule';
export const loadTrainScheduleSuccess: ActionType = 'loadTrainScheduleSuccess';
export const loadTrainScheduleError: ActionType = 'loadTrainScheduleError';
export const loadNews: ActionType = 'loadNews';
export const loadNewsSuccess: ActionType = 'loadNewsSuccess';
export const loadNewsError: ActionType = 'loadNewsError';
export const loadWeather: ActionType = 'loadWeather';
export const loadWeatherSuccess: ActionType = 'loadWeatherSuccess';
export const loadWeatherError: ActionType = 'loadWeatherError';

View File

@@ -1,7 +1,19 @@
export type ActionType = string;
import type { Station } from '../../types';
import { loadTrainSchedule, loadTrainScheduleError, loadTrainScheduleSuccess } from './consts'
import type {Article} from '../../types/article';
import type { LiveBoard } from '../../types/liveboard';
import type {WeatherData} from '../../types/weather';
import {
loadTrainSchedule,
loadTrainScheduleError,
loadTrainScheduleSuccess,
loadNews,
loadNewsError,
loadNewsSuccess,
loadWeather,
loadWeatherError,
loadWeatherSuccess,
} from './consts'
export type Action = {
type: ActionType
@@ -13,7 +25,7 @@ export type LoadTrainSchedule = {
export type LoadTrainScheduleSuccess = {
type: typeof loadTrainScheduleSuccess,
stations: Station[]
liveboard: LiveBoard
}
export type LoadTrainScheduleError = {
@@ -21,3 +33,32 @@ export type LoadTrainScheduleError = {
error: Error
}
export type LoadNews = {
type: typeof loadNews,
}
export type LoadNewsSuccess = {
type: typeof loadNewsSuccess,
news: Article[]
}
export type LoadNewsError = {
type: typeof loadNewsError,
error: Error
}
export type LoadWeather = {
type: typeof loadWeather,
}
export type LoadWeatherSuccess = {
type: typeof loadWeatherSuccess,
weather: WeatherData
}
export type LoadWeatherError = {
type: typeof loadWeatherError,
error: Error
}

View File

@@ -1,7 +1,13 @@
import { type State } from "./state";
export const initialState: State = {
stations: undefined,
error: undefined,
loading: false,
liveboard: undefined,
trainScheduleError: undefined,
trainScheduleLoading: false,
news: undefined,
newsError: undefined,
newsLoading: false,
weather: undefined,
weatherError: undefined,
weatherLoading: false,
};

View File

@@ -5,52 +5,96 @@ import {
loadTrainSchedule,
loadTrainScheduleError,
loadTrainScheduleSuccess,
loadNews,
loadNewsSuccess,
loadNewsError,
loadWeatherError,
loadWeatherSuccess,
loadWeather,
type LoadTrainScheduleError,
type LoadTrainScheduleSuccess,
type LoadWeatherError,
type LoadWeatherSuccess,
type LoadNewsError,
type LoadNewsSuccess,
} from './actions';
//export const reducerInner = (state: State, action: Action): State => {
export const reducer = (state: State, action: Action): State => {
export const reducerInner = (state: State, action: Action): State => {
//export const reducer = (state: State, action: Action): State => {
if(action.type === loadTrainSchedule) {
return {
...state,
error: undefined,
loading: true,
trainScheduleError: undefined,
trainScheduleLoading: true,
}
}
else if(action.type === loadTrainScheduleSuccess) {
return {
...state,
stations: (action as LoadTrainScheduleSuccess).stations,
loading: false,
liveboard: (action as LoadTrainScheduleSuccess).liveboard,
trainScheduleLoading: false,
}
}
else if(action.type === loadTrainScheduleError) {
return {
...state,
error: (action as LoadTrainScheduleError).error,
loading: false,
trainScheduleError: (action as LoadTrainScheduleError).error,
trainScheduleLoading: false,
}
}
if(action.type === loadNews) {
return {
...state,
newsError: undefined,
newsLoading: true,
}
}
else if(action.type === loadNewsSuccess) {
return {
...state,
news: (action as LoadNewsSuccess).news,
newsLoading: false,
}
}
else if(action.type === loadNewsError) {
return {
...state,
newsError: (action as LoadNewsError).error,
newsLoading: false,
}
}
if(action.type === loadWeather) {
return {
...state,
weatherError: undefined,
weatherLoading: true,
}
}
else if(action.type === loadWeatherSuccess) {
return {
...state,
weather: (action as LoadWeatherSuccess).weather,
weatherLoading: false,
}
}
else if(action.type === loadWeatherError) {
return {
...state,
weatherError: (action as LoadWeatherError).error,
weatherLoading: false,
}
}
return state;
}
/*
export const reducer = (state: State, action: Action) => {
if(action.type !== tick) {
console.log(`MD - ${action.type}`);
console.log({action})
console.log({state})
}
console.log(`TS - ${action.type}`);
console.log({action})
console.log({state})
const newState = reducerInner(state, action);
if(action.type !== tick) {
console.log({newState})
}
console.log({newState})
return newState;
}
*/

View File

@@ -1,9 +1,17 @@
import type { Station } from "../types";
import type { Article } from "../types/article";
import type { LiveBoard } from "../types/liveboard";
import type { WeatherData } from "../types/weather";
export type State = {
loading: boolean,
stations: Station[] | undefined,
error: Error | undefined,
trainScheduleLoading: boolean,
liveboard: LiveBoard | undefined,
trainScheduleError: Error | undefined,
weather: WeatherData | undefined,
weatherLoading: boolean,
weatherError: Error | undefined,
news: Article[] | undefined,
newsLoading: boolean,
newsError: Error | undefined,
}

2
src/styles.tsx Normal file
View File

@@ -0,0 +1,2 @@
export const accentColor = '#074c87'
export const accentColor2 = '#032d5d'

29
src/types/article.tsx Normal file
View File

@@ -0,0 +1,29 @@
export interface Article {
article_id: string;
title: string;
link: string;
keywords: string[];
creator: string[];
description: string;
content: string;
pubDate: string;
pubDateTZ: string;
image_url: string;
video_url: string | null;
source_id: string;
source_name: string;
source_priority: number;
source_url: string;
source_icon: string;
language: string;
country: string[];
category: string[];
sentiment: string;
sentiment_stats: string;
ai_tag: string;
ai_region: string;
ai_org: string;
ai_summary: string;
ai_content: string;
duplicate: boolean;
}

57
src/types/liveboard.tsx Normal file
View File

@@ -0,0 +1,57 @@
interface StationInfo {
'@id': string;
id: string;
name: string;
locationX: string;
locationY: string;
standardname: string;
}
interface VehicleInfo {
name: string;
shortname: string;
number: string;
type: string;
locationX: string;
locationY: string;
'@id': string;
}
interface PlatformInfo {
name: string;
normal: string;
}
interface Occupancy {
'@id': string;
name: 'unknown' | 'low' | 'medium' | 'high';
}
export interface DepartureType {
id: string;
station: string;
stationinfo: StationInfo;
time: string;
delay: string;
canceled: '0' | '1';
left: '0' | '1';
isExtra: '0' | '1';
vehicle: string;
vehicleinfo: VehicleInfo;
platform: string;
platforminfo: PlatformInfo;
occupancy: Occupancy;
departureConnection: string;
}
interface Departures {
number: string;
departure: DepartureType[];
}
export interface LiveBoard {
version: string;
timestamp: string;
station: string;
stationinfo: StationInfo;
departures: Departures;
}

57
src/types/weather.tsx Normal file
View File

@@ -0,0 +1,57 @@
export interface Condition {
text: string;
icon: string;
code: number;
}
export interface CurrentWeather {
last_updated_epoch: number;
last_updated: string;
temp_c: number;
temp_f: number;
is_day: number;
condition: Condition;
wind_mph: number;
wind_kph: number;
wind_degree: number;
wind_dir: string;
pressure_mb: number;
pressure_in: number;
precip_mm: number;
precip_in: number;
humidity: number;
cloud: number;
feelslike_c: number;
feelslike_f: number;
windchill_c: number;
windchill_f: number;
heatindex_c: number;
heatindex_f: number;
dewpoint_c: number;
dewpoint_f: number;
vis_km: number;
vis_miles: number;
uv: number;
gust_mph: number;
gust_kph: number;
short_rad: number;
diff_rad: number;
dni: number;
gti: number;
}
export interface Location {
name: string;
region: string;
country: string;
lat: number;
lon: number;
tz_id: string;
localtime_epoch: number;
localtime: string;
}
export interface WeatherData {
location: Location;
current: CurrentWeather;
}