From 696e369ec14edd0cba6de8524c6ecc5e9154849d Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Mon, 29 Jun 2026 10:48:33 -0500 Subject: [PATCH] feat: OG coverage dashboard as default admin view (#94) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New DashboardModel (BaseDatabaseModel) with getStats(), getCoverageByType(), getMissingArticles() — coverage logic moved out of the list template - New View/Dashboard/HtmlView + tmpl/dashboard/default.php: SVG donut coverage gauge, field-gap badges, per-content_type breakdown table, and a list of published articles missing OG tags (linking to the article editor) with a Batch Generate shortcut - DisplayController default_view -> dashboard; Dashboard + Tags submenu entries - Removed the inline coverage.php include from the tags list (it ran 6 uncached COUNT queries on every list load); that logic now lives in DashboardModel - Declared tmpl/dashboard in the manifest; added language strings (en-GB, en-US) --- .../com_mokoog/language/en-GB/com_mokoog.ini | 7 + .../com_mokoog/language/en-US/com_mokoog.ini | 7 + source/packages/com_mokoog/mokoog.xml | 2 + .../src/Controller/DisplayController.php | 2 +- .../com_mokoog/src/Model/DashboardModel.php | 159 ++++++++++++++++++ .../src/View/Dashboard/HtmlView.php | 76 +++++++++ .../com_mokoog/tmpl/dashboard/default.php | 142 ++++++++++++++++ .../com_mokoog/tmpl/tags/coverage.php | 58 ------- .../packages/com_mokoog/tmpl/tags/default.php | 1 - 9 files changed, 394 insertions(+), 60 deletions(-) create mode 100644 source/packages/com_mokoog/src/Model/DashboardModel.php create mode 100644 source/packages/com_mokoog/src/View/Dashboard/HtmlView.php create mode 100644 source/packages/com_mokoog/tmpl/dashboard/default.php delete mode 100644 source/packages/com_mokoog/tmpl/tags/coverage.php diff --git a/source/packages/com_mokoog/language/en-GB/com_mokoog.ini b/source/packages/com_mokoog/language/en-GB/com_mokoog.ini index b920280..7410242 100644 --- a/source/packages/com_mokoog/language/en-GB/com_mokoog.ini +++ b/source/packages/com_mokoog/language/en-GB/com_mokoog.ini @@ -5,6 +5,13 @@ COM_MOKOOG="MokoSuiteOpenGraph" COM_MOKOOG_TAGS_TITLE="MokoSuiteOpenGraph - Tag Manager" COM_MOKOOG_SUBMENU_TAGS="Tags" +COM_MOKOOG_SUBMENU_DASHBOARD="Dashboard" +COM_MOKOOG_DASHBOARD_TITLE="MokoSuiteOpenGraph - Dashboard" +COM_MOKOOG_DASHBOARD_FIELD_GAPS="Field Coverage Gaps" +COM_MOKOOG_DASHBOARD_BY_TYPE="Coverage by Content Type" +COM_MOKOOG_DASHBOARD_MISSING="Articles Missing OG Tags" +COM_MOKOOG_DASHBOARD_ALL_COVERED="All published articles have OG tags." +COM_MOKOOG_DASHBOARD_MISSING_NOTE="Showing up to 20 most recent. Use Batch Generate to create OG tags for all articles at once." COM_MOKOOG_NO_TAGS="No Open Graph tags have been created yet. Tags are created automatically when you edit articles or menu items." COM_MOKOOG_TABLE_CAPTION="Table of Open Graph tags" COM_MOKOOG_AUTO_GENERATED="auto-generated" diff --git a/source/packages/com_mokoog/language/en-US/com_mokoog.ini b/source/packages/com_mokoog/language/en-US/com_mokoog.ini index b920280..7410242 100644 --- a/source/packages/com_mokoog/language/en-US/com_mokoog.ini +++ b/source/packages/com_mokoog/language/en-US/com_mokoog.ini @@ -5,6 +5,13 @@ COM_MOKOOG="MokoSuiteOpenGraph" COM_MOKOOG_TAGS_TITLE="MokoSuiteOpenGraph - Tag Manager" COM_MOKOOG_SUBMENU_TAGS="Tags" +COM_MOKOOG_SUBMENU_DASHBOARD="Dashboard" +COM_MOKOOG_DASHBOARD_TITLE="MokoSuiteOpenGraph - Dashboard" +COM_MOKOOG_DASHBOARD_FIELD_GAPS="Field Coverage Gaps" +COM_MOKOOG_DASHBOARD_BY_TYPE="Coverage by Content Type" +COM_MOKOOG_DASHBOARD_MISSING="Articles Missing OG Tags" +COM_MOKOOG_DASHBOARD_ALL_COVERED="All published articles have OG tags." +COM_MOKOOG_DASHBOARD_MISSING_NOTE="Showing up to 20 most recent. Use Batch Generate to create OG tags for all articles at once." COM_MOKOOG_NO_TAGS="No Open Graph tags have been created yet. Tags are created automatically when you edit articles or menu items." COM_MOKOOG_TABLE_CAPTION="Table of Open Graph tags" COM_MOKOOG_AUTO_GENERATED="auto-generated" diff --git a/source/packages/com_mokoog/mokoog.xml b/source/packages/com_mokoog/mokoog.xml index 8f322e9..684874e 100644 --- a/source/packages/com_mokoog/mokoog.xml +++ b/source/packages/com_mokoog/mokoog.xml @@ -50,6 +50,7 @@ View + dashboard tag tags @@ -72,6 +73,7 @@ COM_MOKOOG + COM_MOKOOG_SUBMENU_DASHBOARD COM_MOKOOG_SUBMENU_TAGS diff --git a/source/packages/com_mokoog/src/Controller/DisplayController.php b/source/packages/com_mokoog/src/Controller/DisplayController.php index 93330a7..9461520 100644 --- a/source/packages/com_mokoog/src/Controller/DisplayController.php +++ b/source/packages/com_mokoog/src/Controller/DisplayController.php @@ -21,5 +21,5 @@ class DisplayController extends BaseController * * @var string */ - protected $default_view = 'tags'; + protected $default_view = 'dashboard'; } diff --git a/source/packages/com_mokoog/src/Model/DashboardModel.php b/source/packages/com_mokoog/src/Model/DashboardModel.php new file mode 100644 index 0000000..0d4b12c --- /dev/null +++ b/source/packages/com_mokoog/src/Model/DashboardModel.php @@ -0,0 +1,159 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoOG\Administrator\Model; + +defined('_JEXEC') or die; + +use Joomla\CMS\MVC\Model\BaseDatabaseModel; + +/** + * Read-only model providing OG tag coverage metrics for the dashboard. + */ +class DashboardModel extends BaseDatabaseModel +{ + /** + * Overall coverage statistics for com_content articles. + * + * @return array{total:int, with_og:int, coverage:int, missing_title:int, missing_description:int, missing_image:int} + */ + public function getStats(): array + { + $db = $this->getDatabase(); + + $total = $this->countContent(); + $withOg = $this->countDistinct(); + $missingTitle = $this->countEmptyField('og_title'); + $missingDesc = $this->countEmptyField('og_description'); + $missingImage = $this->countEmptyField('og_image'); + + return [ + 'total' => $total, + 'with_og' => $withOg, + 'coverage' => $total > 0 ? (int) round(($withOg / $total) * 100) : 0, + 'missing_title' => $missingTitle, + 'missing_description' => $missingDesc, + 'missing_image' => $missingImage, + ]; + } + + /** + * Coverage broken down by content_type. + * + * @return array Rows of {content_type, total, with_title, with_image} + */ + public function getCoverageByType(): array + { + $db = $this->getDatabase(); + $empty = $db->quote(''); + + $query = $db->getQuery(true) + ->select([ + $db->quoteName('content_type'), + 'COUNT(*) AS ' . $db->quoteName('total'), + 'SUM(CASE WHEN ' . $db->quoteName('og_title') . ' <> ' . $empty . ' THEN 1 ELSE 0 END) AS ' . $db->quoteName('with_title'), + 'SUM(CASE WHEN ' . $db->quoteName('og_image') . ' <> ' . $empty . ' THEN 1 ELSE 0 END) AS ' . $db->quoteName('with_image'), + ]) + ->from($db->quoteName('#__mokoog_tags')) + ->where($db->quoteName('published') . ' = 1') + ->group($db->quoteName('content_type')) + ->order($db->quoteName('content_type') . ' ASC'); + + $db->setQuery($query); + + return $db->loadObjectList() ?: []; + } + + /** + * Published articles that have no OG tag yet. + * + * @param int $limit Maximum rows to return + * + * @return array Rows of {id, title} + */ + public function getMissingArticles(int $limit = 20): array + { + $db = $this->getDatabase(); + + $query = $db->getQuery(true) + ->select([$db->quoteName('c.id'), $db->quoteName('c.title')]) + ->from($db->quoteName('#__content', 'c')) + ->leftJoin( + $db->quoteName('#__mokoog_tags', 't') + . ' ON ' . $db->quoteName('t.content_type') . ' = ' . $db->quote('com_content') + . ' AND ' . $db->quoteName('t.content_id') . ' = ' . $db->quoteName('c.id') + ) + ->where($db->quoteName('c.state') . ' = 1') + ->where($db->quoteName('t.id') . ' IS NULL') + ->order($db->quoteName('c.id') . ' DESC'); + + $db->setQuery($query, 0, max(1, $limit)); + + return $db->loadObjectList() ?: []; + } + + /** + * Count published com_content articles. + */ + private function countContent(): int + { + $db = $this->getDatabase(); + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__content')) + ->where($db->quoteName('state') . ' = 1') + ); + + return (int) $db->loadResult(); + } + + /** + * Count distinct articles that have at least one published OG tag. + */ + private function countDistinct(): int + { + $db = $this->getDatabase(); + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(DISTINCT ' . $db->quoteName('content_id') . ')') + ->from($db->quoteName('#__mokoog_tags')) + ->where($db->quoteName('content_type') . ' = ' . $db->quote('com_content')) + ->where($db->quoteName('published') . ' = 1') + ); + + return (int) $db->loadResult(); + } + + /** + * Count published OG tag rows whose given field is empty. + * + * @param string $field One of og_title, og_description, og_image + */ + private function countEmptyField(string $field): int + { + // Whitelist the column name — it is never user input here, but keep it strict. + if (!\in_array($field, ['og_title', 'og_description', 'og_image'], true)) { + return 0; + } + + $db = $this->getDatabase(); + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokoog_tags')) + ->where($db->quoteName('content_type') . ' = ' . $db->quote('com_content')) + ->where($db->quoteName('published') . ' = 1') + ->where($db->quoteName($field) . ' = ' . $db->quote('')) + ); + + return (int) $db->loadResult(); + } +} diff --git a/source/packages/com_mokoog/src/View/Dashboard/HtmlView.php b/source/packages/com_mokoog/src/View/Dashboard/HtmlView.php new file mode 100644 index 0000000..1f2c8aa --- /dev/null +++ b/source/packages/com_mokoog/src/View/Dashboard/HtmlView.php @@ -0,0 +1,76 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoOG\Administrator\View\Dashboard; + +defined('_JEXEC') or die; + +use Joomla\CMS\Language\Text; +use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; +use Joomla\CMS\Toolbar\ToolbarHelper; + +/** + * Dashboard view — OG tag coverage metrics. + */ +class HtmlView extends BaseHtmlView +{ + /** + * Overall coverage stats. + * + * @var array + */ + protected $stats = []; + + /** + * Coverage broken down by content_type. + * + * @var array + */ + protected $byType = []; + + /** + * Published articles missing an OG tag. + * + * @var array + */ + protected $missing = []; + + /** + * Display the view. + * + * @param string $tpl Template name + * + * @return void + */ + public function display($tpl = null): void + { + /** @var \Joomla\Component\MokoOG\Administrator\Model\DashboardModel $model */ + $model = $this->getModel(); + + $this->stats = $model->getStats(); + $this->byType = $model->getCoverageByType(); + $this->missing = $model->getMissingArticles(20); + + $this->addToolbar(); + + parent::display($tpl); + } + + /** + * Add the toolbar. + * + * @return void + */ + protected function addToolbar(): void + { + ToolbarHelper::title(Text::_('COM_MOKOOG_DASHBOARD_TITLE'), 'bookmark'); + ToolbarHelper::preferences('com_mokoog'); + } +} diff --git a/source/packages/com_mokoog/tmpl/dashboard/default.php b/source/packages/com_mokoog/tmpl/dashboard/default.php new file mode 100644 index 0000000..d238d3a --- /dev/null +++ b/source/packages/com_mokoog/tmpl/dashboard/default.php @@ -0,0 +1,142 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +defined('_JEXEC') or die; + +use Joomla\CMS\Language\Text; +use Joomla\CMS\Router\Route; +use Joomla\CMS\Uri\Uri; + +/** @var \Joomla\Component\MokoOG\Administrator\View\Dashboard\HtmlView $this */ + +$s = $this->stats; +$coverage = (int) ($s['coverage'] ?? 0); +$total = (int) ($s['total'] ?? 0); +$withOg = (int) ($s['with_og'] ?? 0); + +$colorClass = $coverage >= 80 ? 'text-success' : ($coverage >= 50 ? 'text-warning' : 'text-danger'); +$stroke = $coverage >= 80 ? '#198754' : ($coverage >= 50 ? '#ffc107' : '#dc3545'); + +$r = 54.0; +$circ = 2 * M_PI * $r; +$dash = round($circ * $coverage / 100, 2); +$gap = round($circ - $dash, 2); +?> +
+
+ +
+
+
+

+ + + + % + +

+
+
+
+ + +
+
+
+

+
    +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
+
+
+
+
+ +
+ +
+
+
+

+ byType)) : ?> +

