Introduction
I run several sites that publish time-limited information: Kindle manga sales, grocery and household deals. Everything on them expires.
That creates a structural weakness. Expired deals left on the page erode trust in the whole site. A reader clicks a link labeled "through July 31" and lands on a full-price page. After that happens a couple of times, they stop coming back.
Removing expired entries by hand does not scale; the more articles you have, the worse it gets. So I moved the deadline into the data and let the build decide what to show.
Architecture
Each article carries metrics.deadline in its JSON, and build.js computes the days remaining at generation time.
articles.json
└ metrics.deadline: "2026-07-31"
↓ build.js
daysUntil(deadline, survey date)
↓
┌─ left < 0 → excluded from listings; article shows "may have ended"
├─ left == 0 → "today only" (emphasized)
├─ left <= 3 → "N days left" (emphasized)
└─ left > 3 → "N days left"
Enter fullscreen mode Exit fullscreen mode
The key design decision is that the reference date is not today but the date the article was last verified. More on that below.
The core of the implementation
Computing days remaining is all there is to it.
// Parse an ISO date (YYYY-MM-DD) as JST and return days remaining from the
// survey date. Expired is negative, same day is 0. The base is the survey
// date (the article's last verification date), not today.
function daysUntil(deadlineIso, baseIso) {
const base = new Date((baseIso || surveyDate()) + "T00:00:00+09:00");
const dl = new Date(deadlineIso + "T00:00:00+09:00");
return Math.round((dl.getTime() - base.getTime()) / 864e5);
}
Enter fullscreen mode Exit fullscreen mode
Spelling out +09:00 is the point. new Date("2026-07-31") parses as UTC, so running in JST shifts the result by nine hours and the date lands a day off. That surfaces as "today only" appearing a day early. If your dates carry a timezone, pin it at parse time.
The expiry check:
// A sale is ended only if it has a deadline and that deadline has passed
// relative to the survey date. Articles with no deadline data are never
// marked as ended (do not fabricate an ended state).
function isEnded(a) {
const dl = a && a.metrics && a.metrics.deadline;
return !!dl && daysUntil(dl) < 0;
}
Enter fullscreen mode Exit fullscreen mode
As the comment says, articles without a deadline are deliberately not treated as ended. Displaying "this has ended" for something whose deadline is merely unknown would state something I have not verified. Deciding what not to render when data is missing is part of the spec for this kind of site.
Excluding them from listings is one filter.
// Ended sales: hidden from listings (top/category/tag/related/RSS/sitemap)
// but kept on the article page and in site search, so they stay reachable
// by direct URL.
function listedArticles() {
return articles.filter((a) => !isEnded(a));
}
Enter fullscreen mode Exit fullscreen mode
Note that this filters rather than deletes. The reason follows.
Rendering branches on days remaining:
const left = daysUntil(m.deadline);
let leftTxt = "", cls = "fact-until";
if (left < 0) { leftTxt = "may have ended"; cls = "fact-until fact-expired"; }
else if (left === 0) { leftTxt = "today only"; cls = "fact-until fact-urgent"; }
else if (left <= 3) { leftTxt = `${left} days left`; cls = "fact-until fact-urgent"; }
else { leftTxt = `${left} days left`; }
Enter fullscreen mode Exit fullscreen mode
What bit me
Using "today" as the reference date makes the article assert things I never checked.
Initially daysUntil was based on the execution date. That looks natural, and it breaks.
Deal information is written on the day the source was verified. Suppose on July 20 I confirm "through July 31". Rendering that in August correctly says "expired". The failure is the other direction: the deadline itself may have changed at the source. If it was extended or cut short, my data still says July 20's version. Presenting "5 days left" as today's information asserts a fact I have not verified.
So the base became the survey date.
const base = new Date((baseIso || surveyDate()) + "T00:00:00+09:00");
Enter fullscreen mode Exit fullscreen mode
Now days remaining means "how many days were left at the time of verification". The wording follows: "as of the source and verification date, listed as running through July 31." Anything past the deadline reads "may have ended", because whether it actually ended is something I did not check.
Second: deleting expired articles kills URLs.
An earlier version dropped expired entries from generation entirely. That was wrong for both SEO and readers. Externally linked URLs began returning 404, and search traffic had nowhere to land.
Now they are removed from listings, RSS, and the sitemap, but kept on the article page and in site search. New discovery paths no longer surface them, while existing links keep working. A page that says "this sale may have ended" serves a reader far better than a 404.
The result
One of the sites running this: https://manga.autoarticles.net
It aggregates Kindle manga sales, each article showing a days-remaining badge. Expired entries drop out of the listings automatically.
Conclusion
If you handle time-limited information, put the deadline in the data, not the prose. Text that merely says "through July 31" is unreadable to the build, so the operation falls back to human labor.
And the choice of reference date determines what you are allowed to claim. Today's date produces assertions; the verification date produces "here is what was confirmed, and when". Pick the base that matches how certain your information actually is — that was the part of this implementation I thought hardest about.
This article is about my own side project. It was written with AI assistance.
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.