{"id":95420,"date":"2023-03-10T00:00:06","date_gmt":"2023-03-10T00:00:06","guid":{"rendered":"https:\/\/www.red-gate.com\/simple-talk\/?p=95420"},"modified":"2023-03-13T21:30:24","modified_gmt":"2023-03-13T21:30:24","slug":"wordpress-archives-page-mysql","status":"publish","type":"post","link":"https:\/\/www.red-gate.com\/simple-talk\/featured\/wordpress-archives-page-mysql\/","title":{"rendered":"Build a Better WordPress Archives Page Using MySQL"},"content":{"rendered":"<section id=\"bertrand-blog-post\">I recently wrote about a way to generate visual, interactive calendars in HTML by combining a calendar table with data from an events table (<a href=\"https:\/\/www.mssqltips.com\/sqlservertip\/7552\/events-calendar-html-sql-server-css\/?utm_source=AaronBertrand\" target=\"_blank\" rel=\"noopener\">part 1<\/a> and <a href=\"https:\/\/www.mssqltips.com\/sqlservertip\/7567\/sql-calendar-table-creation-simplified-with-table-and-view\/?utm_source=AaronBertrand\" target=\"_blank\" rel=\"noopener\">part 2<\/a>). This technique could be used to overlay any type of data onto calendars, but my specific goal was to make the <a href=\"https:\/\/sqlblog.org\/archives\" target=\"_blank\" rel=\"noopener\">monthly archives page<\/a> on my blog a little less boring.<\/p>\n<p>I started with SQL Server because that&#8217;s what I know, but I knew that wouldn&#8217;t be my final solution. Like many of you I&#8217;m sure, my blog runs on WordPress and MySQL. To make matters worse, my host is stuck on MySQL 5.7, which is missing some major conveniences I&#8217;ve been spoiled with in SQL Server, and that have been added to MySQL in the meantime &#8211; most notably, common table expressions (CTEs) and <code style=\"font-family: consolas; background: #e4e4e4; padding: 3px 4px 1px 4px; border-radius: 3px;\">ROW_NUMBER()<\/code>.<\/p>\n<p>In this post, I&#8217;ll show how I worked around that. But let&#8217;s start from the beginning, by starting from the end.<\/p>\n<h2>The End Goal<\/h2>\n<p>Think about the page (or sidebar) where your blog&#8217;s monthly archives (or any monthly breakdown of events, really) are typically represented in this simple way:<\/p>\n<div style=\"width: 50%; max-width: 300px; font-size: 0.8rem; line-height: 1.25rem; margin-bottom: 1em; margin-left: 20px; border: 1px solid #ccc; background: linear-gradient(#ddd,#fff); padding: 10px;\"><b>Archives<\/b><br \/>\n<a href=\"#\">January 2023<\/a> (3)<br \/>\n<a href=\"#\">December 2022<\/a> (2)<br \/>\n<a href=\"#\">November 2022<\/a> (5)<br \/>\n&#8230;ho hum (ad nauseum)<\/div>\n<p>A while back, I made a change to that page on my site so that each month was represented by a visual calendar. But it was static CSS; every month looked the same, with the only changes being the month name and the clickable number (representing number of posts in that month):<\/p>\n<p><img decoding=\"async\" style=\"width: 66%; min-width: 280px; max-width: 500px; border: 1px solid black;\" src=\"\/simple-talk\/wp-content\/uploads\/2023\/02\/ab.cal_.old_.boring.design.png\" alt=\"Initial boring calendar design\" \/><\/p>\n<p>But my vision was for the month to look <em>accurate<\/em>, with the first day starting on the right weekday, along with clickable days when blog posts were published (including special highlighting for days with multiple posts), clickable months to take you to an index for the month, and clickable years to take you to an index for that year.<\/p>\n<p>My initial 2-minute sketch looked like this:<\/p>\n<p><img decoding=\"async\" style=\"width: 80%; min-width: 320px; max-width: 800px; border: 1px solid black;\" src=\"\/simple-talk\/wp-content\/uploads\/2023\/02\/ab.initial.calendar.sketch.png\" alt=\"Initial calendar sketch\" \/><\/p>\n<p>I fine-tuned it in HTML and CSS like this:<\/p>\n<p><img decoding=\"async\" style=\"width: 66%; min-width: 280px; max-width: 500px; border: 1px solid black;\" src=\"\/simple-talk\/wp-content\/uploads\/2023\/02\/ab.ideal_.calendar.w.comments.png\" alt=\"Much more appealing and functional calendar design\" \/><\/p>\n<p>The HTML is easy enough to generate by hand, if you have a limited calendar and number of events:<\/p>\n<p><img decoding=\"async\" style=\"width: 66%; min-width: 280px; max-width: 500px; border: 1px solid black;\" src=\"\/simple-talk\/wp-content\/uploads\/2023\/02\/ab.cal_.sample.html_.w.comments.png\" alt=\"HTML structure\" \/><\/p>\n<p>But since my blog anthology spans more than 16 years and, presumably, you don&#8217;t want to muck with that HTML every month and for every new post you publish, we&#8217;ll need some programmatic help to make this automatic.<\/p>\n<p>You&#8217;ll notice those aren&#8217;t standard HTML elements like <code style=\"font-family: consolas; background: #e4e4e4; padding: 3px 4px 1px 4px; border-radius: 3px;\">&lt;span&gt;<\/code> and <code style=\"font-family: consolas; background: #e4e4e4; padding: 3px 4px 1px 4px; border-radius: 3px;\">&lt;div&gt;<\/code> &#8211; I made my own because it was much easier to build and debug without extra noise, and also so I wouldn&#8217;t have to override any styling applied by templates, plug-ins, and WordPress itself. The <code style=\"font-family: consolas; background: #e4e4e4; padding: 3px 4px 1px 4px; border-radius: 3px;\">&lt;year&gt;<\/code> element is a grid, set up this way:<\/p>\n<pre class=\"theme:tomorrow-night font:consolas font-size:14 whitespace-before:1 whitespace-after:1 lang:css decode:true \">  year \r\n  {\r\n    display: grid; \r\n    grid-template-columns: repeat(auto-fill, 210px);\r\n  }<\/pre>\n<p><code style=\"font-family: consolas; background: #e4e4e4; padding: 3px 4px 1px 4px; border-radius: 3px;\">&lt;month&gt;<\/code> is just a 210px \u00d7 156px, fixed-dimension wrapper around <code style=\"font-family: consolas; background: #e4e4e4; padding: 3px 4px 1px 4px; border-radius: 3px;\">&lt;days&gt;<\/code>, that fills <code style=\"font-family: consolas; background: #e4e4e4; padding: 3px 4px 1px 4px; border-radius: 3px;\">&lt;year&gt;<\/code> in a responsive way, thanks to the auto-fill grid. The dimensions are arbitrary, but were meant for the calendar to fill months 6, 4, or 3 wide, depending on browser window size.<\/p>\n<pre class=\"theme:tomorrow-night font:consolas font-size:14 whitespace-before:1 whitespace-after:1 lang:css decode:true \">  month \r\n  {\r\n    width:  210px;\r\n    height: 156px;\r\n  }<\/pre>\n<p><code style=\"font-family: consolas; background: #e4e4e4; padding: 3px 4px 1px 4px; border-radius: 3px;\">&lt;days&gt;<\/code> becomes a grid-within-a-grid, which allows 30-pixel wide <code style=\"font-family: consolas; background: #e4e4e4; padding: 3px 4px 1px 4px; border-radius: 3px;\">&lt;day&gt;<\/code> elements to flow 7 columns wide and up to 6 rows high (since some months that start on a Friday or Saturday actually have 5 Sundays), within their <code style=\"font-family: consolas; background: #e4e4e4; padding: 3px 4px 1px 4px; border-radius: 3px;\">&lt;month&gt;<\/code> container:<\/p>\n<pre class=\"theme:tomorrow-night font:consolas font-size:14 whitespace-before:1 whitespace-after:1 lang:css decode:true \">  days\r\n  {\r\n    display: grid;\r\n    grid-template-columns: repeat(7, 30px);\r\n    grid-template-rows:    repeat(6, 21px);\r\n  }<\/pre>\n<p>Finally, as I described in an earlier post, starting the weekday accurately (e.g. in the right grid column) relies on <a href=\"https:\/\/zellwk.com\/blog\/calendar-with-css-grid\" target=\"_blank\" rel=\"noopener\">this ingenious CSS from Zell<\/a>:<\/p>\n<pre class=\"theme:tomorrow-night font:consolas font-size:14 whitespace-before:1 whitespace-after:1 lang:css decode:true \">  \/* for each &lt;days class=d{X}&gt; make {X} the first column *\/\r\n  days.d1 day:first-child { grid-column: 1; }\r\n  days.d2 day:first-child { grid-column: 2; }\r\n  days.d3 day:first-child { grid-column: 3; }\r\n  days.d4 day:first-child { grid-column: 4; }\r\n  days.d5 day:first-child { grid-column: 5; }\r\n  days.d6 day:first-child { grid-column: 6; }\r\n  days.d7 day:first-child { grid-column: 7; }<\/pre>\n<p>No, this isn&#8217;t the most responsive design I&#8217;ve ever created, because I rely on explicit pixel sizes, but if you <a href=\"https:\/\/sqlblog.org\/archives\" target=\"_blank\" rel=\"noopener\">view the site<\/a> on a mobile device, you&#8217;ll see these dimensions are adjusted slightly to fill the screen appropriately.<\/p>\n<h2>How to Get There<\/h2>\n<p>My plan seemed simple: just port the same T-SQL queries I already wrote to MySQL, pull them from a PHP page, and call it a day. But, as hinted earlier, limitations in MySQL 5.7 made me step back. I thought about the things I needed to help me automate HTML generation, and how some would be complicated by the lack of modern language features, and by me being more comfortable writing MySQL queries than PHP code:<\/p>\n<ul style=\"line-height: 1.125em;\">\n<li>A calendar table representing all of the dates I would ever need to display:\n<ul>\n<li style=\"margin-top: 7px;\">Start from the first day of the first month of my very first post (December 2006)<\/li>\n<li>End at the last day of some month in the future<\/li>\n<li>For each month, whether it is the first or last month in a (partial!) year<\/li>\n<li>For each day, attributes that indicate:\n<ul>\n<li style=\"margin-top: 7px;\">Whether it is the first or last day of the month<\/li>\n<li>For the first of each month, the weekday it falls on<\/li>\n<\/ul>\n<\/li>\n<\/ul>\n<\/li>\n<li>Data from <code style=\"font-family: consolas; background: #e4e4e4; padding: 3px 4px 1px 4px; border-radius: 3px;\">wp_posts<\/code> for every published post:\n<ul>\n<li style=\"margin-top: 7px;\">The date of the post<\/li>\n<li>The number of posts published that day<\/li>\n<li>Aggregated data for each month<\/li>\n<li>Aggregated data for each year<\/li>\n<\/ul>\n<\/li>\n<\/ul>\n<p>I started with the built-in calendar table that will serve many other purposes down the road. Following at least some of WordPress&#8217; existing naming conventions, I used this syntax to generate 8,432 rows, covering December 2006 (the month of my first post) through the end of 2029:<\/p>\n<pre class=\"theme:tomorrow-night font:consolas font-size:14 lang:mysql whitespace-before:1 whitespace-after:1 decode:true\">  SET @start = DATE('20061201');\r\n  SET @end   = DATE('20291231');\r\n\r\n  CREATE TABLE `wp_calendar`\r\n  SELECT  d     AS TheDate,\r\n    YEAR (d)    AS TheYear,\r\n    MONTH(d)    AS TheMonth,\r\n    DAY  (d)    AS TheDay,\r\n\r\n  # which day of week does the month start on?\r\n\r\n    CASE   WHEN DAY(d) = 1      \r\n           THEN DAYOFWEEK(d) ELSE NULL END\r\n           AS WeekdayOfFirst,\r\n\r\n  # is the current date the last day of the month?\r\n\r\n    CASE   WHEN LAST_DAY(d) = d \r\n           THEN 1 ELSE 0 END\r\n           AS IsLastOfMonth,\r\n\r\n  # do we need a closing &lt;\/year&gt; tag because it is \r\n  # either the last day of January OR the last day of\r\n  # the current month?\r\n\r\n    CASE   WHEN (MONTH(d) = 1 AND LAST_DAY(d) = d)\r\n           OR (LAST_DAY(@start) = d AND MONTH(d) &gt; 1)\r\n           THEN 1 ELSE 0 END \r\n           AS NeedsClosingYear\r\n  FROM \r\n  (\r\n    # add a day to every day in the range\r\n    SELECT DATE(DATE_ADD(@start, INTERVAL n-1 DAY)) AS d \r\n    FROM \r\n    (\r\n       # loop variable to simulate ROW_NUMBER()\r\n       SELECT (@rn := @rn + 1) AS n \r\n         FROM       INFORMATION_SCHEMA.COLUMNS AS c1\r\n         CROSS JOIN INFORMATION_SCHEMA.COLUMNS AS c2\r\n         CROSS JOIN (SELECT @rn := 0)          AS rn\r\n         LIMIT 10980   # more than 30 years!\r\n    ) AS Numbers\r\n  ) AS Days WHERE d &lt;= @end;<\/pre>\n<p><em style=\"color: #777; font-size: 0.9em;\">You can experiment with the contents of such a table <a href=\"https:\/\/dbfiddle.uk\/oVh1XrGa\" target=\"_blank\" rel=\"noopener\">in this db&lt;&gt;fiddle<\/a>, but keep in mind this may generate a forward-compatibility warning in some versions.<\/em><\/p>\n<p>I rely on <code style=\"font-family: consolas; background: #e4e4e4; padding: 3px 4px 1px 4px; border-radius: 3px;\">INFORMATION_SCHEMA<\/code> having enough columns in a WordPress installation, and this gets me well beyond the needed 8K+ rows. If you don&#8217;t have enough rows where you&#8217;re implementing this, you can cross join the view again. In MySQL 8.0, I probably would have used a recursive CTE to generate a series of days (though I would have to manage <a href=\"https:\/\/dev.mysql.com\/doc\/refman\/8.0\/en\/server-system-variables.html#sysvar_cte_max_recursion_depth\" target=\"_blank\" rel=\"noopener\"><code style=\"font-family: consolas; background: #e4e4e4; padding: 3px 4px 1px 4px; border-radius: 3px;\">cte_max_recursion_depth<\/code><\/a> to get more than 1,000 rows). This would avoid cross joins, identifying the number of days required, and pesky loop variables that might produce unwanted warnings.<\/p>\n<pre class=\"theme:tomorrow-night font:consolas font-size:14 lang:mysql whitespace-before:1 whitespace-after:1 decode:true\">  WITH RECURSIVE Days(d)\r\n  AS\r\n  (\r\n    SELECT @start\r\n    UNION ALL\r\n    SELECT DATE_ADD(d, INTERVAL 1 DAY)\r\n      FROM Days WHERE d &lt; @end\r\n  )\r\n  ...<\/pre>\n<p>&#8230;but, I don&#8217;t have that luxury on my site today.<\/p>\n<p>I moved on to creating multiple stacked views to aggregate valid post data by day, then by month, and then by year. I did it this way to prevent the approach from becoming overly complicated in one giant query and to avoid repeating expressions multiple times at the same level. And also because I know I will be able to find other uses for some of these individual views.<\/p>\n<p>First, a view to aggregate posts by day (and filter out drafts and other post types from all subsequent views):<\/p>\n<pre class=\"theme:tomorrow-night font:consolas font-size:14 lang:mysql whitespace-before:1 whitespace-after:1 decode:true \">  CREATE VIEW v_post_days\r\n  AS\r\n    SELECT DATE(post_date) AS TheDate,\r\n           COUNT(*)        AS DayCount,\r\n           CONCAT('\/', YEAR(post_date), \r\n                  '\/', MONTH(post_date), \r\n                  '\/', DAY(post_date), '\/') AS Url\r\n    FROM `wp_posts`\r\n    WHERE post_type   = 'post'\r\n      AND post_status = 'publish'\r\n    GROUP BY DATE(post_date);<\/pre>\n<p>Then we can use that view to aggregate by month:<\/p>\n<pre class=\"theme:tomorrow-night font:consolas font-size:14 lang:mysql whitespace-before:1 whitespace-after:1 decode:true \">  CREATE VIEW v_post_months\r\n  AS\r\n    # first of month, even for leap years:\r\n    SELECT m + INTERVAL 1 DAY - INTERVAL 1 MONTH \r\n                        AS FirstOfMonth,\r\n           m            AS LastOfMonth,\r\n           YEAR(m)      AS TheYear,\r\n           MONTH(m)     AS TheMonth,\r\n           MonthCount,\r\n           CONCAT('\/', YEAR(m), '\/', MONTH(m), '\/') AS Url\r\n         FROM\r\n         (\r\n           SELECT LAST_DAY(TheDate) AS m,\r\n                  SUM(DayCount)     AS MonthCount\r\n           FROM v_post_days\r\n           GROUP BY LAST_DAY(TheDate)\r\n         ) AS x;<\/pre>\n<p>And finally by year:<\/p>\n<pre class=\"theme:tomorrow-night font:consolas font-size:14 lang:mysql whitespace-before:1 whitespace-after:1 decode:true \">  CREATE VIEW v_post_years\r\n  AS\r\n    SELECT TheYear, \r\n           SUM(MonthCount)           AS YearCount,\r\n           CONCAT('\/', TheYear, '\/') AS Url\r\n    FROM v_post_months\r\n    GROUP BY TheYear;<\/pre>\n<h2>Putting it All Together<\/h2>\n<p>To combine our aggregated post data with the overall calendar table, we can select from the latter and outer join to the views. I expose a bunch of additional columns here because many will be used to either inject directly into the output HTML or help with conditional logic to decide what CSS classes to add and whether to end an HTML element:<\/p>\n<pre class=\"theme:tomorrow-night font:consolas font-size:14 lang:mysql whitespace-before:1 whitespace-after:1 decode:true \">  ALTER VIEW v_post_calendar\r\n  AS\r\n    SELECT cal.TheDate,\r\n \r\n      # for partial years, we need &lt;year&gt;\r\n      CASE WHEN y.TheYear IS NOT NULL THEN 1\r\n        ELSE 0 END AS NeedsOpeningYear,\r\n \r\n      # year info\r\n      y.Url AS YearUrl,\r\n      cal.TheYear,\r\n      y.YearCount,\r\n \r\n      # month info\r\n      m.Url AS MonthUrl,\r\n      MONTHNAME(cal.TheDate) AS MonthName,\r\n      cal.TheMonth,\r\n      m.MonthCount,\r\n \r\n      # day info\r\n      cal.WeekdayOfFirst,\r\n      d.Url AS DayUrl,\r\n      d.DayCount,\r\n      cal.TheDay,\r\n \r\n      cal.IsLastOfMonth,     # need a closing &lt;\/month&gt;?\r\n      cal.NeedsClosingYear   # need a closing &lt;\/year&gt;?\r\n    FROM `wp_calendar` AS cal\r\n    LEFT OUTER JOIN v_post_days AS d\r\n      ON cal.TheDate = d.TheDate\r\n    LEFT OUTER JOIN v_post_months AS m\r\n      ON cal.TheDate IN (m.FirstOfMonth, m.LastOfMonth)\r\n      \r\n    # need to know if it's the last month in the\r\n    # current year, especially in the case where\r\n    # there are no posts yet for the current month\r\n    LEFT OUTER JOIN v_post_years AS y\r\n      ON cal.TheDay = 1 \r\n      AND cal.TheYear = y.TheYear\r\n      AND\r\n      (\r\n          cal.TheMonth = 12 \r\n          OR LAST_DAY(cal.TheDate) = LAST_DAY(CURDATE())\r\n      )\r\n    # need to stop on the last day of *this* month:\r\n    WHERE cal.TheDate &lt;= LAST_DAY(CURDATE());<\/pre>\n<p>The complicated part, of course, is building the final HTML output, which can be a very iterative process to get right &#8211; not just when there is a lot of data, but also when there are several edge cases. And when you want the source more legible and easier to troubleshoot, it pays to have additional whitespace peppered in there (like <code style=\"font-family: consolas; background: #e4e4e4; padding: 3px 4px 1px 4px; border-radius: 3px;\">\\r<\/code>, <code style=\"font-family: consolas; background: #e4e4e4; padding: 3px 4px 1px 4px; border-radius: 3px;\">\\n<\/code>, and <code style=\"font-family: consolas; background: #e4e4e4; padding: 3px 4px 1px 4px; border-radius: 3px;\">\\t<\/code>).<\/p>\n<pre class=\"theme:tomorrow-night font:consolas font-size:14 lang:mysql whitespace-before:1 whitespace-after:1 decode:true \">  CREATE VIEW v_post_calendar_html\r\n  AS\r\n    SELECT TheYear, \r\n           TheMonth,\r\n           TheDay, \r\n           CONCAT(OpenYear, OpenMonth, Day, CloseMonth, CloseYear) AS html\r\n    FROM\r\n    (\r\n      SELECT TheYear, \r\n             TheMonth, \r\n             TheDay,\r\n\r\n        # OpenYear (need to add &lt;year&gt;&lt;yearname&gt; etc.):\r\n        CASE WHEN NeedsOpeningYear = 1 \r\n        THEN CONCAT\r\n        (\r\n          '\\r\\n&lt;yearname&gt;',\r\n          CASE WHEN YearCount &gt; 0 THEN \r\n            # if any posts in that year, make it a link\r\n            # and display the number of posts:\r\n            CONCAT\r\n            (\r\n              '&lt;a href=\"', YearUrl, '\"&gt;', \r\n              TheYear, '&lt;\/a&gt; (', YearCount, ')'\r\n            )\r\n            ELSE TheYear END,\r\n          '&lt;\/yearname&gt;\\r\\n&lt;year&gt;'\r\n        ) ELSE '' END \r\n        AS OpenYear,\r\n\r\n        # OpenMonth (need to add &lt;month&gt;&lt;monthname&gt; etc.):\r\n        CASE WHEN TheDay = 1 \r\n        THEN CONCAT\r\n        (\r\n          '\\r\\n\\t&lt;month&gt;\\r\\n\\t\\t&lt;monthname&gt;',\r\n          CASE WHEN MonthCount &gt; 0 THEN\r\n            # if any posts in that month, make it a link\r\n            # and display the number of posts:\r\n            CONCAT\r\n            (\r\n              '&lt;a href=\"', MonthUrl, '\"',\r\n              ' title=\"', MonthName, ' ', TheYear, '\"&gt;',\r\n              MonthName, '&lt;\/a&gt; (', MonthCount, ')'\r\n            )\r\n            ELSE CONCAT('&lt;b&gt;',MonthName,'&lt;\/b&gt;') END,\r\n          '&lt;\/monthname&gt;',\r\n\r\n          # set the right grid column for the first day:\r\n          '\\r\\n\\t\\t&lt;days class=\"d', WeekdayOfFirst, '\"&gt;\\r\\n'\r\n        ) ELSE '' END \r\n        AS OpenMonth,\r\n\r\n        # Day (need one of these for every day in the range)\r\n        CONCAT\r\n        ( \r\n          '\\t\\t\\t&lt;day', \r\n          # need styling if the day has posts or is in the future\r\n          CASE WHEN DayCount &gt; 0 OR TheDate &gt; CURDATE()\r\n          THEN CONCAT\r\n          (\r\n            ' class=\"',\r\n            CASE WHEN DayCount &gt; 0         THEN ' on'     ELSE '' END,\r\n            CASE WHEN DayCount &gt; 1         THEN ' multi'  ELSE '' END,\r\n            CASE WHEN TheDate  &gt; CURDATE() THEN ' future' ELSE '' END,\r\n            '\"'\r\n          ) ELSE '' END, \r\n          '&gt;',\r\n          CASE WHEN DayCount &gt; 0 THEN\r\n          # make the day clickable and title with nicely formatted date\r\n          CONCAT\r\n          (\r\n            '&lt;a href=\"', DayUrl, '\" title=\"', \r\n            DATE_FORMAT(TheDate, '%M %D, %Y'),\r\n            '\\r\\n', DayCount, ' post',\r\n            CASE WHEN DayCount &gt; 1 THEN 's' ELSE '' END,\r\n            '\"&gt;', TheDay, '&lt;\/a&gt;'\r\n          )\r\n          ELSE TheDay END, \r\n          '&lt;\/day&gt;'\r\n        ) AS Day,\r\n  \r\n        # CloseMonth (every last of the month needs &lt;\/month&gt;)\r\n        CASE WHEN IsLastOfMonth = 1 \r\n          THEN '\\r\\n\\t\\t&lt;\/days&gt;\\r\\n\\t&lt;\/month&gt;' \r\n          ELSE '' END AS CloseMonth,\r\n\r\n        # CloseYear (last of each year needs &lt;\/year&gt;)\r\n        CASE WHEN NeedsClosingYear = 1 \r\n          THEN '\\r\\n&lt;\/year&gt;' \r\n          ELSE '' END AS CloseYear\r\n\r\n      FROM v_post_calendar\r\n    ) AS x;<\/pre>\n<p>I added a few niceties here, like prettier title attributes for clickable days, and no pluralization bugs for days with one or multiple posts:<\/p>\n<p><img decoding=\"async\" style=\"width: 20%; min-width: 100px; max-width: 220px; border: 1px solid black;\" src=\"\/simple-talk\/wp-content\/uploads\/2023\/01\/st-cal-title.png\" alt=\"Prettier title tag\" \/><\/p>\n<p>Again, some of this was much more useful to me while developing than it is for the final output.<\/p>\n<p>Anyway, I&#8217;m probably breaking all kinds of tribal coding conventions and best practices when working with MySQL, but I&#8217;m okay with that, since this is about the first thing I&#8217;ve ever published that <em>wasn&#8217;t<\/em> written in Transact-SQL.<\/p>\n<p>Finally, the PHP that queries the view and renders the HTML is rather simple, because we&#8217;ve done all the work in the query. Though it can&#8217;t be called from a regular page; you need to do this from a <a href=\"https:\/\/codex.wordpress.org\/Displaying_Posts_Using_a_Custom_Select_Query\" target=\"_blank\" rel=\"noopener\">custom page template<\/a> and have your regular archives page inherit from it:<\/p>\n<pre class=\"theme:tomorrow-night font:consolas font-size:14 lang:php whitespace-before:1 whitespace-after:1 decode:true \">  &lt;?php\r\n    global $wpdb;\r\n    $query = \"SELECT html \r\n                FROM v_post_calendar_html \r\n                ORDER BY TheYear DESC, TheMonth DESC, TheDay;\"; \r\n\r\n    $rs = $wpdb-&gt;get_results($query); \r\n\r\n    foreach ($rs as $row): \r\n      echo $row-&gt;html.PHP_EOL;\r\n    endforeach; \r\n  ?&gt;<\/pre>\n<p>And now, because the views automatically return data up to and including the current month, you don&#8217;t have to touch this again as months and years roll over, outside of the case where your blog is still running in 2030. Or, maybe, if you unpublish posts from the oldest month and want to stop that month from showing up empty. With my previous solution I had hard-coded the list of years and months, and only pulled the number of posts dynamically from the database, so this is already reducing my workload by a little bit every month.<\/p>\n<p>You can <a href=\"https:\/\/sqlblog.org\/archives\" target=\"_blank\" rel=\"noopener\">see this in action on sqlblog.org<\/a>, and view the source to confirm the output is as expected. You can also view the CSS, which I left in the HTML source instead of squirreling it away into a separate CSS file, and didn&#8217;t minify it so you could grok it easily. Just right-click the page, choose View Page Source, and search for:<\/p>\n<pre class=\"theme:tomorrow-night font:consolas font-size:14 lang:tsql whitespace-before:1 whitespace-after:1\">  \/* Archives page CSS *\/  <\/pre>\n<h2>Further Enhancements<\/h2>\n<p>A couple of enhancements I&#8217;ve contemplated but not published, and may cover in a future post:<\/p>\n<ul>\n<li><b>List the post titles in the title tag<\/b> &#8211; currently I just show how many posts are on a given day, but the title(s) could be surfaced by the underlying views in order to display them as well (granted, a title tag isn&#8217;t a great place to put <em>a lot<\/em> of information).<\/li>\n<li><b>Make single-post days redirect right to the post<\/b> &#8211; the archive-by-date-parts page in WordPress defaults to showing a post listing, even in cases where there&#8217;s only one post. Seems then having to click on that post is an unnecessary step, and either the direct URL could be put into the calendar in that case, or I could dig into the WordPress code or the theme template and have the destination page check the count and decide what to do. This could work for months and years, too, since any of those slices could contain just a single post.<\/li>\n<li><b>Provide more granular calendars<\/b> &#8211; the underlying views could also group by tag or category, allowing an archives page to present a calendar just for that subset of posts. Showing a calendar for <a href=\"https:\/\/sqlblog.org\/bad-habits\" target=\"_blank\" rel=\"noopener\">my Bad Habits series<\/a>, for example, would really highlight when I was very active on Stack Overflow and coming across a lot of terrible stuff there. In multi-author blogs (like this one!), you could make it so users see a visual calendar just for the selected author.<\/li>\n<\/ul>\n<h2>Conclusion<\/h2>\n<p>How you might implement this in your own blog will differ, depending on where you&#8217;re hosting, what version of MySQL you&#8217;re using, and the theme you&#8217;ve implemented. But the core concept is the same: build a calendar table, outer join to your events table, and then build the HTML. No more boring, never-ending lists of month names!<\/p>\n<\/section>\n","protected":false},"excerpt":{"rendered":"<p>I recently wrote about a way to generate visual, interactive calendars in HTML by combining a calendar table with data from an events table (part 1 and part 2). This technique could be used to overlay any type of data onto calendars, but my specific goal was to make the monthly archives page on my&#8230;&hellip;<\/p>\n","protected":false},"author":341115,"featured_media":0,"comment_status":"open","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"_acf_changed":false,"footnotes":""},"categories":[53],"tags":[5854],"coauthors":[158980],"class_list":["post-95420","post","type-post","status-publish","format-standard","hentry","category-featured","tag-mysql"],"acf":[],"_links":{"self":[{"href":"https:\/\/www.red-gate.com\/simple-talk\/wp-json\/wp\/v2\/posts\/95420","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.red-gate.com\/simple-talk\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.red-gate.com\/simple-talk\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.red-gate.com\/simple-talk\/wp-json\/wp\/v2\/users\/341115"}],"replies":[{"embeddable":true,"href":"https:\/\/www.red-gate.com\/simple-talk\/wp-json\/wp\/v2\/comments?post=95420"}],"version-history":[{"count":154,"href":"https:\/\/www.red-gate.com\/simple-talk\/wp-json\/wp\/v2\/posts\/95420\/revisions"}],"predecessor-version":[{"id":96170,"href":"https:\/\/www.red-gate.com\/simple-talk\/wp-json\/wp\/v2\/posts\/95420\/revisions\/96170"}],"wp:attachment":[{"href":"https:\/\/www.red-gate.com\/simple-talk\/wp-json\/wp\/v2\/media?parent=95420"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.red-gate.com\/simple-talk\/wp-json\/wp\/v2\/categories?post=95420"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.red-gate.com\/simple-talk\/wp-json\/wp\/v2\/tags?post=95420"},{"taxonomy":"author","embeddable":true,"href":"https:\/\/www.red-gate.com\/simple-talk\/wp-json\/wp\/v2\/coauthors?post=95420"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}