Skip to content

Conversation

@akirk
Copy link
Member

@akirk akirk commented Jan 21, 2026

Based on #3157.

Motivation for the change, related issues

Personal Playgrounds store data in the browser's Origin Private File System (OPFS), which can be cleared unexpectedly by the browser. Users need a gentle reminder to back up their work periodically to avoid data loss.

This PR adds a backup status indicator that tracks usage days since the last backup and prompts users to download backups based on urgency.

Screenshots

Screenshot 2026-01-22 at 04 11 46 Screenshot 2026-01-22 at 04 18 24 Screenshot 2026-01-22 at 04 18 32 Screenshot 2026-01-22 at 04 18 07 Screenshot 2026-01-22 at 04 18 45

(No button when the backup is up to date)

Auto-Backup

Screenshot 2026-01-26 at 08 58 00 Screenshot 2026-01-26 at 08 55 47

Implementation details

New files:

  • backup-status-indicator.tsx - Component showing days since last backup with color-coded urgency (green ≤1 day, yellow 2-4 days, red 5+ days)
  • backup-status-indicator.module.css - Styles for the indicator button
  • use-backup.ts - Hook that handles backup creation using zipWpContent and file-saver

Modified files:

  • slice-sites.ts - Added metadata fields:
    • backupHistory - Array of recent backups (filename + timestamp)
    • lastAccessDate - Timestamp for tracking usage days
    • daysUsedSinceLastBackup - Counter reset on backup
  • persistent-browser-chrome/index.tsx - Added BackupStatusIndicator to toolbar

Behavior:

  • Indicator only appears after the user returns on a different day than creation
  • Tracks "days used" (not calendar days) - only increments when user actively uses the site
  • Clicking the button downloads a self-contained zip backup
  • Counter resets to 0 after successful backup

Testing Instructions (or ideally a Blueprint)

  1. Create a new persistent Playground
  2. Verify the backup indicator does NOT appear on the first day
  3. Open browser DevTools and use the snippet below to fake the last backup date
  4. The indicator should appear in yellow ("2 days since backup")
  5. Click the indicator button
  6. Verify a zip file downloads with format: {site-name}-backup-{date}-{time}.zip
  7. Verify the indicator disappears (counter reset to 0)
  8. Set daysUsedSinceLastBackup to 5 - indicator should appear in red
  // Fake old backup for testing (could be added to your snippets)
  window.fakeOldBackup = function(days = 3) {
    const store = window.__PLAYGROUND_STORE__;
    if (!store) {
      console.error('Store not available. Make sure you are in dev mode.');
      return;
    }

    const state = store.getState();
    const slug = state.ui.activeSite?.slug;
    if (!slug) {
      console.error('No active site');
      return;
    }

    const site = state.sites.entities[slug];
    if (!site) {
      console.error('Site not found');
      return;
    }

    const daysAgo = Date.now() - days * 24 * 60 * 60 * 1000;
    store.dispatch({
      type: 'sites/updateSite',
      payload: {
        id: slug,
        changes: {
          metadata: {
            ...site.metadata,
            whenCreated: daysAgo - 24 * 60 * 60 * 1000,
            lastAccessDate: Date.now(),
            daysUsedSinceLastBackup: days,
            backupHistory: [],
          },
        },
      },
    });

    console.log(`Faked ${days} days since backup for site "${slug}"`);
  };

  console.log('Run fakeOldBackup(3) to simulate 3 days since backup');

