From fec580e9aefc5cda597dd14173e86237b3584b9f Mon Sep 17 00:00:00 2001 From: vishwajeet-13 Date: Thu, 8 Oct 2026 18:00:47 +0530 Subject: [PATCH 1/3] feat(dashboard): add drill-down dashboard with section and pdf preview Projects list on the left, category -> subcategory -> section cards on the right, site-wide totals on top. Sections open in a dialog with subsection navigation; the page badge slides in the original PDF at that page. --- frontend/src/components/SectionDialog.vue | 229 ++++++++++++++++ frontend/src/data/navigation.js | 6 + frontend/src/pages/Dashboard.vue | 313 ++++++++++++++++++++++ frontend/src/router.js | 5 + frontend/src/utils/format.js | 8 + wikify/api/dashboard.py | 178 ++++++++++++ 6 files changed, 739 insertions(+) create mode 100644 frontend/src/components/SectionDialog.vue create mode 100644 frontend/src/pages/Dashboard.vue create mode 100644 frontend/src/utils/format.js create mode 100644 wikify/api/dashboard.py diff --git a/frontend/src/components/SectionDialog.vue b/frontend/src/components/SectionDialog.vue new file mode 100644 index 0000000..39a4ce1 --- /dev/null +++ b/frontend/src/components/SectionDialog.vue @@ -0,0 +1,229 @@ + + + diff --git a/frontend/src/data/navigation.js b/frontend/src/data/navigation.js index 87b6ad9..d41a65d 100644 --- a/frontend/src/data/navigation.js +++ b/frontend/src/data/navigation.js @@ -1,4 +1,10 @@ export const DESTINATIONS = [ + { + label: "Dashboard", + icon: "lucide-layout-dashboard", + route: "Dashboard", + activeOn: ["Dashboard"], + }, { label: "Projects", icon: "lucide-folder", diff --git a/frontend/src/pages/Dashboard.vue b/frontend/src/pages/Dashboard.vue new file mode 100644 index 0000000..a8906e5 --- /dev/null +++ b/frontend/src/pages/Dashboard.vue @@ -0,0 +1,313 @@ + + + diff --git a/frontend/src/router.js b/frontend/src/router.js index 076bc6c..e5bd681 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -8,6 +8,11 @@ import { } from "@/data/settingsRoute"; const routes = [ + { + path: "/dashboard", + name: "Dashboard", + component: () => import("@/pages/Dashboard.vue"), + }, { path: "/", name: "Projects", diff --git a/frontend/src/utils/format.js b/frontend/src/utils/format.js new file mode 100644 index 0000000..130f1f7 --- /dev/null +++ b/frontend/src/utils/format.js @@ -0,0 +1,8 @@ +export function pageRange(s) { + if (s.page_start == null) return ""; + return s.page_start === s.page_end ? `p${s.page_start}` : `p${s.page_start}–${s.page_end}`; +} + +export function plural(n, word, pluralWord = `${word}s`) { + return `${n} ${n === 1 ? word : pluralWord}`; +} diff --git a/wikify/api/dashboard.py b/wikify/api/dashboard.py new file mode 100644 index 0000000..0908ca5 --- /dev/null +++ b/wikify/api/dashboard.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +from collections import Counter, defaultdict + +import frappe + +from wikify.api.explore import UNTAGGED +from wikify.api.permission import assert_readable, readable_projects + + +def _subcategory(section: dict) -> str: + path = section.get("hierarchy_path") or section.get("title") or "" + return path.split(" > ")[0].strip() or "Untitled" + + +@frappe.whitelist() +def summary( + project: str | None = None, section_type: str | None = None, subcategory: str | None = None +) -> dict: + assert_readable(project) + projects = frappe.get_all( + "Wikify Project", + filters={"name": ["in", readable_projects()]}, + fields=["name", "project_name"], + order_by="is_default desc, project_name asc", + ) + docs = frappe.get_all( + "Source Document", + filters={"project": ["in", [p["name"] for p in projects]]}, + fields=["name", "title", "project"], + ) + doc_by_name = {d["name"]: d for d in docs} + sections = ( + frappe.get_all( + "Source Section", + filters={"source_document": ["in", list(doc_by_name)]}, + fields=[ + "name", + "title", + "hierarchy_path", + "section_type", + "source_document", + "page_start", + "page_end", + ], + order_by="source_document asc, lft asc", + ) + if docs + else [] + ) + for s in sections: + s["project"] = doc_by_name[s["source_document"]]["project"] + s["section_type"] = s["section_type"] or UNTAGGED + s["subcategory"] = _subcategory(s) + + in_project = [s for s in sections if not project or s["project"] == project] + in_type = [s for s in in_project if not section_type or s["section_type"] == section_type] + in_sub = [s for s in in_type if not subcategory or s["subcategory"] == subcategory] + + docs_per_project = Counter(d["project"] for d in docs) + return { + "cards": { + "projects": len(projects), + "documents": len(docs), + "categories": len({s["section_type"] for s in sections}), + "sections": len(sections), + }, + "projects": [{**p, "documents": docs_per_project[p["name"]]} for p in projects], + "categories": _category_rows(in_project), + "subcategories": _subcategory_rows(in_type) if section_type else [], + "sections": _section_rows(in_sub, doc_by_name) if subcategory else [], + } + + +def _group(sections: list[dict], key: str) -> dict[str, list[dict]]: + groups: dict[str, list[dict]] = defaultdict(list) + for s in sections: + groups[s[key]].append(s) + return groups + + +def _category_rows(sections: list[dict]) -> list[dict]: + by_type = _group(sections, "section_type") + types = frappe.get_all( + "Section Type", + fields=["type_name", "label", "color"], + order_by="is_other asc, creation asc", + ) + types.append({"type_name": UNTAGGED, "label": "Untagged", "color": "#cbd5e1"}) + rows = [ + { + "type_name": t["type_name"], + "label": t["label"] or t["type_name"], + "color": t["color"] or "#9ca3af", + "sections": len(own), + "documents": len({s["source_document"] for s in own}), + "subcategories": len({s["subcategory"] for s in own}), + } + for t in types + if (own := by_type.get(t["type_name"])) + ] + return sorted(rows, key=lambda r: -r["sections"]) + + +def _subcategory_rows(sections: list[dict]) -> list[dict]: + rows = [ + { + "title": title, + "sections": len(own), + "documents": len({s["source_document"] for s in own}), + } + for title, own in _group(sections, "subcategory").items() + ] + return sorted(rows, key=lambda r: (-r["sections"], r["title"].lower())) + + +def _section_rows(sections: list[dict], doc_by_name: dict[str, dict]) -> list[dict]: + return [ + { + "name": s["name"], + "title": s["title"], + "hierarchy_path": s["hierarchy_path"], + "page_start": s["page_start"], + "page_end": s["page_end"], + "doc_title": doc_by_name[s["source_document"]]["title"] or s["source_document"], + } + for s in sections + ] + + +@frappe.whitelist() +def section_detail(name: str) -> dict: + section = frappe.db.get_value( + "Source Section", + name, + [ + "name", + "title", + "hierarchy_path", + "page_start", + "page_end", + "markdown", + "section_type", + "source_document", + ], + as_dict=True, + ) + if not section: + raise frappe.DoesNotExistError + doc = frappe.db.get_value( + "Source Document", section.source_document, ["title", "project", "pdf"], as_dict=True + ) + assert_readable(doc.project) + section_type = ( + frappe.get_cached_value("Section Type", section.section_type, ["label", "color"], as_dict=True) + if section.section_type + else None + ) or {} + children = frappe.get_all( + "Source Section", + filters={"parent_source_section": name}, + fields=["name", "title", "page_start", "page_end"], + order_by="lft asc", + ) + return { + "name": section.name, + "title": section.title, + "hierarchy_path": section.hierarchy_path, + "page_start": section.page_start, + "page_end": section.page_end, + "markdown": section.markdown, + "doc_title": doc.title, + "pdf": doc.pdf, + "project_name": frappe.get_cached_value("Wikify Project", doc.project, "project_name"), + "category": section_type.get("label") or section.section_type, + "category_color": section_type.get("color"), + "children": children, + } From 5b350f55e87ae689ba0068b7247938d627e549b0 Mon Sep 17 00:00:00 2001 From: vishwajeet-13 Date: Thu, 8 Oct 2026 20:01:16 +0530 Subject: [PATCH 2/3] test(dashboard): cover drill-down summary, section detail and permissions --- wikify/tests/test_dashboard.py | 98 ++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 wikify/tests/test_dashboard.py diff --git a/wikify/tests/test_dashboard.py b/wikify/tests/test_dashboard.py new file mode 100644 index 0000000..0fc172a --- /dev/null +++ b/wikify/tests/test_dashboard.py @@ -0,0 +1,98 @@ +# Copyright (c) 2026, BWH and contributors +# For license information, please see license.txt +import frappe +from frappe.tests.utils import FrappeTestCase + +from wikify.api import dashboard +from wikify.api import projects as projects_api +from wikify.engine import store +from wikify.engine.loader.sectionizer import Section +from wikify.tests.test_ask_history import make_user + +OTHER_USER = "dashboard-other@example.com" + + +def _sec(title, level, path, p_start, p_end): + return Section( + title=title, + level=level, + hierarchy_path=path, + page_start=p_start, + page_end=p_end, + markdown=f"body of {title}", + ) + + +class TestDashboard(FrappeTestCase): + def setUp(self): + self.tag = frappe.generate_hash(length=6) + self.project = projects_api.create_project(f"Dashboard {self.tag}") + self.sd = frappe.get_doc( + { + "doctype": "Source Document", + "title": f"Policy {self.tag}", + "project": self.project, + "pdf": "/files/policy.pdf", + } + ).insert(ignore_permissions=True) + store.replace_sections( + self.sd.name, + [ + _sec("Medicines", 1, ["Medicines"], 1, 4), + _sec("Storage", 2, ["Medicines", "Storage"], 1, 2), + _sec("Disposal", 2, ["Medicines", "Disposal"], 3, 4), + _sec("Emergencies", 1, ["Emergencies"], 5, 6), + ], + ) + types = { + "Medicines": "medication_management", + "Storage": "medication_management", + "Disposal": "medication_management", + "Emergencies": "emergency_procedures", + } + self.names = {} + for row in frappe.get_all( + "Source Section", filters={"source_document": self.sd.name}, fields=["name", "title"] + ): + frappe.db.set_value("Source Section", row.name, "section_type", types[row.title]) + self.names[row.title] = row.name + self.addCleanup(frappe.set_user, "Administrator") + + def test_categories_are_scoped_to_the_project(self): + rows = {c["type_name"]: c for c in dashboard.summary(project=self.project)["categories"]} + self.assertEqual(set(rows), {"medication_management", "emergency_procedures"}) + self.assertEqual(rows["medication_management"]["sections"], 3) + self.assertEqual(rows["medication_management"]["subcategories"], 1) + self.assertEqual(rows["medication_management"]["documents"], 1) + + def test_subcategory_is_the_top_level_heading(self): + result = dashboard.summary(project=self.project, section_type="medication_management") + self.assertEqual(result["subcategories"], [{"title": "Medicines", "sections": 3, "documents": 1}]) + self.assertEqual(result["sections"], []) + + def test_sections_listed_for_a_subcategory(self): + result = dashboard.summary( + project=self.project, section_type="medication_management", subcategory="Medicines" + ) + self.assertEqual({s["title"] for s in result["sections"]}, {"Medicines", "Storage", "Disposal"}) + self.assertEqual({s["doc_title"] for s in result["sections"]}, {f"Policy {self.tag}"}) + + def test_cards_ignore_the_drill_filters(self): + everything = dashboard.summary()["cards"] + drilled = dashboard.summary(project=self.project, section_type="emergency_procedures")["cards"] + self.assertEqual(everything, drilled) + + def test_section_detail_returns_content_children_and_pdf(self): + detail = dashboard.section_detail(self.names["Medicines"]) + self.assertEqual(detail["markdown"], "body of Medicines") + self.assertEqual(detail["pdf"], "/files/policy.pdf") + self.assertEqual([c["title"] for c in detail["children"]], ["Storage", "Disposal"]) + self.assertEqual(detail["category"], "Medication Management") + + def test_unreadable_project_is_hidden(self): + frappe.set_user(make_user(OTHER_USER)) + self.assertNotIn(self.project, [p["name"] for p in dashboard.summary()["projects"]]) + with self.assertRaises(frappe.PermissionError): + dashboard.summary(project=self.project) + with self.assertRaises(frappe.PermissionError): + dashboard.section_detail(self.names["Storage"]) From 749d2394737def9840e8cccdb01becca8e0f6b90 Mon Sep 17 00:00:00 2001 From: vishwajeet-13 Date: Fri, 9 Oct 2026 16:55:05 +0530 Subject: [PATCH 3/3] fix(dashboard): keep headings containing '>' intact, bound pdf pages, fix counts Subcategories and dialog breadcrumbs come from the section tree instead of splitting hierarchy_path. The PDF panel stops at the document's last page. Documents without sections (failed imports) are no longer counted, the All projects row shows the document total, and untagged sections show an Untagged category in the dialog. --- frontend/src/components/SectionDialog.vue | 16 ++++++---- frontend/src/pages/Dashboard.vue | 2 +- wikify/api/dashboard.py | 39 +++++++++++++++++------ wikify/api/explore.py | 6 ++-- wikify/tests/test_dashboard.py | 36 +++++++++++++++++++++ 5 files changed, 80 insertions(+), 19 deletions(-) diff --git a/frontend/src/components/SectionDialog.vue b/frontend/src/components/SectionDialog.vue index 39a4ce1..4bbb6ec 100644 --- a/frontend/src/components/SectionDialog.vue +++ b/frontend/src/components/SectionDialog.vue @@ -38,10 +38,13 @@ watch( ); const data = computed(() => (detail.data?.name === props.section ? detail.data : null)); -const ancestors = computed(() => { - const path = data.value?.hierarchy_path || ""; - return path.split(" > ").slice(0, -1); -}); +const ancestors = computed(() => data.value?.ancestors || []); +const lastPage = computed(() => data.value?.page_count || Infinity); +const pdfPageLabel = computed(() => + data.value?.page_count + ? `page ${pdfPage.value} of ${data.value.page_count}` + : `page ${pdfPage.value}` +); function go(name) { history.value.push(props.section); @@ -67,7 +70,7 @@ function openPdfTab() { } function showPdf(page) { - pdfPage.value = Math.max(1, page); + pdfPage.value = Math.min(Math.max(1, page), lastPage.value); } @@ -181,7 +184,7 @@ function showPdf(page) {