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 @@
+
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();
?>
-