Add last/next crawl times to feeds, improve local time rendering

This commit is contained in:
Tyler Hallada 2024-02-01 00:13:09 -05:00
parent 457aafbfe3
commit 2fab68241e
9 changed files with 174 additions and 19 deletions

View File

@ -1,9 +1,60 @@
// This file is used to convert UTC timestamps from the server to human-readable local time in the browser.
//
// Usage: Add a time element with a `datetime` attribute and a `data-local-time` attribute to the HTML.
//
// `data-local-time` can be either 'relative' or 'date'.
// 'relative' will show the time in a human-readable format, e.g. "2 hours from now".
// 'date' will show the date in a human-readable format, e.g. "January 1, 2022".
// Any other value will be ignored and time will be shown as-is from the server as a UTC timestamp.
function pluralize(count: number, singular: string, plural: string): string {
return count === 1 ? singular : plural;
}
function formatRelativeTime(utcTime: Date): string {
const now = new Date();
const diffInSeconds = (utcTime.getTime() - now.getTime()) / 1000;
if (diffInSeconds < -86400) {
const days = Math.round(Math.abs(diffInSeconds) / 86400);
return `${days} ${pluralize(days, 'day', 'days')} ago`;
} else if (diffInSeconds < -3600) {
const hours = Math.round(Math.abs(diffInSeconds) / 3600);
return `${hours} ${pluralize(Math.abs(hours), 'hour', 'hours')} ago`;
} else if (diffInSeconds < -60) {
const minutes = Math.round(Math.abs(diffInSeconds) / 60);
return `${minutes} ${pluralize(minutes, 'minute', 'minutes')} ago`;
} else if (diffInSeconds < 0) {
const seconds = Math.abs(diffInSeconds);
return `${seconds} ${pluralize(seconds, 'second', 'seconds')} ago`;
} else if (diffInSeconds < 60) {
return `${diffInSeconds} ${pluralize(
diffInSeconds,
'second',
'seconds'
)} from now`;
} else if (diffInSeconds < 3600) {
const minutes = Math.round(diffInSeconds / 60);
return `${minutes} ${pluralize(minutes, 'minute', 'minutes')} from now`;
} else if (diffInSeconds < 86400) {
const hours = Math.round(diffInSeconds / 3600);
return `${hours} ${pluralize(hours, 'hour', 'hours')} from now`;
} else {
const days = Math.round(diffInSeconds / 86400);
return `${days} ${pluralize(days, 'day', 'days')} from now`;
}
}
function convertTimeElements(): void { function convertTimeElements(): void {
const timeElements = document.querySelectorAll('time.local-time'); const timeElements = document.querySelectorAll('time[data-local-time]');
timeElements.forEach((element) => { timeElements.forEach((element) => {
const utcString = element.getAttribute('datetime'); const utcString = element.getAttribute('datetime');
if (utcString !== null) { if (utcString !== null) {
const utcTime = new Date(utcString); const utcTime = new Date(utcString);
const localTimeType = element.getAttribute('data-local-time');
if (localTimeType === 'relative') {
element.textContent = formatRelativeTime(utcTime);
} else if (localTimeType === 'date') {
element.textContent = utcTime.toLocaleDateString( element.textContent = utcTime.toLocaleDateString(
window.navigator.language, window.navigator.language,
{ {
@ -14,7 +65,13 @@ function convertTimeElements(): void {
); );
} else { } else {
console.error( console.error(
'Missing datetime attribute on time.local-time element', 'Unrecognized data-local-time attribute value on time[data-local-time] element. Local time cannot be displayed.',
element
);
}
} else {
console.error(
'Missing datetime attribute on time[data-local-time] element',
element element
); );
} }

View File

@ -11,6 +11,7 @@ use crate::error::Result;
use crate::htmx::HXTarget; use crate::htmx::HXTarget;
use crate::models::entry::Entry; use crate::models::entry::Entry;
use crate::partials::layout::Layout; use crate::partials::layout::Layout;
use crate::partials::time::date_time;
use crate::uuid::Base62Uuid; use crate::uuid::Base62Uuid;
pub async fn get( pub async fn get(
@ -24,9 +25,6 @@ pub async fn get(
let content_dir = std::path::Path::new(&config.content_dir); let content_dir = std::path::Path::new(&config.content_dir);
let content_path = content_dir.join(format!("{}.html", entry.entry_id)); let content_path = content_dir.join(format!("{}.html", entry.entry_id));
let title = entry.title.unwrap_or_else(|| "Untitled Entry".to_string()); let title = entry.title.unwrap_or_else(|| "Untitled Entry".to_string());
let published_at = entry
.published_at
.to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
let content = fs::read_to_string(content_path).unwrap_or_else(|_| "No content".to_string()); let content = fs::read_to_string(content_path).unwrap_or_else(|_| "No content".to_string());
Ok(layout Ok(layout
.with_subtitle(&title) .with_subtitle(&title)
@ -41,9 +39,7 @@ pub async fn get(
div { div {
span class="text-sm text-gray-600" { span class="text-sm text-gray-600" {
strong { "Published: " } strong { "Published: " }
time datetime=(published_at) class="local-time" { (date_time(entry.published_at))
(published_at)
}
} }
} }
(PreEscaped(content)) (PreEscaped(content))

View File

@ -3,7 +3,6 @@ use std::{collections::HashMap, net::SocketAddr, path::Path, sync::Arc};
use anyhow::Result; use anyhow::Result;
use axum::{ use axum::{
error_handling::HandleErrorLayer, error_handling::HandleErrorLayer,
response::Response,
routing::{get, post}, routing::{get, post},
BoxError, Router, BoxError, Router,
}; };

View File

@ -127,6 +127,12 @@ pub struct GetFeedsOptions {
} }
impl Feed { impl Feed {
pub fn next_crawl_time(&self) -> Option<DateTime<Utc>> {
self.last_crawled_at.map(|last_crawled_at| {
last_crawled_at + chrono::Duration::minutes(self.crawl_interval_minutes as i64)
})
}
pub async fn get(db: impl Executor<'_, Database = Postgres>, feed_id: Uuid) -> Result<Feed> { pub async fn get(db: impl Executor<'_, Database = Postgres>, feed_id: Uuid) -> Result<Feed> {
sqlx::query_as!( sqlx::query_as!(
Feed, Feed,

View File

@ -2,12 +2,14 @@ use maud::{html, Markup};
use crate::models::feed::Feed; use crate::models::feed::Feed;
use crate::partials::link::{link, LinkProps}; use crate::partials::link::{link, LinkProps};
use crate::partials::time::relative_time;
use crate::uuid::Base62Uuid; use crate::uuid::Base62Uuid;
pub struct FeedLink<'a> { pub struct FeedLink<'a> {
pub feed: &'a Feed, pub feed: &'a Feed,
pub pending_crawl: bool, pub pending_crawl: bool,
pub reset_htmx_target: bool, pub reset_htmx_target: bool,
pub show_next_crawl_time: bool,
} }
impl FeedLink<'_> { impl FeedLink<'_> {
@ -16,6 +18,7 @@ impl FeedLink<'_> {
feed, feed,
pending_crawl: false, pending_crawl: false,
reset_htmx_target: false, reset_htmx_target: false,
show_next_crawl_time: false,
} }
} }
@ -29,6 +32,11 @@ impl FeedLink<'_> {
self self
} }
pub fn show_next_crawl_time(&mut self) -> &mut Self {
self.show_next_crawl_time = true;
self
}
pub fn render(&self) -> Markup { pub fn render(&self) -> Markup {
let title = self.feed.title.clone().unwrap_or_else(|| { let title = self.feed.title.clone().unwrap_or_else(|| {
if self.pending_crawl { if self.pending_crawl {
@ -39,7 +47,21 @@ impl FeedLink<'_> {
}); });
let feed_url = format!("/feed/{}", Base62Uuid::from(self.feed.feed_id)); let feed_url = format!("/feed/{}", Base62Uuid::from(self.feed.feed_id));
html! { html! {
div class="flex flex-row gap-4 items-baseline" {
(link(LinkProps { destination: &feed_url, title: &title, reset_htmx_target: self.reset_htmx_target })) (link(LinkProps { destination: &feed_url, title: &title, reset_htmx_target: self.reset_htmx_target }))
@if let Some(last_crawl) = self.feed.last_crawled_at {
span class="text-sm text-gray-600" {
span class="font-semibold" { "last crawl: " }
(relative_time(last_crawl))
}
}
@if let Some(next_crawl) = self.feed.next_crawl_time() {
span class="text-sm text-gray-600" {
span class="font-semibold" { "next crawl: " }
(relative_time(next_crawl))
}
}
}
} }
} }
} }

View File

@ -13,4 +13,5 @@ pub mod login_form;
pub mod opml_import_form; pub mod opml_import_form;
pub mod register_form; pub mod register_form;
pub mod reset_password_form; pub mod reset_password_form;
pub mod time;
pub mod user_name; pub mod user_name;

View File

@ -1,4 +1,4 @@
use maud::{html, Markup, PreEscaped}; use maud::{html, Markup};
pub fn opml_import_form() -> Markup { pub fn opml_import_form() -> Markup {
html! { html! {

59
src/partials/time.rs Normal file
View File

@ -0,0 +1,59 @@
use std::fmt;
use chrono::{DateTime, Utc};
use maud::{html, Markup};
use crate::utils::FormattedUtcTimestamp;
#[derive(Debug)]
pub enum LocalTimeType {
Date,
Relative,
}
impl fmt::Display for LocalTimeType {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
LocalTimeType::Date => write!(f, "date"),
LocalTimeType::Relative => write!(f, "relative"),
}
}
}
#[derive(Debug, Default)]
pub struct TimeProps {
pub timestamp: DateTime<Utc>,
pub local_time_type: Option<LocalTimeType>,
}
pub fn time(
TimeProps {
timestamp,
local_time_type,
}: TimeProps,
) -> Markup {
let time = FormattedUtcTimestamp::from(timestamp);
html! {
time
datetime=(time.rfc3339)
data-local-time=(local_time_type.unwrap_or(LocalTimeType::Date))
title=(time.human_readable)
{
(time.rfc3339)
}
}
}
pub fn date_time(timestamp: DateTime<Utc>) -> Markup {
time(TimeProps {
timestamp,
local_time_type: Some(LocalTimeType::Date),
})
}
pub fn relative_time(timestamp: DateTime<Utc>) -> Markup {
time(TimeProps {
timestamp,
local_time_type: Some(LocalTimeType::Relative),
})
}

View File

@ -1,3 +1,4 @@
use chrono::{DateTime, SecondsFormat, Utc};
use url::Url; use url::Url;
pub fn get_domain(url: &str) -> Option<String> { pub fn get_domain(url: &str) -> Option<String> {
@ -12,3 +13,17 @@ pub fn get_domain(url: &str) -> Option<String> {
} }
}) })
} }
pub struct FormattedUtcTimestamp {
pub rfc3339: String,
pub human_readable: String,
}
impl From<DateTime<Utc>> for FormattedUtcTimestamp {
fn from(dt: DateTime<Utc>) -> Self {
FormattedUtcTimestamp {
rfc3339: dt.to_rfc3339_opts(SecondsFormat::Millis, true),
human_readable: dt.format("%Y-%m-%d %H:%M:%S %Z").to_string(),
}
}
}