use chrono::{prelude::*, Duration}; use gloo_timers::callback::Interval; use num_integer::Integer; use serde::{de::DeserializeOwned, Serialize}; use serde_json as json; use sycamore::prelude::*; use wasm_bindgen::{prelude::*, JsCast}; use web_sys::{Event, HtmlInputElement, window}; #[derive(Clone, Copy)] enum LocalStorage { StartTime, PlayTime, } impl LocalStorage { fn key(&self) -> &'static str { match self { LocalStorage::StartTime => "start-time", LocalStorage::PlayTime => "play-time" } } fn get(&self) -> Option { let local_storage = window()?.local_storage().unwrap_throw()?; local_storage.get_item(self.key()).unwrap_throw() } fn set(&self, value: &str) { let local_storage = window().unwrap_throw().local_storage().unwrap_throw().unwrap_throw(); local_storage.set_item(self.key(), value).unwrap_throw(); } } fn create_persistent_signal(cx: Scope, local_storage: LocalStorage) -> &Signal { let saved_value: Option = local_storage.get().and_then(|value| json::from_str(&value).ok()); let signal = create_signal(cx, saved_value.unwrap_or_default()); create_effect(cx, move || { local_storage.set(&json::to_string(&*signal.get()).unwrap_throw()); }); signal } fn create_now_signal(cx: Scope) -> RcSignal { let now = create_rc_signal(Local::now().naive_local()); let now_for_update = now.clone(); let update_now = Interval::new(60_000, move || { now_for_update.set(Local::now().naive_local()) }); on_cleanup(cx, || { update_now.cancel(); }); now } fn main() { sycamore::render(|cx| { let start_time = create_persistent_signal::>(cx, LocalStorage::StartTime); let play_time_str = create_persistent_signal::(cx, LocalStorage::PlayTime); let play_time_duration = play_time_str.map(cx, |play_time| string_to_duration(play_time)); let now = create_now_signal(cx); let now_for_active_time = now.clone(); let active_time_duration = create_memo(cx, move || start_time.get().map(|start_time| now_for_active_time.clone().get().signed_duration_since(start_time))); view! { cx, h1 { "FFXIV Uptime " small { a(href="https://code.boringcactus.com/misc/ffxiv-uptime/") { "(source)"} } } form { StartPicker(value=start_time) PlayTimePicker(value=play_time_str) } (match *active_time_duration.get() { Some(active_time) => { view! { cx, p { "You started playing FFXIV " (duration_to_string(active_time)) " ago." } (match *play_time_duration.get() { Some(play_time) => { let play_seconds = play_time.num_seconds() as f64; let active_seconds = active_time.num_seconds() as f64; view! { cx, p { "You have played XIV for " (duration_to_string(play_time)) "." } p { "That's " (format!("{:.2}", 100.0 * play_seconds / active_seconds)) "% of the time." } p { "(And " (to_eorzea_time(play_time)) " Eorzea time, if you're wondering.)" } } } None => { view! { cx, p { "Couldn't parse total play time." } } } }) } } None => { view! { cx, } } }) } }); } // 07/10/2021 11:03 PM // Total Play Time: 98 days, 8 hours, 7 minutes #[derive(Prop)] struct StartPickerProps<'a> { value: &'a Signal>, } #[component] fn StartPicker<'a, G: Html>(cx: Scope<'a>, props: StartPickerProps<'a>) -> View { let on_change = |x: Event| { let target: HtmlInputElement = x.target().unwrap_throw().dyn_into().unwrap_throw(); let value = target.value(); let time = NaiveDateTime::parse_from_str(&value, "%Y-%m-%dT%H:%M").ok(); props.value.set(time); }; view! { cx, label { "Start date and time: " input(type="datetime-local", value=(props.value.get().map_or_else(String::new, |date| date.to_string())), on:change=on_change) } } } #[derive(Prop)] struct PlayTimePickerProps<'a> { value: &'a Signal, } #[component] fn PlayTimePicker<'a, G: Html>(cx: Scope<'a>, props: PlayTimePickerProps<'a>) -> View { view! { cx, label { "Total Play Time: " input(type="text", placeholder="98 days, 8 hours, 7 minutes", bind:value=props.value) } } } fn pluralize(qty: u64, unit_singular: &str) -> Option { match qty { 0 => None, 1 => Some(format!("1 {}", unit_singular)), qty => Some(format!("{} {}s", qty, unit_singular)) } } fn duration_to_string(duration: Duration) -> String { let days = pluralize(duration.num_days() as u64, "day"); let hours = pluralize((duration.num_hours() - (duration.num_days() * 24)) as u64, "hour"); let minutes = pluralize((duration.num_minutes() - (duration.num_hours() * 60)) as u64, "minute"); [days, hours, minutes].into_iter().filter_map(|x| x).collect::>().join(", ") } fn string_to_duration(data: &str) -> Option { data.split(", ") .map(|piece| { match piece.trim().split_once(" ") { Some((days, "days" | "day")) => days.parse().ok().map(Duration::days), Some((hours, "hours" | "hour")) => hours.parse().ok().map(Duration::hours), Some((minutes, "minutes" | "minute")) => minutes.parse().ok().map(Duration::minutes), _ => None } }) .collect::>>() .map(|pieces| pieces.into_iter().fold(Duration::zero(), |a, b| a + b)) } fn to_eorzea_time(play_time: Duration) -> String { let total_suns = (play_time.num_seconds() as f64) / (Duration::minutes(70).num_seconds() as f64); let total_bells = (total_suns * 24.0).round() as u64; let (total_suns, bells) = total_bells.div_mod_floor(&24); let (total_weeks, suns) = total_suns.div_mod_floor(&8); let (total_moons, weeks) = total_weeks.div_mod_floor(&4); let (years, moons) = total_moons.div_mod_floor(&12); [ pluralize(years, "year"), pluralize(moons, "moon"), pluralize(weeks, "week"), pluralize(suns, "sun"), pluralize(bells, "bell") ].into_iter().filter_map(|x| x).collect::>().join(", ") }