feat(demo): reset the demo instance every 20 minutes with a countdown
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m34s

DEMO_RESET_MINUTES (default 20, 0 = off) resets the database to the
sample data. Demo accounts get fixed IDs so sessions survive; requests
hold a read lock so the reset never runs under a half-finished request
(streams excluded). DEMO_RESET goes to every connection, /auth/status
announces the next reset. The app shows "✨ Demo · Reset in MM:SS" in
place of the version, then clears cached answers, remounts the views,
reloads in the background and shows a short notice; if no event
arrives (long polling) it asks the server after the countdown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-29 14:22:06 +01:00
1 parent afd3b0347c
commit 6b9eb62caf
12 files changed
+348 -9

No files matched your search

+76 -4
View File
@@ -51,7 +51,8 @@ import { WorkspaceView } from './features/workspace/WorkspaceView';
import { SharedListsView } from './features/lists/SharedListsView';
import { SetupAndAuthView } from './features/auth/SetupAndAuthView';
import { OfflineBanner } from './components/OfflineBanner';
import { outboxCount } from './lib/offline';
import { outboxCount, clearOfflineData } from './lib/offline';
import { DemoCountdown } from './components/DemoCountdown';
import { UserSettingsView } from './features/settings/UserSettingsView';
import { AdminSettingsView } from './features/settings/AdminSettingsView';
import { HelpView } from './features/help/HelpView';
@@ -122,6 +123,12 @@ export const App: React.FC = () => {
instanceName: 'MindSift',
});
const [user, setUser] = useState<User | null>(null);
// Demo mode: when the next reset happens (local clock), and a counter that remounts all views after it
const [demoDeadline, setDemoDeadline] = useState<number | null>(null);
const [dataEpoch, setDataEpoch] = useState(0);
const [demoNotice, setDemoNotice] = useState<string | null>(null);
const lastDemoResetRef = useRef(0);
const handleDemoResetRef = useRef<() => void>(() => {});
// Link from the password reset mail (?resetToken=…); removed from the address bar right away
const [resetToken, setResetToken] = useState<string | null>(() => {
try {
@@ -551,11 +558,50 @@ export const App: React.FC = () => {
}
}, [user?.id]);
useEffect(() => {
setDemoDeadline(
authStatus.demoMode && typeof authStatus.demoResetInSeconds === 'number'
? Date.now() + authStatus.demoResetInSeconds * 1000
: null
);
}, [authStatus.demoMode, authStatus.demoResetInSeconds]);
// The demo database was reset: drop cached answers of the old data, remount the views (they load
// fresh data), refresh counters and the countdown. Sessions stay valid (fixed demo account IDs).
const handleDemoReset = useCallback(() => {
if (Date.now() - lastDemoResetRef.current < 30000) return; // event and countdown may both report it
lastDemoResetRef.current = Date.now();
void clearOfflineData();
setDataEpoch((n) => n + 1);
refreshAll();
api.getAuthStatus().then(setAuthStatus).catch(() => {});
setDemoNotice('Demo-Instanz wurde zurückgesetzt');
window.setTimeout(() => setDemoNotice(null), 4000);
}, [refreshAll]);
handleDemoResetRef.current = handleDemoReset;
// Countdown reached zero but no DEMO_RESET arrived (e.g. long polling): ask the server shortly after
const handleDemoCountdownElapsed = useCallback(() => {
window.setTimeout(() => {
api
.getAuthStatus()
.then((st) => {
if ((st.demoResetInSeconds ?? 0) > 60) handleDemoResetRef.current();
else setAuthStatus(st);
})
.catch(() => {});
}, 5000);
}, []);
// Refresh stats on task/doc/list real-time events (counters in the navigation)
useEffect(() => {
const handleWs = (e: any) => {
const msg = e.detail;
if (!msg) return;
if (msg.event === 'DEMO_RESET') {
handleDemoResetRef.current();
return;
}
if (
typeof msg.event === 'string' &&
['TASK_', 'DOC_', 'SPACE_', 'ITEM_', 'LIST_'].some((prefix) => msg.event.startsWith(prefix))
@@ -742,7 +788,16 @@ export const App: React.FC = () => {
<span className="font-bold text-base text-main tracking-tight leading-none">
{authStatus.instanceName || 'MindSift'}
</span>
<span className="text-[9px] text-muted font-mono leading-tight mt-0.5">{APP_VERSION}</span>
{demoDeadline ? (
<DemoCountdown
deadline={demoDeadline}
minutes={authStatus.demoResetMinutes}
onElapsed={handleDemoCountdownElapsed}
className="text-[9px] mt-0.5"
/>
) : (
<span className="text-[9px] text-muted font-mono leading-tight mt-0.5">{APP_VERSION}</span>
)}
</div>
<ChevronDown
className={`w-4 h-4 text-muted transition-transform duration-150 ${
@@ -817,7 +872,16 @@ export const App: React.FC = () => {
<span className="font-bold text-sm sm:text-base text-main tracking-tight leading-none">
{authStatus.instanceName || 'MindSift'}
</span>
<span className="text-[10px] text-muted font-mono leading-tight mt-0.5">{APP_VERSION}</span>
{demoDeadline ? (
<DemoCountdown
deadline={demoDeadline}
minutes={authStatus.demoResetMinutes}
onElapsed={handleDemoCountdownElapsed}
className="text-[10px] mt-0.5"
/>
) : (
<span className="text-[10px] text-muted font-mono leading-tight mt-0.5">{APP_VERSION}</span>
)}
</div>
</div>
@@ -1053,11 +1117,19 @@ export const App: React.FC = () => {
<MobileHeaderMenu activeTabLabel={activeTabLabelMap[activeTab]} />
<OfflineBanner />
{demoNotice && (
<div
role="status"
className="fixed left-1/2 -translate-x-1/2 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] sm:bottom-4 z-[60] px-3 py-1.5 rounded-full border border-accent/40 bg-panel-elevated text-main text-[11px] font-medium shadow-panel"
>
✨ {demoNotice}
</div>
)}
{/* Main Container */}
<main className="flex-1 max-w-6xl w-full mx-auto px-3 sm:px-4 pt-3 pb-24 sm:pb-6">
<ViewErrorBoundary onReset={() => setActiveTab('triage')}>
<section>
<section key={dataEpoch}>
{activeTab === 'triage' && (
<TriageDeck
deck={deck}
+38
View File
@@ -0,0 +1,38 @@
import React, { useEffect, useState } from 'react';
interface DemoCountdownProps {
deadline: number; // ms timestamp of the next reset, in this browser's clock
minutes?: number;
className?: string;
onElapsed?: () => void;
}
const fmt = (ms: number) => {
const total = Math.max(0, Math.round(ms / 1000));
return `${String(Math.floor(total / 60)).padStart(2, '0')}:${String(total % 60).padStart(2, '0')}`;
};
// "✨ Demo · Reset in MM:SS" in place of the version under the app name while the demo mode resets itself
export const DemoCountdown: React.FC<DemoCountdownProps> = ({ deadline, minutes, className = '', onElapsed }) => {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(timer);
}, []);
const left = deadline - now;
useEffect(() => {
if (left <= 0) onElapsed?.();
// Runs when the deadline passes, not on every tick
}, [left <= 0, deadline]);
return (
<span
className={`font-mono leading-tight text-accent ${className}`}
title={`Diese Test-Instanz wird alle ${minutes || 20} Minuten automatisch auf den Ausgangszustand zurückgesetzt.`}
>
✨ Demo · {left > 0 ? `Reset in ${fmt(left)}` : 'wird zurückgesetzt…'}
</span>
);
};
+4
View File
@@ -10,6 +10,10 @@ export interface AuthStatus {
sharingMode?: SharingMode;
// "Passwort vergessen?" works (SMTP and BASE_URL configured)
passwordReset?: boolean;
// Demo mode with periodic reset: next reset (ISO) and seconds until then
demoNextReset?: string;
demoResetInSeconds?: number;
demoResetMinutes?: number;
// Impressum / Datenschutz of this instance (LEGAL_* environment variables)
legal?: {
imprintUrl?: string;