<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[CountMyDays]]></title><description><![CDATA[CountMyDays]]></description><link>https://countmydays.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Wed, 04 Nov 2026 07:21:05 GMT</lastBuildDate><atom:link href="https://countmydays.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Add to calendar from the browser: generating .ics files for countdown dates]]></title><description><![CDATA[A countdown is more useful when it also lives in the calendar you already check every morning. An "Add to calendar" button is the simplest way to get it…]]></description><link>https://countmydays.hashnode.dev/add-to-calendar-from-the-browser-generating-ics-files-for-countdown-dates</link><guid isPermaLink="true">https://countmydays.hashnode.dev/add-to-calendar-from-the-browser-generating-ics-files-for-countdown-dates</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[webdev]]></category><category><![CDATA[Tutorial]]></category><dc:creator><![CDATA[CountMyDays]]></dc:creator><pubDate>Mon, 02 Nov 2026 20:34:33 GMT</pubDate><content:encoded><![CDATA[<p>A countdown is more useful when it also lives in the calendar you already check every morning. An "Add to calendar" button is the simplest way to get it there, and you don't need a server or a library to build one. An <code>.ics</code> file is plain text, and the browser can create and download it on its own.</p>
<p>This tutorial builds that button step by step for an all-day countdown date like a wedding, a launch or a holiday. It also covers the details from RFC 5545 (the iCalendar spec) that are easy to get wrong.</p>
<h2>The smallest valid file</h2>
<p>An iCalendar file is a <code>VCALENDAR</code> that holds one or more <code>VEVENT</code>s. This is about as small as a valid one gets:</p>
<pre><code>BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example//Countdown//EN
CALSCALE:GREGORIAN
METHOD:PUBLISH
BEGIN:VEVENT
UID:3f9c2a1e-7b1d-4c55-9a0e-2d6f1b8e4c11@example.com
DTSTAMP:20261102T120000Z
DTSTART;VALUE=DATE:20261224
DTEND;VALUE=DATE:20261225
SUMMARY:Christmas Eve
END:VEVENT
END:VCALENDAR
</code></pre>
<p><code>VERSION</code> and <code>PRODID</code> are required on the calendar. <code>UID</code> and <code>DTSTAMP</code> are required on every event. <code>CALSCALE</code> and <code>METHOD</code> are optional, but <code>METHOD:PUBLISH</code> tells clients this is a one-way "here's an event" file and not a meeting invite that expects a reply.</p>
<h2>All-day events: VALUE=DATE and an exclusive end</h2>
<p>A countdown target is usually a day, not a moment in time. If you write <code>DTSTART:20261224T000000Z</code>, you've said "midnight UTC". For someone in New York, that shows up on the evening of the 23rd.</p>
<p>Use a date value instead:</p>
<pre><code>DTSTART;VALUE=DATE:20261224
DTEND;VALUE=DATE:20261225
</code></pre>
<p>A <code>DATE</code> value has no time and no time zone. It means "this calendar day, wherever you are", which is exactly what a countdown wants.</p>
<p>Note that <code>DTEND</code> is <strong>exclusive</strong>. A one-day event on the 24th ends on the 25th. If you set <code>DTEND</code> to the same day as <code>DTSTART</code>, some clients show a zero-length event or reject it. You can also leave <code>DTEND</code> out, because the spec says a date-only <code>DTSTART</code> alone lasts one day. Setting it explicitly is still the most widely compatible choice.</p>
<p>Here's a helper that does the date math on calendar parts, so the user's time zone can't shift the day:</p>
<pre><code class="language-js">// "2026-12-24" -&gt; "20261224"
function toIcsDate(isoDate) {
  return isoDate.replaceAll("-", "");
}

// "2026-12-24" -&gt; "2026-12-25", computed in UTC so DST can't interfere
function nextDay(isoDate) {
  const [y, m, d] = isoDate.split("-").map(Number);
  const dt = new Date(Date.UTC(y, m - 1, d + 1));
  return dt.toISOString().slice(0, 10);
}
</code></pre>
<h2>UID and DTSTAMP</h2>
<p><code>UID</code> identifies the event for good. If a user imports the same file twice, a calendar that sees the same <code>UID</code> can update the event instead of creating a duplicate. So derive it from something stable, like your record's ID, and add a domain to make it globally unique:</p>
<pre><code class="language-js">const uid = `${countdown.id}@example.com`;
</code></pre>
<p><code>DTSTAMP</code> is when the file was created. It must be a UTC date-time with a trailing <code>Z</code>:</p>
<pre><code class="language-js">function toIcsUtc(date) {
  // 2026-11-02T12:00:00.000Z -&gt; 20261102T120000Z
  return date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "");
}
</code></pre>
<h2>Escaping text</h2>
<p>In <code>SUMMARY</code>, <code>DESCRIPTION</code> and <code>LOCATION</code>, four things need escaping: backslash, semicolon, comma and newline. Escape the backslash first, or you'll double-escape everything else:</p>
<pre><code class="language-js">function escapeText(value) {
  return String(value)
    .replace(/\\/g, "\\\\")
    .replace(/;/g, "\\;")
    .replace(/,/g, "\\,")
    .replace(/\r\n|\r|\n/g, "\\n");
}

escapeText("Trip: Rome, Florence; then home\nPack light");
// "Trip: Rome\, Florence\; then home\nPack light"
</code></pre>
<p>Colons don't need escaping in values. Only the first colon on a line separates the property name from its value.</p>
<h2>Line folding at 75 octets</h2>
<p>RFC 5545 says lines <em>should</em> be no longer than 75 <strong>octets</strong> (bytes), not counting the line break. Longer lines are folded: break the line, then start the next one with a single space. When a client reads the file, it removes each "CRLF + space" pair.</p>
<p>Most clients will tolerate long lines anyway, but a long description is exactly where strict parsers fail. The catch is that the limit is in bytes, and you must never split a multi-byte UTF-8 character (an emoji, "ł", "é") in half. So count bytes per character:</p>
<pre><code class="language-js">const encoder = new TextEncoder();

function foldLine(line) {
  const parts = [];
  let current = "";
  let bytes = 0;
  let limit = 75; // first line: 75 octets
  for (const char of line) { // iterates by code point, not UTF-16 unit
    const size = encoder.encode(char).length;
    if (bytes + size &gt; limit) {
      parts.push(current);
      current = "";
      bytes = 0;
      limit = 74; // continuation lines start with a space, which counts
    }
    current += char;
    bytes += size;
  }
  parts.push(current);
  return parts.join("\r\n ");
}
</code></pre>
<h2>CRLF line endings</h2>
<p>Every line in an iCalendar file ends with <code>\r\n</code>, including the last one. A file built with plain <code>\n</code> often still imports, but not everywhere, so build it with CRLF from the start:</p>
<pre><code class="language-js">const lines = [/* ... */];
const ics = lines.map(foldLine).join("\r\n") + "\r\n";
</code></pre>
<h2>Putting it together</h2>
<pre><code class="language-js">function buildIcs({ id, title, date, description, url, reminderDays }) {
  const lines = [
    "BEGIN:VCALENDAR",
    "VERSION:2.0",
    "PRODID:-//Example//Countdown//EN",
    "CALSCALE:GREGORIAN",
    "METHOD:PUBLISH",
    "BEGIN:VEVENT",
    `UID:${id}@example.com`,
    `DTSTAMP:${toIcsUtc(new Date())}`,
    `DTSTART;VALUE=DATE:${toIcsDate(date)}`,
    `DTEND;VALUE=DATE:${toIcsDate(nextDay(date))}`,
    `SUMMARY:${escapeText(title)}`,
  ];
  if (description) lines.push(`DESCRIPTION:${escapeText(description)}`);
  if (url) lines.push(`URL:${url}`);
  if (reminderDays) {
    lines.push(
      "BEGIN:VALARM",
      "ACTION:DISPLAY",
      `DESCRIPTION:${escapeText(title)}`,
      `TRIGGER:-P${reminderDays}D`,
      "END:VALARM"
    );
  }
  lines.push("END:VEVENT", "END:VCALENDAR");
  return lines.map(foldLine).join("\r\n") + "\r\n";
}
</code></pre>
<h3>The optional reminder</h3>
<p>A <code>VALARM</code> inside the event adds a reminder. <code>ACTION:DISPLAY</code> requires both <code>DESCRIPTION</code> and <code>TRIGGER</code>. <code>TRIGGER:-P1D</code> means one day before the start. For an all-day event, the start is midnight local time, so the alert appears the day before at midnight. If you want "9:00 the day before", use <code>-PT15H</code>.</p>
<p>Be aware that support varies. Apple Calendar generally honours alarms in imported files. Google Calendar usually ignores them on import and applies the user's default notifications instead. Treat the reminder as a bonus, not a promise.</p>
<h2>Downloading it with a Blob</h2>
<p>No server needed:</p>
<pre><code class="language-js">function downloadIcs(countdown) {
  const ics = buildIcs(countdown);
  const blob = new Blob([ics], { type: "text/calendar;charset=utf-8" });
  const href = URL.createObjectURL(blob);

  const a = document.createElement("a");
  a.href = href;
  a.download = `${slugify(countdown.title) || "event"}.ics`;
  document.body.appendChild(a);
  a.click();
  a.remove();
  setTimeout(() =&gt; URL.revokeObjectURL(href), 1000);
}

function slugify(text) {
  return text
    .normalize("NFKD")
    .replace(/[\u0300-\u036f]/g, "")
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-|-$/g, "")
    .slice(0, 50);
}
</code></pre>
<p>Call <code>downloadIcs</code> directly from a click handler. Browsers are stricter about downloads that aren't triggered by a user action.</p>
<h2>The alternative: "add" links</h2>
<p>A file download is one more step on desktop, so many apps also offer links that open a pre-filled event form.</p>
<p><strong>Google Calendar</strong> takes all-day dates as <code>YYYYMMDD/YYYYMMDD</code>, with the same exclusive end:</p>
<pre><code class="language-js">function googleCalendarUrl({ title, date, description }) {
  const params = new URLSearchParams({
    action: "TEMPLATE",
    text: title,
    dates: `${toIcsDate(date)}/${toIcsDate(nextDay(date))}`,
    details: description ?? "",
  });
  return `https://calendar.google.com/calendar/render?${params}`;
}
</code></pre>
<p><strong>Outlook on the web</strong> uses ISO dates and an <code>allday</code> flag:</p>
<pre><code class="language-js">function outlookUrl({ title, date, description }) {
  const params = new URLSearchParams({
    path: "/calendar/action/compose",
    rru: "addevent",
    subject: title,
    startdt: date,
    enddt: nextDay(date),
    allday: "true",
    body: description ?? "",
  });
  return `https://outlook.live.com/calendar/0/deeplink/compose?${params}`;
}
</code></pre>
<p>Use <code>outlook.office.com</code> instead of <code>outlook.live.com</code> for work and school accounts. These URL formats aren't formally documented specs, so test them now and then. The <code>.ics</code> file is the only option defined by a standard.</p>
<h2>Common gotchas</h2>
<ul>
<li><strong>Time zones.</strong> For a timed event, either use UTC (<code>...T090000Z</code>) or <code>TZID=Europe/Warsaw</code> together with a matching <code>VTIMEZONE</code> block. A <code>TZID</code> without its <code>VTIMEZONE</code> is technically invalid, even though many clients guess correctly. For countdown days, skip all of that and use <code>VALUE=DATE</code>.</li>
<li><strong>Inclusive end dates.</strong> Setting <code>DTEND</code> to the same day as <code>DTSTART</code> is the most common all-day bug. The end is the day <em>after</em>.</li>
<li><strong>Apple vs Google.</strong> On iOS Safari, a <code>text/calendar</code> download opens the "Add to Calendar" sheet. Google Calendar has no "open this file" step on mobile, so users have to import it from the desktop web settings. That's why a Google link next to the download button helps.</li>
<li><strong>MIME type and extension.</strong> Use <code>text/calendar</code> and a <code>.ics</code> filename. If you serve the file from a server instead, also send <code>Content-Disposition: attachment; filename="event.ics"</code>.</li>
<li><strong>Filenames.</strong> Strip slashes, emoji and other odd characters. Some systems save or open the file badly otherwise.</li>
<li><strong>Duplicates.</strong> A random <code>UID</code> per click means every re-import creates a new event. Keep it stable.</li>
</ul>
<h2>Wrapping up</h2>
<p>The checklist is short: date values for all-day events, an exclusive end, a stable <code>UID</code>, UTC <code>DTSTAMP</code>, escaped text, folded lines, CRLF, and a <code>text/calendar</code> Blob. Add a Google link for people who live in a browser, and you've covered almost everyone.</p>
<p>I wrote this while thinking about calendar export for <a href="https://www.countmydays.to">CountMyDays</a>, a free app for countdowns and "days since" trackers, where you can add any countdown to Google Calendar or download it as an <code>.ics</code> file. If you've run into a calendar client that breaks one of these rules, tell me in the comments.</p>
]]></content:encoded></item><item><title><![CDATA[Per-link Open Graph images for shareable countdowns with Satori and @vercel/og]]></title><description><![CDATA[When someone pastes a countdown link into WhatsApp, Slack or iMessage, the preview card decides whether anyone clicks it. A generic logo gets ignored. A…]]></description><link>https://countmydays.hashnode.dev/per-link-open-graph-images-for-shareable-countdowns-with-satori-and-vercel-og</link><guid isPermaLink="true">https://countmydays.hashnode.dev/per-link-open-graph-images-for-shareable-countdowns-with-satori-and-vercel-og</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[webdev]]></category><category><![CDATA[Vercel]]></category><dc:creator><![CDATA[CountMyDays]]></dc:creator><pubDate>Mon, 02 Nov 2026 20:18:53 GMT</pubDate><content:encoded><![CDATA[<p>When someone pastes a countdown link into WhatsApp, Slack or iMessage, the preview card decides whether anyone clicks it. A generic logo gets ignored. A card that says <strong>"Our wedding · 47 days to go"</strong> gets taps.</p>
<p>That means rendering an image for every link, on demand. Here's how I do it with <a href="https://github.com/vercel/satori">Satori</a>, the JSX-to-SVG engine behind <code>@vercel/og</code>, plus the parts that tripped me up: picking the right number, fonts and emoji, caching, and the fact that link crawlers never run your JavaScript.</p>
<p>The rendering code below was run in Node 20 with <code>satori@0.63</code> and <code>@resvg/resvg-js@2.6</code>. The Vercel route is the same code wrapped in <code>ImageResponse</code>.</p>
<h2>How the pieces fit</h2>
<p>An Open Graph image is just a URL in a meta tag:</p>
<pre><code class="language-html">&lt;meta property="og:image" content="https://example.com/api/og?id=abc123" /&gt;
&lt;meta name="twitter:card" content="summary_large_image" /&gt;
</code></pre>
<p>When a crawler sees the tag, it fetches that URL and expects a PNG or JPEG, ideally 1200×630. So we need an endpoint that:</p>
<ol>
<li>looks up the countdown (title, date, time zone),</li>
<li>works out the number to show,</li>
<li>lays out a card and returns a PNG.</li>
</ol>
<p>Satori handles step 3. It takes a React-like element tree and returns SVG, using a subset of CSS (flexbox only, no grid). A rasteriser such as resvg turns that SVG into a PNG. <code>@vercel/og</code> bundles both behind one <code>ImageResponse</code> class.</p>
<h2>Step 1: the number has to be right</h2>
<p>The image is rendered on a server, usually in UTC. If you compute "days to go" with <code>Date.now()</code> on that server, a card shared at 23:30 in Warsaw can be a day off. The fix is the same one I used in earlier posts: compare <strong>calendar dates in the countdown's own time zone</strong>, not timestamps.</p>
<pre><code class="language-js">export function daysUntil(dateISO, timeZone, now = new Date()) {
  // 'en-CA' formats as YYYY-MM-DD
  const today = new Intl.DateTimeFormat('en-CA', { timeZone }).format(now);
  const toUTC = (s) =&gt; {
    const [y, m, d] = s.split('-').map(Number);
    return Date.UTC(y, m - 1, d);
  };
  return Math.round((toUTC(dateISO) - toUTC(today)) / 86_400_000);
}
</code></pre>
<p>A quick check at <code>2026-12-24T23:30:00Z</code>, with the target date <code>2026-12-26</code>:</p>
<pre><code class="language-js">daysUntil('2026-12-26', 'Europe/Warsaw', now);    // 1  (it's already the 25th in Warsaw)
daysUntil('2026-12-26', 'America/New_York', now); // 2  (still the 24th there)
</code></pre>
<p>Store the time zone with the countdown when it's created. The server can't guess it from the crawler's request, because the crawler is a bot in a data centre, not your user.</p>
<h2>Step 2: lay out the card</h2>
<p>Satori accepts JSX if you have a JSX transform. In plain Node I pass the object form, which is what JSX compiles to anyway:</p>
<pre><code class="language-js">import satori from 'satori';
import { Resvg } from '@resvg/resvg-js';
import { readFileSync } from 'node:fs';

const font = readFileSync('./fonts/Inter-Bold.ttf');

export function clean(s, max = 60) {
  return String(s ?? '').replace(/[\u0000-\u001f]/g, '').trim().slice(0, max) || 'Untitled';
}

