DEV Community

KunStudio
KunStudio

Posted on • Originally published at trial-guard.pages.dev

A cancel-by-date calculator that gets clamped month math right (so it doesn't break on Jan 31 + 1 month)

A cancel-by-date calculator that gets clamped month math right (so it doesn't break on Jan 31 + 1 month)

Most trial-reminder tools just tell you "day 7 of 14." I built Trial Guard to compute the one date that actually matters — the last safe day to cancel — and then get that date out of the browser tab and into a calendar, because a banner you have to remember to look at on the right day is not a reminder system.

Three dates, not one

A signup trial has three distinct dates that are easy to conflate:

  • Trial ends — the last day of the trial window itself
  • Billing date — the day the card actually gets charged (one day after trial end)
  • Cancel-by date — billing date minus a safety buffer the user sets (default a couple of days), so "cancel today" doesn't mean "cancel at 11:59pm and hope"

If the buffer is longer than the trial itself, the cancel-by date would land before the trial even started — the tool clamps it to the start date instead and surfaces a note explaining why, rather than showing a date that makes no sense.

The month-length trap

The naive way to add "1 month" to a date is date.setMonth(date.getMonth() + 1). That breaks the moment someone starts a trial on the 31st of a month: adding one month to Jan 31 in JavaScript doesn't land on Feb 28, it silently rolls forward into March, because Date normalizes an out-of-range day. For a tool whose entire job is telling you the correct day, a silent one-to-three-day drift is exactly the bug you can't have.

The fix is a clamped-add helper: compute the target month, work out that month's actual last day (new Date(year, month+1, 0).getDate()), and cap the day-of-month to whichever is smaller — the original day or the target month's last day. Jan 31 plus one month becomes Feb 28 (or Feb 29 in a leap year), not Mar 3. The same helper handles week- and day-denominated trials by just adding milliseconds instead, so the clamping logic only kicks in for the month case where it's actually needed.

A verdict, not just a countdown

Rather than rendering a bare number, compute() resolves the dates into one of a handful of states: already billed (trial-end date is in the past), inside the buffer window (cancel now), the last safe day, tomorrow's the deadline, or a plain days-remaining count. Each state gets its own copy and its own color on a countdown gauge, because "cancel today" and "you have three weeks" are different actions and should not look like the same UI element with a different number in it.

Getting the date out of the tab

A date sitting in a browser tab you'll close in ten seconds is not a reminder. The tool offers two exports:

  • A one-tap Google Calendar deep link, built entirely from URL parameters (calendar.google.com/calendar/render?action=TEMPLATE&dates=...). The fiddly part: Google's all-day event end date is exclusive, so the link has to add one calendar day to the cancel-by date or the event shows up ending a day early.
  • A hand-built .ics file for every other calendar app, generated client-side as a plain string (BEGIN:VCALENDAR... DTSTART;VALUE=DATE:...) with no library — an ICS file for a single all-day event is a small enough spec that pulling in a dependency for it isn't worth it.

Handing off instead of starting over

Once the tool has a service name and a decision to cancel, it doesn't just say "good luck" — it links to Cancel Helper with the service name pre-filled via a ?service= query parameter, so the next step in the actual task (writing the cancellation message) doesn't make the user retype what they just typed here.

Live tool: https://trial-guard.pages.dev/?utm_source=devto&utm_medium=backlink

Happy to go deeper on the calendar-export edge cases or the clamped date math if useful — drop a comment.

Top comments (0)