Index: src/assets/js/guardian.js
===================================================================
--- src/assets/js/guardian.js	(revision d9ad6c564eccd0e4c23c3d8c2c8a32334f2443bc)
+++ src/assets/js/guardian.js	(revision a7bcf669de39441a2bbc7c41545fc9e17db4b233)
@@ -32,5 +32,12 @@
     catch (e) { return uri; }
   }
-  function when(s) { return String(s || '').slice(0, 16).replace('T', ' '); }
+  // The server hands over a timestamp already formatted in the site's timezone
+  // (Beheer -> Instellingen), the same clock de Krant and Berichten show. The
+  // slice is only a fallback for a row that predates that field: it shows raw
+  // UTC, which is what made a 20:20 call for help read 18:20.
+  function when(item, raw) {
+    if (item && item.when_text) return item.when_text;
+    return String(raw || '').slice(0, 16).replace('T', ' ');
+  }
   function show(id, on) { document.getElementById(id).hidden = !on; }
 
@@ -49,5 +56,5 @@
       else who.textContent = h.actor_name || handleOf(h.actor_uri, h.actor_handle);
       row.appendChild(who);
-      row.appendChild(el('span', 'when', when(h.published || h.created_at)));
+      row.appendChild(el('span', 'when', when(h, h.published || h.created_at)));
       card.appendChild(row);
       var body = el('div', 'body g-note');
@@ -197,5 +204,5 @@
       var head = el('div', 'row');
       head.appendChild(el('span', 'who grow', p.author));
-      if (p.published) head.appendChild(el('span', 'g-when', when(p.published)));
+      if (p.published) head.appendChild(el('span', 'g-when', when(p, p.published)));
       card.appendChild(head);
       var body = el('div', 'feed-body');
Index: src/middleware/render.js
===================================================================
--- src/middleware/render.js	(revision d9ad6c564eccd0e4c23c3d8c2c8a32334f2443bc)
+++ src/middleware/render.js	(revision a7bcf669de39441a2bbc7c41545fc9e17db4b233)
@@ -85,12 +85,33 @@
 const siteTimezone = () => getSetting('timezone') || undefined;
 
+/**
+ * Read a stored timestamp as the moment it actually is.
+ *
+ * SQLite's CURRENT_TIMESTAMP writes UTC without saying so ("2026-07-28
+ * 18:20:33"), and new Date() reads a string in that shape as LOCAL time. That
+ * is right only as long as the server runs on UTC; set the machine to
+ * Europe/Amsterdam and every stored date silently shifts two hours. So say UTC
+ * out loud. Anything already carrying a zone (AP `published` ends in Z) is left
+ * to the normal parser.
+ */
+const parseStamp = (v) => {
+  if (!v) return null;
+  const s = String(v);
+  const d = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2})?$/.test(s)
+    ? new Date(`${s.replace(' ', 'T')}Z`)
+    : new Date(s);
+  return Number.isNaN(d.getTime()) ? null : d;
+};
+
 const formatDate = (iso) => {
-  if (!iso) return '';
-  return new Date(iso).toLocaleDateString('nl-NL', { timeZone: siteTimezone(), day: 'numeric', month: 'long', year: 'numeric' });
+  const d = parseStamp(iso);
+  return d ? d.toLocaleDateString('nl-NL', { timeZone: siteTimezone(), day: 'numeric', month: 'long', year: 'numeric' }) : '';
 };
 
