gioser

tawasuyu/brahman

Rust 476TOML 190Markdown 50JSON 176602 Sutichaykuna

feat(tahuantinsuyu): "Guardar como…" en módulo Retorno planetario (F4)

Cierra la fase B con el botón pedido por el usuario: tener una
carta natal abierta, activar el módulo Retorno planetario con
edad N + cuerpo (ej. Sol, 34 años), y al click guardar la carta
resultante con sufijo automático `rs-34` en el mismo contacto.

Infraestructura nueva (extensible a otros overlays):
- `Control::Action { key, label }` en tahuantinsuyu-modules —
  un botón sin estado que el panel pinta como pill clickeable.
- `PanelEvent::Action { module_id, key }` que el panel emite
  al click y el shell despacha.
- `render_action` en tahuantinsuyu-panel: pill con bg_button
  + hover + border. Wrap en Div plano para tipo coherente.

Backend (eternal-bridge):
- Nueva función pública `compute_planetary_return_chart(chart,
  body, target_age_years, shift_days) -> (StoredBirthData,
  instant_label)` en `tahuantinsuyu-engine`. Reusa el cómputo
  ya existente del overlay: `next_return` + parser ISO-8601
  para extraer year/mm/dd/hh:mm:ss del instant del retorno.
  Hereda lat/lon/alt/TZ del natal — convención clásica del
  Solar return en la ciudad de nacimiento.

Flujo en el shell:
- Handler `on_panel_action` despacha por `(module_id, key)`. Hoy
  solo `planetary_return.save_as_free` está cableado; otros
  módulos overlay (progression, solar_arc, primary_directions,
  transit) son extensión natural — TODO.
- `save_planetary_return_as_free`:
  1) lee config (body, age, shift_days) del module_configs
  2) llama `compute_planetary_return_chart`
  3) construye un `Chart` clonando el natal con birth_data
     nuevo + label `{contacto} rs-34 · 2024-08-12 14:23 UTC`
     (sufijo según cuerpo: `rs` para Sol, `lunar` para Luna,
     nombre directo para los demás)
  4) inserta como FreeChart con id `free-{N}` y la
     selecciona para que el usuario la vea
- El usuario después puede usar el menú contextual de la
  free chart para "Guardar como…" → modal F3 → persiste
  bajo el contacto que elija (típicamente el del natal).

UX completa:
1. Tener natal abierta
2. Panel: módulo "Retorno planetario" → Activar + elegir
   cuerpo + slider edad
3. Click "💾 Guardar retorno como carta libre"
4. La nueva carta aparece en "Cartas libres" seleccionada
5. Click derecho → "Guardar como…" → elegir contacto +
   confirmar nombre

10 tests verdes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

9db0591f28d9895677bd0875fb51e570be7d830f · ruwaq sergio · (5 killa ñawpaq) · tayta dd836522a · kay commitpi sach'ata qhaway

Semántico sayk'uy · 12

crates/apps/tahuantinsuyu/src/shell.rs

crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs

crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs

crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs

  • silq'uyninpi t'ikrakurqan enum Control pub
  • ukhunpi t'ikrakurqan fn controls

crates/modules/tahuantinsuyu/tahuantinsuyu-panel/src/lib.rs

5 qillqakuna · +251 −0

diff --git a/crates/apps/tahuantinsuyu/src/shell.rs b/crates/apps/tahuantinsuyu/src/shell.rs
index 0cd033e..15604de 100644
--- a/crates/apps/tahuantinsuyu/src/shell.rs
+++ b/crates/apps/tahuantinsuyu/src/shell.rs
@@ -1300,6 +1300,83 @@ impl Shell {
                 // Fase 7: encender/apagar módulos enteros desde un
                 // header con switch (vs. el toggle por-control de hoy).
             }
