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>
This commit is contained in:
sergio
2026-05-18 20:11:43 +00:00
parent ca5dd04176
commit 72758e75ce
3 changed files with 209 additions and 5 deletions
+159 -4
View File
@@ -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) {
(