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 · by sergio · 2026-05-18T20:11:43Z (5 months ago) · parent ca5dd0417 · browse the tree at this commit
Semantic delta · 14
crates/apps/tahuantinsuyu/src/shell.rs
crates/modules/tahuantinsuyu/tahuantinsuyu-model/src/lib.rs
crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs
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);
}