Building a Calendar Interface in Astro
I wanted my website to have a clean, static calendar interface to showcase all my RSVP posts. At first, I considered using FullCalendar, an excellent package, but a bit too heavy for my needs. I wanted something that rendered at build time, without extra client-side scripting. Building a lightweight calendar in Astro with date-fns
was surprisingly straightforward.

Screenshot of the calendar interface for this website.
The Code
I am using date-fns to do a lot of the heavy lifting here.
Get the Static Paths
The getStaticPaths
generates multiple page routes from a single .astro
page component.
import { getCollection } from "astro:content";
import dateFns from "date-fns";
export const getStaticPaths = () => {
// The events collection is pretty generic, it would look something like
// this:
//
// const eventsCollection = defineCollection({
// loader: glob({ pattern: "*.md", base: "./src/content/events" }),
// schema: z.object({
// name: z.string(),
// startsAt: z.date(),
// endsAt: z.date().optional(),
// isAllDay: z.boolean(),
// }),
// })
const allEvents = getCollection("events");
// We want to get the upper and lower bound of the calendar pages without it
// we would have to have every possible month.
const eventDates = allEvents
.flatMap((post) => [post.data.startsAt, post.data.endsAt])
.filter(Boolean) as Date[];
const upperBound = dateFns.endOfMonth(dateFns.max(eventDates));
const lowerBound = dateFns.startOfMonth(dateFns.min(eventDates));
// We want to capture all the years and their months between the upper and
// lower bounds.
const yearsBetween = dateFns
.eachYearOfInterval({
start: lowerBound,
end: upperBound,
})
.map((yearStart) => {
const yearEnd = dateFns.endOfYear(yearStart);
const months = dateFns.eachMonthOfInterval({
start: dateFns.isBefore(yearStart, lowerBound) ? lowerBound : yearStart,
end: dateFns.isAfter(yearEnd, upperBound) ? upperBound : yearEnd,
});
return { months };
});
// Finally we can build all the pages that will be generated.
return yearsBetween.flatMap(({ months }) =>
months.map((monthStart) => {
const monthEnd = dateFns.endOfMonth(monthStart);
// Adjust the start and end to the nearest Monday and Sunday for a full
// week of coverage, this does mean the week will start on a Monday and
// end on a Sunday.
const calendarMonthStart = dateFns.isMonday(monthStart)
? monthStart
: dateFns.previousMonday(monthStart);
const calendarMonthEnd = dateFns.isSunday(monthEnd)
? monthEnd
: dateFns.nextSunday(monthEnd);
const days = dateFns
.eachDayOfInterval({
start: calendarMonthStart,
end: calendarMonthEnd,
})
.map((day) => ({
day,
events: allEvents.filter((event) =>
dateFns.isSameDay(event.data.startsAt, day),
),
}));
return {
params: {
// You'll want to change this to how your filename is, mine is
// `[...yyyymmm].astro`.
yyyymm: dateFns.format(monthStart, "yyyy-MM"),
},
props: {
start: calendarMonthStart,
end: calendarMonthEnd,
days,
},
};
}),
);
};
Build the Interface
The above builds three props: start
(first day of the calendar), end
(last day), and days
(an array of days and their events). I use Tailwind CSS for styling this website, Iâll be using their CSS classes.
{
/**
* The border attribute doesn't really work with grids, so to get this to
* work, we are going to add a slight gap between the grid's cells and
* subtract the margin a bit on the right. This means we'll have a
* tic-tac-toe border kind of like this:
*
* MON | TUE | WED | THU | FRI | SAT | SUN
* -----------------------------------------
* 29 | 31 | 1 | 2 | 3 | 4 | 5
* ...
* 27 | 28 | 29 | 30 | 31 | 1 | 2
*/
}
<div class:list={["-mr-0.5 grid grid-cols-7 gap-0.5 bg-black"]}>
{
["mon", "tue", "wed", "thu", "fri", "sat", "sun"].map((weekDay) => (
<h2
class:list={[...className, "bg-white text-right font-bold uppercase"]}
>
{weekDay}
</h2>
))
}
{
days.map(({ day, events }) => (
<div class:list={["h-32 bg-white"]}>
<h3 class:list={["px-1 py-0.5 text-right font-semibold"]}>
{dateFns.format(day, "d")}
</h3>
<ul class:list={["space-y-0.5 overflow-y-hidden text-sm"]}>
{events.map((event) => (
<li>
<a
href={`/events/${event.id}`}
class:list={[
"flex justify-between rounded-sm px-1 py-0.5",
"hover:bg-black/5",
]}
>
<span class:list={["block flex-shrink truncate"]}>
{event.data.name}
</span>
{!event.data.isAllDay && (
<span class:list={["block flex-shrink-0 text-black/75"]}>
{dateFns.format(event.data.startsAt, "h:mm a")}
</span>
)}
</a>
</li>
))}
</ul>
</div>
))
}
</div>
Wrap Up
Now my website has a calendar interface to my RSVP posts.
JSONFeed in Astro with @astrojs/rss
I wanted this website to publish both an RSS feed and a JSONFeed. The catch: I didnât want to maintain two separate feed pipelines.
Astro already has a handy @astrojs/rss
package that outputs RSS feeds. So I thoughtâwhy not reuse the same data shape to generate JSONFeed as well?
Turns out, itâs pretty straightforward.
The Code
Hereâs how I reused the @astrojs/rss
shape to build a JSONFeed endpoint in Astro.
Formatting the Feed
I built a transformer function to take the RSSOptions
and convert them to be a valid JSONFeed 1.1 top-level.
import type { APIContext } from "astro";
import { getCollection } from "astro:content";
import type { RSSFeedItem, RSSOptions } from "@astrojs/rss";
export const formatJsonFeed = (rssOptions: RSSOptions) => {
const { site, title, description } = rssOptions;
const homePageUrl = site instanceof URL ? site.href : site;
return {
version: "https://jsonfeed.org/version/1.1",
title: title,
home_page_url: homePageUrl,
feed_url: new URL("/feed.json", homePageUrl).href,
description: description,
items: [],
};
};
Formatting Feed Items
Next was transforming RSS items to be valid JSONFeed 1.1 items.
export const formatJsonFeedItem = (rssFeedItem: RSSFeedItem) => {
const { link, content, title, pubDate, description } = rssFeedItem;
if (!link) {
throw new Error("RSS feed item must have a link.");
}
return {
id: link,
url: link,
title: title,
summary: description,
date_published: pubDate?.toISOString(),
content_html: content || "",
};
};
Putting It Together
Next, youâll need to create a Static File Endpoint to serve the JSONFeed. I put mine at /src/pages/feed.json.ts
, which servers it at https://example.com/feed.json
.
export async function GET(context: APIContext): Promise<Response> {
const posts = await getCollection("posts");
const jsonFeedItems = posts.map((post) =>
formatJsonFeedItem({
link: url.href,
title: post.data.name,
pubDate: post.data.publishedAt,
}),
);
const jsonFeed = formatJsonFeed({
title: "Website Feed",
description: "This is a JSONFeed!!!",
site: context.site,
items: jsonFeedItems,
});
return new Response(JSON.stringify(jsonFeed, null, 2), {
headers: { "Content-Type": "application/feed+json" },
});
}
Auto-Discovery of Your New JSONFeed
To make your JSONFeed discoverable by browsers and feed readers, add a <link>
tag in your siteâs <head>
. For example:
<link
rel="alternate"
type="application/rss+xml"
title="RSS Feed"
href="/rss.xml"
/>
<link
rel="alternate"
type="application/feed+json"
title="JSONFeed"
href="/feed.json"
/>
This way, clients that support feed auto-discovery will automatically pick up both formats.
Wrap Up
Now my site has two feeds: RSS for the traditionalists and JSONFeed for the modernists. If youâre already using @astrojs/rss
, this pattern makes JSONFeed nearly free. Give it a tryâand let me know if youâd like me to extend this into Atom or other formats. đą
Broadacre City â Frank Lloyd Wright's Utopia Dystopia
Democracy⌠we have started toward a new integrationâto an integration along the horizontal line which we call the great highway.
- Frank Lloyd Wright, September 1931.
Today I learned about Broadacre City from Phil Edwardsâ YouTube video, The truth about Utopias.
See also
- Frank Lloyd Wrightâs Utopian Dystopia by Katherine Don, 8th April 2010.
- Revisiting Frank Lloyd Wrightâs Vision for âBroadacre Cityâ by Frank Lloyd Wright Foundation, 8th September 2017.
Icebergs can get stuck on underwater mountains
The current largest iceberg, A-23A, is currently spinning in place on top of an underwater mountain:
After leaving Antarctic waters, the iceberg got stuck in a vortex over a seamount, or an underwater mountain. Imagine a piece of ice about 1,500 square miles in area and as deep as the Empire State Building spinning slowly but steadily enough to fully rotate it on its head over the course of about 24 days.
- Tumin, R. (2024, August 7). After breaking free, worldâs largest iceberg is stuck spinning in circles. New York Times.
Exploring Toronto's Restaurant Inspection Data with DineSafe
As a lover of good food, Iâve always been curious about restaurant health inspections. So, I decided to take matters into my own hands and create a tool that makes it easy to explore Torontoâs DineSafe data.
I built a scraper using Python and the sqlite-utils library. It gathers data on restaurant inspections and stores it in a SQLite database. But I didnât stop there â I added some automation magic to make the process even more fun!
Now, thanks to a GitHub Actions workflow, the scraper runs every day at 9 am and deploys the database to an instance of Datasette running on Vercel.
Datasette is a super cool tool that lets you explore and visualize data in a fun and interactive way. So, whether youâre a foodie, data enthusiast, or just curious about whatâs happening behind the scenes in your favorite restaurant, this project is for you.
Check out the GitHub repository to see the code and learn how to set up your own instance of the scraper. You can also explore the data and play around with visualizations on the DineSafe Toronto website.
Arc App's JSON Export to SQLite
I started working on a Dogsheep utility for processing Arc Appâs Daily (or Monthly) JSON exports into a SQLite database. This provides the ability to analysis the places I visit and activities I am doing in Datasette.
Iâve published a working alpha version of the Dogsheep utility, arc-to-sqlite on my GitHub. Check it out if you are interested.
đ˘ My Stupid Weather Twitter Profile Bot Got Suspended
My little weather-updating Twitter bot was finally suspended after seven years.
Seven years ago, I created a little Python script that would add a weather emoji to my Twitter handle, based on the current conditions in Toronto. I donât think anyone besides myself notices, but it always made me happy to see my handle change to âď¸ Myles Braithwaite
on a sunny day.
Today, I received an email from the Twitter Developer Platform, informing me that my bot was suspended from accessing the Twitter API. I tried to appeal the decision, but the web form kept giving me an error.
I knew this day was coming when I had to give up on this bot, I used DarkSkyâs API to get the current weather, which API was disconnected on 31 March 2023. Strangely enough, my bot continued to work for a while after that. Just all feels pretty sudden.