aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore3
-rw-r--r--Cargo.toml16
-rw-r--r--index.html13
-rw-r--r--src/main.rs210
4 files changed, 242 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..9f9b51c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+/target
+/Cargo.lock
+/dist
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..7eabd99
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "ffxiv-uptime"
+version = "0.1.0"
+edition = "2021"
+
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+[dependencies]
+chrono = { version = "0.4", features = ["serde"] }
+gloo-timers = "0.2.5"
+num-integer = "0.1.45"
+serde = "1.0"
+serde_json = "1.0"
+sycamore = "0.8"
+wasm-bindgen = "0.2.83"
+web-sys = { version = "0.3", features = ["Event", "HtmlInputElement", "Storage", "Window"] }
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..d2b7410
--- /dev/null
+++ b/index.html
@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="utf-8"/>
+ <title>FFXIV uptime</title>
+ <style>
+ input[type=text] {
+ width: 15em;
+ }
+ </style>
+</head>
+<body></body>
+</html>
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(", ")
+}