Time-Ago Fix for JSON Dates

December 28, 2025

This is a lightweight two-part helper that upgrades a JSON file that currently stores “createdAt” as strings like "1 week ago" (which can’t update correctly on its own).

What it does:

  • Step 1 (one-time PHP converter): reads data.json, turns "2 days ago" into a real ISO timestamp like "2025-12-26T03:22:10Z", and saves it as createdAtISO.
  • Step 2 (tiny JS renderer): displays “time ago” from that ISO timestamp and keeps it updating as time passes.

Why this is needed: If your JSON only stores "1 week ago" with no anchor date, then a week later it still “looks” like 1 week ago. The fix is to store an actual date (timestamp) and calculate the “ago” text at display time.

Install (newbie-friendly):

  1. Put your data.json in the same folder as the PHP converter (or edit the path in the config).
  2. Create convert_createdAt.php and paste the PHP code below.
  3. Run it once:
    • Browser: visit /convert_createdAt.php?token=CHANGE_ME
    • CLI (optional): php convert_createdAt.php
  4. Delete the converter file (or keep it protected) after it runs.
  5. Add the JS helper to your site and render using createdAtISO.
<?php
declare(strict_types=1);

/**
 * convert_createdAt.php
 * One-time converter:
 * - Reads data.json
 * - Converts "createdAt": "2 days ago" -> adds "createdAtISO": "....Z"
 * - Uses an anchor time so conversions are consistent:
 *    1) JSON top-level "asOf" (ISO) if present
 *    2) else filemtime(data.json)
 *
 * SECURITY:
 * - Set a token and delete this file after running, or keep it protected.
 */

// -------- CONFIG ----------
$JSON_PATH = __DIR__ . '/data.json';
$TOKEN     = 'CHANGE_ME'; // set to something random, then run with ?token=...
$BACKUP    = true;        // create data.json.bak before writing
// -------------------------

function fail(string $msg, int $code = 400): void {
  if (PHP_SAPI !== 'cli') http_response_code($code);
  header('Content-Type: text/plain; charset=utf-8');
  echo $msg;
  exit;
}

function parse_iso_to_ts(string $iso): ?int {
  $ts = strtotime($iso);
  return $ts === false ? null : $ts;
}

function unit_seconds(string $unit): int {
  $unit = strtolower($unit);
  return match ($unit) {
    'second', 'seconds' => 1,
    'minute', 'minutes' => 60,
    'hour',   'hours'   => 3600,
    'day',    'days'    => 86400,
    'week',   'weeks'   => 604800,
    // months/years are approximations for "ago" strings; for exactness store real timestamps at creation time
    'month',  'months'  => 2629800,  // ~30.44 days
    'year',   'years'   => 31557600, // ~365.25 days
    default => 0
  };
}

function parse_relative_ago(string $s): ?array {
  // Accepts: "1 week ago", "2 days ago", "  3 hours ago"
  $s = trim($s);
  if (!preg_match('/^(\d+)\s+(second|minute|hour|day|week|month|year)s?\s+ago$/i', $s, $m)) return null;
  $n = (int)$m[1];
  $u = strtolower($m[2]) . ($n === 1 ? '' : 's');
  return [$n, $u];
}

function normalize_items(&$data): array {
  // Supports either:
  // 1) top-level array: [ {..}, {..} ]
  // 2) object with items: { "items":[...], ... }
  if (is_array($data) && array_is_list($data)) return [$data, null]; // items, set back target = root
  if (is_array($data) && isset($data['items']) && is_array($data['items'])) return [$data['items'], 'items'];
  return [null, null];
}

// --- token check (web only) ---
if (PHP_SAPI !== 'cli') {
  $got = $_GET['token'] ?? '';
  if (!is_string($got) || $got !== $TOKEN) fail("Forbidden. Provide ?token=YOUR_TOKEN", 403);
}

if (!is_file($JSON_PATH)) fail("Missing file: {$JSON_PATH}", 404);

$raw = file_get_contents($JSON_PATH);
if ($raw === false) fail("Failed reading: {$JSON_PATH}", 500);

$data = json_decode($raw, true);
if (!is_array($data)) fail("data.json is not valid JSON (expected array or object).", 400);