export async function renderOg({ title, date, tz = 'UTC', now }) {
  const n = daysUntil(date, tz, now);
  const big = n === 0 ? 'Today' : String(Math.abs(n));
  const sub = n === 0 ? ''
    : n &gt; 0 ? (n === 1 ? 'day to go' : 'days to go')
    : (n === -1 ? 'day since' : 'days since');

  const svg = await satori(
    {
      type: 'div',
      props: {
        style: {
          width: '100%', height: '100%', display: 'flex', flexDirection: 'column',
          alignItems: 'center', justifyContent: 'center',
          background: '#0f172a', color: 'white', padding: 60,
        },
        children: [
          { type: 'div', props: { style: { fontSize: 56, textAlign: 'center' }, children: clean(title) } },
          { type: 'div', props: { style: { fontSize: 200, lineHeight: 1 }, children: big } },
          { type: 'div', props: { style: { fontSize: 44, color: '#94a3b8' }, children: sub } },
        ],
      },
    },
    { width: 1200, height: 630, fonts: [{ name: 'Inter', data: font, weight: 700, style: 'normal' }] },
  );

  return new Resvg(svg).render().asPng();
}
</code></pre>
<p>Some things I learned from this:</p>
<ul>
<li><strong>Every <code>div</code> with more than one child needs <code>display: 'flex'</code>.</strong> Satori throws an error rather than guessing.</li>
<li><strong>You must supply font bytes.</strong> There are no system fonts. Load a TTF/OTF or WOFF; WOFF2 isn't supported. Ship only the weights you use, because each one adds to cold-start time.</li>
<li><strong>Count-up and countdown share the card.</strong> A negative number becomes "days since", so the same endpoint works for "days since I quit smoking".</li>
</ul>
<h2>Step 3: user input is text, not HTML</h2>
<p>Titles come from users. With Satori they are only ever text nodes, so a title like <code>Christmas &lt;script&gt;</code> is drawn literally as those characters. There's no HTML to inject. You still want to:</p>
<ul>
<li><strong>cap the length</strong> (<code>clean()</code> cuts at 60 characters) so a 5,000-character title can't make the layout explode or slow the render;</li>
<li><strong>look the title up by ID</strong> instead of trusting <code>?title=</code> from the query string. Otherwise anyone can mint a card on your domain that says anything. If you must take parameters, sign them with an HMAC and reject bad signatures.</li>
</ul>
<h2>Step 4: emoji and non-Latin scripts</h2>
<p>People love emoji in countdown titles ("Japan trip 🇯🇵"). My first render showed an empty box (tofu) where the 🎄 should have been, because the font has no emoji glyphs. There are two fixes:</p>
<ul>
<li>In <code>@vercel/og</code>, set <code>emoji: 'twemoji'</code> (or another supported set) and it fetches the SVG for each emoji.</li>
<li>In raw Satori, implement <code>loadAdditionalAsset(code, segment)</code>. Return an emoji image as a data URL, or font bytes for scripts like Japanese or Arabic that your main font lacks.</li>
</ul>
<p>Test with a title in each script your users actually write in. Missing glyphs fail silently.</p>
<h2>Step 5: the Vercel route</h2>
<p>On Vercel, <code>@vercel/og</code> wraps all of this in a <code>Response</code>. For a framework-free function (my app is a Vite SPA), <code>api/og.jsx</code> looks roughly like this:</p>
<pre><code class="language-jsx">import { ImageResponse } from '@vercel/og';

export const config = { runtime: 'edge' };

