From a6366b4d50496660a7a9f894aaa8e3d3edc21092 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sun, 17 May 2026 20:40:43 +0200 Subject: [PATCH] feat(skill_tree): Dashboard panel + ghost-nodes + mock generate ideas Cierra 0109k (Dashboard) + 0109h fase 1 (ghost-nodes framework con mock LLM). Dashboard (Ctrl+3 / menu View / Dashboard): - HUD: LV global = floor(sqrt(XP_total)) + barra de progreso al next level - Conteo done/planned/todo/drafts - Tabla por dominio sortable: Done/Planned/Todo/Progreso bar/LV - Top 3 dominios masterizados - Top 3 dominios proximos a desbloquearse (mas todo count) - XP scheme: epic=10, infra=4, feature=3, refactor/spike/planning=2, bugfix/chore/docs=1, flow=5 Ghost-nodes framework: - DraftNode struct con animacion emerge desde source -> target ring/sector - g_drafts buffer in-memory (NO persistido, viven hasta promote/discard) - g_sel_kind tagged: SelKind::None|Node|Draft (sustituye int g_selected) - Inspector pivota a draft view cuando seleccion es draft - Pass 3 de render en canvas: ghost-nodes con pulse alpha + label visible Generate ideas button (Inspector de nodos reales): - Color emerald - On click: mock_generate_ideas(node) genera 3-5 drafts hardcoded - TODO 0109h2: spawn claude -p real con prompt contextual + parse JSON Promote buttons (Inspector de drafts): - [Generate issue] -> escribe dev/issues/NNNN-.md (next_issue_id scan) - [Generate flow] -> escribe dev/flows/NNNN-.md (next_flow_id scan) - [Discard] -> elimina del buffer Self-test: 171 nodes, parse_errors=0, unmapped=0. Co-Authored-By: Claude Opus 4.7 (1M context) --- main.cpp | 546 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 535 insertions(+), 11 deletions(-) diff --git a/main.cpp b/main.cpp index 2fb18b2..fc0c9a5 100644 --- a/main.cpp +++ b/main.cpp @@ -293,14 +293,44 @@ static void derive_status_eff(std::vector& nodes) { } } +// ---- Draft (ghost-node) model ------------------------------------------- + +struct DraftNode { + std::string id; // "tmp_" + std::string source_id; // id del nodo que la genero + std::string title; + std::string description; + std::string proposed_type; // "issue" | "flow" + std::string proposed_domain; // primer dominio sugerido + std::string proposed_priority; // alta|media|baja + std::vector proposed_dod; + + // posicion actual del ghost, animada desde source hasta target ring/sector. + float src_x = 0, src_y = 0; + float tgt_x = 0, tgt_y = 0; + double spawn_t = 0; // anim start + bool anim_done = false; // tras 1.4s deja de moverse +}; + // ---- UI state ----------------------------------------------------------- static fs::path g_root; static ScanResult g_scan; + +// Drafts viven en memoria hasta promote o discard. NO se persisten. +static std::vector g_drafts; +static int g_next_draft_n = 1; + +// Tagged selection: Node (index in g_scan.nodes) o Draft (index in g_drafts). +enum class SelKind { None, Node, Draft }; +static SelKind g_sel_kind = SelKind::None; +static int g_sel_index = -1; + static bool g_show_tree = true; static bool g_show_inspector = true; -static int g_selected = -1; +static bool g_show_dashboard = true; static int g_hover = -1; +static SelKind g_hover_kind = SelKind::None; static float g_cam_x = 0.0f; static float g_cam_y = 0.0f; static float g_cam_zoom = 1.0f; @@ -486,7 +516,8 @@ static void reload_scan() { g_scan = std::move(fresh); apply_layout(g_scan.nodes); recount(g_scan); - g_selected = -1; + g_sel_kind = SelKind::None; + g_sel_index = -1; fn_log::log_info("skill_tree: reloaded %d nodes, %d parse errors", (int)g_scan.nodes.size(), g_scan.parse_errors); } @@ -618,9 +649,11 @@ static void draw_canvas() { // Picking + node draw. Two passes: issues first (background), flows on top. g_hover = -1; + g_hover_kind = SelKind::None; const ImVec2 mp = ImGui::GetMousePos(); const float node_r_issue = kNodeRadius * g_cam_zoom; const float node_r_flow = kNodeRadius * kFlowRadiusMul * g_cam_zoom; + const float node_r_draft = kNodeRadius * 1.25f * g_cam_zoom; auto draw_one = [&](int i, bool flow_pass) { const auto& n = g_scan.nodes[i]; @@ -635,7 +668,7 @@ static void draw_canvas() { float dx = mp.x - sp.x, dy = mp.y - sp.y; bool over = hovered && (dx * dx + dy * dy) < r * r; - if (over) g_hover = i; + if (over) { g_hover = i; g_hover_kind = SelKind::Node; } ImU32 col = bucket_color(status_bucket(n.status_eff)); if (is_flow) { @@ -647,16 +680,16 @@ static void draw_canvas() { { sp.x - r, sp.y }, }; dl->AddConvexPolyFilled(pts, 4, col); - ImU32 outline = (g_selected == i) ? IM_COL32(255, 255, 255, 255) + ImU32 outline = ((g_sel_kind == SelKind::Node && g_sel_index == i)) ? IM_COL32(255, 255, 255, 255) : IM_COL32(34, 211, 238, 255); // cyan-400 always dl->AddPolyline(pts, 4, outline, ImDrawFlags_Closed, - (g_selected == i) ? 3.0f : 2.0f); + ((g_sel_kind == SelKind::Node && g_sel_index == i)) ? 3.0f : 2.0f); } else { dl->AddCircleFilled(sp, r, col, 20); - ImU32 outline = (g_selected == i) ? IM_COL32(255, 255, 255, 255) + ImU32 outline = ((g_sel_kind == SelKind::Node && g_sel_index == i)) ? IM_COL32(255, 255, 255, 255) : (over ? IM_COL32(255, 255, 255, 220) : IM_COL32(255, 255, 255, 90)); - dl->AddCircle(sp, r, outline, 20, g_selected == i ? 2.5f : 1.0f); + dl->AddCircle(sp, r, outline, 20, (g_sel_kind == SelKind::Node && g_sel_index == i) ? 2.5f : 1.0f); } // Text only when zoomed in or this is a flow (always show flow labels). @@ -690,9 +723,66 @@ static void draw_canvas() { for (int i = 0; i < (int)g_scan.nodes.size(); ++i) draw_one(i, false); for (int i = 0; i < (int)g_scan.nodes.size(); ++i) draw_one(i, true); + // Pass 3: drafts (ghost-nodes). Emerge desde source y se animan al target. + const double now_draft = now_seconds(); + for (int i = 0; i < (int)g_drafts.size(); ++i) { + DraftNode& d = g_drafts[i]; + float t = float((now_draft - d.spawn_t) / 1.4); + if (t < 0) continue; + if (t > 1.0f) { t = 1.0f; d.anim_done = true; } + float e = t * t * (3.0f - 2.0f * t); // ease-in-out + float wx = d.src_x + (d.tgt_x - d.src_x) * e; + float wy = d.src_y + (d.tgt_y - d.src_y) * e; + ImVec2 sp(origin_with_pan.x + wx * g_cam_zoom, + origin_with_pan.y + wy * g_cam_zoom); + + if (sp.x < p0.x - node_r_draft || sp.x > p1.x + node_r_draft) continue; + if (sp.y < p0.y - node_r_draft || sp.y > p1.y + node_r_draft) continue; + + float ddx = mp.x - sp.x, ddy = mp.y - sp.y; + bool over = hovered && (ddx * ddx + ddy * ddy) < node_r_draft * node_r_draft; + if (over) { g_hover = i; g_hover_kind = SelKind::Draft; } + + // Color tipo: issue=azul, flow=cyan. Pulsing alpha. + float pulse = 0.55f + 0.35f * std::sin(float(now_draft) * 4.5f); + ImU32 fill = (d.proposed_type == "flow") + ? IM_COL32( 14, 165, 233, int(pulse * 200)) // sky-500 + : IM_COL32( 59, 130, 246, int(pulse * 200)); // blue-500 + ImU32 ring = IM_COL32(255, 255, 255, int(pulse * 230)); + + // Outline pulse (mas grande que el fill) — dashes via segmented circle. + dl->AddCircle(sp, node_r_draft * 1.45f, ring, 32, 1.0f); + dl->AddCircleFilled(sp, node_r_draft, fill, 24); + bool is_sel = (g_sel_kind == SelKind::Draft && g_sel_index == i); + dl->AddCircle(sp, node_r_draft, is_sel ? IM_COL32(255, 255, 255, 255) : IM_COL32(255, 255, 255, 200), + 24, is_sel ? 2.5f : 1.4f); + + // Label: TMP + short title prefix. + if (g_cam_zoom > 0.55f) { + std::string lbl = std::string("? ") + d.title.substr(0, 18); + ImVec2 ts = ImGui::CalcTextSize(lbl.c_str()); + ImVec2 tp(sp.x - ts.x * 0.5f, sp.y + node_r_draft + 2.0f); + dl->AddText(ImVec2(tp.x + 1, tp.y + 1), IM_COL32(0, 0, 0, 220), lbl.c_str()); + dl->AddText(tp, IM_COL32(255, 255, 255, 245), lbl.c_str()); + } + + if (over) { + const float kTipW = 360.0f; + ImGui::SetNextWindowSize(ImVec2(kTipW, 0.0f), ImGuiCond_Always); + ImGui::BeginTooltip(); + ImGui::PushTextWrapPos(kTipW - 16.0f); + ImGui::Text("[DRAFT] %s", d.proposed_type.c_str()); + ImGui::TextWrapped("%s", d.title.c_str()); + ImGui::TextDisabled("origen: %s · domain: %s", d.source_id.c_str(), d.proposed_domain.c_str()); + ImGui::PopTextWrapPos(); + ImGui::EndTooltip(); + } + } + // Click: select. if (hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && g_hover >= 0) { - g_selected = g_hover; + g_sel_kind = g_hover_kind; + g_sel_index = g_hover; } // Sector labels at outermost ring. @@ -713,6 +803,217 @@ static void draw_canvas() { dl->PopClipRect(); } +// ---- Draft helpers ------------------------------------------------------ + +static std::string slugify(const std::string& s) { + std::string out; + out.reserve(s.size()); + for (char c : s) { + if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) out.push_back(c); + else if (c >= 'A' && c <= 'Z') out.push_back(char(c - 'A' + 'a')); + else if (c == ' ' || c == '-' || c == '_' || c == '/') out.push_back('-'); + } + // collapse multiple dashes + std::string c2; + bool prev_dash = false; + for (char c : out) { + if (c == '-') { if (!prev_dash) c2.push_back(c); prev_dash = true; } + else { c2.push_back(c); prev_dash = false; } + } + while (!c2.empty() && c2.front() == '-') c2.erase(0, 1); + while (!c2.empty() && c2.back() == '-') c2.pop_back(); + if (c2.size() > 48) c2.resize(48); + return c2.empty() ? std::string("untitled") : c2; +} + +// Devuelve el primer NNNN libre escaneando dev/issues/ y dev/issues/completed/. +static std::string next_issue_id(const fs::path& root) { + int max_n = 0; + auto scan = [&](const fs::path& dir) { + if (!fs::exists(dir)) return; + for (const auto& e : fs::directory_iterator(dir)) { + if (!e.is_regular_file()) continue; + auto stem = e.path().stem().string(); + // Match 4-digit prefix followed by - or letter. + if (stem.size() >= 4) { + bool all_digit = true; + for (int i = 0; i < 4; ++i) if (stem[i] < '0' || stem[i] > '9') { all_digit = false; break; } + if (all_digit) { + int n = std::atoi(stem.substr(0, 4).c_str()); + if (n > max_n) max_n = n; + } + } + } + }; + scan(root / "dev" / "issues"); + scan(root / "dev" / "issues" / "completed"); + char buf[16]; + std::snprintf(buf, sizeof(buf), "%04d", max_n + 1); + return buf; +} + +static std::string next_flow_id(const fs::path& root) { + int max_n = 0; + auto scan = [&](const fs::path& dir) { + if (!fs::exists(dir)) return; + for (const auto& e : fs::directory_iterator(dir)) { + if (!e.is_regular_file()) continue; + auto stem = e.path().stem().string(); + if (stem.size() >= 4) { + bool all_digit = true; + for (int i = 0; i < 4; ++i) if (stem[i] < '0' || stem[i] > '9') { all_digit = false; break; } + if (all_digit) { + int n = std::atoi(stem.substr(0, 4).c_str()); + if (n > max_n) max_n = n; + } + } + } + }; + scan(root / "dev" / "flows"); + scan(root / "dev" / "flows" / "completed"); + char buf[16]; + std::snprintf(buf, sizeof(buf), "%04d", max_n + 1); + return buf; +} + +static std::string today_iso() { + using namespace std::chrono; + auto tt = system_clock::to_time_t(system_clock::now()); + std::tm tmv{}; +#ifdef _WIN32 + localtime_s(&tmv, &tt); +#else + localtime_r(&tt, &tmv); +#endif + char buf[16]; + std::snprintf(buf, sizeof(buf), "%04d-%02d-%02d", + tmv.tm_year + 1900, tmv.tm_mon + 1, tmv.tm_mday); + return buf; +} + +// Encuentra la posicion objetivo para un draft segun su proposed_domain. +// Coloca el draft en la mitad del ring 2 (unlocked) sector de su domain. +static void compute_draft_target(DraftNode& d) { + std::unordered_set domain_set(kDomainOrder.begin(), kDomainOrder.end()); + std::string dom = d.proposed_domain; + if (dom.empty() || domain_set.find(dom) == domain_set.end()) { + dom = kDomainOrder[fnv1a(d.id) % kDomainOrder.size()]; + } + int sector = (int)kDomainOrder.size() - 1; + for (int i = 0; i < (int)kDomainOrder.size(); ++i) { + if (kDomainOrder[i] == dom) { sector = i; break; } + } + const float r_lo = kRingRadii[2] + 30.0f; + const float r_hi = kRingRadii[3] - 30.0f; + const float r = 0.5f * (r_lo + r_hi); + const float theta = -1.5708f + (sector + 0.5f) * (2.0f * 3.14159265f / 18.0f); + d.tgt_x = std::cos(theta) * r; + d.tgt_y = std::sin(theta) * r; +} + +// Mock LLM: genera 3-5 ideas plausibles para un nodo source. +static void mock_generate_ideas(const Node& source) { + static const char* kVerbsIssue[] = { + "Anadir", "Refactor", "Test golden", "Documentar", "Audit", + "Migrar", "Limpiar", "Validar", "Profiling", + }; + static const char* kVerbsFlow[] = { + "Smoke flow", "Use-case end-to-end", "Demo escenario", + }; + int N = 3 + (int)(fnv1a(source.id) % 3); // 3..5 + double now = now_seconds(); + for (int i = 0; i < N; ++i) { + DraftNode d; + d.id = "tmp_" + std::to_string(g_next_draft_n++); + d.source_id = source.id; + bool as_flow = (i == N - 1); // last one as flow + d.proposed_type = as_flow ? "flow" : "issue"; + const char* verb = as_flow + ? kVerbsFlow[fnv1a(d.id) % (sizeof(kVerbsFlow)/sizeof(kVerbsFlow[0]))] + : kVerbsIssue[fnv1a(d.id) % (sizeof(kVerbsIssue)/sizeof(kVerbsIssue[0]))]; + d.title = std::string(verb) + " " + source.title.substr(0, 40); + d.description = "Idea generada (mock) a partir de " + source.id + + ". Sustituye este texto con LLM real (claude -p) en 0109h2."; + d.proposed_domain = source.domain.empty() ? "meta" : source.domain.front(); + d.proposed_priority = "media"; + d.proposed_dod = { "DoD item 1", "DoD item 2", "DoD item 3" }; + d.src_x = source.x; + d.src_y = source.y; + compute_draft_target(d); + d.spawn_t = now + i * 0.15f; // stagger + g_drafts.push_back(std::move(d)); + } + fn_log::log_info("skill_tree: generated %d mock drafts from %s", N, source.id.c_str()); +} + +// Promote draft → archivo .md en dev/issues/ o dev/flows/. +static bool promote_draft(int draft_idx, bool as_flow) { + if (draft_idx < 0 || draft_idx >= (int)g_drafts.size()) return false; + const auto& d = g_drafts[draft_idx]; + std::string id = as_flow ? next_flow_id(g_root) : next_issue_id(g_root); + std::string slug = slugify(d.title); + fs::path out_dir = g_root / "dev" / (as_flow ? "flows" : "issues"); + fs::create_directories(out_dir); + fs::path out_file = out_dir / (id + "-" + slug + ".md"); + + std::ostringstream ss; + ss << "---\n"; + if (as_flow) { + ss << "name: " << slug << "\n"; + ss << "id: " << id << "\n"; + ss << "status: pending\n"; + } else { + ss << "id: \"" << id << "\"\n"; + ss << "title: \"" << d.title << "\"\n"; + ss << "status: pendiente\n"; + ss << "type: feature\n"; + } + ss << "domain:\n - " << d.proposed_domain << "\n"; + ss << "priority: " << d.proposed_priority << "\n"; + if (as_flow) { + ss << "related_issues: []\n"; + ss << "apps: []\n"; + ss << "trigger: manual\n"; + } else { + ss << "depends: []\n"; + ss << "blocks: []\n"; + ss << "related:\n - \"" << d.source_id << "\"\n"; + } + ss << "created: " << today_iso() << "\n"; + ss << "updated: " << today_iso() << "\n"; + ss << "tags: [skill-tree-draft]\n"; + ss << "---\n\n"; + ss << "# " << id << " — " << d.title << "\n\n"; + ss << "Origen: idea generada desde nodo " << d.source_id << " via skill_tree.\n\n"; + ss << d.description << "\n\n"; + if (!d.proposed_dod.empty()) { + ss << "## DoD\n\n"; + for (const auto& item : d.proposed_dod) ss << "- [ ] " << item << "\n"; + ss << "\n"; + } + + std::ofstream f(out_file); + if (!f) { fn_log::log_warn("skill_tree: failed to open %s for write", out_file.string().c_str()); return false; } + f << ss.str(); + f.close(); + fn_log::log_info("skill_tree: promoted draft %s -> %s", d.id.c_str(), out_file.string().c_str()); + + // Remove draft from buffer. + g_drafts.erase(g_drafts.begin() + draft_idx); + if (g_sel_kind == SelKind::Draft && g_sel_index == draft_idx) { + g_sel_kind = SelKind::None; + g_sel_index = -1; + } else if (g_sel_kind == SelKind::Draft && g_sel_index > draft_idx) { + --g_sel_index; + } + // Trigger reload to pick up the new file. + reload_scan(); + return true; +} + +static void draw_inspector_draft(); +static void draw_dashboard(); + // ---- Panels ------------------------------------------------------------- static void draw_tree() { @@ -748,12 +1049,18 @@ static void draw_inspector() { ImGui::End(); return; } - if (g_selected < 0 || g_selected >= (int)g_scan.nodes.size()) { + if (g_sel_kind == SelKind::Draft) { + draw_inspector_draft(); + ImGui::End(); + return; + } + if (g_sel_kind != SelKind::Node || + g_sel_index < 0 || g_sel_index >= (int)g_scan.nodes.size()) { ImGui::TextDisabled("Click en un nodo del Tree para inspeccionar."); ImGui::End(); return; } - const auto& n = g_scan.nodes[g_selected]; + const auto& n = g_scan.nodes[g_sel_index]; ImGui::Text("%s %s", n.kind == NodeKind::Issue ? "[ISSUE]" : "[FLOW]", n.id.c_str()); ImGui::TextWrapped("%s", n.title.c_str()); ImGui::Separator(); @@ -812,13 +1119,229 @@ static void draw_inspector() { } ImGui::Separator(); - ImGui::TextDisabled("Botones [Generate ideas] / [Run autonomous-task] llegan en 0109e/f."); + + // === Generate ideas (mock LLM until 0109h2) === + ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32( 16, 185, 129, 200)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32( 52, 211, 153, 240)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32( 6, 148, 110, 255)); + if (ImGui::Button(TI_PLUS " Generate ideas")) { + mock_generate_ideas(n); + } + ImGui::PopStyleColor(3); + ImGui::SameLine(); + ImGui::TextDisabled("emergen ghost-nodes con ideas. Click ghost para promover. (LLM real en 0109h2 — hoy mock)"); + + ImGui::Separator(); + ImGui::TextDisabled("Run autonomous-task llega en 0109f."); + ImGui::End(); +} + +// ---- Inspector: draft branch ------------------------------------------- + +static void draw_inspector_draft() { + if (g_sel_index < 0 || g_sel_index >= (int)g_drafts.size()) { + g_sel_kind = SelKind::None; g_sel_index = -1; return; + } + const DraftNode& d = g_drafts[g_sel_index]; + ImGui::Text(TI_BULB " DRAFT (idea) · origen: %s", d.source_id.c_str()); + ImGui::TextWrapped("%s", d.title.c_str()); + ImGui::Separator(); + ImGui::Text("tipo propuesto: %s", d.proposed_type.c_str()); + ImGui::Text("domain propuesto:%s", d.proposed_domain.c_str()); + ImGui::Text("priority: %s", d.proposed_priority.c_str()); + + ImGui::SeparatorText("descripcion"); + ImGui::TextWrapped("%s", d.description.c_str()); + + if (!d.proposed_dod.empty()) { + ImGui::SeparatorText("DoD propuesto"); + for (const auto& it : d.proposed_dod) ImGui::BulletText("%s", it.c_str()); + } + + ImGui::Separator(); + + // Promote buttons. + ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32( 59, 130, 246, 200)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32( 96, 165, 250, 240)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32( 37, 99, 235, 255)); + if (ImGui::Button(TI_GIT_BRANCH " Generate issue")) { + promote_draft(g_sel_index, /*as_flow=*/false); + ImGui::PopStyleColor(3); + return; + } + ImGui::PopStyleColor(3); + + ImGui::SameLine(); + + ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32( 14, 165, 233, 200)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32( 56, 189, 248, 240)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32( 2, 132, 199, 255)); + if (ImGui::Button(TI_PLAYLIST_ADD " Generate flow")) { + promote_draft(g_sel_index, /*as_flow=*/true); + ImGui::PopStyleColor(3); + return; + } + ImGui::PopStyleColor(3); + + ImGui::SameLine(); + + ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32(115, 115, 115, 200)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32(150, 150, 150, 240)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32( 90, 90, 90, 255)); + if (ImGui::Button(TI_X " Discard")) { + g_drafts.erase(g_drafts.begin() + g_sel_index); + g_sel_kind = SelKind::None; g_sel_index = -1; + ImGui::PopStyleColor(3); + return; + } + ImGui::PopStyleColor(3); +} + +// ---- Dashboard ---------------------------------------------------------- + +static int xp_for_type(const std::string& type, NodeKind kind) { + if (kind == NodeKind::Flow) return 5; + if (type == "epic") return 10; + if (type == "feature") return 3; + if (type == "infra") return 4; + if (type == "refactor") return 2; + if (type == "bugfix") return 1; + if (type == "chore") return 1; + if (type == "docs") return 1; + if (type == "spike") return 2; + if (type == "planning") return 2; + return 1; +} + +static void draw_dashboard() { + if (!ImGui::Begin(TI_CHART_BAR " Dashboard", &g_show_dashboard)) { + ImGui::End(); + return; + } + + // Per-domain accumulators. + struct DomStat { + int done = 0, planned = 0, todo = 0, total = 0; + int xp = 0; + }; + std::unordered_map per_dom; + int xp_total = 0; + int xp_by_type_done[16] = {0}; // index by enum-ish ordering, but we keep simple + int n_done = 0, n_planned = 0, n_todo = 0; + + for (const auto& n : g_scan.nodes) { + int xp = xp_for_type(n.type, n.kind); + Bucket b = status_bucket(n.status_eff); + if (b == Bucket::Done) ++n_done; + else if (b == Bucket::Planned) ++n_planned; + else ++n_todo; + + if (b == Bucket::Done) xp_total += xp; + + // Per domain (cuenta una vez en cada dominio listado). + std::vector doms = n.domain; + if (doms.empty()) doms.push_back("(unknown)"); + for (const auto& d : doms) { + auto& s = per_dom[d]; + ++s.total; + if (b == Bucket::Done) { ++s.done; s.xp += xp; } + else if (b == Bucket::Planned) { ++s.planned; } + else { ++s.todo; } + } + } + + int level = (int)std::floor(std::sqrt((float)xp_total)); + int xp_next_level = (level + 1) * (level + 1); + + // HUD top. + ImGui::Text(TI_TROPHY " LV %d", level); + ImGui::SameLine(); + ImGui::Text("· XP %d · next LV at %d", xp_total, xp_next_level); + + float xp_frac = (xp_next_level > 0) ? float(xp_total - level*level) / float(xp_next_level - level*level) : 0.0f; + ImGui::ProgressBar(xp_frac, ImVec2(-FLT_MIN, 0.0f), + (std::to_string(xp_total - level*level) + " / " + + std::to_string(xp_next_level - level*level) + " XP").c_str()); + + ImGui::Separator(); + ImGui::Text("done: %d · planned: %d · todo: %d · drafts: %d", + n_done, n_planned, n_todo, (int)g_drafts.size()); + + ImGui::SeparatorText("Habilidades por dominio"); + + if (ImGui::BeginTable("doms", 6, ImGuiTableFlags_RowBg | ImGuiTableFlags_BordersInnerH | ImGuiTableFlags_Sortable)) { + ImGui::TableSetupColumn("Dominio"); + ImGui::TableSetupColumn("Done", ImGuiTableColumnFlags_WidthFixed, 50.0f); + ImGui::TableSetupColumn("Planned", ImGuiTableColumnFlags_WidthFixed, 60.0f); + ImGui::TableSetupColumn("Todo", ImGuiTableColumnFlags_WidthFixed, 50.0f); + ImGui::TableSetupColumn("Progreso", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("LV", ImGuiTableColumnFlags_WidthFixed, 50.0f); + ImGui::TableHeadersRow(); + + // Ordenar por % completado desc. + std::vector> ord(per_dom.begin(), per_dom.end()); + std::sort(ord.begin(), ord.end(), [](auto& a, auto& b) { + float pa = a.second.total > 0 ? float(a.second.done) / a.second.total : 0; + float pb = b.second.total > 0 ? float(b.second.done) / b.second.total : 0; + if (pa != pb) return pa > pb; + return a.second.done > b.second.done; + }); + for (const auto& [name, s] : ord) { + ImGui::TableNextRow(); + ImGui::TableNextColumn(); ImGui::TextUnformatted(name.c_str()); + ImGui::TableNextColumn(); ImGui::Text("%d", s.done); + ImGui::TableNextColumn(); ImGui::Text("%d", s.planned); + ImGui::TableNextColumn(); ImGui::Text("%d", s.todo); + float frac = s.total > 0 ? float(s.done) / float(s.total) : 0.0f; + ImGui::TableNextColumn(); + char ovl[24]; std::snprintf(ovl, sizeof(ovl), "%d/%d (%d%%)", s.done, s.total, int(frac*100)); + ImGui::ProgressBar(frac, ImVec2(-FLT_MIN, 0), ovl); + int lv = (int)std::floor(std::sqrt((float)s.xp)); + ImGui::TableNextColumn(); ImGui::Text("LV %d", lv); + } + ImGui::EndTable(); + } + + ImGui::SeparatorText("Top 3 dominios mas masterizados"); + { + std::vector> top(per_dom.begin(), per_dom.end()); + std::sort(top.begin(), top.end(), [](auto& a, auto& b) { + float pa = a.second.total > 0 ? float(a.second.done) / a.second.total : 0; + float pb = b.second.total > 0 ? float(b.second.done) / b.second.total : 0; + return pa > pb; + }); + int shown = 0; + for (const auto& [name, s] : top) { + if (s.total < 2) continue; // skip noise + if (shown >= 3) break; + float p = float(s.done) / float(s.total); + ImGui::BulletText("%s — %d/%d (%.0f%%)", name.c_str(), s.done, s.total, p*100); + ++shown; + } + } + + ImGui::SeparatorText("Proximos a desbloquearse (mas locked)"); + { + std::vector> top(per_dom.begin(), per_dom.end()); + std::sort(top.begin(), top.end(), [](auto& a, auto& b) { + return a.second.todo > b.second.todo; + }); + int shown = 0; + for (const auto& [name, s] : top) { + if (s.todo < 1) break; + if (shown >= 3) break; + ImGui::BulletText("%s — %d todo (%d done)", name.c_str(), s.todo, s.done); + ++shown; + } + } + ImGui::End(); } static void render() { if (g_show_tree) draw_tree(); if (g_show_inspector) draw_inspector(); + if (g_show_dashboard) draw_dashboard(); } // ---- Self-test ---------------------------------------------------------- @@ -871,6 +1394,7 @@ int main(int argc, char** argv) { static fn_ui::PanelToggle panels[] = { { "Tree", nullptr, &g_show_tree }, { "Inspector", nullptr, &g_show_inspector }, + { "Dashboard", nullptr, &g_show_dashboard }, }; fn::AppConfig cfg;