@akirk akirk force-pushed the persistent/health-check-recovery branch from de5b08e to 1baf5fd Compare January 21, 2026 18:14
@akirk akirk force-pushed the persistent/backup-reminder branch from 73357f6 to 6db275d Compare January 21, 2026 18:14
@akirk akirk force-pushed the persistent/health-check-recovery branch from 1baf5fd to 9676f6f Compare January 22, 2026 03:10
@akirk akirk force-pushed the persistent/backup-reminder branch from 6db275d to 4cff483 Compare January 22, 2026 03:10
@akirk akirk force-pushed the persistent/health-check-recovery branch from 6192b58 to 30ade19 Compare January 22, 2026 14:41
@akirk akirk force-pushed the persistent/backup-reminder branch from ccebb0e to 1555eba Compare January 22, 2026 14:41
@akirk akirk force-pushed the persistent/health-check-recovery branch from 30ade19 to 6ee19d9 Compare January 23, 2026 05:03
@akirk akirk force-pushed the persistent/backup-reminder branch from 1555eba to 4da5ad5 Compare January 23, 2026 05:03
@akirk akirk changed the title [website] Personal Playground: Backup reminder UI [personal-wp] Backup reminder UI Jan 23, 2026
@akirk akirk force-pushed the persistent/health-check-recovery branch from 6ee19d9 to c9b2a78 Compare January 23, 2026 12:07
@akirk akirk force-pushed the persistent/backup-reminder branch 2 times, most recently from 817712c to 7158776 Compare January 23, 2026 13:17
Base automatically changed from persistent/health-check-recovery to trunk January 23, 2026 13:49
const response = await playground.run({
code: `<?php
require_once '/wordpress/wp-load.php';
echo html_entity_decode(get_option('blogname', 'WordPress'), ENT_QUOTES, 'UTF-8');
Copy link
Collaborator

@adamziel adamziel Jan 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use WordPress HTML API for this? Or decode is in JavaScript? html_entity_decode doesn't do its job well.

Copy link
Member Author

@akirk akirk Jan 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about using json_encode() in PHP and JSON.parse() in JS? That would fully avoid any HTML entities.

Copy link
Member Author

@akirk akirk Jan 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or actually, right now:

  1. get_option()Alex&#039;s WordPress
  2. html_entity_decode()Alex's WordPress
  3. sanitizeForFilename()Alex-s-WordPress (apostrophe becomes -)

I just verified that WordPress stores the blogname via

case 'blogdescription':
case 'blogname':
    // ...
    $value = esc_html( $value );
    break;

Which itself uses _wp_specialchars which itself uses htmlspecialchars for which html_entity_decode should be appropriate.

Copy link
Collaborator

@adamziel adamziel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I left some notes, the blocking ones are filename sanitization, HTML decoding of the title, and the import question. The rest I think is ready for merging.

@akirk akirk force-pushed the persistent/backup-reminder branch from 7158776 to b72b187 Compare January 23, 2026 16:09
@akirk akirk requested a review from adamziel January 23, 2026 18:25
Adds a dropdown to the backup reminder UI that lets users schedule automatic
backup downloads on a daily, every-2-days, or weekly basis. When enabled,
the toolbar backup indicator is hidden and backups trigger automatically
after WordPress boots.
@akirk
Copy link
Member Author

akirk commented Jan 26, 2026

I realized that the button started to get annoying, so I added an auto-download (or ignore backup) dropdown:

Screenshot 2026-01-26 at 08 58 00 Screenshot 2026-01-26 at 08 55 47

Remove redundant daysUsedSinceLastBackup tracking. The backup indicator
now derives days since last backup directly from backupHistory timestamps,
which is simpler and more reliable.
const [showHistory, setShowHistory] = useState(false);
const importInputRef = useRef<HTMLInputElement>(null);

// TODO: Support local directory sites. With a directory handle, we could
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice comment

} from '../../lib/hooks/use-backup-constants';
import { isSameDay } from '../../lib/utils/date';

function getDaysSinceBackup(lastBackupTimestamp: number | undefined): number {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have some datetime formatting utils we could reuse here. Not a blocker.


const message = hasBackup
? 'Are you sure you want to start over? This will delete all your data and reset WordPress to a fresh install.'
: 'Are you sure you want to start over? You have never made a backup – all your data will be permanently lost.';
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice

return;
}

// Reset trigger flag when switching to a different site
Copy link
Collaborator

@adamziel adamziel Jan 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh, you can switch to a different site here? how? or is this just "defense in depth" in case someone finds a button we forgot to hide?

code: `<?php
require_once '/wordpress/wp-load.php';
$name = get_option('blogname', 'WordPress');
echo html_entity_decode($name, ENT_QUOTES, 'UTF-8');
Copy link
Collaborator

@adamziel adamziel Jan 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hm I can't see my previous comment, I'm pretty sure I've left it here 🤔 html_entity_decode is pretty bad, we'd be better off decoding in JS. I wouldn't say it's a blocker because of how the siteName is used, but changing it might save us some &amp literals.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot echo $name and decode the entity in JS in a secure way.

Copy link
Contributor

Copilot AI commented Jan 26, 2026

@adamziel I've opened a new pull request, #3190, to work on those changes. Once the pull request is ready, I'll request review from you.

@@ -0,0 +1,31 @@
export function getRelativeDate(inputDate: Date): string {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: we could extract this to a shared package and import in both website and personal-wp. Not blocking.

Copy link
Collaborator

@adamziel adamziel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM aside of the html encoding issue. I've asked copilot to adjust it. We can merge regardless.

@akirk akirk merged commit e64a06b into trunk Jan 27, 2026
35 checks passed
@akirk akirk deleted the persistent/backup-reminder branch January 27, 2026 12:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants