NaN im Dashboard: zwei Zeitstempel-Formate, ein Parser
Bei Devlogs und Backup stand "vor NaN Tagen". Ursache war meine Annahme, die
Datenbank speichere Zeit durchgehend im SQLite-Format. Stimmt nur zur Haelfte:
audit.ts "2026-07-30 12:34:56" datetime('now'), UTC ohne Zone
devlogs.posted_at "2026-07-30T12:34:56.789Z" toISOString()
Einstellung last_backup ebenso
Mein Code haengte immer ein Z an. Beim ersten Format ist das noetig, sonst
liest der Browser die Zeit als Ortszeit. Beim zweiten ergibt es "...ZZ" und
damit ein ungueltiges Datum — daher das NaN. Auf der Team-Seite fiel es nicht
auf, weil dort nur das Protokoll steht, und das hat das erste Format.
Jetzt erkennt alsDatum() die Zone am String und ergaenzt nur, was fehlt.
Unlesbares ergibt null statt NaN, die Anzeige faellt dann auf ihren Ersatztext
zurueck. Die Funktion liegt einmal auf Modulebene und wird von beiden Stellen
benutzt, damit sie nicht wieder auseinanderlaufen.
Geprueft mit acht Faellen direkt gegen die ausgelieferte Funktion, nicht gegen
eine Kopie: beide echten Formate ergeben denselben Zeitpunkt, dazu Offset
statt Z, und null/leer/Unsinn ergeben null. Danach im Browser mit dem Format
gegengeprueft, an dem es wirklich gescheitert ist — der erste Testlauf lief
noch mit dem SQLite-Format und waere gruen gewesen, ohne etwas zu beweisen.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,149 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Harness</title>
|
||||||
|
<link rel="stylesheet" crossorigin href="/src/style.css">
|
||||||
|
<script>
|
||||||
|
// Nur zum Ansehen: liefert Beispieldaten statt echter API.
|
||||||
|
history.replaceState(null, '', sessionStorage.getItem('harnessPfad') || '/');
|
||||||
|
const jetzt = new Date().toISOString();
|
||||||
|
// Panel-Modus: eingeloggt als Owner, damit /settings rendert
|
||||||
|
const alsOwner = sessionStorage.getItem('harnessOwner') === '1';
|
||||||
|
const MODULE = [
|
||||||
|
['devlogs','Devlogs','inhalte',true,[]], ['commit_feed','Commit-Feed','inhalte',true,[]],
|
||||||
|
['releases','Release-Ankuendigungen','inhalte',true,[]], ['weekly_recap','Wochen-Rueckblick','inhalte',true,[]],
|
||||||
|
['devlog_threads','Devlog-Threads','inhalte',true,[]], ['social','Twitch & YouTube','inhalte',true,[]],
|
||||||
|
['levels','Level-System','community',true,[]], ['starboard','Starboard','community',false,[]],
|
||||||
|
['gallery','Galerie','community',true,[]], ['voting','Feature-Wuensche','community',true,[]],
|
||||||
|
['giveaways','Verlosungen','community',true,[]], ['birthdays','Geburtstage','community',true,[]],
|
||||||
|
['events','Events','community',true,[]], ['playtester','Playtester','community',false,[]],
|
||||||
|
['alpha_keys','Alpha-Keys','community',true,[]], ['role_menus','Rollen-Menues','community',true,[]],
|
||||||
|
['applications','Bewerbungen','community',true,[]],
|
||||||
|
['moderation','Moderation','moderation',true,[]], ['modlog','Protokoll','moderation',true,[{label:'Mod-Log-Kanal'}]],
|
||||||
|
['modmail','Modmail','moderation',true,[]], ['tickets','Tickets','moderation',true,[]],
|
||||||
|
['welcome','Willkommens-Karten','moderation',true,[]], ['autorole','Auto-Rolle','moderation',false,[]],
|
||||||
|
['sticky_roles','Rollen merken','moderation',true,[]], ['bug_reports','Fehlermeldungen','moderation',true,[{label:'Gitea-Token'}]],
|
||||||
|
['server_monitor','Server-Monitor','server',true,[]], ['watchdog','Waechter','server',true,[]],
|
||||||
|
['temp_voice','Temp-Voice','server',false,[]], ['triggers','Auto-Antworten','server',true,[]],
|
||||||
|
['reminders','Erinnerungen','server',true,[]], ['scheduled_posts','Geplante Beitraege','server',true,[]],
|
||||||
|
['tags','Textbausteine','server',true,[]], ['backups','DB-Sicherung','server',true,[]],
|
||||||
|
['repo_backups','Repo-Sicherung','server',false,[]], ['member_gate','Member-Gate','server',true,[]],
|
||||||
|
].map(([id, name, group, enabled, missing]) => ({ id, name, group, enabled, missing, desc: '', tab: 'system' }));
|
||||||
|
|
||||||
|
const TAGE = Array.from({ length: 30 }, (_, i) => ({
|
||||||
|
day: new Date(Date.now() - (29 - i) * 86400000).toISOString().slice(0, 10),
|
||||||
|
messages: Math.round(120 + Math.sin(i / 2.2) * 70 + (i % 7 === 0 ? 90 : 0)),
|
||||||
|
joins: 2 + (i % 3), leaves: i % 4 === 0 ? 2 : 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const DATEN = {
|
||||||
|
'/api/me': alsOwner
|
||||||
|
? { user: { id: '1', username: 'D4rkst3r', avatar: null }, admin: true, scopes: [] }
|
||||||
|
: { user: null, admin: false, scopes: [] },
|
||||||
|
'/api/serverstats': { members: 1234, days: TAGE },
|
||||||
|
'/api/modules': {
|
||||||
|
modules: MODULE, favorites: ['devlogs', 'welcome'], maxFavorites: 8,
|
||||||
|
groups: [
|
||||||
|
{ id: 'inhalte', label: 'Inhalte & Feeds' }, { id: 'community', label: 'Community' },
|
||||||
|
{ id: 'moderation', label: 'Moderation & Support' }, { id: 'server', label: 'Server & Technik' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'/api/auditlog': { entries: [] },
|
||||||
|
'/api/alphakeys': { keys: [], free: 0, playtestersWithout: [] },
|
||||||
|
'/api/texts': { templates: [], groups: [] },
|
||||||
|
'/api/tuning': { values: [], groups: [] },
|
||||||
|
'/api/webadmins': { admins: [], scopes: [], owner: { user_id: '1', name: 'D4rkst3r', avatar: null } },
|
||||||
|
'/api/pages': { pages: [] },
|
||||||
|
'/api/settings': {
|
||||||
|
settings: {}, channels: [], roles: [], categories: [],
|
||||||
|
status: {
|
||||||
|
botTag: 'd4rkbot#4021', botName: 'd4rkbot', botAvatar: null, botDescription: '',
|
||||||
|
uptimeSeconds: 1051980, guilds: 1, giteaTokenConfigured: true,
|
||||||
|
// Volles ISO mit Z — genau so schreibt es toISOString() in der
|
||||||
|
// echten Datenbank, und genau daran ist die Anzeige gescheitert.
|
||||||
|
lastBackup: new Date(Date.now() - 6 * 3600000).toISOString(),
|
||||||
|
devlogs: 142, commits: 3184, dbSizeBytes: 50331648,
|
||||||
|
lastDevlog: new Date(Date.now() - 2 * 86400000).toISOString(),
|
||||||
|
lastCommit: new Date(Date.now() - 3600000).toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/api/invite': { invite: 'https://discord.gg/example' },
|
||||||
|
'/api/legal': { split: true, hubUrl: 'https://d4rkst3r.de' },
|
||||||
|
// Stundeneimer wie uptimeBuckets() sie liefert: meist voll erreichbar,
|
||||||
|
// ein harter Ausfall, eine teilweise Stunde, und eine Messluecke.
|
||||||
|
'/api/servers': (() => {
|
||||||
|
const eimer = [];
|
||||||
|
const jetzt = new Date(); jetzt.setMinutes(0, 0, 0);
|
||||||
|
for (let i = 167; i >= 0; i--) {
|
||||||
|
if (i > 40 && i < 52) continue; // Luecke: Bot war aus
|
||||||
|
const d = new Date(jetzt.getTime() - i * 3600000);
|
||||||
|
let an = 30;
|
||||||
|
if (i >= 92 && i <= 97) an = 0; // Ausfall
|
||||||
|
else if (i === 91 || i === 98 || i === 20) an = 17; // teilweise
|
||||||
|
eimer.push({ stunde: d.toISOString().slice(0, 13), proben: 30, an });
|
||||||
|
}
|
||||||
|
return { servers: [
|
||||||
|
{ id: 1, name: 'EcoGame RP', icon: '\ud83c\udfae', online: true, players: 64, max: 128, ping: 23, map: 'Los Santos',
|
||||||
|
address: 'play.d4rkst3r.de:30120', connect_url: 'fivem://connect/play.d4rkst3r.de', checkedAt: jetzt,
|
||||||
|
history: Array.from({length: 24}, (_, i) => ({ online: i !== 5, players: 20 + i })), uptime: eimer },
|
||||||
|
{ id: 2, name: 'Testserver', icon: '\ud83e\uddea', online: false, players: 0, max: 32, history: [], uptime: [], checkedAt: jetzt },
|
||||||
|
]}; })(),
|
||||||
|
'/api/services': { services: [] },
|
||||||
|
'/api/devlogs': { total: 2, pageSize: 10, items: [
|
||||||
|
{ message_id: '1', posted_at: jetzt, content: 'Devlog \u2014 EcoGame\n\nHeute am Fahrzeug-Handling gearbeitet.\n\n*3 Commits heute*', images: [] },
|
||||||
|
{ message_id: '2', posted_at: new Date(Date.now() - 86400000 * 40).toISOString(), content: 'Devlog \u2014 Bot\n\nAltes Update.', images: [] },
|
||||||
|
]},
|
||||||
|
'/api/devlog-projects': { projects: [{ name: 'EcoGame', n: 1 }, { name: 'Bot', n: 1 }] },
|
||||||
|
'/api/roadmap': { milestones: [
|
||||||
|
{ title: 'Alpha 1', state: 'open', open: 3, closed: 7, due_on: jetzt, description: 'Erste spielbare Fassung.' },
|
||||||
|
{ title: 'Grundger\u00fcst', state: 'closed', open: 0, closed: 12 },
|
||||||
|
]},
|
||||||
|
'/api/wishes': { canVote: false, wishes: [{ message_id: '9', idea: 'Mehr Fahrzeuge', author: 'Lisa', score: 5, voted: false }] },
|
||||||
|
'/api/heatmap': { days: [{ day: jetzt.slice(0,10), n: 3 }] },
|
||||||
|
'/api/gallery': { total: 1, pageSize: 12, items: [
|
||||||
|
{ author_name: 'Nova', posted_at: jetzt, images: ['https://cdn.discordapp.com/embed/avatars/2.png'] },
|
||||||
|
]},
|
||||||
|
'/api/levels': { levels: [
|
||||||
|
{ user_id: '1', display_name: 'Lisa', username: 'lisa', avatar: null, level: 12, xp: 45231 },
|
||||||
|
{ user_id: '2', display_name: 'Nova', username: 'nova', avatar: null, level: 8, xp: 18400 },
|
||||||
|
]},
|
||||||
|
'/api/events': { events: [
|
||||||
|
{ url: 'https://discord.com/x', name: 'Playtest #4', start: jetzt, interested: 12, description: 'Kommt vorbei.' },
|
||||||
|
]},
|
||||||
|
'/api/releases': { total: 1, pageSize: 10, items: [
|
||||||
|
{ repo: 'D4rkst3r/d4rkbot', tag: 'v1.2.0', prerelease: true, published_at: jetzt, name: 'Team & Sprachen', body: '- Team-Seite\n- Englisch', url: 'https://git.d4rkst3r.de' },
|
||||||
|
]},
|
||||||
|
'/api/features': { groups: [
|
||||||
|
{ id: 'inhalte', label: 'Inhalte & Feeds' }, { id: 'community', label: 'Community' },
|
||||||
|
], features: [
|
||||||
|
{ id: 'devlogs', group: 'inhalte', name: 'Devlogs', desc: 'Entwicklungs-Berichte als gebrandetes Embed posten.' },
|
||||||
|
{ id: 'levels', group: 'community', name: 'Level-System', desc: 'XP f\u00fcrs Mitreden.' },
|
||||||
|
{ id: 'gibtsnicht', group: 'community', name: 'Modul ohne \u00dcbersetzung', desc: 'F\u00e4llt bewusst auf Deutsch zur\u00fcck.' },
|
||||||
|
]},
|
||||||
|
};
|
||||||
|
const echt = window.fetch;
|
||||||
|
window.fetch = (url, opts) => {
|
||||||
|
const pfad = String(url).split('?')[0];
|
||||||
|
if (pfad.startsWith('/api/')) {
|
||||||
|
// Alles Unbekannte: gaengige Listen-Schluessel als leeres Array, damit
|
||||||
|
// .filter/.map nicht auf undefined laufen
|
||||||
|
const leer = {};
|
||||||
|
for (const k of ['keys', 'playtesters', 'menus', 'tags', 'posts', 'forms', 'triggers',
|
||||||
|
'servers', 'apps', 'services', 'pages', 'modules', 'groups', 'templates',
|
||||||
|
'values', 'entries', 'admins', 'items', 'levels', 'events', 'wishes',
|
||||||
|
'milestones', 'days', 'projects', 'scopes']) leer[k] = [];
|
||||||
|
return Promise.resolve(new Response(JSON.stringify(DATEN[pfad] ?? leer), {
|
||||||
|
status: 200, headers: { 'Content-Type': 'application/json' },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return echt(url, opts);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<script type="module" crossorigin src="/src/main.jsx"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -97,10 +97,28 @@ const TAB_SCOPES = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** „vor 3 Minuten" statt rohem Zeitstempel */
|
/** „vor 3 Minuten" statt rohem Zeitstempel */
|
||||||
function vorWieLange(iso) {
|
/**
|
||||||
// Die Datenbank speichert UTC ohne Kennzeichnung — sonst rechnet der
|
* Zeitstempel aus der Datenbank lesen — es kommen zwei Formate an:
|
||||||
// Browser sie als Ortszeit und alles sieht Stunden alt aus.
|
*
|
||||||
const min = Math.floor((Date.now() - new Date(iso.replace(' ', 'T') + 'Z').getTime()) / 60000);
|
* "2026-07-30 12:34:56" SQLite datetime('now'), UTC ohne Kennzeichnung
|
||||||
|
* "2026-07-30T12:34:56.789Z" toISOString(), Zone schon dabei
|
||||||
|
*
|
||||||
|
* Beim ersten muss das Z ergänzt werden, sonst rechnet der Browser die Zeit
|
||||||
|
* als Ortszeit. Beim zweiten darf es das gerade nicht — "…ZZ" ergibt ein
|
||||||
|
* ungültiges Datum und in der Anzeige stand dann NaN.
|
||||||
|
*/
|
||||||
|
function alsDatum(wert) {
|
||||||
|
if (!wert) return null;
|
||||||
|
const s = String(wert).trim();
|
||||||
|
const hatZone = /[Zz]$|[+-]\d{2}:?\d{2}$/.test(s);
|
||||||
|
const d = new Date(hatZone ? s : `${s.replace(' ', 'T')}Z`);
|
||||||
|
return Number.isNaN(d.getTime()) ? null : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function vorWieLange(wert) {
|
||||||
|
const d = alsDatum(wert);
|
||||||
|
if (!d) return '—';
|
||||||
|
const min = Math.floor((Date.now() - d.getTime()) / 60000);
|
||||||
if (min < 1) return 'gerade eben';
|
if (min < 1) return 'gerade eben';
|
||||||
if (min < 60) return `vor ${min} Min`;
|
if (min < 60) return `vor ${min} Min`;
|
||||||
const std = Math.floor(min / 60);
|
const std = Math.floor(min / 60);
|
||||||
@@ -1112,9 +1130,10 @@ export default function Settings({ me }) {
|
|||||||
const unfinished = activeModules.filter((m) => m.missing.length > 0);
|
const unfinished = activeModules.filter((m) => m.missing.length > 0);
|
||||||
|
|
||||||
/** Wie alt ist ein Zeitstempel, in Tagen — fuer die Nebenzeilen */
|
/** Wie alt ist ein Zeitstempel, in Tagen — fuer die Nebenzeilen */
|
||||||
function alterInTagen(iso) {
|
function alterInTagen(wert) {
|
||||||
if (!iso) return null;
|
const d = alsDatum(wert);
|
||||||
return Math.floor((Date.now() - new Date(iso.replace(' ', 'T') + 'Z').getTime()) / 86400000);
|
if (!d) return null;
|
||||||
|
return Math.floor((Date.now() - d.getTime()) / 86400000);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** „vor 3 Tagen" bzw. „heute" fuer eine Nebenzeile */
|
/** „vor 3 Tagen" bzw. „heute" fuer eine Nebenzeile */
|
||||||
|
|||||||
Reference in New Issue
Block a user