+            PanelEvent::Action { module_id, key } => {
+                self.on_panel_action(module_id.clone(), key.clone(), cx);
+            }
+        }
+    }
+
+    /// Click sobre un `Control::Action` del panel. Por ahora maneja:
+    /// - planetary_return.save_as_free → captura la carta del
+    ///   retorno actual (cuerpo + edad) como FreeChart con sufijo
+    ///   `rs-{N}` / `lunar-{N}` / etc. según el cuerpo elegido.
+    ///
+    /// Otros módulos overlay (progression, solar_arc, primary_directions)
+    /// son extensión natural — TODO.
+    fn on_panel_action(&mut self, module_id: String, key: String, cx: &mut Context<Self>) {
+        if module_id == "planetary_return" && key == "save_as_free" {
+            self.save_planetary_return_as_free(cx);
+        }
+    }
+
+    /// Computa la carta del retorno planetario actual (con cuerpo +
+    /// edad del módulo) y la inserta como FreeChart. El usuario
+    /// puede después "Guardar como…" para persistirla bajo un
+    /// contacto (típicamente el mismo del natal).
+    fn save_planetary_return_as_free(&mut self, cx: &mut Context<Self>) {
+        let Some(natal) = self.current_chart.as_ref() else {
+            eprintln!("[shell] save_planetary_return: sin carta activa");
+            return;
+        };
+        if natal.id == ChartId::default() {
+            eprintln!("[shell] save_planetary_return: la carta activa es libre, no natal");
+            return;
+        }
+        let cfg = self
+            .module_configs
+            .get("planetary_return")
+            .cloned()
+            .unwrap_or(serde_json::json!({}));
+        let body = cfg
+            .get("body")
+            .and_then(|v| v.as_str())
+            .unwrap_or("sun")
+            .to_string();
+        let age = self.module_age_or_current("planetary_return");
+        let shift_days = cfg
+            .get("shift_days")
+            .and_then(|v| v.as_f64())
+            .unwrap_or(0.0) as i64;
+
+        // Pedimos al engine la fecha exacta del retorno. La engine
+        // expone `compute_planetary_return_chart` que devuelve un
+        // `StoredBirthData` listo para reusar como carta natal.
+        match tahuantinsuyu_engine::compute_planetary_return_chart(
+            natal, &body, age, shift_days,
+        ) {
+            Ok((birth, instant_label)) => {
+                let suffix = match body.as_str() {
+                    "sun" => "rs",
+                    "moon" => "lunar",
+                    other => other,
+                };
+                let label = format!(
+                    "{} {}-{:.0}a · {}",
+                    natal.label, suffix, age, instant_label
+                );
+                let id = FreeChartId(format!("free-{}", self.next_free_id));
+                self.next_free_id += 1;
+                let mut chart = natal.clone();
+                chart.id = ChartId::default();
+                chart.label = label;
+                chart.birth_data = birth;
+                self.free_charts.insert(id.clone(), chart);
+                self.push_free_charts_to_tree(cx);
+                self.apply_selection(TreeSelection::FreeChart(id), cx);
+            }
+            Err(e) => {
+                eprintln!("[shell] compute_planetary_return_chart: {}", e);
+            }
         }
     }
 }
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs
index d77acaf..8fbe0bc 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs
@@ -1017,6 +1017,100 @@ fn build_synastry_overlay(
 /// Sun = retorno solar anual, Moon = mensual, Júpiter/Saturno =
 /// generacionales. Esa nueva carta va en el anillo externo (compartido
 /// con Transit/Synastry, mutuamente excluyentes a nivel de Shell).
+/// Computa la carta del retorno planetario actual y devuelve los
+/// datos necesarios para construir un `Chart` standalone que el
+/// caller puede mostrar/persistir.
+///
+/// Devuelve `(StoredBirthData, instant_label)`:
+/// - `StoredBirthData` con birth_data del retorno (year/month/day/...
+///   del instante del retorno, mismas coordenadas que el natal).
+/// - `instant_label` formato corto del momento (ej. "2024-03-14
+///   05:22 UTC") — el shell lo concatena en el label final.
+pub fn compute_planetary_return_chart(
+    chart: &Chart,
+    body_str: &str,
+    target_age_years: f64,
+    shift_days: i64,
+) -> Result<(tahuantinsuyu_model::StoredBirthData, String), EngineError> {
+    let (birth_e, config_e, _observer) = build_eternal_inputs(chart, 0)?;
+    let session = session()?;
+    let natal = NatalChart::compute(&birth_e, &config_e, session)
+        .map_err(|e| EngineError::Eternal(format!("NatalChart::compute: {:?}", e)))?;
+    let body = map_body(body_str)
+        .ok_or_else(|| EngineError::Eternal(format!("body desconocido: {}", body_str)))?;
+    let natal_p = natal.placement(body).ok_or_else(|| {
+        EngineError::Eternal(format!(
+            "natal chart sin {} — return imposible",
+            body.name()
+        ))
+    })?;
+    let natal_lon = natal_p.longitude.longitude_rad();
+
+    let after_seconds =
+        (target_age_years * 365.242190 - 30.0 + shift_days as f64) * 86400.0;
+    const TWO_TROPICAL: f64 = 365.242190 * 86400.0 * 2.0;
+    let after_utc = natal
+        .birth
+        .instant
+        .utc()
+        .add_seconds(after_seconds.max(-TWO_TROPICAL));
+    let after = ESInstant::from_utc(after_utc);
+
+    let return_instant = next_return(session, body, natal_lon, after, None)
+        .map_err(|e| EngineError::Eternal(format!("next_return {}: {:?}", body.name(), e)))?;
+
+    // Extraer year/month/day/hour/min/sec del momento del retorno.
+    // `to_iso8601` devuelve "YYYY-MM-DDTHH:MM:SS.sss" — parseamos los
+    // 5 campos relevantes. La precisión está en sub-segundo; usamos
+    // segundo entero (StoredBirthData::second es f64 pero el campo
+    // se persiste así).
+    let iso = return_instant.utc().to_iso8601();
+    let (year, month, day, hour, minute, second) = parse_iso8601_components(&iso)
+        .ok_or_else(|| EngineError::Eternal(format!("iso8601 inválido: {}", iso)))?;
+
+    let stored = tahuantinsuyu_model::StoredBirthData {
+        year,
+        month,
+        day,
+        hour,
+        minute,
+        second,
+        // El return se computa en la TZ del observador natal (es la
+        // convención clásica del Solar return). Heredamos también
+        // lat/lon/alt.
+        tz_offset_minutes: chart.birth_data.tz_offset_minutes,
+        latitude_deg: chart.birth_data.latitude_deg,
+        longitude_deg: chart.birth_data.longitude_deg,
+        altitude_m: chart.birth_data.altitude_m,
+        time_certainty: Default::default(),
+        subject_name: chart.birth_data.subject_name.clone(),
+        birthplace_label: chart.birth_data.birthplace_label.clone(),
+    };
+    let label = format!(
+        "{:04}-{:02}-{:02} {:02}:{:02} UTC",
+        year, month, day, hour, minute
+    );
+    Ok((stored, label))
+}
+
+/// Parsea "YYYY-MM-DDTHH:MM:SS[.fff]" a `(year, month, day, hour,
+/// minute, second_float)`. Retorna `None` si el formato no encaja.
+fn parse_iso8601_components(s: &str) -> Option<(i32, u32, u32, u32, u32, f64)> {
+    // Split en T y luego campo por campo.
+    let mut parts = s.splitn(2, 'T');
+    let date = parts.next()?;
+    let time = parts.next()?;
+    let mut d = date.split('-');
+    let year: i32 = d.next()?.parse().ok()?;
+    let month: u32 = d.next()?.parse().ok()?;
+    let day: u32 = d.next()?.parse().ok()?;
+    let mut t = time.split(':');
+    let hour: u32 = t.next()?.parse().ok()?;
+    let minute: u32 = t.next()?.parse().ok()?;
+    let second: f64 = t.next()?.parse().ok()?;
+    Some((year, month, day, hour, minute, second))
+}
+
 /// Cross aspects natal × return.
 fn build_planetary_return_overlay(
     natal: &NatalChart,
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs
index 309a451..015c0c6 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs
@@ -422,6 +422,21 @@ pub fn compute_with_transits_at_now(
     compose(chart, offset_minutes, &[PipelineRequest::Transit])
 }
 
+/// Computa la carta del retorno planetario actual (cuerpo + edad)
+/// como `StoredBirthData` standalone — la app la usa para crear
+/// una `FreeChart` que el usuario puede después persistir en un
+/// contacto. Devuelve también un label-corto del instante para
+/// concatenar al nombre.
+#[cfg(feature = "eternal-bridge")]
+pub fn compute_planetary_return_chart(
+    chart: &Chart,
+    body: &str,
+    target_age_years: f64,
+    shift_days: i64,
+) -> Result<(tahuantinsuyu_model::StoredBirthData, String), EngineError> {
+    bridge::compute_planetary_return_chart(chart, body, target_age_years, shift_days)
+}
+
 /// Helper retrocompatible: construye un `PlanetaryReturn` con
 /// `shift_days = 0`. Útil para llamadores que no necesitan ajuste
 /// fino (todos los Solar return y muchos casos básicos).
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs
index 27ce5a6..229cc11 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs
@@ -109,6 +109,14 @@ pub enum Control {
         key: String,
         label: String,
     },
+    /// Botón sin estado — el click dispara un `PanelEvent::Action`
+    /// con `key`. El panel lo pinta como pill clickeable. Útil para
+    /// "Guardar como carta libre" en los módulos overlay con
+    /// transformación (RS, progresión, solar arc, GR).
+    Action {
+        key: String,
+        label: String,
+    },
 }
 
 #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -507,6 +515,14 @@ pub mod planetary_return {
                     step: 1.0,
                     default: 0.0,
                 },
