feat(tahuantinsuyu): fase 11 — persistencia de module_configs por carta
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 <noreply@anthropic.com>
This commit is contained in:
@@ -32,7 +32,7 @@ use tahuantinsuyu_canvas::{
|
|||||||
AstrologyCanvas, CanvasEvent, CanvasMode, ThumbnailItem, ThumbnailScope,
|
AstrologyCanvas, CanvasEvent, CanvasMode, ThumbnailItem, ThumbnailScope,
|
||||||
};
|
};
|
||||||
use tahuantinsuyu_engine::{LayerKind, PipelineRequest, compose};
|
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_panel::{ControlPanel, PanelEvent};
|
||||||
use tahuantinsuyu_store::Store;
|
use tahuantinsuyu_store::Store;
|
||||||
use tahuantinsuyu_tree::{TahuantinsuyuTree, TreeEvent};
|
use tahuantinsuyu_tree::{TahuantinsuyuTree, TreeEvent};
|
||||||
@@ -118,9 +118,10 @@ impl Shell {
|
|||||||
let age = current_age_years(&chart.birth_data);
|
let age = current_age_years(&chart.birth_data);
|
||||||
self.current_chart = Some(chart.clone());
|
self.current_chart = Some(chart.clone());
|
||||||
self.current_offset_minutes = 0;
|
self.current_offset_minutes = 0;
|
||||||
// Inicializar la edad objetivo de los módulos basados
|
// 1) Defaults frescos para esta carta: edad objetivo =
|
||||||
// en edad con la edad actual del sujeto, así sus
|
// edad actual. Estos quedan en module_configs como
|
||||||
// sliders arrancan donde corresponde al activarse.
|
// valor base si el usuario nunca tocó el slider.
|
||||||
|
self.module_configs.clear();
|
||||||
for module_id in ["progression", "solar_arc"] {
|
for module_id in ["progression", "solar_arc"] {
|
||||||
let entry = self
|
let entry = self
|
||||||
.module_configs
|
.module_configs
|
||||||
@@ -130,12 +131,15 @@ impl Shell {
|
|||||||
map.insert("target_age_years".into(), serde_json::json!(age));
|
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| {
|
self.panel.update(cx, |p, cx| {
|
||||||
p.set_active_kind(Some(chart.kind), 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) => {
|
TreeSelection::Contact(id) => {
|
||||||
self.current_chart = None;
|
self.current_chart = None;
|
||||||
@@ -225,6 +229,101 @@ impl Shell {
|
|||||||
siblings.into_iter().find(|c| c.id != current.id)
|
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<Self>) {
|
||||||
|
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
|
/// Lee `target_age_years` del módulo o cae a la edad actual del
|
||||||
/// sujeto (calculada desde la fecha de nacimiento y el reloj).
|
/// sujeto (calculada desde la fecha de nacimiento y el reloj).
|
||||||
fn module_age_or_current(&self, module_id: &str) -> f64 {
|
fn module_age_or_current(&self, module_id: &str) -> f64 {
|
||||||
@@ -282,6 +381,7 @@ impl Shell {
|
|||||||
// module_configs["transit"]["enabled"] + re-render.
|
// module_configs["transit"]["enabled"] + re-render.
|
||||||
if matches!(kind, LayerKind::Outer) {
|
if matches!(kind, LayerKind::Outer) {
|
||||||
set_module_enabled(&mut self.module_configs, "transit", *visible);
|
set_module_enabled(&mut self.module_configs, "transit", *visible);
|
||||||
|
self.persist_module("transit");
|
||||||
self.panel.update(cx, |p, cx| {
|
self.panel.update(cx, |p, cx| {
|
||||||
p.set_toggle("transit", "enabled", *visible, cx)
|
p.set_toggle("transit", "enabled", *visible, cx)
|
||||||
});
|
});
|
||||||
@@ -353,6 +453,7 @@ impl Shell {
|
|||||||
self.panel.update(cx, |p, cx| {
|
self.panel.update(cx, |p, cx| {
|
||||||
p.set_toggle(&other_str, "enabled", false, 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)
|
c.set_layer_visible(LayerKind::Outer, bool_val, cx)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
self.persist_module(module_id);
|
||||||
self.render_current(cx);
|
self.render_current(cx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -550,7 +550,7 @@ fn now_ms() -> i64 {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use tahuantinsuyu_model::{StoredBirthData, StoredChartConfig};
|
use tahuantinsuyu_model::{ModuleState, StoredBirthData, StoredChartConfig};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn open_and_migrate() {
|
fn open_and_migrate() {
|
||||||
@@ -559,6 +559,80 @@ mod tests {
|
|||||||
assert!(groups.is_empty());
|
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]
|
#[test]
|
||||||
fn full_hierarchy_roundtrip() {
|
fn full_hierarchy_roundtrip() {
|
||||||
let s = Store::in_memory().unwrap();
|
let s = Store::in_memory().unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user