From 22e6ed6a71e9020477fe871c094467644a086409 Mon Sep 17 00:00:00 2001 From: sergio Date: Sun, 17 May 2026 11:11:29 +0000 Subject: [PATCH] =?UTF-8?q?feat(tahuantinsuyu):=20fase=2011=20=E2=80=94=20?= =?UTF-8?q?persistencia=20de=20module=5Fconfigs=20por=20carta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Los toggles, sliders y partner pickers de cada overlay (transit, progression, solar_arc, synastry) ahora persisten por carta en la tabla SQLite `module_state` (que estaba creada desde fase 1 pero sin cablear). Cambiar de carta y volver mantiene exactamente el estado que el usuario dejó. - shell: - apply_selection(Chart): tras setear defaults (target_age_years = edad actual), llama load_persisted_module_states(chart.id) que mergea sobre los defaults los valores guardados. Luego sync_panel_from_configs empuja todos los toggles/sliders al panel para reflejar el estado restaurado. Render al final. - load_persisted_module_states: lee list_module_states(chart_id), reconstruye el JSON combinado (mergea `enabled` de la columna SQL en el config), y lo mergea sobre lo que ya hay en module_configs. Vacant entries se insertan tal cual; occupied se patchean field-a-field para no perder defaults no guardados. - sync_panel_from_configs: itera module_configs, push toggle/slider al panel por cada key Bool/f64. - persist_module(module_id): extrae enabled del JSON, deja resto en config_json, llama upsert_module_state. Invocada desde on_panel_event "else" tras cada update + desde on_canvas_event para [T] + tras auto-disable del conflicting module en mutual exclusion. - store: nuevo test module_state_roundtrip que cubre upsert/list + cambio de enabled vía upsert (UPSERT clause de fase 1 vuelve a validarse). Flujo de usuario: ajustás el slider de progresión a 42.5 años, activás synastry, cambiás de carta, volvés — todo está como lo dejaste. La DB persiste por chart_id, así que distintos sujetos mantienen estados independientes. Co-Authored-By: Claude Opus 4.7 --- crates/apps/tahuantinsuyu/src/shell.rs | 116 ++++++++++++++++-- .../tahuantinsuyu-store/src/lib.rs | 76 +++++++++++- 2 files changed, 184 insertions(+), 8 deletions(-) diff --git a/crates/apps/tahuantinsuyu/src/shell.rs b/crates/apps/tahuantinsuyu/src/shell.rs index 5b5aad4..fcdc296 100644 --- a/crates/apps/tahuantinsuyu/src/shell.rs +++ b/crates/apps/tahuantinsuyu/src/shell.rs @@ -32,7 +32,7 @@ use tahuantinsuyu_canvas::{ AstrologyCanvas, CanvasEvent, CanvasMode, ThumbnailItem, ThumbnailScope, }; use tahuantinsuyu_engine::{LayerKind, PipelineRequest, compose}; -use tahuantinsuyu_model::{Chart, TreeSelection}; +use tahuantinsuyu_model::{Chart, ChartId, ModuleState, TreeSelection}; use tahuantinsuyu_panel::{ControlPanel, PanelEvent}; use tahuantinsuyu_store::Store; use tahuantinsuyu_tree::{TahuantinsuyuTree, TreeEvent}; @@ -118,9 +118,10 @@ impl Shell { let age = current_age_years(&chart.birth_data); self.current_chart = Some(chart.clone()); self.current_offset_minutes = 0; - // Inicializar la edad objetivo de los módulos basados - // en edad con la edad actual del sujeto, así sus - // sliders arrancan donde corresponde al activarse. + // 1) Defaults frescos para esta carta: edad objetivo = + // edad actual. Estos quedan en module_configs como + // valor base si el usuario nunca tocó el slider. + self.module_configs.clear(); for module_id in ["progression", "solar_arc"] { let entry = self .module_configs @@ -130,12 +131,15 @@ impl Shell { map.insert("target_age_years".into(), serde_json::json!(age)); } } - self.render_current(cx); + // 2) Sobreescribir con lo que el usuario persistió la + // última vez para esta carta (SQLite `module_state`). + self.load_persisted_module_states(chart.id); + // 3) Sincronizar panel: active_kind + toggles/sliders. self.panel.update(cx, |p, cx| { p.set_active_kind(Some(chart.kind), cx); - p.set_slider("progression", "target_age_years", age, cx); - p.set_slider("solar_arc", "target_age_years", age, cx); }); + self.sync_panel_from_configs(cx); + self.render_current(cx); } TreeSelection::Contact(id) => { self.current_chart = None; @@ -225,6 +229,101 @@ impl Shell { siblings.into_iter().find(|c| c.id != current.id) } + /// Lee `module_state` desde SQLite para la carta dada y los mergea + /// con los defaults ya cargados en `module_configs`. Los valores + /// persistidos ganan sobre los defaults. + fn load_persisted_module_states(&mut self, chart_id: ChartId) { + let states = match self.store.list_module_states(chart_id) { + Ok(s) => s, + Err(e) => { + eprintln!("[shell] list_module_states {}: {}", chart_id, e); + return; + } + }; + for st in states { + // Re-mergeamos `enabled` (columna separada en SQL) dentro + // del JSON config, así el resto del shell sigue leyendo + // todo desde una única estructura. + let mut combined = match st.config { + serde_json::Value::Object(m) => serde_json::Value::Object(m), + _ => serde_json::json!({}), + }; + if let serde_json::Value::Object(map) = &mut combined { + map.insert("enabled".into(), serde_json::Value::Bool(st.enabled)); + } + // Mergear sobre defaults previos (no sobreescribir si la + // entrada nueva no trae un campo). + match self.module_configs.entry(st.module_id) { + std::collections::hash_map::Entry::Vacant(v) => { + v.insert(combined); + } + std::collections::hash_map::Entry::Occupied(mut o) => { + if let (serde_json::Value::Object(dst), serde_json::Value::Object(src)) = + (o.get_mut(), &combined) + { + for (k, v) in src { + dst.insert(k.clone(), v.clone()); + } + } else { + o.insert(combined); + } + } + } + } + } + + /// Pushea cada toggle/slider del `module_configs` al panel para que + /// la UI refleje el estado persistido al cargar una carta. + fn sync_panel_from_configs(&mut self, cx: &mut Context) { + let snapshot: Vec<(String, serde_json::Value)> = self + .module_configs + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + self.panel.update(cx, |p, cx| { + for (module_id, config) in &snapshot { + if let serde_json::Value::Object(map) = config { + for (key, value) in map { + if let Some(b) = value.as_bool() { + p.set_toggle(module_id, key, b, cx); + } else if let Some(f) = value.as_f64() { + p.set_slider(module_id, key, f, cx); + } + } + } + } + }); + } + + /// Persiste el estado actual de un módulo a SQLite. Extrae + /// `enabled` del JSON y lo guarda en la columna dedicada; el resto + /// va al `config_json`. + fn persist_module(&self, module_id: &str) { + let Some(chart) = self.current_chart.as_ref() else { + return; + }; + let Some(config) = self.module_configs.get(module_id) else { + return; + }; + let enabled = config + .get("enabled") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let mut clean = config.clone(); + if let serde_json::Value::Object(map) = &mut clean { + map.remove("enabled"); + } + let state = ModuleState { + chart_id: chart.id, + module_id: module_id.to_string(), + enabled, + config: clean, + }; + if let Err(e) = self.store.upsert_module_state(&state) { + eprintln!("[shell] upsert_module_state {}: {}", module_id, e); + } + } + /// Lee `target_age_years` del módulo o cae a la edad actual del /// sujeto (calculada desde la fecha de nacimiento y el reloj). fn module_age_or_current(&self, module_id: &str) -> f64 { @@ -282,6 +381,7 @@ impl Shell { // module_configs["transit"]["enabled"] + re-render. if matches!(kind, LayerKind::Outer) { set_module_enabled(&mut self.module_configs, "transit", *visible); + self.persist_module("transit"); self.panel.update(cx, |p, cx| { p.set_toggle("transit", "enabled", *visible, cx) }); @@ -353,6 +453,7 @@ impl Shell { self.panel.update(cx, |p, cx| { p.set_toggle(&other_str, "enabled", false, cx) }); + self.persist_module(&other_str); } } } @@ -364,6 +465,7 @@ impl Shell { c.set_layer_visible(LayerKind::Outer, bool_val, cx) }); } + self.persist_module(module_id); self.render_current(cx); } } diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-store/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-store/src/lib.rs index bb345c4..439c1d7 100644 --- a/crates/modules/tahuantinsuyu/tahuantinsuyu-store/src/lib.rs +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-store/src/lib.rs @@ -550,7 +550,7 @@ fn now_ms() -> i64 { #[cfg(test)] mod tests { use super::*; - use tahuantinsuyu_model::{StoredBirthData, StoredChartConfig}; + use tahuantinsuyu_model::{ModuleState, StoredBirthData, StoredChartConfig}; #[test] fn open_and_migrate() { @@ -559,6 +559,80 @@ mod tests { assert!(groups.is_empty()); } + #[test] + fn module_state_roundtrip() { + let s = Store::in_memory().unwrap(); + let g = s.create_group(None, "Familia", None).unwrap(); + let c = s.create_contact(Some(g.id), "Sergio", None).unwrap(); + let chart = s + .create_chart( + c.id, + ChartKind::Natal, + "Natal", + &StoredBirthData { + year: 1987, + month: 3, + day: 14, + hour: 5, + minute: 22, + second: 0.0, + tz_offset_minutes: -240, + latitude_deg: 10.4806, + longitude_deg: -66.9036, + altitude_m: 900.0, + time_certainty: Default::default(), + subject_name: None, + birthplace_label: None, + }, + &StoredChartConfig::default(), + None, + ) + .unwrap(); + + // Persistir dos módulos con configs distintos. + let state1 = ModuleState { + chart_id: chart.id, + module_id: "transit".into(), + enabled: true, + config: serde_json::json!({}), + }; + let state2 = ModuleState { + chart_id: chart.id, + module_id: "progression".into(), + enabled: false, + config: serde_json::json!({ "target_age_years": 42.5 }), + }; + s.upsert_module_state(&state1).unwrap(); + s.upsert_module_state(&state2).unwrap(); + + let loaded = s.list_module_states(chart.id).unwrap(); + assert_eq!(loaded.len(), 2); + let by_id: std::collections::HashMap<_, _> = + loaded.into_iter().map(|m| (m.module_id.clone(), m)).collect(); + assert_eq!(by_id["transit"].enabled, true); + assert_eq!(by_id["progression"].enabled, false); + assert_eq!( + by_id["progression"] + .config + .get("target_age_years") + .and_then(|v| v.as_f64()), + Some(42.5) + ); + + // Upsert: cambiar enabled de transit a false. + let state1_off = ModuleState { + chart_id: chart.id, + module_id: "transit".into(), + enabled: false, + config: serde_json::json!({}), + }; + s.upsert_module_state(&state1_off).unwrap(); + let loaded = s.list_module_states(chart.id).unwrap(); + let by_id: std::collections::HashMap<_, _> = + loaded.into_iter().map(|m| (m.module_id.clone(), m)).collect(); + assert_eq!(by_id["transit"].enabled, false); + } + #[test] fn full_hierarchy_roundtrip() { let s = Store::in_memory().unwrap();