+                // Botón: captura la carta del retorno actual (cuerpo +
+                // edad) como FreeChart con label `{contacto} rs-{N}`
+                // (o `lunar-{N}` etc. según el cuerpo). El usuario
+                // luego decide si guardarla en un contacto.
+                Control::Action {
+                    key: "save_as_free".into(),
+                    label: "💾 Guardar retorno como carta libre".into(),
+                },
             ]
         }
         fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-panel/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-panel/src/lib.rs
index d2a127f..6ca8af1 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-panel/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-panel/src/lib.rs
@@ -45,6 +45,13 @@ pub enum PanelEvent {
         key: String,
         value: serde_json::Value,
     },
+    /// Click sobre un `Control::Action`. El shell decide qué hacer
+    /// (típicamente: capturar la carta derivada del overlay como
+    /// `FreeChart`).
+    Action {
+        module_id: String,
+        key: String,
+    },
 }
 
 /// Opción que el host inyecta al panel para que los `Control::ChartPicker`
@@ -544,9 +551,51 @@ impl ControlPanel {
                 default,
             } => self.render_select(theme, module_id, key, label, options, default, cx),
             Control::TextInput { label, default, .. } => display_row(theme, label, default),
+            Control::Action { key, label } => {
+                self.render_action(theme, module_id, key, label, cx)
+            }
         }
     }
 
+    fn render_action(
+        &self,
+        theme: &Theme,
+        module_id: &str,
+        key: &str,
+        label: &str,
+        cx: &mut Context<'_, Self>,
+    ) -> gpui::Div {
+        let id_str: SharedString =
+            SharedString::from(format!("tts-action-{}-{}", module_id, key));
+        let id_for_listener = (module_id.to_string(), key.to_string());
+        let btn = div()
+            .id(gpui::ElementId::from(id_str))
+            .flex()
+            .flex_row()
+            .items_center()
+            .justify_center()
+            .px(px(10.0))
+            .py(px(5.0))
+            .rounded(px(6.0))
+            .bg(theme.bg_button())
+            .hover(|s| s.bg(theme.bg_button_hover()))
+            .border_1()
+            .border_color(theme.border)
+            .text_size(px(11.0))
+            .text_color(theme.fg_text)
+            .child(SharedString::from(label.to_string()))
+            .on_click(cx.listener(move |_this, _: &ClickEvent, _, cx| {
+                let (m, k) = id_for_listener.clone();
+                cx.emit(PanelEvent::Action {
+                    module_id: m,
+                    key: k,
+                });
+            }));
+        // Wrap en Div plano para que el tipo coincida con el resto
+        // de los renderers (`render_control` espera `gpui::Div`).
+        div().child(btn)
+    }
+
     fn render_toggle(
         &self,
         theme: &Theme,