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}