tawasuyu

tawasuyu/brahman

Rust 483TOML 191Markdown 50JSON 176777 Definiciones

feat(tahuantinsuyu): "Cielo ahora" + "General" como rows fijos al top del tree

Dos entradas siempre presentes en la cima del árbol:

1. **⏱ Cielo ahora** (leaf): selecciona una carta efímera del
   instante actual en Greenwich (UTC, lat 51.4769°, lon 0°,
   alt 47 m). NO se persiste en la store — `build_present_sky_chart`
   la construye al vuelo con `Chart { id: Default::default(), ... }`
   y birth_data tomado de `SystemTime::now()` via `unix_to_civil_utc`
   (algoritmo Howard Hinnant, exacto y proleptic-Gregoriano).

   La carta queda **seleccionada por default** al boot — el usuario
   abre la app y ya está viendo el firmamento actual, incluso si
   no tiene contactos cargados.

2. **◇ General** (branch): contenedor virtual para los contactos
   sin grupo asignado (parent=None). Antes esos contactos
   aparecían sueltos al nivel raíz; ahora viven dentro de
   "General" y se ofrece como destino claro para "Nuevo
   contacto" desde su menú. Click sobre General muestra
   thumbnails de TODAS las cartas de esos contactos en el canvas.

Soporte en `TreeSelection`: dos variantes nuevas `PresentSky` y
`GeneralRoot`. `parse_row` reconoce los IDs sentinela `sky:now`
y `general`. El shell maneja ambos casos en `apply_selection`:
- PresentSky → set `current_chart` + render
- GeneralRoot → grilla de thumbnails

`MenuTarget::from_selection` mapea PresentSky/GeneralRoot →
MenuTarget::Root (mismo menú "Nuevo grupo / Nuevo contacto").

`unix_to_civil_utc` con 4 tests cubre: epoch (1970-01-01),
2024-02-29 (año bisiesto), pre-epoch (-1 → 1969-12-31), y
year 2000.

Total 10 tests verdes (6 anteriores + 4 nuevos del calendario).

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

72758e75ce515f355f27615671c95ecaf8c2a8bc · por sergio · (hace 5 meses) · padre ca5dd0417 · ver el árbol en este commit

Delta semántico · 14

crates/apps/tahuantinsuyu/src/shell.rs

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

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

3 archivos · +209 −5

diff --git a/crates/apps/tahuantinsuyu/src/shell.rs b/crates/apps/tahuantinsuyu/src/shell.rs
index 5a5e8d3..744e99d 100644
--- a/crates/apps/tahuantinsuyu/src/shell.rs
+++ b/crates/apps/tahuantinsuyu/src/shell.rs
@@ -35,7 +35,10 @@ use tahuantinsuyu_engine::{
     LayerKind, NatalOptions, OUTER_RING_MODULES, PipelineRequest, compose_with_options,
     svg_export,
 };
-use tahuantinsuyu_model::{Chart, ChartId, ModuleState, TreeSelection};
+use tahuantinsuyu_model::{
+    Chart, ChartId, ChartKind, ContactId, ModuleState, StoredBirthData, StoredChartConfig,
+    TreeSelection,
+};
 use tahuantinsuyu_panel::{ChartOption, ControlPanel, PanelEvent};
 use tahuantinsuyu_store::Store;
 use tahuantinsuyu_tree::{parse_city_atlas_tsv, TahuantinsuyuTree, TreeEvent};
@@ -205,6 +208,10 @@ impl Shell {
         shell.apply_dock(dock, cx);
         shell.refresh_chart_options(cx);
         shell.spawn_brahman_status_loop(cx);
+        // Carta "Cielo ahora" cargada por default al boot — el usuario
+        // siempre arranca viendo el estado del firmamento actual,
+        // incluso si la store está vacía.
+        shell.apply_selection(TreeSelection::PresentSky, cx);
         shell
     }
 