// Determine anchor time ("asOf")
$asOfTs = null;
if (isset($data['asOf']) && is_string($data['asOf'])) {
  $asOfTs = parse_iso_to_ts($data['asOf']);
}
if ($asOfTs === null) {
  $asOfTs = (int)@filemtime($JSON_PATH);
  if ($asOfTs <= 0) $asOfTs = time();
}

// Ensure JSON has asOf stored (so future runs are consistent)
$data['asOf'] = gmdate('c', $asOfTs);

[$items, $target] = normalize_items($data);
if ($items === null) fail("Could not find items. Use a top-level array, or an object with an 'items' array.", 400);

$changed = 0;
$skipped = 0;

foreach ($items as &$it) {
  if (!is_array($it)) { $skipped++; continue; }

  // If already converted, skip
  if (isset($it['createdAtISO']) && is_string($it['createdAtISO']) && $it['createdAtISO'] !== '') {
    $skipped++;
    continue;
  }

  $createdAt = $it['createdAt'] ?? null;
  if (!is_string($createdAt)) { $skipped++; continue; }

  $rel = parse_relative_ago($createdAt);
  if ($rel === null) { $skipped++; continue; }

  [$n, $unit] = $rel;
  $sec = unit_seconds($unit);
  if ($sec <= 0) { $skipped++; continue; }

  $ts = $asOfTs - ($n * $sec);
  $it['createdAtISO']   = gmdate('c', $ts);
  $it['createdAtEpoch'] = $ts; // optional, handy for sorting
  $changed++;
}
unset($it);

// Put items back if needed
if ($target === 'items') $data['items'] = $items;

// Backup
if ($BACKUP) {
  @copy($JSON_PATH, $JSON_PATH . '.bak');
}

// Write updated JSON
$out = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
if ($out === false) fail("Failed encoding JSON.", 500);

if (file_put_contents($JSON_PATH, $out) === false) fail("Failed writing: {$JSON_PATH}", 500);

header('Content-Type: text/plain; charset=utf-8');
echo "Done.\nChanged: {$changed}\nSkipped: {$skipped}\nAnchor (asOf): " . $data['asOf'] . "\n";
?>
// ===============================
// timeago.js (drop-in)
// ===============================
<script>
(function(){
  function timeAgoFromISO(iso){
    var ts = Date.parse(iso);
    if (!isFinite(ts)) return '';
    var s = Math.floor((Date.now() - ts) / 1000);
    if (s < 5) return 'just now';
    if (s < 60) return s + ' seconds ago';

    var m = Math.floor(s/60);
    if (m < 60) return m + (m===1?' minute':' minutes') + ' ago';

    var h = Math.floor(m/60);
    if (h < 24) return h + (h===1?' hour':' hours') + ' ago';

    var d = Math.floor(h/24);
    if (d < 7) return d + (d===1?' day':' days') + ' ago';

    var w = Math.floor(d/7);
    if (w < 5) return w + (w===1?' week':' weeks') + ' ago';

    // Approx months/years (good enough for display)
    var mo = Math.floor(d/30);
    if (mo < 12) return mo + (mo===1?' month':' months') + ' ago';

    var y = Math.floor(d/365);
    return y + (y===1?' year':' years') + ' ago';
  }

  function updateTimeAgo(){
    var nodes = document.querySelectorAll('[data-timeago]');
    for (var i=0;i<nodes.length;i++){
      var el = nodes[i];
      var iso = el.getAttribute('data-timeago') || '';
      var txt = timeAgoFromISO(iso);
      if (txt) el.textContent = txt;
    }
  }

  // Update now + every 30s
  document.addEventListener('DOMContentLoaded', function(){
    updateTimeAgo();
    setInterval(updateTimeAgo, 30000);
  });

  // Export helper if you want to use it manually
  window.timeAgoFromISO = timeAgoFromISO;
})();
</script>

// ===============================
// Example usage (rendering)
// ===============================
// After converting, your data items have createdAtISO.
// In your HTML, output something like:
//
// <time data-timeago="2025-12-26T03:22:10Z"></time>
//
// Or if you fetch JSON and build the DOM, set that attribute from item.createdAtISO.

Comments (0)

No comments yet — be the first.

← Back to all scripts