export default async function handler(req) {
  const id = new URL(req.url).searchParams.get('id');
  const c = await getCountdown(id); // your DB lookup
  if (!c) return new Response('Not found', { status: 404 });

  const n = daysUntil(c.date, c.timeZone);
  return new ImageResponse(&lt;Card title={clean(c.title)} n={n} /&gt;, {
    width: 1200,
    height: 630,
    emoji: 'twemoji',
    headers: { 'Cache-Control': 'public, max-age=0, s-maxage=3600, stale-while-revalidate=86400' },
  });
}
</code></pre>
<p><code>&lt;Card&gt;</code> is the same tree as above, written as JSX. In Next.js App Router you'd use <code>ImageResponse</code> from <code>next/og</code> in an <code>opengraph-image.tsx</code> file instead.</p>
<h2>Step 6: caching a number that changes every day</h2>
<p>A countdown card goes stale at midnight. A few options:</p>
<ul>
<li><strong>Short CDN cache</strong> (<code>s-maxage=3600</code>). It's cheap and at most an hour stale around midnight.</li>
<li><strong>Put the date in the URL</strong>: <code>/api/og?id=abc&amp;d=2026-12-24</code>. Each day gets a new URL that can be cached forever. This also beats the platforms' own caches; Facebook and LinkedIn cache previews by URL for days, so a new URL is the only reliable way to refresh them.</li>
</ul>
<p>I prefer the second option: generate the meta tag with today's date in the countdown's time zone.</p>
<h2>The SPA trap: crawlers don't run your JavaScript</h2>
<p>This is the most common reason previews don't show up. Setting <code>og:image</code> with <code>react-helmet</code> or <code>document.head</code> does nothing for previews. WhatsApp, Slack, Discord and iMessage fetch the raw HTML and read the meta tags without running a line of JS.</p>
<p>So the HTML for <code>/c/abc123</code> must contain the tags when it leaves the server. In a Vite SPA, either:</p>
<ul>
<li>add a rewrite for <code>/c/:id</code> to a small function that reads <code>index.html</code>, injects the per-countdown <code>&lt;title&gt;</code>, <code>og:title</code>, <code>og:description</code> and <code>og:image</code>, and returns it; or</li>
<li>move those routes to a framework with server rendering.</li>
</ul>
<p>Then check what bots see with <code>curl -A "Slackbot" https://your.site/c/abc123 | grep og:</code>. If the tags aren't in that output, no preview will show.</p>
<h2>Checklist</h2>
<ul>
<li>1200×630 PNG, <code>summary_large_image</code>, absolute <code>https://</code> URLs</li>
<li>Number computed from calendar dates in the countdown's stored time zone</li>
<li>Font bytes loaded, emoji handled, titles length-capped</li>
<li>Data looked up by ID, not taken raw from the query string</li>
<li>A URL that changes daily, or a short CDN cache</li>
<li>Meta tags present in the server HTML, checked with curl</li>
</ul>
<p>I wrote this while thinking about link previews for <a href="https://www.countmydays.to">CountMyDays</a>, a free, ad-free app for countdowns and "days since" trackers. If you've found a cleverer way to keep preview cards fresh, tell me in the comments.</p>
]]></content:encoded></item><item><title><![CDATA[Building a small web app to last: the future of CountMyDays]]></title><description><![CDATA[Over the last few posts I've written about the unglamorous parts of building CountMyDays: counting days without off-by-one bugs, streak counters that…]]></description><link>https://countmydays.hashnode.dev/building-a-small-web-app-to-last-the-future-of-countmydays</link><guid isPermaLink="true">https://countmydays.hashnode.dev/building-a-small-web-app-to-last-the-future-of-countmydays</guid><category><![CDATA[webdev]]></category><category><![CDATA[React]]></category><category><![CDATA[JavaScript]]></category><dc:creator><![CDATA[CountMyDays]]></dc:creator><pubDate>Tue, 27 Oct 2026 02:02:20 GMT</pubDate><content:encoded><![CDATA[<p>Over the last few posts I've written about the unglamorous parts of building <a href="https://www.countmydays.to">CountMyDays</a>: counting days without off-by-one bugs, streak counters that survive midnight and DST, and "tomorrow" vs "in 2 days" labels that respect the reader's time zone. This one is different. It's less about code and more about intent: what this little app is for, and how I want it to age.</p>
<h2>What CountMyDays is</h2>
<p>CountMyDays is a free web app for two simple questions:</p>
<ul>
<li><strong>How long until</strong> something? A wedding, a trip, an exam, a birthday.</li>
<li><strong>How long since</strong> something? A new habit, a quit date, an anniversary.</li>
</ul>
<p>You type a name and a date, and you get a countdown or a count-up with a progress bar. You can share it with one link, add it to your calendar, put it in fullscreen on a big screen, or install it to your home screen like an app. There are no ads, and you don't need an account to start. If you want your countdowns on more than one device, you can sign in with a passwordless magic link.</p>
<p>It's built by one developer in Iceland on React 19, Supabase and Vercel. That's the whole team.</p>
<h2>Why "futureproof" matters for a countdown app</h2>
<p>A countdown is a promise about the future. Someone creates one for a wedding that's 400 days away and sends the link to their family. That link has to still work, and still show the right number, on day 1 and on day 400.</p>
<p>That's a higher bar than it sounds. Most small web apps are judged on day one: does it look nice, is it fast? A countdown is judged on the day the event arrives. So the question I keep asking is not "what can I add?" but "what will still be true in two years?"</p>
<p>Here are the principles I want CountMyDays to keep, whatever else changes.</p>
<h2>1. The core stays free and needs no account</h2>
<p>Creating a countdown, sharing it and counting days since something should never sit behind a sign-up or a paywall. Guest mode isn't a trial; it's the product. Accounts exist for convenience (sync across devices), not as a gate.</p>
<p>This also keeps the app honest. If the basic thing works without me knowing who you are, there's less to go wrong and less to protect.</p>
<h2>2. Your data is yours</h2>
<p>A countdown is personal. You should be able to take your data with you. Today the app has a backup code and a downloadable text archive with one-click restore. I want that kind of exit door to stay a first-class feature: plain, readable formats over clever ones, and no lock-in.</p>
<h2>3. Links that keep working</h2>
<p>A shared link is a small contract. The direction is simple:</p>
<ul>
<li>Share URLs should be stable. If the app changes shape, old links should still land somewhere sensible.</li>
<li>The number on the page has to be computed in the viewer's time zone, using calendar days, not raw milliseconds. (That's what the earlier posts in this series were really about.)</li>
<li>A finished countdown shouldn't just break. It should move into a "passed" state and become a memory rather than an error page.</li>
</ul>
<h2>4. Boring, standard web tech</h2>
<p>I'd rather lean on the platform than on a pile of dependencies. A few examples from this series:</p>
<pre><code class="language-js">// Wording comes from the platform, in any locale the browser supports
new Intl.RelativeTimeFormat('pl', { numeric: 'auto' }).format(1, 'day');
// "jutro"

// Calendar-day maths in UTC, where every day is exactly 24 hours
const dayIndex = (y, m, d) =&gt; Date.UTC(y, m - 1, d) / 86400000;
</code></pre>
<p><code>Intl</code>, <code>Date.UTC</code>, <code>localStorage</code>, <code>.ics</code> calendar files, service workers and web app manifests are standards. They'll still be here long after today's favourite library has a new major version. When the Temporal API is widely available, the date code can get even simpler without changing what users see.</p>
<p>The stack is deliberately small: a React front end, a Postgres database with row-level security, and static hosting with a few server routes. Small enough for one person to understand completely, which matters more than any single feature.</p>
<h2>5. Offline and installable, as a habit of thought</h2>
<p>A countdown is something you glance at, often on your phone, sometimes on bad Wi-Fi. Thinking "what happens with no network?" early makes the whole app better: local-first data for guests, cached assets, and sync as a bonus rather than a requirement. Progressive Web App support is part of that, and keeping it working well is ongoing work rather than a box I ticked once.</p>
<h2>6. Accessible and calm</h2>
<p>No ads, no pop-ups, no dark patterns. Big, readable numbers. A compact view for people with many countdowns and a spacious one for people with a single important date. Accessibility is never "done", and I'd like it to keep improving with every change rather than in one big push.</p>
<h2>Resilience: fixing things in public</h2>
<p>Here's my favourite part of writing in public. After my first post about the app's architecture, a reader left a thoughtful comment pointing out a weakness in how the live-updating shared view used Supabase Realtime broadcasts. In short: on a public channel, the app trusted the content of broadcast messages a bit too much.</p>
<p>They were right. Nothing in the database could be changed or lost, but it's worth fixing properly, and that fix is underway. The idea behind it is a good general lesson, so here it is:</p>
<blockquote>
<p>Treat a realtime message as a <strong>signal</strong>, not as <strong>data</strong>. When something says "this changed", go and fetch the real state from the database, where access rules apply.</p>
</blockquote>
<pre><code class="language-js">channel.on('broadcast', { event: '*' }, () =&gt; {
  // Don't render the payload. Just refetch the source of truth.
  refetchSharedItem();
});
</code></pre>
<p>With that change, viewers still see updates live, but nothing on screen comes from an untrusted message. Tighter channel permissions are a possible next step after that.</p>
<p>So, a sincere thank you to that reader, and to everyone who has read, commented on or shared these posts. A small app gets more resilient when the community is generous enough to point out its weak spots.</p>
<h2>What's next</h2>
<p>I won't give you a roadmap with dates, because roadmaps for one-person projects are mostly fiction. What I can promise is a direction:</p>
<ul>
<li>keep the free, no-account core</li>
<li>keep shared links working and numbers correct</li>
<li>keep the stack small and standard</li>
<li>keep improving offline support, accessibility and privacy</li>
<li>keep fixing things openly when people find problems</li>
</ul>
<p>If any of that sounds useful, try it: create a countdown to something you're looking forward to, or a "days since" for a habit you're proud of, at <a href="https://www.countmydays.to"><strong>countmydays.to</strong></a>. If you find a bug or have an idea, I'd genuinely love to hear it in the comments or on X at <a href="https://x.com/countmydays_to">@countmydays_to</a>.</p>
<p>Thanks for reading, and for counting along.</p>
]]></content:encoded></item><item><title><![CDATA["Tomorrow" or "in 2 days"? A timezone-safe 'time until' label with Intl.RelativeTimeFormat]]></title><description><![CDATA[Every countdown UI needs a short, friendly label: in 45 minutes, tomorrow, in 3 weeks, za 5 miesięcy. Modern JavaScript has a built-in for the wording…]]></description><link>https://countmydays.hashnode.dev/tomorrow-or-in-2-days-a-timezone-safe-time-until-label-with-intl-relativetimeformat</link><guid isPermaLink="true">https://countmydays.hashnode.dev/tomorrow-or-in-2-days-a-timezone-safe-time-until-label-with-intl-relativetimeformat</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[webdev]]></category><category><![CDATA[Tutorial]]></category><category><![CDATA[i18n]]></category><dc:creator><![CDATA[CountMyDays]]></dc:creator><pubDate>Tue, 27 Oct 2026 00:32:26 GMT</pubDate><content:encoded><![CDATA[<p>Every countdown UI needs a short, friendly label: <em>in 45 minutes</em>, <em>tomorrow</em>, <em>in 3 weeks</em>, <em>za 5 miesięcy</em>. Modern JavaScript has a built-in for the wording, <code>Intl.RelativeTimeFormat</code>, so you no longer need a date library for it. But the API only formats a number and a unit that <strong>you</strong> pass in. Picking that number is where countdowns go wrong.</p>
<p>Here's the bug that pushed me to write this. It's 23:30 on 24 December in Warsaw, and the event is at midnight on the 26th, local time. Plain millisecond maths says "24.5 hours away → 1 day → <em>tomorrow</em>". The person reading it knows Christmas Day comes first, so for them the event is <em>in 2 days</em>.</p>
<p>This post builds a small helper that gets that right, works in any locale, and is tested in several time zones.</p>
<h2>The easy part: wording</h2>
<pre><code class="language-js">const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });

rtf.format(1, 'day');     // "tomorrow"
rtf.format(-1, 'day');    // "yesterday"
rtf.format(3, 'week');    // "in 3 weeks"
rtf.format(0, 'second');  // "now"

new Intl.RelativeTimeFormat('pl', { numeric: 'auto' }).format(5, 'month');
// "za 5 miesięcy"
</code></pre>
<p><code>numeric: 'auto'</code> turns <code>1 day</code> into "tomorrow" and <code>0 seconds</code> into "now". With <code>numeric: 'always'</code> you get "in 1 day". Pluralisation, word order and grammatical case are handled for every locale the runtime supports, which is a lot of work you don't have to do.</p>
<h2>The hard part: which number?</h2>
<p>The usual approach is:</p>
<pre><code class="language-js">const days = Math.round((target - now) / 86_400_000);
</code></pre>
<p>This has two problems:</p>
<ol>
<li><strong>It counts 24-hour blocks, not calendar days.</strong> People think "how many midnights until then?", not "how many periods of 86,400,000 ms?"</li>
<li><strong>It ignores whose midnight counts.</strong> The same instant is 24 December in New York and already 25 December in Auckland.</li>
</ol>
<p>The fix is to turn both instants into calendar dates <em>in the viewer's time zone</em>, then subtract the dates.</p>
<h2>Step 1: a day key in a given time zone</h2>
<p><code>Intl.DateTimeFormat</code> can tell you the calendar date of an instant in any IANA zone. The <code>en-CA</code> locale formats dates as <code>YYYY-MM-DD</code>, but I use <code>formatToParts</code> so the code doesn't depend on that formatting:</p>
<pre><code class="language-js">export function dayKey(date, timeZone) {
  const parts = new Intl.DateTimeFormat('en-CA', {
    timeZone, year: 'numeric', month: '2-digit', day: '2-digit',
  }).formatToParts(date);
  const get = (t) =&gt; parts.find((p) =&gt; p.type === t).value;
  return `${get('year')}-${get('month')}-${get('day')}`;
}
</code></pre>
<p><code>dayKey(new Date('2026-12-24T22:30:00Z'), 'Pacific/Auckland')</code> returns <code>"2026-12-25"</code>, because it's already Christmas morning there.</p>
<h2>Step 2: subtract dates, not instants</h2>
<p>Once you have two <code>YYYY-MM-DD</code> strings, build both as <strong>UTC</strong> midnights and subtract. UTC has no daylight saving time, so every day is exactly 86,400,000 ms and the division is exact:</p>
<pre><code class="language-js">export function daysBetweenKeys(fromKey, toKey) {
  const [y1, m1, d1] = fromKey.split('-').map(Number);
  const [y2, m2, d2] = toKey.split('-').map(Number);
  return Math.round(
    (Date.UTC(y2, m2 - 1, d2) - Date.UTC(y1, m1 - 1, d1)) / 86_400_000
  );
}

