By: Aaron Bertrand | Updated: 2023-02-07 | Comments | Related: > Dates
Problem
Lists can be boring. On blog sites where I have a lot of content, I like a little more pizzazz on my archives page rather than just a long list of "{month name} {year}."
But calendars are irregular. Between leap years and a varying number of days, it can be challenging to construct a visual calendar that is accurate and self-maintaining. How can I generate this HTML and CSS from the database?
Solution
While I doubt many people want to browse my blog posts by month, sometimes I want to take a quick glance to see when my busiest weeks or months have been. And maybe your blog has a broader audience, or you aren't presenting blog posts at all - this applies to any type of event.
Let's start from the end - what we want the output to be. Currently, my archives page might look like this:
But what if I want something prettier, like this (say, rendered on January 25th)?
The month name is clickable and will take you to the archives page for January. The days of the month are accurate – there was a post on Tuesday, January 10th, highlighted in green, and clicking that box would take you to the archives for that day (rather than to the post itself, since any given day could have multiple posts). Similarly, there was a post on Thursday, January 19th, and clicking that day will take you to the archives page for the 19th. The days after the 25th look slightly different from the rest because they're in the future.
Admittedly, January 2023 is a convenient and pedestrian example because the 1st of the month is on a Sunday, so no tricks are required to offset the first day of the month. Let's look at how we'd want the last three months of 2022 to look on a visual calendar:
Notice how the start days of the month are lined up correctly – December started on a Thursday and ended on a Saturday, November was Tuesday to Wednesday, and October was Saturday to Monday. This is accomplished with a little trick I picked up from Zell. Essentially, we are using a CSS grid that is 7 x 6 (7 days times a max of 6 weeks falling in any given month). And we can push the 1st of the month to start at the appropriate grid column by using a class for each possible weekday. Here's the CSS:
days { display: grid; grid-template-columns: repeat(7, 24px); grid-template-rows: repeat(6, 18px); } days.d1 day:first-child { grid-column: 1; } days.d2 day:first-child { grid-column: 2; } days.d3 day:first-child { grid-column: 3; } days.d4 day:first-child { grid-column: 4; } days.d5 day:first-child { grid-column: 5; } days.d6 day:first-child { grid-column: 6; } days.d7 day:first-child { grid-column: 7; }
And the HTML for October:
<year>2022</year> ... <month> <monthname><a href="/2022/10/">October</a> (6)</monthname> <days class="d7"> <day>1</day> <day>2</day> <day class="on"><a href="/2022/10/03">3</a></day> <day>4</day> <day>5</day> <day class="on"><a href="/2022/10/06">6</a></day> <day>7</day> ... <day>30</day> <day>31</day> </days> </month> ... </year>
I made up my own HTML elements for the day, month, year, etc., instead of relying on common element names, as I find it frustrating to have to override attributes enforced by theme templates or plug-ins. Probably not the best for accessibility, but the simplicity makes the HTML a lot less confusing.
Getting the grid cells to look right (and behave like a table) uses a CSS trick from Håkan Save Hansson. But this tip is less about the presentation and more about getting the database to generate the HTML for you.
We need to think about that generation in two steps:
- Building the entire list of days (essentially, the month containing the very first event, all the way through to the current month).
- Outer joining that list of dates to the actual events.
The list of days can be generated with some simple set generation. Let's
say you have a table called dbo.Events
and a column
called EventDate
.
CREATE TABLE dbo.Events(EventDateTime datetime2); INSERT dbo.Events(EventDateTime) VALUES ('20221003 03:35'),('20221006 14:26'),('20221011 05:00'), ('20221021 06:34'),('20221024 16:33'),('20221026 17:27'), ('20221104 03:35'),('20221109 14:26'),('20221114 05:00'), ('20221118 06:34'),('20221121 16:33'),('20221124 17:27'), ('20221127 17:27'), ('20221207 03:35'),('20221212 14:26'),('20221216 05:00'), ('20221222 06:34'),('20221228 17:27'), ('20230110 05:55'),('20230119 11:32');
We need to know all the days from the beginning of the month containing the earliest
EventDate
up until now. A neat trick I've used
to generate a series of n
numbers, while waiting for
GENERATE_SERIES()
to be
more mainstream, is to run STRING_SPLIT()
against
a single-character string replicated n-1
times. For
example, this produces the numbers 1 to 20:
DECLARE @numRows int = 20; SELECT rn = ROW_NUMBER() OVER (ORDER BY @@SPID) FROM STRING_SPLIT(REPLICATE('|', @numRows - 1), '|') AS x;
Another trick I've come to appreciate is using EOMONTH
to retrieve the first day of a month. For example, the first day of this month can
be derived from the following:
SELECT DATEADD(DAY, 1, EOMONTH(GETDATE(), -1));
Incorporating those into calculating the days we want in our range:
DECLARE @firstDate date = (SELECT MIN(EventDateTime) FROM dbo.Events), @lastDate date = EOMONTH(GETDATE()), @numDays int; SET @firstDate = DATEADD(DAY, 1, EOMONTH(@firstDate, -1)); SET @numDays = DATEDIFF(DAY, @firstDate, @lastDate) + 1; SELECT TheDay = DATEADD(DAY, rn-1, @firstDate) FROM ( SELECT rn = ROW_NUMBER() OVER (ORDER BY @@SPID) FROM STRING_SPLIT(REPLICATE('|', @numDays - 1), '|') AS x ) AS x;
When run in January 2023, this produces 123 rows – the sequence of days from October 1st through January 31st:
TheDay ---------- 2022-10-01 2022-10-02 2022-10-03 ... 2023-01-29 2023-01-30 2023-01-31
To build our HTML, we also need to know the weekday (regardless of
DATEFIRST
settings) and, when it is the first of the
month, which weekday that day falls on. We can add an outer query to help calculate
this:
SELECT TheDay = src.d, DayOfMonth = DATEPART(DAY, src.d), WeekdayOfFirst = CONCAT(CASE DATEPART(DAY, src.d) WHEN 1 THEN COALESCE(NULLIF((DATEPART(WEEKDAY, src.d) + @@DATEFIRST) % 7, 0), 7) END, '') FROM ( SELECT d = DATEADD(DAY, rn-1, @firstDate) FROM ( SELECT rn = ROW_NUMBER() OVER (ORDER BY @@SPID) FROM STRING_SPLIT(REPLICATE(',', @numDays - 1), ',') AS x ) AS x ) AS src;
Which produces:
TheDay DayOfMonth WeekdayOfFirst ---------- --------- -------------- 2022-10-01 1 7 2022-10-02 2 2022-10-03 3 ... 2022-10-31 31 2022-11-01 1 3 2022-11-02 2 ... 2022-11-30 30 2022-12-01 1 5 2022-12-02 2 ... 2022-12-31 31 2023-01-01 1 1 2023-01-02 2 ... 2023-01-29 29 2023-01-30 30 2023-01-31 31
Next, we'll additionally need a way to know whether a day is the first or last day of a month, or the first or last day of the year (which may not be the first or last day of the calendar year, because we're only showing the first month a blog post appears through the current month). We need to know this so we can close any outer HTML tags. Extending the query above might look like this:
SELECT TheDay = src.d, DayOfMonth = src.md, WeekdayOfFirst = CONCAT(CASE src.md WHEN 1 THEN COALESCE(NULLIF((src.wd + @@DATEFIRST) % 7, 0), 7) END,''), IsFirstOfYear = CASE ROW_NUMBER() OVER (PARTITION BY src.y ORDER BY src.d) WHEN 1 THEN '1' ELSE '' END, IsLastOfYear = CASE ROW_NUMBER() OVER (PARTITION BY src.y ORDER BY src.d DESC) WHEN 1 THEN '1' ELSE '' END, IsLastOfMonth = CASE WHEN src.eom = src.d THEN '1' ELSE '' END FROM ( SELECT d, y = YEAR(d), wd = DATEPART(WEEKDAY, d), md = DATEPART(DAY, d), eom = EOMONTH(d) FROM ( SELECT d = DATEADD(DAY, rn-1, @firstDate) FROM ( SELECT rn = ROW_NUMBER() OVER (ORDER BY @@SPID) FROM STRING_SPLIT(REPLICATE(',', @numDays - 1), ',') AS x ) AS x ) AS y ) AS src ORDER BY TheDay;
Abridged results:
TheDay DayOfMonth WeekdayOfFirst IsFirstOfYear IsLastOfYear IsLastOfMonth ---------- ---------- -------------- ------------- ------------ ------------- 2022-10-01 1 7 1 ... 2022-10-31 31 1 2022-11-01 1 3 2022-11-02 2 ... 2022-11-30 30 1 2022-12-01 1 5 2022-12-02 2 ... 2022-12-31 31 1 1 2023-01-01 1 1 1 2023-01-02 2 ... 2023-01-31 31 1 1
To generate the HTML of just the calendars shown above, we can order differently
and use CASE
expressions to dictate where HTML entities
start and end.
SELECT html = CASE WHEN IsLastMonth = '1' AND DayOfMonth = 1 THEN CONCAT('<yearname>', TheYear, '</yearname><year>') ELSE '' END + CASE DayOfMonth WHEN 1 THEN CONCAT('<month><monthname>', DATENAME(MONTH, TheDay), '</monthname><days class="d', WeekdayOfFirst, '">') ELSE '' END + CONCAT('<day', CASE WHEN TheDay > GETDATE() THEN ' class="future"' END, '>', DayOfMonth, '</day>') + CASE IsLastOfMonth WHEN '1' THEN '</days></month>' ELSE '' END + CASE WHEN IsLastOfMonth = '1' AND IsFirstMonth = '1' THEN '</year>' ELSE '' END FROM ( SELECT TheDay = src.d, TheMonth = src.m, TheYear = src.y, DayOfMonth = src.md, WeekdayOfFirst = CONCAT(CASE src.md WHEN 1 THEN COALESCE(NULLIF((src.wd + @@DATEFIRST) % 7, 0), 7) END,''), IsLastOfMonth = CASE WHEN src.eom = src.d THEN '1' ELSE '' END, IsFirstMonth = CASE ROW_NUMBER() OVER (PARTITION BY src.y ORDER BY src.m, src.d DESC) WHEN 1 THEN '1' ELSE '' END, IsLastMonth = CASE ROW_NUMBER() OVER (PARTITION BY src.y ORDER BY src.m DESC, src.d) WHEN 1 THEN '1' ELSE '' END FROM ( SELECT d, m = MONTH(d), y = YEAR(d), wd = DATEPART(WEEKDAY, d), md = DATEPART(DAY, d), eom = EOMONTH(d) FROM ( SELECT d = DATEADD(DAY, rn-1, @firstDate) FROM ( SELECT rn = ROW_NUMBER() OVER (ORDER BY @@SPID) FROM STRING_SPLIT(REPLICATE(',', @numDays - 1), ',') AS x ) AS x ) AS y ) AS src ) AS html ORDER BY TheYear DESC, TheMonth DESC, DayOfMonth;
The output with carriage returns and indentation added after the fact:
html --------------------------------------------------------------------------- <yearname>2023</yearname> <year> <month> <monthname>January</monthname> <days class="d1"> <day>1</day> <day>2</day> ... <day class="future">30</day> <day class="future">31</day> </days> </month> </year> <yearname>2022</yearname> <year> <month> <monthname>December</monthname> <days class="d5"> <day>1</day> <day>2</day> ... <day>30</day> <day>31</day> </days> </month> <month> <monthname>November</monthname> <days class="d3"> <day>1</day> <day>2</day> ...
That's not the prettiest output, but we can loop through the result, dump the HTML to the screen, and have the basic calendar spanning all the months in our event range.
Next, to hook in our actual events, we can outer join to the events table and
add styling and <a>
to the
<day>
cells with a match.
SELECT html = CASE WHEN IsLastMonth = '1' AND DayOfMonth = 1 THEN CONCAT('<yearname>', TheYear, '</yearname><year>') ELSE '' END + CASE DayOfMonth WHEN 1 THEN CONCAT('<month><monthname>', '<a href="/', cal.TheYear, '/', cal.TheMonth, '/">', DATENAME(MONTH, TheDay), '</a> (', COALESCE(mc.MonthCount, 0), ')</monthname><days class="d', WeekdayOfFirst, '">') ELSE '' END + CONCAT('<day', CASE WHEN TheDay > GETDATE() THEN ' class="future"' END, CASE WHEN e.EventDate IS NOT NULL THEN CONCAT(' class="on"><a href="/', cal.TheYear, '/', cal.TheMonth, '/', DATEPART(DAY, cal.TheDay), '/"') END, '>', DayOfMonth, '</a></day>') + CASE IsLastOfMonth WHEN '1' THEN '</days></month>' ELSE '' END + CASE WHEN IsLastOfMonth = '1' AND IsFirstMonth = '1' THEN '</year>' ELSE '' END FROM ( SELECT TheDay = src.d, TheMonth = src.m, TheYear = src.y, DayOfMonth = src.md, WeekdayOfFirst = CONCAT(CASE src.md WHEN 1 THEN COALESCE(NULLIF((src.wd + @@DATEFIRST) % 7, 0), 7) END,''), IsLastOfMonth = CASE WHEN src.eom = src.d THEN '1' ELSE '' END, IsFirstMonth = CASE ROW_NUMBER() OVER (PARTITION BY src.y ORDER BY src.m, src.d DESC) WHEN 1 THEN '1' ELSE '' END, IsLastMonth = CASE ROW_NUMBER() OVER (PARTITION BY src.y ORDER BY src.m DESC, src.d) WHEN 1 THEN '1' ELSE '' END FROM ( SELECT d, m = MONTH(d), y = YEAR(d), wd = DATEPART(WEEKDAY, d), md = DATEPART(DAY, d), eom = EOMONTH(d) FROM ( SELECT d = DATEADD(DAY, rn-1, @firstDate) FROM ( SELECT rn = ROW_NUMBER() OVER (ORDER BY @@SPID) FROM STRING_SPLIT(REPLICATE(',', @numDays - 1), ',') AS x ) AS x ) AS y ) AS src ) AS cal LEFT OUTER JOIN ( SELECT EventDate = CONVERT(date, EventDateTime) FROM dbo.Events AS e GROUP BY CONVERT(date, EventDateTime) ) AS e ON e.EventDate = cal.TheDay LEFT OUTER JOIN ( SELECT TheMonth = DATEFROMPARTS(YEAR(EventDateTime), MONTH(EventDateTime), 1), MonthCount = COUNT(*) FROM dbo.Events GROUP BY YEAR(EventDateTime), MONTH(EventDateTime) ) AS mc ON mc.TheMonth = cal.TheDay ORDER BY cal.TheYear DESC, cal.TheMonth DESC, cal.DayOfMonth;
A little ugly, sure, but it can be very handy to produce this from a central location instead of processing all the conditionals on the client side (especially when multiple clients are involved). Here is some of the output (with, again, formatting added after the fact):
html --------------------------------------------------- <yearname>2023</yearname> <year> <month> <monthname> <a href="/2023/1/">January</a> (2) </monthname> <days class="d1"> <day>1</a></day> <day>2</a></day> ... <day>9</a></day> <day class="on"> <a href="/2023/1/10/">10</a> </day> <day>11</a></day> ... <day class="future">31</a></day> </days> </month> </year> <yearname>2022</yearname> <year> <month> <monthname> <a href="/2022/12/">December</a> (6) </monthname> <days class="d5"> <day>1</a></day> <day>2</a></day> ... <day>27</a></day> <day class="on"> <a href="/2022/12/28/">28</a> </day> <day>29</a></day> <day>30</a></day> <day>31</a></day> </days> </month> <month> <monthname> <a href="/2022/11/">November</a> (7) </monthname> <days class="d3"> <day>1</a></day> <day>2</a></day> ...
Attached is a working example including the full SQL script, more sample data, and formatting niceties:
This assumes your post or event archives are accessible via simple
y/m
and y/m/d
URLs (like
WordPress), but it should be trivial to adjust the URL patterns if you use a different
format.
It also assumes events don't exist in the future; otherwise, you will need
to make minor adjustments to allow both future
and
on
CSS classes to coexist.
Finally, it assumes your event data is stored in SQL Server. I originally intended
to show the equivalent query in MySQL since many of you who use WordPress are tied
to the PHP/MySQL stack. The queries here will mostly work in MySQL 8.0, I think,
but many hosts are still on older versions (e.g., 5.7), which lack many enhancements
like CTEs and ROW_NUMBER()
. So maybe I could write
a port in a future tip.
Technically, this calendar is static until you publish a new post or move into a new month, so you can generate it once and only rebuild it when those events happen. But you also need to consider when you remove a post or change its publication date and that you’ll need to remember to generate a new grid every month. You could at least store the pre-joined data as a sort of calendar table, but perhaps something else I'll leave for a future tip.
Next Steps
Do something interesting with your archives page! Once you have the basic structure down, CSS is a very powerful way to make these calendar widgets more interesting and engaging. See the following tips for other ideas for managing calendar-related data:
- Creating a date dimension or calendar table in SQL Server
- Using a calendar table in SQL Server – Part 1
- Calendar Table in SQL Server to Identify Date Gaps and Islands
- Create a Calendar Table in SQL Server to Optimize Scheduling Problems
- All SQL Server date tips
About the author
This author pledges the content of this article is based on professional experience and not AI generated.
View all my tips
Article Last Updated: 2023-02-07