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-<slug>.md (next_issue_id scan)
- [Generate flow] -> escribe dev/flows/NNNN-<slug>.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) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 20:40:43 +02:00
parent bbf9cb9cbf
commit a6366b4d50
+535 -11
View File
@@ -293,14 +293,44 @@ static void derive_status_eff(std::vector<Node>& nodes) {
}
}
// ---- Draft (ghost-node) model -------------------------------------------
struct DraftNode {
std::string id; // "tmp_<n>"
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<std::string> 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<DraftNode> 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<std::string> 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<std::string, DomStat> 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<std::string> 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<std::pair<std::string, DomStat>> 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<std::pair<std::string, DomStat>> 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<std::pair<std::string, DomStat>> 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;