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
@@ -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,
}
// =====================================================================
@@ -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);
}