@@ -507,6 +514,61 @@ impl Shell {
                 });
                 self.panel.update(cx, |p, cx| p.set_active_kind(None, cx));
             }
+            TreeSelection::GeneralRoot => {
+                // "General" agrupa los contactos sin grupo padre. El
+                // canvas muestra thumbnails de TODAS las cartas de
+                // esos contactos.
+                self.current_chart = None;
+                self.current_offset_minutes = 0;
+                let mut items: Vec<ThumbnailItem> = Vec::new();
+                if let Ok(contacts) = self.store.list_contacts(None) {
+                    for ct in contacts {
+                        if let Ok(charts) = self.store.list_charts(ct.id) {
+                            for c in charts {
+                                items.push(ThumbnailItem {
+                                    chart_id: c.id,
+                                    label: SharedString::from(c.label),
+                                    subtitle: Some(SharedString::from(format!(
+                                        "{} · {:?}",
+                                        ct.name, c.kind
+                                    ))),
+                                    preview: None,
+                                });
+                            }
+                        }
+                    }
+                }
+                // Reusamos el scope Group con un id sentinela "vacío":
+                // como GeneralRoot no es un Group real, dejamos que el
+                // canvas pinte la grilla con el set de items y nada
+                // más — el `scope` no se usa para nada que requiera
+                // el id.
+                self.canvas.update(cx, |c, cx| {
+                    c.set_mode(
+                        CanvasMode::Thumbnails {
+                            scope: ThumbnailScope::Group(Default::default()),
+                            items,
+                        },
+                        cx,
+                    );
+                });
+                self.panel.update(cx, |p, cx| p.set_active_kind(None, cx));
+            }
+            TreeSelection::PresentSky => {
+                // Carta efímera del momento: birth_data = ahora en
+                // Greenwich (UTC, lat=0, lon=0). Se construye al
+                // vuelo, no se persiste — el id sintético es
+                // `Default::default()`. Cada selección de PresentSky
+                // recomputa contra el reloj actual.
+                let chart = build_present_sky_chart();
+                self.current_chart = Some(chart);
+                self.current_offset_minutes = 0;
+                self.module_configs.clear();
+                self.panel
+                    .update(cx, |p, cx| p.set_active_kind(Some(ChartKind::Natal), cx));
+                self.sync_panel_from_configs(cx);
+                self.render_current(cx);
+            }
         }
     }
 
@@ -1040,6 +1102,76 @@ fn load_city_atlas_from_xdg() -> Option<Vec<tahuantinsuyu_tree::CityPreset>> {
     Some(atlas)
 }
 
