persist: localStorage autosave + JSON export/import engine

- STORAGE_KEY aex.dashboard, 500 ms debounced autosave via store.track()
- deferred load after hydration keeps SSR markup authoritative
- parse_dashboard version-checks the file (the migration hook), errors
  are RU messages via ImportError (thiserror)
- clear writes the blank dashboard back so a reload stays blank
- export_download builds a Blob URL and revokes it after the click
- 5 unit tests: round trip, blank, garbage, newer and older versions
This commit is contained in:
yaroslav k 2026-08-16 11:28:19 +03:00
parent 0409d8edad
commit 043977cc1f
2 changed files with 275 additions and 0 deletions

View File

@ -2,4 +2,8 @@
//! //!
//! The whole dashboard lives in one `reactive_stores::Store<Dashboard>` //! The whole dashboard lives in one `reactive_stores::Store<Dashboard>`
//! (alias `Context`), created in `App` and shared through Leptos context. //! (alias `Context`), created in `App` and shared through Leptos context.
//! Persistence (localStorage autosave, JSON export/import) lives in
//! [`persist`].
pub (crate) mod persist;

271
src/state/persist.rs Normal file
View File

@ -0,0 +1,271 @@
//! Persistence: localStorage autosave + JSON export/import.
//!
//! Everything here is client-side. The server build (`ssr`) never touches
//! storage: it renders mock data, and the hydrated client swaps in the
//! saved state a moment after mount ([`init_hydrated`]).
//!
//! Flow:
//! - on mount, [`init_hydrated`] loads the saved state (mock stays as the
//! first-visit fallback) and starts the debounced autosave effect,
//! - the header's Save / Load / Clear buttons call [`export_download`],
//! [`apply_import`] and [`clear_all`],
//! - the saved format is the whole `Dashboard` JSON, version-checked by
//! [`parse_dashboard`] (the migration hook).
#[cfg(feature = "hydrate")]
use std::{ cell::Cell, rc::Rc, time::Duration };
use leptos::prelude::*;
use crate::prelude::*;
/// The localStorage key under which the whole dashboard is stored.
#[cfg(feature = "hydrate")]
pub const STORAGE_KEY: &str = "aex.dashboard";
/// Autosave debounce: changes are written this long after the last change.
#[cfg(feature = "hydrate")]
const SAVE_DEBOUNCE: Duration = Duration::from_millis(500);
/// Failure modes when parsing an imported or stored file.
#[derive(Debug, thiserror::Error)]
pub enum ImportError {
/// The file is not valid JSON or not a dashboard.
#[error("Файл повреждён или не является файлом кампании.")]
Parse,
/// The file comes from a newer app version.
#[error(
"Версия файла ({0}) новее, чем поддерживает приложение (текущая: {current}).",
current = Settings::VERSION
)]
NewerVersion(u32),
/// The file comes from an older app version; no migration exists yet.
#[error(
"Версия файла ({0}) устарела; перенос данных пока не реализован (текущая: {current}).",
current = Settings::VERSION
)]
OldVersion(u32),
}
/// Parses and version-checks a dashboard JSON document.
///
/// This is the migration hook: a future format bump adds an arm here that
/// converts the old version into the new one and recurses.
pub fn parse_dashboard(text: &str) -> std::result::Result<Dashboard, ImportError> {
let dashboard: Dashboard = serde_json::from_str(text).map_err(|_| ImportError::Parse)?;
match dashboard.settings.version {
version if version == Settings::VERSION => Ok(dashboard),
version if version > Settings::VERSION => Err(ImportError::NewerVersion(version)),
version => Err(ImportError::OldVersion(version)),
}
}
/// Serializes the current dashboard to pretty JSON.
#[cfg(feature = "hydrate")]
pub fn serialize_dashboard(store: &Context) -> Option<String> {
let guard = store.try_read_untracked()?;
serde_json::to_string_pretty(&*guard).ok()
}
/// Replaces the whole dashboard inside the store.
pub fn replace_state(store: &Context, dashboard: Dashboard) {
if let Some(mut guard) = store.try_write() {
*guard = dashboard;
}
}
/// Parses `text` and applies it: replaces the state and saves it back.
///
/// Returns the error for the UI to show; nothing is applied on error.
pub fn apply_import(store: &Context, text: &str) -> std::result::Result<(), ImportError> {
let dashboard = parse_dashboard(text)?;
replace_state(store, dashboard);
#[cfg(feature = "hydrate")]
save_to_storage(store);
Ok(())
}
/// Client-only startup: load the saved state after mount, start autosave.
///
/// Called from `App`. The load is deferred out of the hydration render so
/// the SSR markup stays authoritative; the swap happens before the browser
/// draws the first frame.
#[cfg(feature = "hydrate")]
pub fn init_hydrated(store: &Context) {
let store = store.clone();
let pending: Rc<Cell<Option<TimeoutHandle>>> = Rc::new(Cell::new(None));
// Load the saved state (or keep mock) once hydration has settled.
let store_load = store.clone();
let _ = set_timeout_with_handle(
move || {
load_from_storage(&store_load);
},
Duration::ZERO,
);
// Debounced autosave: any store change restarts the timer.
let store_save = store.clone();
Effect::new(move |_| {
store_save.track();
if let Some(handle) = pending.take() {
handle.clear();
}
let handle = set_timeout_with_handle(
{
let store = store_save.clone();
move || {
save_to_storage(&store);
}
},
SAVE_DEBOUNCE,
);
if let Ok(handle) = handle {
pending.set(Some(handle));
}
});
}
/// Writes the current dashboard to localStorage.
#[cfg(feature = "hydrate")]
pub fn save_to_storage(store: &Context) {
let Some(storage) = web_sys::window().and_then(|w| w.local_storage().ok().flatten()) else {
return;
};
let Some(json) = serialize_dashboard(store) else {
return;
};
if let Err(err) = storage.set_item(STORAGE_KEY, &json) {
e!("не удалось сохранить состояние: {err:?}");
}
}
/// Loads the saved dashboard from localStorage and replaces the store.
///
/// Returns `true` when a valid saved state was applied.
#[cfg(feature = "hydrate")]
pub fn load_from_storage(store: &Context) -> bool {
let Some(storage) = web_sys::window().and_then(|w| w.local_storage().ok().flatten()) else {
return false;
};
let Some(text) = storage.get_item(STORAGE_KEY).ok().flatten() else {
return false;
};
match parse_dashboard(&text) {
Ok(dashboard) => {
replace_state(store, dashboard);
true
}
Err(err) => {
e!("сохранённое состояние повреждено: {err}");
false
}
}
}
/// Wipes the saved state and replaces the dashboard with the blank slate.
///
/// The blank dashboard is written back to storage so it survives a reload;
/// mock data stays the fallback for the very first visit only.
#[cfg(feature = "hydrate")]
pub fn clear_all (store: &Context) {
replace_state(store, Dashboard::blank());
save_to_storage(store);
}
/// SSR stub: there is no browser, nothing to wipe.
#[cfg(not(feature = "hydrate"))]
pub fn clear_all (_store: &Context) {}
/// SSR stub: no browser, nothing to download.
#[cfg(not(feature = "hydrate"))]
pub fn export_download (_store: &Context) {}
/// Downloads the current dashboard as `dashboard.json`.
#[cfg(feature = "hydrate")]
pub fn export_download (store: &Context) {
use wasm_bindgen::JsCast;
let Some(json) = serialize_dashboard(store) else {
return;
};
let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
return;
};
let blob = match web_sys::Blob::new_with_str_sequence(&js_sys::Array::of1(
&wasm_bindgen::JsValue::from_str(&json),
)) {
Ok(blob) => blob,
Err(_) => return,
};
let Ok(url) = web_sys::Url::create_object_url_with_blob(&blob) else {
return;
};
let Ok(anchor) = doc.create_element("a") else {
return;
};
let anchor = anchor.unchecked_into::<web_sys::HtmlAnchorElement>();
let _ = anchor.set_attribute("href", &url);
let _ = anchor.set_attribute("download", "dashboard.json");
anchor.click();
// Release the object URL after the download has started.
queue_microtask(move || {
let _ = web_sys::Url::revoke_object_url(&url);
});
}
#[cfg(test)]
mod tests {
use super::*;
/// The whole dashboard survives a JSON round trip with the version intact.
#[test]
fn round_trip_keeps_state() {
let dashboard = Dashboard::mock();
let json = serde_json::to_string(&dashboard).unwrap();
let loaded = parse_dashboard(&json).unwrap();
assert_eq!(loaded.campaign, dashboard.campaign);
assert_eq!(loaded.player.name.first, dashboard.player.name.first);
assert_eq!(loaded.player.inventory.rows.len(), dashboard.player.inventory.rows.len());
assert_eq!(loaded.settings.version, Settings::VERSION);
}
/// Files from a newer app version are rejected with a clear error.
#[test]
fn rejects_newer_version() {
let mut dashboard = Dashboard::mock();
dashboard.settings.version = Settings::VERSION + 1;
let json = serde_json::to_string(&dashboard).unwrap();
assert!(matches!(parse_dashboard(&json), Err(ImportError::NewerVersion(_))));
}
/// Files from an older version are rejected until a migration exists.
#[test]
fn rejects_older_version() {
let mut dashboard = Dashboard::mock();
dashboard.settings.version = Settings::VERSION - 1;
let json = serde_json::to_string(&dashboard).unwrap();
assert!(matches!(parse_dashboard(&json), Err(ImportError::OldVersion(_))));
}
/// Garbage input fails with the parse error.
#[test]
fn rejects_garbage() {
assert!(matches!(parse_dashboard("not json"), Err(ImportError::Parse)));
assert!(matches!(parse_dashboard("[]"), Err(ImportError::Parse)));
}
/// An empty dashboard round-trips too (Clear produces valid files).
#[test]
fn blank_round_trips() {
let json = serde_json::to_string(&Dashboard::blank()).unwrap();
let loaded = parse_dashboard(&json).unwrap();
assert!(loaded.campaign.is_empty());
assert!(loaded.player.inventory.rows.is_empty());
assert_eq!(loaded.settings.version, Settings::VERSION);
}
}