daysBetweenKeys('2026-03-28', '2026-03-30'); // 2, across the EU clock change
daysBetweenKeys('2026-11-01', '2026-11-02'); // 1, across the US clock change
</code></pre>
<p>If you did the same subtraction with local midnights (<code>new Date(2026, 2, 30)</code>), the 23-hour day in March would give you 1.958… days. That rounds to the right answer, but only by luck.</p>
<h2>Step 3: put it together</h2>
<p>Use clock time for things less than a day away ("in 45 minutes" is more useful than "today"). Use calendar days for everything else, and switch to weeks, months and years as the distance grows:</p>
<pre><code class="language-js">export function timeUntil(target, now = new Date(), {
  locale = undefined,
  timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone,
} = {}) {
  const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
  const diffSec = Math.round((target - now) / 1000);
  const abs = Math.abs(diffSec);

  if (abs &gt;= 24 * 3600) {
    const days = daysBetweenKeys(dayKey(now, timeZone), dayKey(target, timeZone));
    if (Math.abs(days) &gt;= 365) return rtf.format(Math.trunc(days / 365), 'year');
    if (Math.abs(days) &gt;= 60)  return rtf.format(Math.trunc(days / 30), 'month');
    if (Math.abs(days) &gt;= 14)  return rtf.format(Math.trunc(days / 7), 'week');
    return rtf.format(days, 'day');
  }
  if (abs &gt;= 3600) return rtf.format(Math.trunc(diffSec / 3600), 'hour');
  if (abs &gt;= 60)   return rtf.format(Math.trunc(diffSec / 60), 'minute');
  return rtf.format(diffSec, 'second');
}
</code></pre>
<p>Notes on the choices:</p>
<ul>
<li><strong><code>Math.trunc</code>, not <code>Math.round</code>, for the coarse units.</strong> 13 days should say "in 13 days", not "in 2 weeks", and 59 days shouldn't round up to "in 2 months". Under-promising reads better on a countdown.</li>
<li><strong>Months are 30 days.</strong> That's fine for a label. If you need "exactly 3 months on the calendar", compare year/month pieces instead, but no one reading "in 5 months" will check.</li>
<li><strong>Negative values work for free.</strong> The same helper says "4 days ago", which is a "days since" tracker for nothing.</li>
<li><strong><code>timeZone</code> is a parameter.</strong> On the server (SSR, or an OG image function) there is no "viewer zone", so pass one explicitly. That might come from a cookie or from the zone the event was created in.</li>
</ul>
<h2>Testing it in three time zones</h2>
<p>Same instant (22:30 UTC on 24 December), same targets, three runtimes:</p>
<pre><code class="language-js">// test.mjs
import assert from 'node:assert/strict';
import { timeUntil, daysBetweenKeys } from './until.mjs';

const tz = process.env.TZ;
const now = new Date('2026-12-24T22:30:00Z');
const o = { locale: 'en', timeZone: tz };

console.log(tz,
  timeUntil(new Date('2026-12-25T23:00:00Z'), now, o),
  timeUntil(new Date('2026-12-24T23:15:00Z'), now, o),
  timeUntil(new Date('2026-12-20T12:00:00Z'), now, o));

assert.equal(daysBetweenKeys('2026-03-28', '2026-03-30'), 2);
assert.equal(timeUntil(new Date('2026-12-24T23:15:00Z'), now, o), 'in 45 minutes');
assert.equal(timeUntil(now, now, o), 'now');
</code></pre>
<pre><code class="language-bash">$ for z in Europe/Warsaw America/New_York Pacific/Auckland; do TZ=$z node test.mjs; done
Europe/Warsaw    in 2 days   in 45 minutes   4 days ago
America/New_York tomorrow    in 45 minutes   4 days ago
Pacific/Auckland tomorrow    in 45 minutes   4 days ago
</code></pre>
<p>Warsaw is the case from the start of the post. It's 23:30 on the 24th there, and the target falls at 00:00 on the 26th, so it's <strong>two</strong> midnights away. New York is still on the afternoon of the 24th and the target is the evening of the 25th, so "tomorrow" is correct. Each answer matches what a person in that city would say, and none of them comes from rounding hours.</p>
<h2>Keeping it live</h2>
<p>For a ticking label, don't re-render every second when the text only changes once a day. Work out when the label will next change: the next minute if it shows minutes, otherwise the viewer's next midnight. Set one <code>setTimeout</code> for that moment. That's cheaper and avoids layout jitter.</p>
<h2>Wrap-up</h2>
<ul>
<li>Let <code>Intl.RelativeTimeFormat</code> handle the <strong>words</strong>, including "tomorrow", plurals and other languages.</li>
<li>Work out the <strong>number</strong> yourself, using calendar days in an explicit time zone.</li>
<li>Turn instants into <code>YYYY-MM-DD</code> with <code>Intl.DateTimeFormat</code>, then subtract <code>Date.UTC</code> values so DST never gets involved.</li>
<li>Test with <code>TZ=... node</code> in at least one zone east and one zone west of UTC.</li>
</ul>
<p>I use this approach in <a href="https://www.countmydays.to">Count My Days</a>, a free, ad-free app for countdowns and "days since" trackers. If you find an edge case it misses, I'd like to hear about it in the comments.</p>
]]></content:encoded></item><item><title><![CDATA[Build a 'days since' streak counter in React with localStorage (that survives midnight, DST and bad data)]]></title><description><![CDATA["Days since I last smoked." "Days since the last production incident." "Days without sugar." A streak counter looks like the easiest widget in the world…]]></description><link>https://countmydays.hashnode.dev/build-a-days-since-streak-counter-in-react-with-localstorage-that-survives-midnight-dst-and-bad-data</link><guid isPermaLink="true">https://countmydays.hashnode.dev/build-a-days-since-streak-counter-in-react-with-localstorage-that-survives-midnight-dst-and-bad-data</guid><category><![CDATA[React]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[webdev]]></category><category><![CDATA[Tutorial]]></category><dc:creator><![CDATA[CountMyDays]]></dc:creator><pubDate>Tue, 27 Oct 2026 00:02:59 GMT</pubDate><content:encoded><![CDATA[<p>"Days since I last smoked." "Days since the last production incident." "Days without sugar." A streak counter looks like the easiest widget in the world: store a start date and subtract. Then it shows 0 for two days after a clock change, crashes on the one user whose localStorage holds garbage, and stays on yesterday's number when the tab is left open overnight.</p>
<p>Here's the version I ended up with. It's plain JavaScript plus one React hook, with no libraries. All the logic below was run under Node 20 with <code>TZ=Europe/Warsaw</code> and <code>TZ=America/New_York</code>.</p>
<h2>Rule 1: store calendar days, not timestamps</h2>
<p>A streak is about <em>days the user lived through</em>, not milliseconds. If you store <code>Date.now()</code> and divide by 86,400,000, a 23-hour DST day or a trip across time zones will shift your count. So store a <strong>local day key</strong>, a plain <code>YYYY-MM-DD</code> string:</p>
<pre><code class="language-js">export function toDayKey(date = new Date()) {
  const y = date.getFullYear();
  const m = String(date.getMonth() + 1).padStart(2, "0");
  const d = String(date.getDate()).padStart(2, "0");
  return `${y}-${m}-${d}`;
}
</code></pre>
<p>Note the use of <code>getFullYear</code>/<code>getMonth</code>/<code>getDate</code>, which are local, not <code>toISOString()</code>. <code>toISOString()</code> is UTC, so in Warsaw shortly after midnight it would still say yesterday.</p>
<h2>Rule 2: subtract day keys in UTC</h2>
<p>To count the days between two keys, turn each into a UTC midnight. UTC has no DST, so every day there really is 86,400,000 ms long and <code>Math.round</code> handles the rest:</p>
<pre><code class="language-js">export function daysBetweenKeys(a, b) {
  const [y1, m1, d1] = a.split("-").map(Number);
  const [y2, m2, d2] = b.split("-").map(Number);
  return Math.round(
    (Date.UTC(y2, m2 - 1, d2) - Date.UTC(y1, m1 - 1, d1)) / 86400000
  );
}

