mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
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
102 lines
4.7 KiB
JavaScript
102 lines
4.7 KiB
JavaScript
'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`);
|
|
}
|
|
});
|