Skip to content

A days-since-last-maintenance badge — color-coding staleness across many sites

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.

Why a date alone isn’t enough

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.

Four-tier color coding

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.

Consolidate into helper functions

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.

Safari-compatible date normalization

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.

Future-date clamping (clock-skew defense)

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.

i18n fallback

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.

Lock the color thresholds with regression tests

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.

Closing — “relative quantity + risk” beats “absolute value”

Three principles from this round:

  1. Inventory UIs should show relative quantity over absolute value. “(15 days ago)” offloads the user’s mental math better than “last maintained 2026-05-21.” Add color for risk and the sites that need attention rise out of the list as you scroll
  2. Build cross-browser defenses into date parsing from the start. Safari’s 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
  3. Always attach an i18n fallback to dynamically generated text. The “raw key shows on screen during the i18n load race” bug is structurally prevented by a ?? '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.