-const formatDateTime = (iso) => {
-  if (!iso) return '';
-  return new Date(iso).toLocaleString('nl-NL', { timeZone: siteTimezone(), dateStyle: 'medium', timeStyle: 'short' });
+/** A timestamp in the site's own timezone (Beheer → Instellingen). Exported so
+ *  surfaces outside the EJS pages (the Guardian PWA) read the same clock. */
+export const formatDateTime = (iso) => {
+  const d = parseStamp(iso);
+  return d ? d.toLocaleString('nl-NL', { timeZone: siteTimezone(), dateStyle: 'medium', timeStyle: 'short' }) : '';
 };
 
Index: src/routes/guardian.js
===================================================================
--- src/routes/guardian.js	(revision d9ad6c564eccd0e4c23c3d8c2c8a32334f2443bc)
+++ src/routes/guardian.js	(revision a7bcf669de39441a2bbc7c41545fc9e17db4b233)
@@ -19,5 +19,5 @@
 import * as Guardianship from '../services/guardianship/index.js';
 import { t as i18nT, resolveLang } from '../services/i18n.js';
-import { injectCspNonce, renderNoteBody } from '../middleware/render.js';
+import { injectCspNonce, renderNoteBody, formatDateTime } from '../middleware/render.js';
 import { emojiName } from '../services/NoteRender.js';
 
@@ -62,4 +62,7 @@
     body_html: renderNoteBody(h, L),
     name_html: emojiName(h.actor_name || '', h.actor_emoji_json),
+    // In the site's own timezone, the same as everywhere else in Klonkt. The
+    // PWA used to slice the raw UTC string, so a 20:20 call for help read 18:20.
+    when_text: formatDateTime(h.published || h.created_at),
   }));
   return {
@@ -141,4 +144,5 @@
       url: p.url,
       published: p.published || p.created_at,
+      when_text: formatDateTime(p.published || p.created_at),
       cw: p.cw || null,
       media: p.media_json ? JSON.parse(p.media_json) : [],
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision d9ad6c564eccd0e4c23c3d8c2c8a32334f2443bc)
+++ src/services/ActivityPubService.js	(revision a7bcf669de39441a2bbc7c41545fc9e17db4b233)
@@ -3521,5 +3521,5 @@
   try {
     const rows = db.prepare(`
-      SELECT i.kind, i.actor_name, i.actor_handle, i.actor_url, i.actor_icon, i.content, i.created_at, i.visibility,
+      SELECT i.kind, i.actor_name, i.actor_handle, i.actor_url, i.actor_icon, i.content, i.created_at, i.published, i.visibility,
              i.emoji_json, i.actor_emoji_json, i.media_json, i.quote_json, i.embed_json,
              p.slug AS post_slug, p.title AS post_title
@@ -3531,4 +3531,8 @@
       type: r.kind, name: r.actor_name, handle: r.actor_handle, url: r.actor_url, icon: r.actor_icon,
       content: stripLeadingMentions(r.content), post_slug: r.post_slug, post_title: r.post_title, created_at: r.created_at,
+      // When the post was written, for display. created_at (when it reached us)
+      // stays the sort key and the unread watermark: a note that federated late
+      // is still new to you.
+      published: r.published,
       emoji_json: r.emoji_json, actor_emoji_json: r.actor_emoji_json,   // FEP-9098 (messages render)
       media_json: r.media_json, quote_json: r.quote_json, embed_json: r.embed_json,   // rendered like a Krant post
@@ -3555,8 +3559,8 @@
   } catch { /* ignore */ }
   try {
-    for (const r of db.prepare(`SELECT object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, actor_url, content, wave, help_request, created_at,
+    for (const r of db.prepare(`SELECT object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, actor_url, content, wave, help_request, created_at, published,
                                        emoji_json, actor_emoji_json, media_json, quote_json, embed_json
                                 FROM ap_mentions WHERE slug = ? ORDER BY created_at DESC LIMIT ?`).all(slug, L)) {
-      out.push({ type: 'mention', name: r.actor_name, handle: r.actor_handle, url: r.actor_url || r.actor_uri, icon: r.actor_icon, content: stripLeadingMentions(r.content), note_url: r.note_url || r.object_uri, wave: r.wave ? 1 : 0, help_request: r.help_request ? 1 : 0, actorUri: r.actor_uri, created_at: r.created_at,
+      out.push({ type: 'mention', name: r.actor_name, handle: r.actor_handle, url: r.actor_url || r.actor_uri, icon: r.actor_icon, content: stripLeadingMentions(r.content), note_url: r.note_url || r.object_uri, wave: r.wave ? 1 : 0, help_request: r.help_request ? 1 : 0, actorUri: r.actor_uri, created_at: r.created_at, published: r.published,
         // Same trimmings a Krant row has, so Berichten renders the post identically.
         emoji_json: r.emoji_json, actor_emoji_json: r.actor_emoji_json, media_json: r.media_json, quote_json: r.quote_json, embed_json: r.embed_json });
Index: src/views/partials/msg-item.ejs
===================================================================
--- src/views/partials/msg-item.ejs	(revision d9ad6c564eccd0e4c23c3d8c2c8a32334f2443bc)
+++ src/views/partials/msg-item.ejs	(revision a7bcf669de39441a2bbc7c41545fc9e17db4b233)
@@ -44,5 +44,8 @@
               <% } %>
               <% if (_new) { %><span class="msg-new" title="<%= t('msg.new') %>"></span><% } %>
-              <% if (n.created_at) { %><span class="msg-time"><%= formatDateTime(n.created_at) %></span><% } %>
+              <% /* When the post was written, like de Krant and de Guardian PWA
+                    show it. created_at (when it reached us) stays the sort key
+                    and drives the "new since your last visit" dot above. */ %>
+              <% if (n.published || n.created_at) { %><span class="msg-time"><%= formatDateTime(n.published || n.created_at) %></span><% } %>
             </div>
 
Index: test/timestamps.test.js
===================================================================
--- test/timestamps.test.js	(revision a7bcf669de39441a2bbc7c41545fc9e17db4b233)
+++ test/timestamps.test.js	(revision a7bcf669de39441a2bbc7c41545fc9e17db4b233)
@@ -0,0 +1,54 @@
+// Every surface shows the same clock: the timezone from Beheer -> Instellingen.
+// The Guardian PWA used to slice the raw UTC string, so a call for help sent at
+// 20:20 Amsterdam time read 18:20 on the dashboard.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'fs';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://test.example';
+
+const dbMod = await import('../src/config/database.js');
+dbMod.initializeDatabase();
+const { formatDateTime } = await import('../src/middleware/render.js');
+// Through the service, not straight into the table: settings are cached, so a
+// raw INSERT would leave the running process on the old timezone.
+const { setSetting } = await import('../src/services/SettingsService.js');
+const setTz = (tz) => setSetting('timezone', tz);
+
+test('an AP timestamp is shown in the site timezone, not in UTC', () => {
+  setTz('Europe/Amsterdam');
+  const s = formatDateTime('2026-07-28T18:20:33.000Z');   // summer: UTC+2
+  assert.match(s, /20:20/, `expected 20:20 Amsterdam time, got "${s}"`);
+});
+
+test('the same moment in another timezone reads differently', () => {
+  setTz('Pacific/Auckland');
+  assert.match(formatDateTime('2026-07-28T18:20:33.000Z'), /06:20/);
+  setTz('UTC');
+  assert.match(formatDateTime('2026-07-28T18:20:33.000Z'), /18:20/);
+});
+
+test("a SQLite timestamp is UTC even though it does not say so", () => {
+  // CURRENT_TIMESTAMP writes "2026-07-28 18:20:33". new Date() reads a string in
+  // that shape as LOCAL time, which is only right while the server runs on UTC.
+  setTz('Europe/Amsterdam');
+  const stored = formatDateTime('2026-07-28 18:20:33');
+  const explicit = formatDateTime('2026-07-28T18:20:33.000Z');
+  assert.equal(stored, explicit, 'a stored timestamp must not depend on the machine timezone');
+  assert.match(stored, /20:20/);
+});
+
+test('a missing or unparseable timestamp renders as nothing, never as Invalid Date', () => {
+  assert.equal(formatDateTime(null), '');
+  assert.equal(formatDateTime(''), '');
+  assert.equal(formatDateTime('ooit'), '');
+});
+
+test('the Guardian PWA gets its timestamps pre-formatted', () => {
+  // The dashboard builds its cards in the browser and has no timezone of its
+  // own, so the server hands over when_text alongside the raw value.
+  const src = fs.readFileSync(new URL('../src/routes/guardian.js', import.meta.url), 'utf8');
+  assert.match(src, /when_text: formatDateTime\(h\.published \|\| h\.created_at\)/, 'help requests');
+  assert.match(src, /when_text: formatDateTime\(p\.published \|\| p\.created_at\)/, "the wards' feed");
+});