export function daysSince(startKey, todayKey = toDayKey()) {
  return Math.max(0, daysBetweenKeys(startKey, todayKey));
}
</code></pre>
<p>Quick checks that used to bite me:</p>
<pre><code class="language-js">daysBetweenKeys("2026-03-28", "2026-03-30"); // 2  (EU DST weekend)
daysBetweenKeys("2026-10-24", "2026-10-26"); // 2  (EU DST ends)
daysBetweenKeys("2024-02-28", "2024-03-01"); // 2  (leap year)
daysSince("2026-11-01", "2026-10-26");       // 0  (future start never goes negative)
</code></pre>
<p><code>Math.max(0, …)</code> matters more than it looks. Users change their device clock, and a negative streak is a bug report waiting to happen.</p>
<h2>Rule 3: resetting should keep the record</h2>
<p>People like seeing their best streak, and it makes a reset feel less painful. A reset is a pure function that takes the old state and returns the new one:</p>
<pre><code class="language-js">export function reset(state, todayKey = toDayKey()) {
  const current = daysSince(state.startKey, todayKey);
  return {
    startKey: todayKey,
    best: Math.max(state.best ?? 0, current),
    resets: (state.resets ?? 0) + 1,
  };
}

reset({ startKey: "2026-10-01", best: 10, resets: 0 }, "2026-10-26");
// { startKey: "2026-10-26", best: 25, resets: 1 }
</code></pre>
<p>Because <code>todayKey</code> is a parameter, it's trivial to test without mocking <code>Date</code>.</p>
<h2>Rule 4: treat localStorage as hostile</h2>
<p>localStorage can be missing (some private modes), full, or hold an old format from last year's version of your app. Wrap it so that the worst case is "start fresh", never a white screen:</p>
<pre><code class="language-js">export function load(storage, key, todayKey = toDayKey()) {
  try {
    const raw = storage.getItem(key);
    if (!raw) return { startKey: todayKey, best: 0, resets: 0 };
    const s = JSON.parse(raw);
    if (!/^\d{4}-\d{2}-\d{2}$/.test(s.startKey)) throw new Error("bad data");
    return { best: 0, resets: 0, ...s };
  } catch {
    return { startKey: todayKey, best: 0, resets: 0 };
  }
}

export function save(storage, key, state) {
  try {
    storage.setItem(key, JSON.stringify(state));
  } catch {
    // quota exceeded or storage disabled: keep working in memory
  }
}
</code></pre>
<p>Passing <code>storage</code> in, instead of touching <code>window.localStorage</code> directly, lets you test it in Node with a five-line fake:</p>
<pre><code class="language-js">const mem = new Map();
const storage = {
  getItem: (k) =&gt; mem.get(k) ?? null,
  setItem: (k, v) =&gt; mem.set(k, v),
};
mem.set("streak", "{oops");
load(storage, "streak", "2026-10-27").startKey; // "2026-10-27", no crash
</code></pre>
<p>If you ever change the shape, add a <code>version</code> field now. Future you will thank you.</p>
<h2>Rule 5: tick over at local midnight</h2>
<p>The classic bug: the tab stays open overnight and still shows yesterday's number. You don't need a one-second interval. Schedule a single timeout for the next local midnight, and also refresh when the tab becomes visible again, because timers get throttled in background tabs and laptops go to sleep.</p>
<pre><code class="language-jsx">import { useEffect, useState, useCallback } from "react";
import { toDayKey, daysSince, reset, load, save } from "./streak";

function msUntilNextMidnight(now = new Date()) {
  const next = new Date(now);
  next.setHours(24, 0, 0, 0); // local midnight, DST-aware
  return next - now;
}

function useToday() {
  const [today, setToday] = useState(toDayKey);
  useEffect(() =&gt; {
    let timer;
    const refresh = () =&gt; setToday(toDayKey());
    const schedule = () =&gt; {
      timer = setTimeout(() =&gt; { refresh(); schedule(); }, msUntilNextMidnight() + 1000);
    };
    schedule();
    document.addEventListener("visibilitychange", refresh);
    return () =&gt; {
      clearTimeout(timer);
      document.removeEventListener("visibilitychange", refresh);
    };
  }, []);
  return today;
}

