Articles

In-depth, long-form writing, essays, tutorials, and reflections.

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 September 2025 calendar interface on myles.garden, showing one event on Sunday the 21st.

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

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.

Screenshot of the Datasette for DineSafe Toronto — slothful-myles

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.

Screenshot of Datasette showing the timeline_items table in a SQLite database. There is a map at the bottom that is showing the locations I traveled in the last few days.

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.