+/// Carta efímera del "Cielo ahora": birth_data = momento actual en
+/// Greenwich (UTC, lat 51.4769°, lon 0°). El `Chart` se construye al
+/// vuelo, NO se persiste en la store, y los IDs son `Default` (todo
+/// ceros) — la carta es un singleton conceptual de la vista, no un
+/// registro. Los módulos que consultan `current_chart.id` deben
+/// tolerar este ID sentinela.
+fn build_present_sky_chart() -> Chart {
+    use std::time::{SystemTime, UNIX_EPOCH};
+    let secs = SystemTime::now()
+        .duration_since(UNIX_EPOCH)
+        .map(|d| d.as_secs() as i64)
+        .unwrap_or(0);
+    let (year, month, day, hour, minute, second) = unix_to_civil_utc(secs);
+    let birth = StoredBirthData {
+        year,
+        month,
+        day,
+        hour,
+        minute,
+        second: second as f64,
+        tz_offset_minutes: 0,
+        // Greenwich Royal Observatory — origen histórico del meridiano
+        // primario. Lat = 51°28'38"N, Lon = 0°.
+        latitude_deg: 51.4769,
+        longitude_deg: 0.0,
+        altitude_m: 47.0,
+        time_certainty: Default::default(),
+        subject_name: Some("Cielo".into()),
+        birthplace_label: Some("Greenwich (UTC)".into()),
+    };
+    Chart {
+        id: ChartId::default(),
+        contact_id: ContactId::default(),
+        kind: ChartKind::Natal,
+        label: format!(
+            "Cielo {:04}-{:02}-{:02} {:02}:{:02} UTC",
+            year, month, day, hour, minute
+        ),
+        birth_data: birth,
+        config: StoredChartConfig::default(),
+        related_chart_id: None,
+        created_at_ms: 0,
+    }
+}
+
+/// Convierte un timestamp Unix (segundos UTC desde 1970-01-01) a
+/// componentes calendario proleptic-Gregorianos `(year, month, day,
+/// hour, minute, second)`. Algoritmo de Howard Hinnant
+/// (`days_to_civil`), exacto en todo el rango representable por i64.
+fn unix_to_civil_utc(secs: i64) -> (i32, u32, u32, u32, u32, u32) {
+    let day_seconds: i64 = 86_400;
+    let z = secs.div_euclid(day_seconds);
+    let s = secs.rem_euclid(day_seconds);
+    // Hinnant: shift z para que el "era" empiece en 0000-03-01.
+    let z = z + 719_468;
+    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
+    let doe = (z - era * 146_097) as u32; // [0, 146096]
+    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
+    let y = yoe as i64 + era * 400;
+    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
+    let mp = (5 * doy + 2) / 153; // [0, 11]
+    let day = doy - (153 * mp + 2) / 5 + 1;
+    let month = if mp < 10 { mp + 3 } else { mp - 9 };
+    let year = if month <= 2 { (y + 1) as i32 } else { y as i32 };
+    let hour = (s / 3600) as u32;
+    let minute = ((s % 3600) / 60) as u32;
+    let second = (s % 60) as u32;
+    (year, month, day, hour, minute, second)
+}
+
 /// Etiqueta breve para mostrar al elegir una carta en el picker:
 /// `"YYYY-MM-DD · Lugar"` cuando hay lugar, sino solo la fecha.
 fn format_birth_brief(birth: &tahuantinsuyu_model::StoredBirthData) -> String {
@@ -1286,9 +1418,32 @@ impl Render for Shell {
 mod tests {
     use super::*;
     use gpui::TestAppContext;
-    use tahuantinsuyu_model::{
-        ChartKind, ContactId, StoredBirthData, StoredChartConfig,
-    };
+
+    #[test]
+    fn unix_to_civil_at_epoch() {
+        assert_eq!(unix_to_civil_utc(0), (1970, 1, 1, 0, 0, 0));
+    }
+
+    #[test]
+    fn unix_to_civil_known_dates() {
+        // 2024-01-01T00:00:00 UTC = 1704067200
+        assert_eq!(unix_to_civil_utc(1_704_067_200), (2024, 1, 1, 0, 0, 0));
+        // 2024-02-29T12:34:56 UTC = año bisiesto
+        let secs = 1_704_067_200 + (31 + 28) * 86_400 + 12 * 3600 + 34 * 60 + 56;
+        assert_eq!(unix_to_civil_utc(secs), (2024, 2, 29, 12, 34, 56));
+    }
+
+    #[test]
+    fn unix_to_civil_pre_epoch_wraps_correctly() {
+        // -1 segundo = 1969-12-31T23:59:59 UTC
+        assert_eq!(unix_to_civil_utc(-1), (1969, 12, 31, 23, 59, 59));
+    }
+
+    #[test]
+    fn unix_to_civil_year_2000() {
+        // 2000-01-01T00:00:00 UTC = 946684800
+        assert_eq!(unix_to_civil_utc(946_684_800), (2000, 1, 1, 0, 0, 0));
+    }
 
     fn sample_chart_for(_contact_id: ContactId) -> (StoredBirthData, StoredChartConfig) {
         (
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-model/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-model/src/lib.rs
index 52a4604..3737d6a 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-model/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-model/src/lib.rs
@@ -315,11 +315,17 @@ pub struct ModuleState {
 /// Item activo del tree. El canvas reacciona a este tipo:
 /// - `Chart` → abre la carta puntual.
 /// - `Contact` / `Group` → muestra thumbnails de las cartas descendientes.
+/// - `PresentSky` → carta efímera del instante presente (cielo ahora);
+///   no vive en la store, se computa al vuelo cuando el host la pide.
+/// - `GeneralRoot` → nodo branch virtual que agrupa los contactos
+///   sin grupo padre (contacts con parent=None).
 #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
 pub enum TreeSelection {
     Group(GroupId),
     Contact(ContactId),
     Chart(ChartId),
+    PresentSky,
+    GeneralRoot,
 }
 
 // =====================================================================
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs
index 380c066..3f29abe 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs
@@ -41,6 +41,9 @@ use yahweh_widget_tree::{RowId, RowKind, TreeEvent as InnerTreeEvent, TreeRow, T
 const PREFIX_GROUP: &str = "g:";
 const PREFIX_CONTACT: &str = "c:";
 const PREFIX_CHART: &str = "h:";
+/// IDs sentinela para los dos rows virtuales fijos al top del tree.
+const ROW_SKY_NOW: &str = "sky:now";
+const ROW_GENERAL: &str = "general";
 
 // =====================================================================
 // Eventos públicos
@@ -74,6 +77,12 @@ impl MenuTarget {
             TreeSelection::Group(id) => MenuTarget::Group(*id),
             TreeSelection::Contact(id) => MenuTarget::Contact(*id),
             TreeSelection::Chart(id) => MenuTarget::Chart(*id),
+            // "General" comparte menu con Root — el target lógico es
+            // crear contactos sin grupo padre.
+            TreeSelection::GeneralRoot => MenuTarget::Root,
+            // "Cielo ahora" no admite operaciones de menu — es una
+            // carta efímera del momento, no se edita ni borra.
+            TreeSelection::PresentSky => MenuTarget::Root,
         }
     }
 }
@@ -382,8 +391,36 @@ impl TahuantinsuyuTree {
 
     pub fn refresh(&mut self, cx: &mut Context<'_, Self>) {
         let mut rows = Vec::new();
+
+        // 1) Cielo ahora — siempre al top, leaf, ícono distintivo.
+        //    No participa de search filter (es atemporal).
+        rows.push(TreeRow {
+            id: RowId::new(ROW_SKY_NOW.to_string()),
+            label: "Cielo ahora".to_string(),
+            depth: 0,
+            kind: RowKind::Leaf,
+            expanded: false,
+            icon: Some("⏱".into()),
+        });
+
+        // 2) General — branch fijo. Contiene los contactos sin grupo
+        //    asignado (parent=None). Aparece siempre, sin filtro.
+        let general_expanded = self.expanded.contains(ROW_GENERAL);
+        rows.push(TreeRow {
+            id: RowId::new(ROW_GENERAL.to_string()),
+            label: "General".to_string(),
+            depth: 0,
+            kind: RowKind::Branch,
+            expanded: general_expanded,
+            icon: Some("◇".into()),
+        });
+        if general_expanded {
+            self.append_contacts(None, 1, &mut rows);
+        }
+
+        // 3) Resto: groups top-level con sus contenidos.
         self.append_groups(None, 0, &mut rows);
-        self.append_contacts(None, 0, &mut rows);
+
         self.inner.update(cx, |t, cx| t.set_rows(rows, cx));
     }
 
@@ -1189,6 +1226,12 @@ fn find_contact_in_groups(
 
 fn parse_row(id: &RowId) -> Option<TreeSelection> {
     let s = id.as_str();
+    if s == ROW_SKY_NOW {
+        return Some(TreeSelection::PresentSky);
+    }
+    if s == ROW_GENERAL {
+        return Some(TreeSelection::GeneralRoot);
+    }
     if let Some(rest) = s.strip_prefix(PREFIX_GROUP) {
         return rest.parse().ok().map(TreeSelection::Group);
     }