add live updating table
All checks were successful
Create and publish a Docker image / build-and-push-image (push) Successful in 9m20s

This commit is contained in:
Nicholas Orlowsky 2026-01-14 23:18:35 -05:00
parent 2ca76548d6
commit 6773e6ae30
No known key found for this signature in database
GPG key ID: A9F3BA4C0AA7A70B
7 changed files with 210 additions and 124 deletions

View file

@ -1,6 +1,7 @@
use actix_web::{get, web::{self, Data}, HttpRequest, Responder};
use actix_web::{HttpRequest, HttpResponse, Responder, get, web::{self, Data}};
use anyhow::anyhow;
use chrono::Timelike;
use askama::Template;
use chrono::{NaiveTime, Timelike};
use chrono_tz::America::New_York;
use libseptastic::stop_schedule::{self, LiveTrip, Trip, TripTracking};
use log::info;
@ -34,18 +35,10 @@ async fn get_stops_html(req: HttpRequest, state: Data<Arc<AppState>>) -> impl Re
}
}).await
}
#[get("/stop/{stop_id}")]
async fn get_stop_html(req: HttpRequest, state: Data<Arc<AppState>>, path: web::Path<String>) -> impl Responder {
crate::perform_action(req, move || {
let statex = state.clone();
let pathx = path.clone();
async move {
let stop_id = pathx;
let start_time = Instant::now();
if let Some(stop) = statex.gtfs_service.get_stop_by_id(&stop_id) {
let routes: Vec<libseptastic::route::Route> = statex.gtfs_service.get_routes_at_stop(&stop.id).iter().filter_map(|route| {
match statex.gtfs_service.get_route(route.clone()) {
async fn get_trip_perspective_for_stop(state: &Data<Arc<AppState>>,stop: &libseptastic::stop::Stop) -> Vec<TripPerspective> {
let routes: Vec<libseptastic::route::Route> = state.gtfs_service.get_routes_at_stop(&stop.id).iter().filter_map(|route| {
match state.gtfs_service.get_route(route.clone()) {
Ok(route) => Some(route),
Err(_) => None
}
@ -53,7 +46,7 @@ async fn get_stop_html(req: HttpRequest, state: Data<Arc<AppState>>, path: web::
let route_ids: HashSet<String> = routes.iter().map(|route| route.id.clone()).collect();
let mut trips = statex.gtfs_service.get_all_trips().iter().filter_map(|trip| {
let mut trips = state.gtfs_service.get_all_trips().iter().filter_map(|trip| {
if route_ids.contains(trip.0) {
Some(trip.1.clone())
} else {
@ -62,7 +55,7 @@ async fn get_stop_html(req: HttpRequest, state: Data<Arc<AppState>>, path: web::
}).flatten().collect();
statex.trip_tracking_service.annotate_trips(&mut trips).await;
state.trip_tracking_service.annotate_trips(&mut trips).await;
let now_utc = chrono::Utc::now();
let now = now_utc.with_timezone(&New_York);
@ -73,7 +66,7 @@ async fn get_stop_html(req: HttpRequest, state: Data<Arc<AppState>>, path: web::
//if !trip.is_active_on(&now.naive_local()) {
// return None;
//}
//
// poor midnight handling?
if !trip.calendar_day.is_calendar_active_for_date(&now.naive_local().date()) {
return None
@ -81,7 +74,7 @@ async fn get_stop_html(req: HttpRequest, state: Data<Arc<AppState>>, path: web::
let mut est_arrival_time = 0;
let mut is_tracked = false;
let stop_sched : Vec<_> = trip.schedule.iter().filter(|stop_schedule| {
if stop_schedule.stop.id != stop_id {
if stop_schedule.stop.id != stop.id {
return false;
}
@ -128,6 +121,57 @@ async fn get_stop_html(req: HttpRequest, state: Data<Arc<AppState>>, path: web::
_ => f.perspective_stop.arrival_time
});
filtered_trips
}
#[get("/stop/{stop_id}/table")]
async fn get_stop_table_html(req: HttpRequest, state: Data<Arc<AppState>>, path: web::Path<String>) -> impl Responder {
let stop_id = path;
if let Some(stop) = state.gtfs_service.get_stop_by_id(&stop_id) {
let filtered_trips = get_trip_perspective_for_stop(&state, &stop).await;
let now_utc = chrono::Utc::now();
let now = now_utc.with_timezone(&New_York);
let naive_time = now.time();
let cur_time = i64::from(naive_time.num_seconds_from_midnight());
HttpResponse::Ok().body(crate::templates::StopTableTemplate {
trips: filtered_trips,
current_time: cur_time
}.render().unwrap())
}
else {
HttpResponse::InternalServerError().body("Error")
}
}
#[get("/stop/{stop_id}")]
async fn get_stop_html(req: HttpRequest, state: Data<Arc<AppState>>, path: web::Path<String>) -> impl Responder {
crate::perform_action(req, move || {
let statex = state.clone();
let pathx = path.clone();
async move {
let stop_id = pathx;
let start_time = Instant::now();
if let Some(stop) = statex.gtfs_service.get_stop_by_id(&stop_id) {
let routes: Vec<libseptastic::route::Route> = statex.gtfs_service.get_routes_at_stop(&stop.id).iter().filter_map(|route| {
match statex.gtfs_service.get_route(route.clone()) {
Ok(route) => Some(route),
Err(_) => None
}
}).collect();
let filtered_trips = get_trip_perspective_for_stop(&statex, &stop).await;
let now_utc = chrono::Utc::now();
let now = now_utc.with_timezone(&New_York);
let naive_time = now.time();
let cur_time = i64::from(naive_time.num_seconds_from_midnight());
Ok(crate::templates::ContentTemplate {
page_title: Some(stop.name.clone()),
page_desc: Some(String::from("Stop information")),

View file

@ -110,6 +110,7 @@ async fn main() -> ::anyhow::Result<()> {
// .service(controllers::route::get_directions)
.service(controllers::stop::get_stops_html)
.service(controllers::stop::get_stop_html)
.service(controllers::stop::get_stop_table_html)
.service(get_index)
.service(actix_files::Files::new("/assets", "./assets"))
})

View file

@ -77,6 +77,13 @@ pub struct StopTemplate {
pub current_time: i64
}
#[derive(askama::Template)]
#[template(path = "stop_table_impl.html")]
pub struct StopTableTemplate {
pub trips: Vec<TripPerspective>,
pub current_time: i64
}
pub fn build_timetables(
directions: Vec<Direction>,
trips: Vec<Trip>,
@ -183,6 +190,7 @@ mod filters {
return Ok(format!("{}ns", nanos));
}
}
pub fn format_time(
seconds_since_midnight: &i64,
_: &dyn askama::Values,
@ -190,8 +198,10 @@ mod filters {
let total_minutes = seconds_since_midnight / 60;
let (hours, ampm) = {
let hrs = total_minutes / 60;
if hrs >= 12 {
if hrs > 12 {
(hrs - 12, "PM")
} else if hrs == 12 {
(12, "PM")
} else if hrs > 0 {
(hrs, "AM")
} else {
@ -201,4 +211,26 @@ mod filters {
let minutes = total_minutes % 60;
Ok(format!("{}:{:02} {}", hours, minutes, ampm))
}
pub fn format_time_with_seconds(
seconds_since_midnight: &i64,
_: &dyn askama::Values,
) -> askama::Result<String> {
let total_minutes = seconds_since_midnight / 60;
let (hours, ampm) = {
let hrs = total_minutes / 60;
if hrs > 12 {
(hrs - 12, "PM")
} else if hrs == 12 {
(12, "PM")
} else if hrs > 0 {
(hrs, "AM")
} else {
(12, "AM")
}
};
let minutes = total_minutes % 60;
let seconds = seconds_since_midnight % 60;
Ok(format!("{}:{:02}:{:02} {}", hours, minutes, seconds, ampm))
}
}

View file

@ -18,6 +18,7 @@
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js"></script>
<script>
window.onload = function () {
setTimeout(() => {

View file

@ -1,11 +1,5 @@
{%- import "route_symbol.html" as scope -%}
<style>
.trip-desc {
display: flex;
justify-content: start;
}
</style>
{%- import "stop_table.html" as stop_table -%}
<div style="display: flex; align-items: center;">
<h1>{{ stop.name }}</h1>
@ -20,7 +14,6 @@
{% endfor %}
</div>
{#{% if let libseptastic::stop::StopType::MultiPlatform(platforms) = stop.platforms %}
<div>
<p>Platforms at this station:</p>
@ -30,45 +23,6 @@
</div>
{% endif %}#}
<table class="train-direction-table">
<tr>
<th>ROUTE</th>
<th>DESTINATION</th>
<th>BOARDING AREA</th>
<th>TIME</th>
<th>VEHICLE</th>
</tr>
{% for trip in trips %}
<tr>
<td>
{% call scope::route_symbol(trip.trip.route) %}
</td>
<td>
<p>{{ trip.trip.direction.direction_destination }}</p>
</td>
<td>
<p>{{ trip.perspective_stop.platform.name }}</p>
</td>
{% if let Tracked(tracked_trip) = trip.trip.tracking_data %}
<td style="color: #008800">
<p style="font-size: small;">{{ &trip.perspective_stop.get_arrival_time(&tracked_trip) | format_time }}</p>
<p style="font-size: x-small; font-style: italic;">{{ ( trip.perspective_stop.get_arrival_time(&tracked_trip) - current_time) / 60 }} mins</p>
</td>
{% else %}
<td>
<p style="font-size: small;">{{ trip.perspective_stop.arrival_time | format_time }}</p>
<p style="font-size: x-small; font-style: italic;">{{ (trip.perspective_stop.arrival_time - current_time) / 60 }} mins</p>
</td>
{% endif %}
{% if let Tracked(tracked_trip) = trip.trip.tracking_data %}
<td>
{{ tracked_trip.vehicle_ids.join(", ") }}
</td>
{% else %}
<td>
-
</td>
{% endif %}
</tr>
{% endfor %}
</table>
<div hx-get="/stop/{{ stop.id }}/table" hx-trigger="every 5s">
{% call stop_table::stop_table(trips, current_time) %}
</div>

View file

@ -0,0 +1,51 @@
{%- import "route_symbol.html" as scope -%}
{% macro stop_table(trips, current_time) %}
<table class="train-direction-table">
<tr>
<th>ROUTE</th>
<th>DESTINATION</th>
<th>BOARDING AREA</th>
<th>TIME</th>
<th>VEHICLE</th>
</tr>
{% for trip in trips %}
<tr>
<td>
{% call scope::route_symbol(trip.trip.route) %}
</td>
<td>
<p>{{ trip.trip.direction.direction_destination }}</p>
</td>
<td>
<p>{{ trip.perspective_stop.platform.name }}</p>
</td>
{% if let Tracked(tracked_trip) = trip.trip.tracking_data %}
<td style="color: #008800">
<p style="font-size: small;">{{ &trip.perspective_stop.get_arrival_time(&tracked_trip) | format_time }}</p>
<p style="font-size: x-small; font-style: italic;">{{ ( trip.perspective_stop.get_arrival_time(&tracked_trip) - current_time) / 60 }} mins</p>
</td>
{% else %}
<td>
<p style="font-size: small;">{{ trip.perspective_stop.arrival_time | format_time }}</p>
<p style="font-size: x-small; font-style: italic;">{{ (trip.perspective_stop.arrival_time - current_time) / 60 }} mins</p>
</td>
{% endif %}
{% if let Tracked(tracked_trip) = trip.trip.tracking_data %}
<td>
{{ tracked_trip.vehicle_ids.join(", ") }}
</td>
{% else %}
<td>
-
</td>
{% endif %}
</tr>
{% endfor %}
<tr>
<td colspan="5">
<p>Updated at: {{ current_time | format_time_with_seconds }}</p>
</td>
</tr>
</table>
{% endmacro %}

View file

@ -0,0 +1,3 @@
{%- import "stop_table.html" as stop_table -%}
{% call stop_table::stop_table(trips, current_time) %}