+ + + + + + + + + + + + byType as $row) : ?> + + + + + + + + +
escape($row->content_type); ?>total; ?>with_title; ?>with_image; ?>
+ +
+
+
+ + +
+
+
+
+

+ + + + +
+ missing)) : ?> +

+ + +

+ + + + +
+
+
+
+
diff --git a/source/packages/com_mokoog/tmpl/tags/coverage.php b/source/packages/com_mokoog/tmpl/tags/coverage.php deleted file mode 100644 index 931039d..0000000 --- a/source/packages/com_mokoog/tmpl/tags/coverage.php +++ /dev/null @@ -1,58 +0,0 @@ - - * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. - * @license GNU General Public License version 3 or later; see LICENSE - */ - -defined('_JEXEC') or die; - -use Joomla\CMS\Factory; -use Joomla\CMS\Language\Text; - -$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class); - -// Total published articles -$db->setQuery($db->getQuery(true)->select('COUNT(*)')->from('#__content')->where('state = 1')); -$totalArticles = (int) $db->loadResult(); - -// Articles with OG tags -$db->setQuery($db->getQuery(true)->select('COUNT(DISTINCT content_id)')->from('#__mokoog_tags')->where("content_type = 'com_content'")->where('published = 1')); -$articlesWithOg = (int) $db->loadResult(); - -// Articles missing OG data fields -$db->setQuery($db->getQuery(true)->select('COUNT(*)')->from('#__mokoog_tags')->where("content_type = 'com_content'")->where("og_title = ''")->where('published = 1')); -$missingTitle = (int) $db->loadResult(); - -$db->setQuery($db->getQuery(true)->select('COUNT(*)')->from('#__mokoog_tags')->where("content_type = 'com_content'")->where("og_description = ''")->where('published = 1')); -$missingDesc = (int) $db->loadResult(); - -$db->setQuery($db->getQuery(true)->select('COUNT(*)')->from('#__mokoog_tags')->where("content_type = 'com_content'")->where("og_image = ''")->where('published = 1')); -$missingImage = (int) $db->loadResult(); - -$coverage = $totalArticles > 0 ? round(($articlesWithOg / $totalArticles) * 100) : 0; -?> -
-
-

-
-
-
- % -
- -
-
-
    -
  • -
  • -
  • -
  • -
-
-
-
-
diff --git a/source/packages/com_mokoog/tmpl/tags/default.php b/source/packages/com_mokoog/tmpl/tags/default.php index 81b4d49..77e8c56 100644 --- a/source/packages/com_mokoog/tmpl/tags/default.php +++ b/source/packages/com_mokoog/tmpl/tags/default.php @@ -21,7 +21,6 @@ use Joomla\CMS\Session\Session; $token = Session::getFormToken(); ?> -