devto 2026-07-07 원문 보기 ↗
When you maintain a number of WordPress sites, showing the "last maintenance date" in the site list is the obvious move. A column of dates like 2026-05-21. But in actual use, that alone falls short.
A client put it well: "Besides the last maintenance date, it'd help to also show how many days have passed. And it'd be even better if the color changed at 15 / 30 / 60 days so I can see the risk level." This post walks through that step — from "absolute date" to "relative elapsed days + color" — including the small design details.
An absolute date like 2026-05-21 is precise, but it pushes the "difference from today" calculation onto the user's head. Fine for five sites; as the managed set grows, reading "which ones are getting neglected" off a column of dates gets hard.
The point of a maintenance inventory is to grasp which sites need attention at a glance. If so, what you should surface is less the absolute date and more the relative quantity — "how many days since the last maintenance" — and ideally let color convey "how many days until it's risky." The client's request landed exactly on this "absolute → relative + risk" shift.
We went with four tiers by elapsed days. A small badge like (15 days ago) sits right after the last-maintenance date, and the color changes by threshold.
| Elapsed | tier | color | meaning |
|---|---|---|---|
| 0–14 days | fresh | green | recently maintained, fine |
| 15–29 days | normal | gray | standard |
| 30–59 days | warn | amber | needs attention |
| 60+ days | danger | red | needs action |
green → gray → amber → red — just scrolling the list, "lots of red here" or "a cluster of sites I haven't touched lately" jumps out visually. The badge also gets a hover tooltip ("N days since last maintenance") to back up the number's meaning.
The display logic is called from multiple places (list view, grid view), so scattering inline day calculations would be a DRY violation. We consolidated into a set of helpers.
// Returns elapsed days. null for empty/invalid input, future dates clamped to 0
function _daysSinceLastRun(lastRunStr) {
if (!lastRunStr) return null;
// Safari compat: normalize "YYYY-MM-DD" to "YYYY/MM/DD" before Date
const normalized = lastRunStr.replace(/-/g, '/');
const d = new Date(normalized);
if (isNaN(d.getTime())) return null;
const diffMs = Date.now() - d.getTime();
const days = Math.floor(diffMs / 86400000);
return days < 0 ? 0 : days; // clamp future dates to 0 (clock-skew defense)
}
// Returns a color object by threshold
function _daysAgoStyle(days) {
if (days <= 14) return { tier: 'fresh', color: '#15803d', bg: '#dcfce7' };
if (days <= 29) return { tier: 'normal', color: '#4b5563', bg: '#f3f4f6' };
if (days <= 59) return { tier: 'warn', color: '#92400e', bg: '#fef3c7' };
return { tier: 'danger', color: '#7f1d1d', bg: '#fee2e2' };
}
Three "small but worthwhile" defenses are baked in.
new Date("2026-05-21") works in Chrome, but Safari can return Invalid Date depending on environment — a known quirk where Safari doesn't reliably parse hyphenated YYYY-MM-DD. Replacing hyphens with slashes to 2026/05/21 before passing to Date parses stably in Safari too. If you're not pulling in a date library, normalizing up front is the safe path for cross-browser date parsing.
If the server and client clocks disagree, or a maintenance date somehow lands in the future, elapsed days go negative. A (-3 days ago) display is nonsense, so days < 0 ? 0 : days clamps to zero — treating it as "today." An edge case, but the kind that screams "this is broken" the moment it shows.
The elapsed-days text generates Today / 1 day ago / N days ago through i18n. The trap here: if rendering runs before the i18n keys load, raw keys like site_list.days_ago_n show up on screen verbatim.
function _formatDaysAgoText(days) {
// Reference _i18n directly, fall back to English if unregistered
if (days === 0) return _i18n?.days_ago_today ?? 'Today';
if (days === 1) return _i18n?.days_ago_one ?? '1 day ago';
const tmpl = _i18n?.days_ago_n ?? '${days} days ago';
return tmpl.replace('${days}', days);
}
The ?? 'fallback' guarantees meaningful text regardless of i18n load timing. The lesson: any dynamically generated text needs a fallback that accounts for the i18n initialization race.
The thresholds (14 / 29 / 59 / 60+) are the spec, so we locked them with regression tests. tests/test_days_since_last_run.py has 18 cases — whether the four-tier boundaries (14 vs 15, 29 vs 30, 59 vs 60) return the right tier, the null / NaN / future-clamp defenses, Safari normalization, that both list and grid views call the helpers, and that no old inline calculation remains (DRY-violation detection).
The boundary tests matter most. Off-by-one errors like "is 30 days warn or normal" are easy to miss reading the spec alone, and without test-locking they drift silently in a future refactor.
Three principles from this round:
YYYY-MM-DD issue and future-date clamping are the kind of thing that, addressed after the fact, leaves you debugging "can't reproduce, environment-dependent." Normalize and clamp from day one?? 'fallback'
Inside the modest feature "show a date" sat several quiet design decisions — relative-time presentation, cross-browser date parsing, the i18n race, boundary tests. For an admin screen spanning many sites, translating "when" into "how many days until it's risky" lowers the effort of spotting what needs attention and makes it easier to head off missed maintenance.