mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 22:33:12 -06:00
Tell people what to do next, from what the account actually contains
A user reported not knowing how to get content onto a screen. There was already onboarding — a modal wizard — but it is gated on a localStorage flag: skip it once and it never comes back, and it never knew whether you succeeded at anything. Someone who closed it was left with no thread to pull, which is exactly what was described. A second tour would repeat that mistake. Tours are dismissed and forgotten, and they describe the product rather than the account. This is a checklist on the dashboard that reads real state, so it cannot claim you have done something you have not, it is still there tomorrow, and it names the one thing to do next rather than everything the product can do. The steps are the shortest true path to a screen showing something: connect a screen, add content, put it in a playlist, send it to the screen. Only the last one cannot be satisfied by creating an object and walking away — a screen has to actually be pointed at something — so an account full of playlists with nothing playing is correctly reported as unfinished, which is the failure that was reported. Steps stay in dependency order, so nobody is sent to a page they cannot use yet. It disappears on its own once the first screen is live and can be hidden before then, so it never nags someone who already knows the product. Once hidden or finished it costs no extra request at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
This commit is contained in:
parent
ce7d8642fa
commit
68dd1b3e05
142
frontend/js/components/getting-started.js
Normal file
142
frontend/js/components/getting-started.js
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
// "What do I do next?" — answered from what the account ACTUALLY contains.
|
||||
//
|
||||
// There was already an onboarding wizard, but it is a one-time modal gated on a localStorage
|
||||
// flag: skip it once and it never returns, and it never knew whether you succeeded at anything.
|
||||
// Someone who closed it and then wondered how to get content on a screen had nothing left to go
|
||||
// on, which is exactly the confusion that got reported.
|
||||
//
|
||||
// A second tour would repeat that mistake. Tours are dismissed and forgotten, and they describe
|
||||
// the product rather than the account. This reads real state instead, so it cannot claim you have
|
||||
// done something you have not, it is still there tomorrow, and it disappears by itself once the
|
||||
// first screen is actually live — no nagging anyone who already knows the product.
|
||||
//
|
||||
// The steps are the shortest true path to a screen showing something: get a screen connected,
|
||||
// get media in, arrange it, put it on the screen.
|
||||
|
||||
import { t } from '../i18n.js';
|
||||
|
||||
// Pure: given what the account holds, which steps are done and which is next. Separated from the
|
||||
// DOM so the logic that decides "you are finished" is testable — a checklist that congratulates
|
||||
// you too early is worse than none.
|
||||
export function computeSteps({ devices = [], content = [], playlists = [] } = {}) {
|
||||
const hasDevice = devices.length > 0;
|
||||
const hasContent = content.length > 0;
|
||||
const hasPlaylist = playlists.length > 0;
|
||||
// "On screen" is the only step that cannot be faked by creating an object and walking away:
|
||||
// some screen has to actually be pointed at something.
|
||||
const isAssigned = devices.some((d) => d.playlist_id || d.default_content_id || d.layout_id);
|
||||
|
||||
const steps = [
|
||||
{
|
||||
key: 'device',
|
||||
done: hasDevice,
|
||||
title: t('gs.device.title'),
|
||||
desc: t('gs.device.desc'),
|
||||
cta: t('gs.device.cta'),
|
||||
href: '#/',
|
||||
action: 'add-device',
|
||||
},
|
||||
{
|
||||
key: 'content',
|
||||
done: hasContent,
|
||||
title: t('gs.content.title'),
|
||||
desc: t('gs.content.desc'),
|
||||
cta: t('gs.content.cta'),
|
||||
href: '#/content',
|
||||
},
|
||||
{
|
||||
key: 'playlist',
|
||||
done: hasPlaylist,
|
||||
title: t('gs.playlist.title'),
|
||||
desc: t('gs.playlist.desc'),
|
||||
cta: t('gs.playlist.cta'),
|
||||
href: '#/playlists',
|
||||
},
|
||||
{
|
||||
key: 'assign',
|
||||
done: isAssigned,
|
||||
title: t('gs.assign.title'),
|
||||
desc: t('gs.assign.desc'),
|
||||
cta: t('gs.assign.cta'),
|
||||
href: '#/',
|
||||
},
|
||||
];
|
||||
|
||||
// The NEXT step is the first unfinished one — in order, because each genuinely depends on the
|
||||
// one before it. Highlighting anything else would send someone to a screen they cannot use yet.
|
||||
const nextIndex = steps.findIndex((s) => !s.done);
|
||||
return {
|
||||
steps,
|
||||
nextIndex,
|
||||
complete: nextIndex === -1,
|
||||
doneCount: steps.filter((s) => s.done).length,
|
||||
};
|
||||
}
|
||||
|
||||
const DISMISS_KEY = 'rd_gs_dismissed';
|
||||
export const isDismissed = () => localStorage.getItem(DISMISS_KEY) === '1';
|
||||
export const dismiss = () => localStorage.setItem(DISMISS_KEY, '1');
|
||||
export const undismiss = () => localStorage.removeItem(DISMISS_KEY);
|
||||
|
||||
// Show it while there is still something to do and the user has not put it away. Deliberately
|
||||
// NOT gated on "is this a new account" — someone who has had the product a month and still has no
|
||||
// content is exactly who needs it.
|
||||
export function shouldShow(state) {
|
||||
return !state.complete && !isDismissed();
|
||||
}
|
||||
|
||||
export function render(host, state, { onAction } = {}) {
|
||||
if (!host) return;
|
||||
if (!shouldShow(state)) { host.innerHTML = ''; host.style.display = 'none'; return; }
|
||||
host.style.display = '';
|
||||
|
||||
const { steps, nextIndex, doneCount } = state;
|
||||
host.innerHTML = `
|
||||
<div style="border:1px solid var(--border);border-radius:var(--radius-lg);background:var(--bg-secondary);padding:16px;margin-bottom:16px">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px">
|
||||
<div>
|
||||
<div style="font-weight:600;font-size:15px">${t('gs.title')}</div>
|
||||
<div style="color:var(--text-muted);font-size:12px;margin-top:2px">${t('gs.progress').replace('{done}', doneCount).replace('{total}', steps.length)}</div>
|
||||
</div>
|
||||
<button class="btn btn-sm" id="gsDismiss" style="color:var(--text-muted)">${t('gs.dismiss')}</button>
|
||||
</div>
|
||||
<div style="height:4px;background:var(--bg-primary);border-radius:2px;overflow:hidden;margin-bottom:14px">
|
||||
<div style="height:100%;width:${(doneCount / steps.length) * 100}%;background:var(--accent,#3B82F6);transition:width .3s"></div>
|
||||
</div>
|
||||
<div style="display:grid;gap:8px">
|
||||
${steps.map((s, i) => {
|
||||
const isNext = i === nextIndex;
|
||||
return `
|
||||
<div style="display:flex;align-items:flex-start;gap:10px;padding:10px;border-radius:8px;
|
||||
${isNext ? 'background:var(--bg-primary);border:1px solid var(--accent,#3B82F6)' : 'border:1px solid transparent'}">
|
||||
<div style="flex:0 0 20px;height:20px;border-radius:50%;margin-top:1px;display:flex;align-items:center;justify-content:center;
|
||||
font-size:11px;font-weight:700;
|
||||
${s.done ? 'background:#22c55e;color:#fff' : isNext ? 'background:var(--accent,#3B82F6);color:#fff' : 'background:var(--bg-primary);color:var(--text-muted);border:1px solid var(--border)'}">
|
||||
${s.done ? '✓' : i + 1}
|
||||
</div>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-size:13px;font-weight:${isNext ? '600' : '500'};${s.done ? 'color:var(--text-muted);text-decoration:line-through' : ''}">${s.title}</div>
|
||||
${!s.done ? `<div style="color:var(--text-muted);font-size:12px;margin-top:2px">${s.desc}</div>` : ''}
|
||||
</div>
|
||||
${!s.done && isNext ? `<button class="btn btn-primary btn-sm" data-gs-step="${s.key}" style="flex:0 0 auto">${s.cta}</button>` : ''}
|
||||
</div>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
host.querySelector('#gsDismiss')?.addEventListener('click', () => {
|
||||
dismiss();
|
||||
host.innerHTML = '';
|
||||
host.style.display = 'none';
|
||||
});
|
||||
host.querySelectorAll('[data-gs-step]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const step = steps.find((s) => s.key === btn.dataset.gsStep);
|
||||
if (!step) return;
|
||||
// An in-page action (open the pairing dialog) beats navigating somewhere and leaving the
|
||||
// user to find the button again.
|
||||
if (step.action && onAction && onAction(step.action)) return;
|
||||
window.location.hash = step.href;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -1,6 +1,25 @@
|
|||
// English translations. This file is the source of truth for keys —
|
||||
// every other locale should mirror its keys (or fall back to en).
|
||||
export default {
|
||||
// Getting-started checklist (components/getting-started.js). Driven by real account state,
|
||||
// not a one-time flag, so it can tell someone what is actually left to do.
|
||||
'gs.title': 'Get your first screen live',
|
||||
'gs.progress': '{done} of {total} done',
|
||||
'gs.dismiss': 'Hide',
|
||||
'gs.device.title': 'Connect a screen',
|
||||
'gs.device.desc': 'Open the player on your display, then enter the code it shows.',
|
||||
'gs.device.cta': 'Add screen',
|
||||
'gs.content.title': 'Add some content',
|
||||
'gs.content.desc': 'Upload images or video, or add a web page or widget.',
|
||||
'gs.content.cta': 'Add content',
|
||||
'gs.playlist.title': 'Put content in a playlist',
|
||||
'gs.playlist.desc': 'A playlist is the running order your screen loops through.',
|
||||
'gs.playlist.cta': 'New playlist',
|
||||
'gs.assign.title': 'Send it to the screen',
|
||||
'gs.assign.desc': 'Open the screen and assign the playlist — it starts playing straight away.',
|
||||
'gs.assign.cta': 'Assign',
|
||||
'gs.reopen': 'Show getting-started checklist',
|
||||
|
||||
// #zone-orphan dashboard warnings
|
||||
'device.pl_item.orphan_zone': 'Zone from a different layout — reassign',
|
||||
'device.pl_item.orphan_zone_tip': "This item's zone isn't part of the device's current layout. It still plays (recovered into the largest zone), but reassign it to a zone in this layout.",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { on, off, requestScreenshot } from '../socket.js';
|
|||
import { showToast } from '../components/toast.js';
|
||||
import { esc, livenessBadge } from '../utils.js';
|
||||
import { t, tn } from '../i18n.js';
|
||||
import * as gettingStarted from '../components/getting-started.js';
|
||||
import { showDeviceOwnerQRModal } from '../components/device-owner-qr-modal.js';
|
||||
|
||||
const DESTRUCTIVE_COMMANDS = ['reboot', 'shutdown'];
|
||||
|
|
@ -273,7 +274,8 @@ export function render(container) {
|
|||
</button>
|
||||
<button class="btn btn-sm" id="clearSelectionBtn">Clear</button>
|
||||
</div>
|
||||
<div id="dashStats" class="dash-stats-row" style="display:flex;gap:12px;margin-bottom:16px"></div>
|
||||
<div id="gettingStarted"></div>
|
||||
<div id="dashStats" class="dash-stats-row" style="display:flex;gap:12px;margin-bottom:16px"></div>
|
||||
<div style="display:flex;gap:12px;margin-bottom:16px;align-items:center">
|
||||
<input type="text" id="deviceSearch" class="input" placeholder="${t('dashboard.search')}" style="max-width:300px">
|
||||
<select id="deviceFilter" class="input" style="width:180px;background:var(--bg-input)">
|
||||
|
|
@ -522,6 +524,23 @@ async function loadDashboard() {
|
|||
for (const d of rawDevices) seen.set(d.id, d);
|
||||
const devices = Array.from(seen.values());
|
||||
|
||||
// Getting started. Skipped entirely once put away or finished, so the extra content
|
||||
// lookup only ever happens for an account that still has something left to do.
|
||||
const gsHost = document.getElementById('gettingStarted');
|
||||
if (gsHost && !gettingStarted.isDismissed()) {
|
||||
try {
|
||||
const content = await api.getContent();
|
||||
const state = gettingStarted.computeSteps({ devices, content: content || [], playlists: playlists || [] });
|
||||
if (state.complete) gettingStarted.dismiss(); // finished: never costs a fetch again
|
||||
gettingStarted.render(gsHost, state, {
|
||||
onAction: (a) => {
|
||||
if (a === 'add-device') { document.getElementById('addDeviceBtn')?.click(); return true; }
|
||||
return false;
|
||||
},
|
||||
});
|
||||
} catch (_) { /* guidance must never break the dashboard */ }
|
||||
}
|
||||
|
||||
// Stats
|
||||
const online = devices.filter(d => d.status === 'online').length;
|
||||
const offline = devices.filter(d => d.status === 'offline').length;
|
||||
|
|
|
|||
101
server/test/getting-started-checklist.test.js
Normal file
101
server/test/getting-started-checklist.test.js
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
'use strict';
|
||||
|
||||
// A user reported not knowing how to get content onto a screen. There WAS onboarding — a modal
|
||||
// wizard — but it is gated on a localStorage flag: skip it once and it never returns, and it
|
||||
// never knew whether you actually succeeded at anything. Someone who closed it was left with no
|
||||
// thread to pull.
|
||||
//
|
||||
// The replacement reads the account's real state instead of a flag. That is the property worth
|
||||
// pinning: it must not congratulate someone who has not finished, must not nag someone who has,
|
||||
// and must point at the FIRST thing that is actually possible rather than the first thing missing.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const { pathToFileURL } = require('node:url');
|
||||
|
||||
// The component and the i18n module it imports both read localStorage — that is not incidental,
|
||||
// it is how the dismissal persists. Give them a real one rather than stubbing the behaviour out,
|
||||
// so the dismissal tests below exercise the actual mechanism.
|
||||
const store = new Map();
|
||||
globalThis.localStorage = {
|
||||
getItem: (k) => (store.has(k) ? store.get(k) : null),
|
||||
setItem: (k, v) => store.set(k, String(v)),
|
||||
removeItem: (k) => store.delete(k),
|
||||
clear: () => store.clear(),
|
||||
};
|
||||
globalThis.navigator = globalThis.navigator || { language: 'en' };
|
||||
|
||||
const MOD = pathToFileURL(path.join(__dirname, '..', '..', 'frontend', 'js', 'components', 'getting-started.js')).href;
|
||||
let GS;
|
||||
test('load', async () => {
|
||||
GS = await import(MOD);
|
||||
assert.ok(typeof GS.computeSteps === 'function');
|
||||
});
|
||||
|
||||
const withDevice = [{ id: 'd1' }];
|
||||
const assignedDevice = [{ id: 'd1', playlist_id: 'p1' }];
|
||||
|
||||
test('an empty account is at step one, and step one is the screen', async () => {
|
||||
const s = GS.computeSteps({ devices: [], content: [], playlists: [] });
|
||||
assert.equal(s.doneCount, 0);
|
||||
assert.equal(s.complete, false);
|
||||
assert.equal(s.steps[s.nextIndex].key, 'device', 'nothing else is possible until a screen exists');
|
||||
});
|
||||
|
||||
test('progress reflects what is really there, not what was clicked through', async () => {
|
||||
const s = GS.computeSteps({ devices: withDevice, content: [{ id: 'c1' }], playlists: [] });
|
||||
assert.equal(s.doneCount, 2);
|
||||
assert.equal(s.steps[s.nextIndex].key, 'playlist');
|
||||
});
|
||||
|
||||
test('THE POINT: it is only finished when something is actually ON a screen', async () => {
|
||||
// Creating a playlist and walking away is the exact failure the report described — plenty of
|
||||
// objects, nothing playing. That must NOT read as complete.
|
||||
const almost = GS.computeSteps({ devices: withDevice, content: [{ id: 'c1' }], playlists: [{ id: 'p1' }] });
|
||||
assert.equal(almost.complete, false, 'objects exist but no screen is showing anything');
|
||||
assert.equal(almost.steps[almost.nextIndex].key, 'assign');
|
||||
|
||||
const done = GS.computeSteps({ devices: assignedDevice, content: [{ id: 'c1' }], playlists: [{ id: 'p1' }] });
|
||||
assert.equal(done.complete, true);
|
||||
assert.equal(done.nextIndex, -1);
|
||||
});
|
||||
|
||||
test('any of the three ways of assigning counts', async () => {
|
||||
for (const d of [{ id: 'x', playlist_id: 'p' }, { id: 'x', default_content_id: 'c' }, { id: 'x', layout_id: 'l' }]) {
|
||||
const s = GS.computeSteps({ devices: [d], content: [{ id: 'c' }], playlists: [{ id: 'p' }] });
|
||||
assert.equal(s.complete, true, `${Object.keys(d).join(',')} should count as assigned`);
|
||||
}
|
||||
});
|
||||
|
||||
test('steps stay in dependency order — never sent somewhere unusable', async () => {
|
||||
// Content before a screen exists is not wrong, but it cannot be PUT anywhere, so the next
|
||||
// action must remain the screen.
|
||||
const s = GS.computeSteps({ devices: [], content: [{ id: 'c1' }], playlists: [{ id: 'p1' }] });
|
||||
assert.equal(s.steps[s.nextIndex].key, 'device');
|
||||
});
|
||||
|
||||
test('it shows while there is work left and hides once finished', async () => {
|
||||
GS.undismiss();
|
||||
assert.equal(GS.shouldShow(GS.computeSteps({ devices: [], content: [], playlists: [] })), true);
|
||||
assert.equal(GS.shouldShow(GS.computeSteps({ devices: assignedDevice, content: [{ id: 'c' }], playlists: [{ id: 'p' }] })), false,
|
||||
'a finished account is never nagged');
|
||||
});
|
||||
|
||||
test('dismissing sticks — it is guidance, not a demand', async () => {
|
||||
GS.undismiss();
|
||||
const empty = GS.computeSteps({ devices: [], content: [], playlists: [] });
|
||||
assert.equal(GS.shouldShow(empty), true);
|
||||
GS.dismiss();
|
||||
assert.equal(GS.shouldShow(empty), false, 'stays hidden even with everything still to do');
|
||||
GS.undismiss();
|
||||
assert.equal(GS.shouldShow(empty), true, 'and can be brought back');
|
||||
});
|
||||
|
||||
test('every step offers a way to act on it', async () => {
|
||||
const s = GS.computeSteps({ devices: [], content: [], playlists: [] });
|
||||
for (const step of s.steps) {
|
||||
assert.ok(step.title && step.desc && step.cta, `${step.key} is explained`);
|
||||
assert.ok(step.href, `${step.key} goes somewhere`);
|
||||
}
|
||||
});
|
||||
Loading…
Reference in a new issue