export function useStreak(key = "streak") {
  const today = useToday();
  const [state, setState] = useState(() =&gt;
    typeof window === "undefined"
      ? { startKey: today, best: 0, resets: 0 }
      : load(window.localStorage, key, today)
  );

  useEffect(() =&gt; {
    save(window.localStorage, key, state);
  }, [key, state]);

  // keep several tabs in sync
  useEffect(() =&gt; {
    const onStorage = (e) =&gt; {
      if (e.key === key) setState(load(window.localStorage, key, toDayKey()));
    };
    window.addEventListener("storage", onStorage);
    return () =&gt; window.removeEventListener("storage", onStorage);
  }, [key]);

  const doReset = useCallback(() =&gt; setState((s) =&gt; reset(s, toDayKey())), []);

  return {
    days: daysSince(state.startKey, today),
    best: Math.max(state.best, daysSince(state.startKey, today)),
    resets: state.resets,
    reset: doReset,
  };
}
</code></pre>
<p><code>setHours(24, 0, 0, 0)</code> asks the engine for "the next local midnight", so DST is handled for you. The extra second avoids landing at 23:59:59.999 because of timer jitter.</p>
<h2>The component</h2>
<pre><code class="language-jsx">export function StreakCard({ label }) {
  const { days, best, reset } = useStreak(`streak:${label}`);
  return (
    &lt;section aria-live="polite"&gt;
      &lt;h2&gt;{label}&lt;/h2&gt;
      &lt;p style={{ fontSize: "3rem", margin: 0 }}&gt;{days}&lt;/p&gt;
      &lt;p&gt;{days === 1 ? "day" : "days"} · best: {best}&lt;/p&gt;
      &lt;button onClick={() =&gt; confirm("Reset this streak?") &amp;&amp; reset()}&gt;
        Reset
      &lt;/button&gt;
    &lt;/section&gt;
  );
}
</code></pre>
<p>Two small UX details: <code>aria-live</code> so screen readers announce the new count at midnight, and a confirm step, because an accidental tap that wipes a 200-day streak is the fastest way to lose a user.</p>
<h2>Server rendering note</h2>
<p>If you render on the server (Next.js and friends), the server doesn't know the user's time zone or their localStorage. Render a neutral placeholder and fill in the number after hydration, or you'll get hydration mismatch warnings around midnight. The <code>typeof window</code> guard above covers the initial state.</p>
<h2>Recap</h2>
<ul>
<li>Store <strong>local day keys</strong> (<code>YYYY-MM-DD</code>), not timestamps.</li>
<li>Subtract them in <strong>UTC</strong>, and clamp at zero.</li>
<li>Make reset a <strong>pure function</strong> that keeps the best streak.</li>
<li>Treat <strong>localStorage as unreliable</strong>: validate, catch and fall back.</li>
<li>Re-render at <strong>local midnight</strong> and on <code>visibilitychange</code>.</li>
</ul>
<p>That's about 100 lines for a counter that behaves correctly on DST weekends, on leap days, and in tabs left open for a week.</p>
<hr />
<p>If you'd rather not build it yourself, I made this into a free app with countdowns and "days since" trackers, no ads and no sign-up: <a href="https://www.countmydays.to">CountMyDays</a>.</p>
]]></content:encoded></item><item><title><![CDATA[Counting days between two dates in JavaScript without off-by-one bugs]]></title><description><![CDATA["How many days until 24 December?" sounds like a one-liner. In JavaScript it's a small trap with three jaws: time zones, daylight saving time and the way…]]></description><link>https://countmydays.hashnode.dev/counting-days-between-two-dates-in-javascript-without-off-by-one-bugs</link><guid isPermaLink="true">https://countmydays.hashnode.dev/counting-days-between-two-dates-in-javascript-without-off-by-one-bugs</guid><category><![CDATA[Tutorial]]></category><category><![CDATA[React]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[CountMyDays]]></dc:creator><pubDate>Mon, 26 Oct 2026 23:50:05 GMT</pubDate><content:encoded><![CDATA[<p>"How many days until 24 December?" sounds like a one-liner. In JavaScript it's a small trap with three jaws: time zones, daylight saving time and the way <code>Date</code> parses strings. I ran into all three while building a countdown app, so here's what goes wrong and the small set of helpers I ended up trusting.</p>
<p>All snippets below were run in Node 20. The Temporal one uses the <code>@js-temporal/polyfill</code> package.</p>
<h2>Pitfall 1: a day is not always 86,400,000 ms</h2>
<p>The classic approach:</p>
<pre><code class="language-js">const a = new Date(2026, 2, 28); // 28 March 2026, local midnight
const b = new Date(2026, 2, 30); // 30 March 2026, local midnight
console.log((b - a) / 86400000);
</code></pre>
<p>In a European time zone (I ran it with <code>TZ=Europe/Warsaw</code>) this prints:</p>
<pre><code>1.9583333333333333
</code></pre>
<p>Clocks went forward on 29 March, so that local "day" was only 23 hours long. Depending on whether you then use <code>Math.floor</code>, <code>Math.ceil</code> or nothing, you get 1, 2 or a fraction. In autumn you get the opposite: a 25-hour day and 1.04-ish.</p>
<p>The underlying problem: you're measuring <em>elapsed time</em>, but the question was about <em>calendar days</em>. Those are different things.</p>
<h2>Pitfall 2: <code>new Date('2026-03-29')</code> is UTC, but <code>new Date('2026-03-29T00:00')</code> is local</h2>
<pre><code class="language-js">console.log(new Date('2026-03-29').toISOString());      // 2026-03-29T00:00:00.000Z
console.log(new Date('2026-03-29T00:00').getHours());   // 0 (local midnight)
</code></pre>
<p>A date-only ISO string is parsed as <strong>UTC midnight</strong>. Add a time and it becomes <strong>local</strong>. So for a user in New York, <code>new Date('2026-03-29').getDate()</code> returns <code>28</code>, because UTC midnight is still the previous evening there. If you mix the two forms in one app (say, one from an <code>&lt;input type="date"&gt;</code> value and one from a database timestamp), you'll get off-by-one errors that only show up for some users.</p>
<h2>Fix: compare calendar days, not milliseconds</h2>
<p>The trick is to throw away the time of day and the time zone <em>before</em> subtracting. Take the year, month and day the user sees, and build a UTC midnight from them. UTC has no DST, so every day in it is exactly 86,400,000 ms.</p>
<pre><code class="language-js">function toUTCDay(d) {
  return Date.UTC(d.getFullYear(), d.getMonth(), d.getDate());
}

function daysBetween(start, end) {
  return Math.round((toUTCDay(end) - toUTCDay(start)) / 86400000);
}

// 23:30 on the 28th to 00:05 on the 30th, across the DST change:
console.log(daysBetween(new Date(2026, 2, 28, 23, 30), new Date(2026, 2, 30, 0, 5))); // 2
</code></pre>
<p><code>Math.round</code> is a belt-and-braces guard; with both sides at UTC midnight the division is already an integer.</p>
<p>If your dates arrive as <code>YYYY-MM-DD</code> strings (date inputs, a <code>date</code> column in Postgres), don't hand them to <code>new Date()</code> at all. Parse the parts yourself:</p>
<pre><code class="language-js">function parseDateOnly(str) {
  const [y, m, d] = str.split('-').map(Number);
  return Date.UTC(y, m - 1, d);
}

const diff = (parseDateOnly('2026-12-24') - parseDateOnly('2026-10-27')) / 86400000;
console.log(diff); // 58
</code></pre>
<p>Treat date-only values as <em>labels for a calendar day</em>, not as instants. That one mental rule removes most of these bugs.</p>
<h2>"Today" depends on where you are</h2>
<p>Countdowns need "today", and "today" is a time-zone question. At 23:30 UTC on 26 October it's already the 27th in Tokyo. <code>Intl.DateTimeFormat</code> can tell you the calendar date in any zone without a library. The <code>en-CA</code> locale happens to format as <code>YYYY-MM-DD</code>, which plugs straight into <code>parseDateOnly</code>:</p>
<pre><code class="language-js">function todayIn(timeZone, now = new Date()) {
  return new Intl.DateTimeFormat('en-CA', {
    timeZone, year: 'numeric', month: '2-digit', day: '2-digit',
  }).format(now);
}

const now = new Date(Date.UTC(2026, 9, 26, 23, 30));
console.log(todayIn('Atlantic/Reykjavik', now));  // 2026-10-26
console.log(todayIn('Asia/Tokyo', now));          // 2026-10-27
console.log(todayIn('America/Los_Angeles', now)); // 2026-10-26
</code></pre>
<p>For anything shown to a user, compute "today" in <em>their</em> zone (the default when you omit <code>timeZone</code>), not the server's. A server-rendered "3 days left" from a server in UTC can easily be wrong for half your users.</p>
<h2>"Days since": decide whether day one is 0 or 1</h2>
<p>Counting up has its own off-by-one, and it isn't a bug in the code, it's a product decision. If I start a habit today, is that "0 days" or "day 1"?</p>
<pre><code class="language-js">function daysSince(startStr, todayStr) {
  return (parseDateOnly(todayStr) - parseDateOnly(startStr)) / 86400000;
}

console.log(daysSince('2026-10-26', '2026-10-26')); // 0
console.log(daysSince('2026-10-26', '2026-10-27')); // 1
</code></pre>
<p>"Days since" (elapsed) naturally starts at 0. "Day N of a streak" starts at 1, so it's <code>daysSince(...) + 1</code>. Pick one, name the function after it, and don't let the <code>+ 1</code> drift around the codebase.</p>
<p>For display, <code>Intl.RelativeTimeFormat</code> handles the wording and pluralisation in any language:</p>
<pre><code class="language-js">const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
console.log(rtf.format(-1, 'day')); // yesterday
console.log(rtf.format(58, 'day')); // in 58 days
</code></pre>
<h2>Temporal: the real fix (when you can use it)</h2>
<p>The Temporal API was designed for exactly this. It has a <code>PlainDate</code> type, a calendar date with no time and no zone, so the whole class of bugs above can't happen:</p>
<pre><code class="language-js">import { Temporal } from '@js-temporal/polyfill';

const start = Temporal.PlainDate.from('2026-10-27');
const end = Temporal.PlainDate.from('2026-12-24');
console.log(start.until(end, { largestUnit: 'day' }).days); // 58

const today = Temporal.Now.plainDateISO('Atlantic/Reykjavik'); // today in that zone
</code></pre>
<p>Native support is still arriving across browsers and runtimes, so check support for your targets or ship the polyfill. The <code>toUTCDay</code> approach above is a good bridge until then, and it maps cleanly onto <code>PlainDate</code> later.</p>
<h2>Live countdowns are a different problem</h2>
<p>Everything above is about <em>whole days</em>. A ticking "2d 4h 13m 09s" timer is about elapsed time, and there the millisecond difference is the right tool, as long as you always derive it from the clock instead of accumulating ticks. <code>setInterval(tick, 1000)</code> drifts and fires slightly late. Recomputing from <code>Date.now()</code> on each frame doesn't.</p>
<h2>How CountMyDays handles it</h2>
<p>CountMyDays is my side project for countdowns and "days since" trackers, built with React 19, Supabase and Vercel as an installable web app. A few of the choices in it come straight from the lessons above:</p>
<ul>
<li><strong>The timer is pure date math.</strong> Every frame it computes the difference from <code>Date.now()</code>, and a <code>requestAnimationFrame</code> loop only calls <code>setState</code> when the visible second changes. Because the value is always derived from the clock, there's no accumulated error. Background tabs pause rAF, so the hook switches to a 500 ms interval on <code>visibilitychange</code> and back again when the tab returns.</li>
<li><strong>One function, four cases.</strong> When a countdown passes zero it flips to counting the time elapsed since the date, and a "days since" tracker with a future start date counts <em>down</em> until it starts. Keeping that in a single calculation function means the edge cases live in one place.</li>
<li><strong>Normalise dates on the way in.</strong> Older data had <code>targetDate</code>, newer data <code>target_date</code>. The code accepts both and converts to ISO strings once, instead of letting two formats leak into the date math.</li>
</ul>
<h2>Cheat sheet</h2>
<ul>
<li>Days between dates: normalise both to UTC midnight (<code>Date.UTC(y, m, d)</code>), then divide.</li>
<li>Never pass <code>YYYY-MM-DD</code> to <code>new Date()</code> and then read local fields. Parse it yourself.</li>
<li>Compute "today" in the user's zone (<code>Intl.DateTimeFormat</code> with <code>timeZone</code>).</li>
<li>Decide whether "days since" starts at 0 or 1, and encode it in the function name.</li>
<li>Live timers: derive from the clock every frame, never count ticks.</li>
<li>Use <code>Temporal.PlainDate</code> where you can.</li>
</ul>
<p>If you want to see these in practice, CountMyDays is free with no ads and no sign-up: <a href="https://www.countmydays.to">countmydays.to</a>. I'd love to hear about any date bugs that have bitten you in the comments.</p>
]]></content:encoded></item><item><title><![CDATA[How I built a countdown & days-since tracker with React 19, Supabase and Vercel]]></title><description><![CDATA[I wanted a place to count down to things – a trip, a birthday, a deadline – and to count up from things, like the day I started a habit. Most countdown…]]></description><link>https://countmydays.hashnode.dev/how-i-built-a-countdown-days-since-tracker-with-react-19-supabase-and-vercel</link><guid isPermaLink="true">https://countmydays.hashnode.dev/how-i-built-a-countdown-days-since-tracker-with-react-19-supabase-and-vercel</guid><category><![CDATA[React]]></category><category><![CDATA[supabase]]></category><category><![CDATA[Vercel]]></category><category><![CDATA[webdev]]></category><category><![CDATA[sideproject]]></category><dc:creator><![CDATA[CountMyDays]]></dc:creator><pubDate>Mon, 26 Oct 2026 19:56:26 GMT</pubDate><content:encoded><![CDATA[<p>I wanted a place to count down to things – a trip, a birthday, a deadline – and to count <em>up</em> from things, like the day I started a habit. Most countdown sites I tried were full of ads or wanted an account before I could type a date. So I built my own: CountMyDays. It's a solo side project, and this post is about how it's put together and a few things that bit me along the way.</p>
<h2>The stack</h2>
<ul>
<li><strong>React 19 + Vite + TypeScript</strong> for the frontend</li>
<li><strong>Supabase</strong> for Postgres, magic-link auth and Realtime</li>
<li><strong>Vercel</strong> for hosting</li>
<li>Tailwind for styling, and a service worker so it installs as a PWA and works offline</li>
</ul>
<p>Nothing exotic. The interesting parts are in how data moves between "guest" and "signed in", and in how the timer stays accurate.</p>
<h2>Guest first, account later</h2>
<p>I didn't want a sign-up wall. When you open the app you can create countdowns immediately, and they're stored in <code>localStorage</code> under a single key:</p>
<pre><code class="language-ts">const STORAGE_KEY_LOCAL_COUNTDOWNS = 'countmydays_local_milestones';
</code></pre>
<p>Pins and favourites live in their own small keys (<code>countmydays_pinned_ids</code>, <code>countmydays_favorite_ids</code>) so they're cheap to toggle.</p>
<p>Sign-in is passwordless: Supabase sends a magic link. The tricky bit is what happens to the guest data afterwards. I didn't want people to lose the countdowns they made before signing in, so after login the app maps the local items into rows for the <code>countdowns</code> table and inserts them in one go:</p>
<pre><code class="language-ts">const rows = local.map((m) =&gt; ({
  user_id: session.user.id,
  title: (m.title || 'Untitled Milestone').trim().slice(0, 120),
  target_date: new Date(m.target_date || m.targetDate || Date.now()).toISOString(),
  type: m.type || 'countdown',
  is_pinned: Boolean(m.is_pinned ?? m.isPinned),
  is_favorite: Boolean(m.is_favorite ?? m.isFavorite),
  show_progress_bar: m.show_progress_bar !== false &amp;&amp; m.showProgressBar !== false,
  start_date: m.start_date || m.startDate || null,
  created_at: m.created_at || m.createdAt || new Date().toISOString(),
}));

