Introduction
I run a handful of sites that publish time-limited deals. The hard part is not writing the articles. It is that published content turns false on its own, without anyone touching it.
"500 yen off, through August 10" is correct the day it is written. On August 11 the exact same string is misinformation. Not one byte of the file changed.
Cleaning up by hand does not work. On 2026-08-02 I walked my own sites and found an article about a Prime Day sale that ended on July 13 still displaying "listed as running through July 13". Strictly speaking that sentence is not a lie. But nothing on the page tells the reader the sale is over.
So I moved to a different shape: store the deadline as data, and compare it against today every time the page is rendered. This post is about the design reasoning. The implementation details live in a separate post.
The shape
Articles are a JSON array. One object per article; the deadline lives in metrics.deadline as an ISO 8601 date (YYYY-MM-DD).
data/articles.json article data (metrics.deadline / ended)
│
├─ build.js compares against "today (JST)" at render time
│ 0 = current / 1 = grey / 2 = ended
│ → dropped from listings, RSS, sitemap; article URL kept
│
└─ preflight checks the GENERATED HTML for expired entries in listings
not generated yet → fail, never pass
Three points matter:
- The deadline lives in the data, not in Japanese prose inside the body
- The "is it over" decision happens at render time, not at write time
- Whether the rule actually works is verified against the generated HTML
The core of the implementation
The end-of-sale test is this small:
// Ended if there is a deadline and it is in the past, relative to the survey date.
// An article with no deadline is never treated as ended (do not fabricate an ended state).
function isEnded(a) {
if (a && a.ended === true) return true; // a human marked it confirmed-ended
const dl = a && a.metrics && a.metrics.deadline;
return !!dl && daysUntil(dl) < 0;
}
Short, but three design decisions are packed in.
One. An article with no deadline is not treated as ended. Some deals never publish an end date. If you resolve "no deadline" to "probably over by now", you display an ended state for something that is still running. The general rule is: do not resolve an unknown toward a confident assertion.
Two. The human-set ended: true is checked first. This part was genuinely broken for a while. The old code decided purely on the deadline date, so a hand-written ended: true — meaning "I confirmed this ended early" — was read by nothing and silently ignored. There was effectively no way to pull a sale that ended ahead of schedule. The lesson: when you add an automatic decision, separately confirm you have not blocked the manual override path.
Three. The reference date is "today", not "the day I surveyed it". This was the highest-leverage call.
// Order by "today (JST)". Anchoring to the survey date produces an error whose direction is
// "the staler the data, the more in-period an expired deal looks", which keeps ended deals
// pinned near the top of listings.
const ORDER_TODAY_JST = new Date(Date.now() + 9 * 3600e3)
.toISOString()
.slice(0, 10);
Concretely. An article surveyed on July 10 with a deadline of July 13. If the reference date is the survey date, it still reads "3 days left" in August. The staler the article, the more current it looks. That is the worst possible direction for the error: neglected articles look the healthiest.
Anchor to today and the direction flips. Neglected articles sink faster. One line, and the decay runs the right way.
I ended up with three tiers:
// 0 = current / 1 = grey (possibly ended) / 2 = ended
function freshRank(a) {
if (isEnded(a)) return 2;
if (isStale(a)) return 1;
return 0;
}
// Shared comparator: freshness tier → newest first → slug (keeps the build deterministic)
function byFreshness(a, b) {
return (
freshRank(a) - freshRank(b) ||
String(b.date).localeCompare(String(a.date)) ||
String(a.slug).localeCompare(String(b.slug))
);
}
"Grey" means not confirmed over, but suspicious: either (1) the published deadline is past relative to today, or (2) no deadline was published and the last confirmation is more than 14 days old. Note the constraint on (2): an article whose deadline is published and still in the future never goes grey, no matter how old the confirmation is. The deadline is the source's published value, and my own staleness should not override it.
The third sort key is slug to keep the build deterministic. The same data always produces the same HTML, so any diff is evidence that data changed.
What tripped me up
Do not delete it. Keep it and say it ended.
At first I only dropped ended articles from the listings. But as noted above, anyone arriving at the URL directly still sees a body written on the assumption that the price is still good. Being gone from listings does not stop search traffic.
Returning 410 would hit indexing abruptly. So instead of removing, I keep the page and state the end explicitly.
function endedNoticeHtml(a) {
if (!isEnded(a)) return "";
const dl = a && a.metrics && a.metrics.deadline;
const when = dl ? `(出典表記の期限: ${jday(dl)})` : "";
return `<p class="ended-note"><strong>このセールは終了しています</strong>${when}。` +
`以下は終了時点までの記録で、掲載中の価格・割引率は現在のものではありません。</p>\n`;
}
The banner says: this sale has ended (source-stated deadline: ...); what follows is a record as of that point, and the listed prices and discount rates are not current. I also prefix the <title> with a marker for "ended" and downgrade the call-to-action from "see the sale price" to "see the current price". The body stays as written; only the frame is redrawn against today.
The same idea was needed for release announcements. "Goes on sale starting August 18, 2026" stays in the future tense on August 20. I found a few such cases per site. Rewriting the prose just goes stale again next week, so this is rendered from today as well. One caveat: a metrics.start of 2026-08 (no day) is not evaluated, because "on sale in August 2026" is not past during August.
The biggest trap was verifying the rule actually works.
Writing the exclusion tells you nothing about whether it fires. So a pre-publish check inspects the generated public/index.html for links to expired articles. Checking the artifact, not the data, is the point.
That check produced a false positive once. A naive substring match on the slug hit the JSON index used for on-site search, which intentionally retains ended entries. Measured on 2026-07-28: a site that was excluding correctly failed the check. Readers can only click links, so the check now looks for the slug appearing as an href.
One more. If public/index.html has not been generated, the check fails rather than passes. "Could not verify" is not "nothing wrong". Resolving a missing measurement to a pass means the check goes quietly dead the day someone reorders the build.
The result
One of the sites running this in production: https://beer.autoarticles.net
Compare the listing order against what you see when you open an expired article directly; the tiers described here show up as-is.
Summary
"Never show expired information" is decided by data structure and choice of reference date, not by operational diligence.
- Store the deadline in one machine-readable field, not in prose
- Decide freshness at render time, and anchor to "today" rather than "the survey date"
- Do not resolve unknowns toward assertion (no deadline is not the same as ended)
- When you add automation, check you have not blocked the manual override
- Verify the exclusion against the generated artifact, and never let an unverifiable state pass
The third point is worth repeating: one line decides which way your content decays. Anchored to the survey date, neglected articles look the freshest. Anchored to today, they sink. Same mechanism, opposite outcome.
This article is about my own side project. It was written with AI assistance.
Top comments (0)