use-calendar-favicon.ts2.9 KBView on GitHub
import { useEffect } from 'react';

import { calendarDayIconHref } from '@/lib/site-config';

/**
 * Puts today's date on the tab icon while the calendar is open, the way Google Calendar does.
 *
 * The link element is created here rather than declared in the route's `meta` because the day has
 * to come from the browser: the server renders the same HTML for everyone, so a date baked in at
 * that point would be the server's day, and wrong for anyone far enough east or west. Adding it
 * from an effect also keeps it out of React Router's hands — `<Meta />` owns the nodes it renders
 * and would overwrite an href mutated underneath it.
 *
 * This is deliberately the *only* `rel="icon"` on the calendar route — its `meta` declares none.
 * Browsers pick between competing icon links by their own rules, and a `rel="icon"` PNG carrying
 * an explicit `sizes` beat this SVG often enough that the tab just kept showing the Cedar mark.
 * With no icon link at all until this one lands, there is nothing to lose to; a browser too old
 * for SVG icons ignores it and falls back to /favicon.ico, which is the Cedar mark anyway.
 */
export function useCalendarFavicon() {
  useEffect(() => {
    let link: HTMLLinkElement | null = null;
    let rollover: ReturnType<typeof setTimeout>;

    const paint = () => {
      const now = new Date();
      const href = calendarDayIconHref(now.getDate());

      // Replace the element rather than re-pointing its href. Browsers re-read the icon when a
      // rel="icon" link is inserted, but are unreliable about noticing an href change on one
      // already in the document — which is why setting the href after appending showed nothing.
      // Only when the day actually changed, though: `paint` also runs on every return to the
      // tab, and re-inserting the same link makes the browser re-fetch and visibly blink the
      // icon for no reason.
      if (!link || link.getAttribute('href') !== href) {
        link?.remove();
        link = document.createElement('link');
        link.rel = 'icon';
        link.type = 'image/svg+xml';
        link.href = href;
        document.head.appendChild(link);
      }

      // Turn the page at local midnight. The listener below is what actually catches most
      // rollovers — a laptop that slept through 00:00 fires this timer late, if at all, so the
      // date is also re-read whenever the tab becomes visible again.
      const midnight = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
      clearTimeout(rollover);
      rollover = setTimeout(paint, midnight.getTime() - now.getTime() + 1_000);
    };

    const repaintIfVisible = () => {
      if (document.visibilityState === 'visible') paint();
    };

    paint();
    document.addEventListener('visibilitychange', repaintIfVisible);

    return () => {
      clearTimeout(rollover);
      document.removeEventListener('visibilitychange', repaintIfVisible);
      link?.remove();
    };
  }, []);
}