diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/main.rs | 210 |
1 files changed, 210 insertions, 0 deletions
diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..3ad6e62 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,210 @@ +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<String> { + 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<T: Default + DeserializeOwned + Serialize>(cx: Scope, local_storage: LocalStorage) -> &Signal<T> { + let saved_value: Option<T> = 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<NaiveDateTime> { + 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::<Option<NaiveDateTime>>(cx, LocalStorage::StartTime); + let play_time_str = create_persistent_signal::<String>(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" } + 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<Option<NaiveDateTime>>, +} + +#[component] +fn StartPicker<'a, G: Html>(cx: Scope<'a>, props: StartPickerProps<'a>) -> View<G> { + 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<String>, +} + +#[component] +fn PlayTimePicker<'a, G: Html>(cx: Scope<'a>, props: PlayTimePickerProps<'a>) -> View<G> { + 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<String> { + 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::<Vec<_>>().join(", ") +} + +fn string_to_duration(data: &str) -> Option<Duration> { + 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::<Option<Vec<_>>>() + .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::<Vec<_>>().join(", ") +} |