let { data, error } = await supabase.from('countdowns').insert(rows).select();
</code></pre>
<p>A few lessons from this:</p>
<ol>
<li><strong>Normalize on the way in.</strong> Early versions stored camelCase (<code>targetDate</code>), later ones snake_case (<code>target_date</code>). The migration accepts both rather than pretending old data doesn't exist.</li>
<li><strong>Have a fallback for schema drift.</strong> If the insert fails (for example an older database without the pin/favourite columns), it retries without those fields instead of failing the whole migration.</li>
<li><strong>Only clear local data after the insert succeeds.</strong> The <code>localStorage</code> keys are removed after Supabase confirms the rows, never before.</li>
</ol>
<p>Security-wise, the browser only ever has the publishable key; what a user can read and write is enforced by Row Level Security policies in Postgres, so each row is tied to <code>user_id</code>.</p>
<h2>A timer that doesn't drift</h2>
<p>The obvious approach is <code>setInterval(tick, 1000)</code>. The problem is that intervals drift and can fire just <em>after</em> a second boundary, so the display looks laggy next to a real clock.</p>
<p>Instead the timer is pure date math – every frame it computes the difference from <code>Date.now()</code> – and a <code>requestAnimationFrame</code> loop only calls <code>setState</code> when the visible second actually changes:</p>
<pre><code class="language-ts">const tick = () =&gt; {
  const current = calculateLiveTime(targetDate, type);
  if (current.seconds !== lastSecondRef.current) {
    lastSecondRef.current = current.seconds;
    setTime(current);
  }
  animId = requestAnimationFrame(tick);
};
</code></pre>
<p>Because the value is always derived from the clock, there's no accumulated error. Browsers pause <code>requestAnimationFrame</code> in background tabs, so the hook listens for <code>visibilitychange</code> and switches to a 500 ms interval while hidden, then back to rAF when the tab returns.</p>
<p>One small product decision lives in <code>calculateLiveTime</code>: when a countdown passes zero, it doesn't just show "0". It flips to counting the elapsed time since the date. And "days since" trackers with a future start date count <em>down</em> until they start. Same function, four cases.</p>
<h2>Live shared links with Supabase Realtime</h2>
<p>Every countdown can be shared via a public link. I wanted the shared page to update the moment the owner edits it – handy if you put a countdown on a TV and change the title from your phone.</p>
<p>The shared view subscribes to one channel per countdown and listens to two things: Postgres changes on that single row, and broadcast events.</p>
<pre><code class="language-ts">channel = supabase
  .channel(`live_milestone_${id}`)
  .on('broadcast', { event: 'milestone:live_stream' }, ({ payload }) =&gt; {
    /* apply title / date changes instantly */
  })
  .on('broadcast', { event: 'milestone:deleted' }, () =&gt; onNavigate('404'))
  .on('postgres_changes',
    { event: '*', schema: 'public', table: 'countdowns', filter: `id=eq.${id}` },
    (payload) =&gt; { /* source of truth from the database */ })
  .subscribe();
</code></pre>
<p>Why both? <code>postgres_changes</code> is the reliable source of truth, but it only fires after the write lands. Broadcast lets the editor push changes straight to viewers with less delay. If the owner un-shares the countdown (<code>is_shared === false</code>), viewers are sent to a 404 straight away.</p>
<p>On the sending side, I keep a small pool of joined channels in a <code>Map</code> so repeated broadcasts don't re-subscribe each time. Channels are removed when the view unmounts.</p>
<h2>The Vercel SPA rewrite gotcha</h2>
<p>The app is a single-page app, so every route needs to serve the same HTML. The standard Vercel config is a rewrite that sends everything except the API to <code>index.html</code>:</p>
<pre><code class="language-json">{
  "rewrites": [
    { "source": "/((?!api/).*)", "destination": "/index.html" }
  ]
}
</code></pre>
<p>That works – until you turn on <code>cleanUrls</code>. While experimenting with adding static pages, I enabled <code>cleanUrls: true</code>, and suddenly existing routes started returning 404. With <code>cleanUrls</code>, Vercel serves <code>/index.html</code> at <code>/</code> and treats <code>.html</code> paths differently, so a rewrite <em>to</em> <code>/index.html</code> no longer resolves the way you'd expect. The fix was to point the rewrite destination at <code>/</code> instead:</p>
<pre><code class="language-json">{ "source": "/((?!api/).*)", "destination": "/" }
</code></pre>
<p>The related SEO lesson: a catch-all rewrite also means <em>every</em> unknown URL returns 200 with your app shell – a "soft 404" for search engines. I also had to add real <code>robots.txt</code> and <code>sitemap.xml</code> files, because before that, those URLs simply returned the app's HTML.</p>
<h2>What I'd do differently</h2>
<ul>
<li><strong>Decide on one data shape on day one.</strong> Supporting both camelCase and snake_case cost more code than it should.</li>
<li><strong>Think about crawlers early.</strong> A client-rendered SPA shows an empty <code>&lt;body&gt;</code> to anything that doesn't run JavaScript. Pre-rendering key pages is next on my list.</li>
<li><strong>Keep the guest path.</strong> Letting people try the app without an account was the best decision I made.</li>
</ul>
<h2>Try it</h2>
<p>If you count down to anything – or count days since something – it's free and has no ads: <a href="https://www.countmydays.to/?utm_source=devto">countmydays.to</a>. Feedback on the code approach is very welcome in the comments.</p>
]]></content:encoded></item></channel></rss>