@cat-factory/app 0.213.1 → 0.215.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +107 -13
- package/app/components/observability/StepMetricsBar.vue +11 -0
- package/app/components/panels/AgentStepDetail.vue +14 -0
- package/app/components/panels/MergerResultView.vue +20 -2
- package/app/components/panels/ObservabilityPanel.vue +57 -0
- package/app/components/panels/ResultWindowShell.vue +77 -0
- package/app/components/panels/StepReproductionReport.vue +167 -0
- package/app/components/pipeline/BinaryOutputStepPicker.vue +25 -0
- package/app/components/tutorial/TutorialCatalogue.vue +14 -1
- package/app/components/tutorial/TutorialNudge.vue +107 -0
- package/app/components/tutorial/TutorialOverlay.vue +92 -11
- package/app/composables/api/execution.ts +5 -2
- package/app/composables/api/tutorial.ts +25 -0
- package/app/composables/useApi.ts +2 -0
- package/app/composables/usePipelineErrorToast.ts +4 -0
- package/app/composables/useTutorialNudge.ts +77 -0
- package/app/composables/useTutorialSync.ts +141 -0
- package/app/modular/external-tools.spec.ts +1 -0
- package/app/modular/nav-contributions.spec.ts +2 -0
- package/app/modular/nav-contributions.ts +11 -0
- package/app/modular/nav-gates.ts +10 -0
- package/app/modular/registry.spec.ts +1 -0
- package/app/modular/tutorial-tours.spec.ts +55 -4
- package/app/modular/tutorial-tours.ts +231 -9
- package/app/pages/index.vue +20 -1
- package/app/stores/tutorial.prompt.ts +59 -0
- package/app/stores/tutorial.record.ts +191 -0
- package/app/stores/tutorial.spec.ts +207 -0
- package/app/stores/tutorial.ts +78 -91
- package/app/stores/workspace/hydrate.ts +5 -0
- package/app/types/domain.ts +3 -0
- package/app/types/reproduction.ts +11 -0
- package/app/utils/binaryOutput.spec.ts +56 -2
- package/app/utils/binaryOutput.ts +55 -32
- package/app/utils/observability.spec.ts +44 -1
- package/app/utils/observability.ts +50 -0
- package/app/utils/reproduction.ts +51 -0
- package/app/utils/tutorial.spec.ts +255 -0
- package/app/utils/tutorial.ts +173 -0
- package/i18n/locales/de.json +148 -6
- package/i18n/locales/en.json +153 -6
- package/i18n/locales/es.json +148 -6
- package/i18n/locales/fr.json +148 -6
- package/i18n/locales/he.json +148 -6
- package/i18n/locales/it.json +148 -6
- package/i18n/locales/ja.json +148 -6
- package/i18n/locales/pl.json +148 -6
- package/i18n/locales/tr.json +148 -6
- package/i18n/locales/uk.json +148 -6
- package/package.json +2 -2
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { ref } from 'vue'
|
|
2
|
+
|
|
3
|
+
/** The launch-prompt answer. `null` = never answered, so the app asks again next launch. */
|
|
4
|
+
export type TutorialDecision = 'accepted' | 'declined'
|
|
5
|
+
|
|
6
|
+
/** The server's copy of a user's record, as the snapshot delivers it. */
|
|
7
|
+
export interface RemoteTutorialRecord {
|
|
8
|
+
decision: TutorialDecision | null
|
|
9
|
+
completedTourIds: readonly string[]
|
|
10
|
+
nudgedTourIds: readonly string[]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The PERSISTED tier of the tutorial's state: the standing record of what this PERSON has done.
|
|
15
|
+
*
|
|
16
|
+
* Extracted from `stores/tutorial.ts` along the boundary that store's own docblock already draws.
|
|
17
|
+
* Everything here outlives a visit and is mirrored to the signed-in user's server row; everything
|
|
18
|
+
* left behind (the prompt, the catalogue, the step cursor, the resume point, which offer is on
|
|
19
|
+
* screen) is session state that a reload is right to discard. Splitting on that line also put every
|
|
20
|
+
* piece of the sync story in one module: the union rule, what counts as "this browser knows more",
|
|
21
|
+
* and the flag the mirror acts on.
|
|
22
|
+
*
|
|
23
|
+
* A factory rather than a second Pinia store, because the two tiers are one store's worth of state
|
|
24
|
+
* to every consumer: the returned refs go straight into the setup store's state, so
|
|
25
|
+
* `persist: { pick: [...] }` and every existing `tutorial.completedTourIds` read are unchanged.
|
|
26
|
+
*/
|
|
27
|
+
export function createTutorialRecord() {
|
|
28
|
+
/** The saved launch-prompt answer. Written only by an explicit accept/decline/reset. */
|
|
29
|
+
const decision = ref<TutorialDecision | null>(null)
|
|
30
|
+
/** Ids of tours the user finished (reached the last step's Done). */
|
|
31
|
+
const completedTourIds = ref<string[]>([])
|
|
32
|
+
/**
|
|
33
|
+
* Ids the CONTEXTUAL offer has already been spent on (see `newlyAvailableTour`). Persisted beside
|
|
34
|
+
* the completions, because "we have mentioned this once" is a fact about the person, not about
|
|
35
|
+
* this visit: re-offering the parked-run walkthrough on every reload is a nag, and the gates it
|
|
36
|
+
* watches flip several times per run.
|
|
37
|
+
*/
|
|
38
|
+
const nudgedTourIds = ref<string[]>([])
|
|
39
|
+
/**
|
|
40
|
+
* This browser holds progress the server row does not, so the mirror owes it a write.
|
|
41
|
+
*
|
|
42
|
+
* Session-lived and a FLAG rather than a direct call, because the merge below runs inside the
|
|
43
|
+
* snapshot fan-out, which is synchronous and store-only. `useTutorialSync` watches it.
|
|
44
|
+
*/
|
|
45
|
+
const serverPushNeeded = ref(false)
|
|
46
|
+
/**
|
|
47
|
+
* Counts LOCAL changes to this record, and nothing else. Session-lived.
|
|
48
|
+
*
|
|
49
|
+
* The mirror watches this rather than the state itself, and the distinction is the whole reason
|
|
50
|
+
* it exists: adopting the server's ids in {@link mergeServerProgress} also changes the state, so
|
|
51
|
+
* a watcher over the state pushes the server's own row straight back at it on every fresh-browser
|
|
52
|
+
* board load. Counting only what the USER did means a write happens exactly when this browser has
|
|
53
|
+
* something to say.
|
|
54
|
+
*/
|
|
55
|
+
const localRev = ref(0)
|
|
56
|
+
/**
|
|
57
|
+
* The local `decision` has changed and has not been mirrored yet, so it must survive a snapshot.
|
|
58
|
+
*
|
|
59
|
+
* Without this, a failed mirror write silently UNDOES the user's answer: they click "No thanks",
|
|
60
|
+
* the push fails, and the next snapshot re-adopts the older `accepted` from the server row,
|
|
61
|
+
* re-arming every contextual offer they just declined. The server row wins on `decision` because
|
|
62
|
+
* it is the shared record of the latest answer, but only where this browser is not itself holding
|
|
63
|
+
* a newer one that the server has not seen.
|
|
64
|
+
*/
|
|
65
|
+
const decisionDirty = ref(false)
|
|
66
|
+
|
|
67
|
+
const isCompleted = (tourId: string) => completedTourIds.value.includes(tourId)
|
|
68
|
+
const wasNudged = (tourId: string) => nudgedTourIds.value.includes(tourId)
|
|
69
|
+
|
|
70
|
+
/** Record a finished walkthrough. Idempotent: the same tour taken twice is still one entry. */
|
|
71
|
+
function markCompleted(tourId: string) {
|
|
72
|
+
if (isCompleted(tourId)) return
|
|
73
|
+
completedTourIds.value = [...completedTourIds.value, tourId]
|
|
74
|
+
localRev.value += 1
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Spend the contextual offer for a tour. Idempotent, so an offer can never be made twice. */
|
|
78
|
+
function markNudged(tourId: string) {
|
|
79
|
+
if (wasNudged(tourId)) return
|
|
80
|
+
nudgedTourIds.value = [...nudgedTourIds.value, tourId]
|
|
81
|
+
localRev.value += 1
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Record an answer to the launch prompt, and ask the mirror to carry it. */
|
|
85
|
+
function answer(next: TutorialDecision) {
|
|
86
|
+
if (decision.value === next) return
|
|
87
|
+
decision.value = next
|
|
88
|
+
decisionDirty.value = true
|
|
89
|
+
localRev.value += 1
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Starting a tour IS accepting the tutorial, however the user got there. */
|
|
93
|
+
function acceptOffer() {
|
|
94
|
+
answer('accepted')
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The explicit "no thanks": saved, so the launch prompt never auto-opens again. */
|
|
98
|
+
function declineOffer() {
|
|
99
|
+
answer('declined')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Fold the signed-in user's SERVER copy into this browser's, and report whether the local copy
|
|
104
|
+
* held anything the server did not.
|
|
105
|
+
*
|
|
106
|
+
* A union rather than a replace, in the same direction and for the same reason the server merges:
|
|
107
|
+
* both lists are grow-only sets of things that HAPPENED, so neither side is ever right to un-say
|
|
108
|
+
* one. A replace here would lose a tour finished while the mirror write was failing; a replace on
|
|
109
|
+
* the server would lose a tour finished on another machine. `decision` is taken from the server
|
|
110
|
+
* when it HAS one and this browser is not holding an un-mirrored answer of its own
|
|
111
|
+
* ({@link decisionDirty}), because it is a preference someone re-answers rather than an
|
|
112
|
+
* accumulating fact — while a server row with no answer is not evidence that the local answer
|
|
113
|
+
* never happened.
|
|
114
|
+
*
|
|
115
|
+
* The return value closes the loop: `true` means this browser knows something the server does not,
|
|
116
|
+
* so the merged state is worth pushing back. Recomputing that comparison at the call site would be
|
|
117
|
+
* a second copy of the union rule. It is also what makes the server's un-guarded merge safe: the
|
|
118
|
+
* PUT's response goes through here too, so a merge that lost a concurrent writer's ids comes back
|
|
119
|
+
* missing something local, and the re-push is automatic rather than hoped for.
|
|
120
|
+
*/
|
|
121
|
+
function mergeServerProgress(remote: RemoteTutorialRecord | null): boolean {
|
|
122
|
+
// No server copy at all (no accounts, no store wired, or the read degraded) is NOT a reason to
|
|
123
|
+
// push: there is nothing to reconcile against, and treating an absent row as an empty one would
|
|
124
|
+
// make every such board load write a mirror nothing reads.
|
|
125
|
+
if (!remote) return false
|
|
126
|
+
const union = (mine: string[], theirs: readonly string[]) => [...new Set([...theirs, ...mine])]
|
|
127
|
+
const completed = union(completedTourIds.value, remote.completedTourIds)
|
|
128
|
+
const nudged = union(nudgedTourIds.value, remote.nudgedTourIds)
|
|
129
|
+
const keepLocalDecision = decisionDirty.value || remote.decision === null
|
|
130
|
+
const localOnly =
|
|
131
|
+
completed.length > remote.completedTourIds.length ||
|
|
132
|
+
nudged.length > remote.nudgedTourIds.length ||
|
|
133
|
+
(keepLocalDecision && decision.value !== remote.decision)
|
|
134
|
+
completedTourIds.value = completed
|
|
135
|
+
nudgedTourIds.value = nudged
|
|
136
|
+
if (!keepLocalDecision) decision.value = remote.decision
|
|
137
|
+
if (localOnly) serverPushNeeded.value = true
|
|
138
|
+
return localOnly
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The mirror has caught up: this browser's state is on the server, including its answer.
|
|
143
|
+
*
|
|
144
|
+
* Called BEFORE the response is reconciled, so that a response missing something local can flip
|
|
145
|
+
* {@link serverPushNeeded} back on and re-trigger the watcher. Clearing it after would look
|
|
146
|
+
* identical and would swallow exactly the retry that matters.
|
|
147
|
+
*/
|
|
148
|
+
function markServerPushed() {
|
|
149
|
+
serverPushNeeded.value = false
|
|
150
|
+
decisionDirty.value = false
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Forget the whole record, including the answered offer.
|
|
155
|
+
*
|
|
156
|
+
* The decision goes with it deliberately: "Reset" is asked for by someone handing the app to a
|
|
157
|
+
* colleague, demoing it, or re-walking the product after it changed, and every one of those wants
|
|
158
|
+
* the first-launch experience back, which a cleared completion list alone does not restore. The
|
|
159
|
+
* spent contextual offers go too, or a board that has already run something would never make
|
|
160
|
+
* those offers again to the colleague the app was just handed to.
|
|
161
|
+
*
|
|
162
|
+
* Deliberately does NOT bump {@link localRev}, and cancels any pending mirror write. The server
|
|
163
|
+
* side of a reset is a DELETE, and a PUT of the freshly-emptied state racing it would re-create
|
|
164
|
+
* the row the DELETE just removed — leaving "reset it" distinguishable from "never touched the
|
|
165
|
+
* tutorial", which is the one thing the reset has to get right.
|
|
166
|
+
*/
|
|
167
|
+
function reset() {
|
|
168
|
+
completedTourIds.value = []
|
|
169
|
+
nudgedTourIds.value = []
|
|
170
|
+
decision.value = null
|
|
171
|
+
serverPushNeeded.value = false
|
|
172
|
+
decisionDirty.value = false
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
decision,
|
|
177
|
+
completedTourIds,
|
|
178
|
+
nudgedTourIds,
|
|
179
|
+
serverPushNeeded,
|
|
180
|
+
localRev,
|
|
181
|
+
isCompleted,
|
|
182
|
+
wasNudged,
|
|
183
|
+
markCompleted,
|
|
184
|
+
markNudged,
|
|
185
|
+
acceptOffer,
|
|
186
|
+
declineOffer,
|
|
187
|
+
mergeServerProgress,
|
|
188
|
+
markServerPushed,
|
|
189
|
+
reset,
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -121,10 +121,16 @@ describe('useTutorialStore catalogue', () => {
|
|
|
121
121
|
tutorial.setStepIndex(2)
|
|
122
122
|
tutorial.stopTour()
|
|
123
123
|
|
|
124
|
+
tutorial.offerNudge('answer-park')
|
|
124
125
|
tutorial.resetProgress()
|
|
125
126
|
expect(tutorial.completedTourIds).toEqual([])
|
|
126
127
|
expect(tutorial.interruptedAt('run-task')).toBeNull()
|
|
127
128
|
expect(tutorial.decision).toBeNull()
|
|
129
|
+
// The spent contextual offers go too, or a board that has already run something would
|
|
130
|
+
// never make them again to the colleague the app was just handed to.
|
|
131
|
+
expect(tutorial.nudgedTourIds).toEqual([])
|
|
132
|
+
expect(tutorial.pendingNudgeId).toBeNull()
|
|
133
|
+
expect(tutorial.wasNudged('answer-park')).toBe(false)
|
|
128
134
|
})
|
|
129
135
|
|
|
130
136
|
it('leaves a running tour alone when progress is reset', () => {
|
|
@@ -300,3 +306,204 @@ describe('useTutorialStore resuming a broken-off tour', () => {
|
|
|
300
306
|
expect(tutorial.interruptedAt('gone-away')).toBeNull()
|
|
301
307
|
})
|
|
302
308
|
})
|
|
309
|
+
|
|
310
|
+
describe('useTutorialStore contextual offer', () => {
|
|
311
|
+
it('raises the offer and spends the id in one act', () => {
|
|
312
|
+
// The guard against re-offering is the PERSISTED list, not the visible state, so an offer
|
|
313
|
+
// cannot be made twice however many times the live gates flip.
|
|
314
|
+
const tutorial = useTutorialStore()
|
|
315
|
+
tutorial.offerNudge('answer-park')
|
|
316
|
+
expect(tutorial.pendingNudgeId).toBe('answer-park')
|
|
317
|
+
expect(tutorial.wasNudged('answer-park')).toBe(true)
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
it('never re-raises an offer already spent, even once dismissed', () => {
|
|
321
|
+
const tutorial = useTutorialStore()
|
|
322
|
+
tutorial.offerNudge('answer-park')
|
|
323
|
+
tutorial.dismissNudge()
|
|
324
|
+
expect(tutorial.pendingNudgeId).toBeNull()
|
|
325
|
+
tutorial.offerNudge('answer-park')
|
|
326
|
+
expect(tutorial.pendingNudgeId).toBeNull()
|
|
327
|
+
expect(tutorial.nudgedTourIds).toEqual(['answer-park'])
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it('holds one offer at a time, keeping the newer arrival', () => {
|
|
331
|
+
const tutorial = useTutorialStore()
|
|
332
|
+
tutorial.offerNudge('answer-park')
|
|
333
|
+
tutorial.offerNudge('diagnose-failure')
|
|
334
|
+
expect(tutorial.pendingNudgeId).toBe('diagnose-failure')
|
|
335
|
+
expect(tutorial.nudgedTourIds).toEqual(['answer-park', 'diagnose-failure'])
|
|
336
|
+
})
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
describe('useTutorialStore server reconciliation', () => {
|
|
340
|
+
it('unions the server copy into the local one, keeping what only this browser knew', () => {
|
|
341
|
+
// Both lists are grow-only sets of things that HAPPENED, so neither side may un-say one. A
|
|
342
|
+
// replace here loses a tour finished while the mirror write was failing.
|
|
343
|
+
const tutorial = useTutorialStore()
|
|
344
|
+
tutorial.startTour('board-basics')
|
|
345
|
+
tutorial.completeTour()
|
|
346
|
+
const pushNeeded = tutorial.mergeServerProgress({
|
|
347
|
+
decision: 'accepted',
|
|
348
|
+
completedTourIds: ['first-task'],
|
|
349
|
+
nudgedTourIds: [],
|
|
350
|
+
})
|
|
351
|
+
expect(tutorial.completedTourIds).toEqual(['first-task', 'board-basics'])
|
|
352
|
+
// This browser held something the server did not, so the mirror owes it a write.
|
|
353
|
+
expect(pushNeeded).toBe(true)
|
|
354
|
+
expect(tutorial.serverPushNeeded).toBe(true)
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
it('asks for no push when the server copy is already a superset', () => {
|
|
358
|
+
// The ordinary reload. Writing anyway would mean every board load posted a mirror nobody reads.
|
|
359
|
+
const tutorial = useTutorialStore()
|
|
360
|
+
expect(
|
|
361
|
+
tutorial.mergeServerProgress({
|
|
362
|
+
decision: 'accepted',
|
|
363
|
+
completedTourIds: ['board-basics'],
|
|
364
|
+
nudgedTourIds: ['answer-park'],
|
|
365
|
+
}),
|
|
366
|
+
).toBe(false)
|
|
367
|
+
expect(tutorial.completedTourIds).toEqual(['board-basics'])
|
|
368
|
+
expect(tutorial.serverPushNeeded).toBe(false)
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
it('never duplicates an id both sides already hold', () => {
|
|
372
|
+
const tutorial = useTutorialStore()
|
|
373
|
+
tutorial.startTour('board-basics')
|
|
374
|
+
tutorial.completeTour()
|
|
375
|
+
tutorial.mergeServerProgress({
|
|
376
|
+
decision: null,
|
|
377
|
+
completedTourIds: ['board-basics'],
|
|
378
|
+
nudgedTourIds: [],
|
|
379
|
+
})
|
|
380
|
+
expect(tutorial.completedTourIds).toEqual(['board-basics'])
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
it('takes the server decision, but never clears a local one with an absent row', () => {
|
|
384
|
+
// The decision is a preference someone re-answers, so the shared record wins when it has an
|
|
385
|
+
// answer. A server row with no answer is not evidence that the local answer never happened.
|
|
386
|
+
const tutorial = useTutorialStore()
|
|
387
|
+
tutorial.decline()
|
|
388
|
+
tutorial.markServerPushed()
|
|
389
|
+
tutorial.mergeServerProgress({
|
|
390
|
+
decision: null,
|
|
391
|
+
completedTourIds: [],
|
|
392
|
+
nudgedTourIds: [],
|
|
393
|
+
})
|
|
394
|
+
expect(tutorial.decision).toBe('declined')
|
|
395
|
+
expect(tutorial.serverPushNeeded).toBe(true)
|
|
396
|
+
|
|
397
|
+
tutorial.markServerPushed()
|
|
398
|
+
tutorial.mergeServerProgress({
|
|
399
|
+
decision: 'accepted',
|
|
400
|
+
completedTourIds: [],
|
|
401
|
+
nudgedTourIds: [],
|
|
402
|
+
})
|
|
403
|
+
expect(tutorial.decision).toBe('accepted')
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
it('holds an UN-MIRRORED local answer against the server, so a failed push cannot undo it', () => {
|
|
407
|
+
// The mirror is best-effort, so a decline can be sitting in this browser and nowhere else. If
|
|
408
|
+
// the snapshot re-adopted the older server answer, "No thanks" would silently come back as
|
|
409
|
+
// "accepted" and every contextual offer the user just declined would re-arm.
|
|
410
|
+
const tutorial = useTutorialStore()
|
|
411
|
+
tutorial.decline()
|
|
412
|
+
expect(
|
|
413
|
+
tutorial.mergeServerProgress({
|
|
414
|
+
decision: 'accepted',
|
|
415
|
+
completedTourIds: [],
|
|
416
|
+
nudgedTourIds: [],
|
|
417
|
+
}),
|
|
418
|
+
).toBe(true)
|
|
419
|
+
expect(tutorial.decision).toBe('declined')
|
|
420
|
+
|
|
421
|
+
// Once the answer HAS been mirrored, the shared record is authoritative again: another machine
|
|
422
|
+
// re-answering is a real change, and this browser has nothing newer to defend.
|
|
423
|
+
tutorial.markServerPushed()
|
|
424
|
+
tutorial.mergeServerProgress({ decision: 'accepted', completedTourIds: [], nudgedTourIds: [] })
|
|
425
|
+
expect(tutorial.decision).toBe('accepted')
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
it('treats an absent server copy as nothing to reconcile, not as an empty one', () => {
|
|
429
|
+
// No accounts, no store wired, or a degraded read. Pushing here would write a mirror on a
|
|
430
|
+
// deployment that has nowhere to put it; clearing would be worse.
|
|
431
|
+
const tutorial = useTutorialStore()
|
|
432
|
+
tutorial.startTour('board-basics')
|
|
433
|
+
tutorial.completeTour()
|
|
434
|
+
expect(tutorial.mergeServerProgress(null)).toBe(false)
|
|
435
|
+
expect(tutorial.completedTourIds).toEqual(['board-basics'])
|
|
436
|
+
expect(tutorial.serverPushNeeded).toBe(false)
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
it('clears the push request once the mirror has caught up', () => {
|
|
440
|
+
const tutorial = useTutorialStore()
|
|
441
|
+
tutorial.startTour('board-basics')
|
|
442
|
+
tutorial.completeTour()
|
|
443
|
+
tutorial.mergeServerProgress({ decision: null, completedTourIds: [], nudgedTourIds: [] })
|
|
444
|
+
expect(tutorial.serverPushNeeded).toBe(true)
|
|
445
|
+
tutorial.markServerPushed()
|
|
446
|
+
expect(tutorial.serverPushNeeded).toBe(false)
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
it('re-arms the push when a merge RESPONSE comes back missing something local', () => {
|
|
450
|
+
// What makes the server's un-guarded merge safe. Two concurrent merges can lose one writer's
|
|
451
|
+
// ids, and the loser finds out because the response it gets back is a row without them. The
|
|
452
|
+
// ordering is the load-bearing part: the push is marked done FIRST, so re-arming flips the flag
|
|
453
|
+
// and re-triggers the mirror rather than being swallowed by a later clear.
|
|
454
|
+
const tutorial = useTutorialStore()
|
|
455
|
+
tutorial.startTour('board-basics')
|
|
456
|
+
tutorial.completeTour()
|
|
457
|
+
tutorial.markServerPushed()
|
|
458
|
+
expect(tutorial.serverPushNeeded).toBe(false)
|
|
459
|
+
tutorial.mergeServerProgress({
|
|
460
|
+
decision: 'accepted',
|
|
461
|
+
completedTourIds: ['first-task'],
|
|
462
|
+
nudgedTourIds: [],
|
|
463
|
+
})
|
|
464
|
+
expect(tutorial.serverPushNeeded).toBe(true)
|
|
465
|
+
})
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
describe('useTutorialStore local revision (what the mirror watches)', () => {
|
|
469
|
+
it('counts only what the USER did, never what the server told us', () => {
|
|
470
|
+
// The mirror watches this instead of the state, because adopting the server's own ids is a
|
|
471
|
+
// state change too: watching the state posts the server's row straight back at it on every
|
|
472
|
+
// fresh-browser board load.
|
|
473
|
+
const tutorial = useTutorialStore()
|
|
474
|
+
const before = tutorial.localRev
|
|
475
|
+
tutorial.mergeServerProgress({
|
|
476
|
+
decision: 'accepted',
|
|
477
|
+
completedTourIds: ['board-basics', 'first-task'],
|
|
478
|
+
nudgedTourIds: ['answer-park'],
|
|
479
|
+
})
|
|
480
|
+
expect(tutorial.completedTourIds).toEqual(['board-basics', 'first-task'])
|
|
481
|
+
expect(tutorial.localRev).toBe(before)
|
|
482
|
+
|
|
483
|
+
tutorial.startTour('run-task')
|
|
484
|
+
tutorial.completeTour()
|
|
485
|
+
expect(tutorial.localRev).toBeGreaterThan(before)
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
it('does not bump on a re-recorded completion, an unchanged answer, or a reset', () => {
|
|
489
|
+
const tutorial = useTutorialStore()
|
|
490
|
+
// Two real changes: the accept `startTour` writes, and the completion.
|
|
491
|
+
tutorial.startTour('board-basics')
|
|
492
|
+
tutorial.completeTour()
|
|
493
|
+
const settled = tutorial.localRev
|
|
494
|
+
expect(settled).toBe(2)
|
|
495
|
+
|
|
496
|
+
// Repeating the same tour changes nothing that is persisted: the answer is already 'accepted'
|
|
497
|
+
// and the completion is already recorded, so there is nothing for the mirror to carry.
|
|
498
|
+
tutorial.startTour('board-basics')
|
|
499
|
+
tutorial.completeTour()
|
|
500
|
+
expect(tutorial.localRev).toBe(settled)
|
|
501
|
+
|
|
502
|
+
// A reset's server side is a DELETE. A push of the freshly-emptied state racing it would
|
|
503
|
+
// re-create the row the DELETE removed, leaving "reset it" distinguishable from "never touched
|
|
504
|
+
// the tutorial" — the one thing the reset has to get right.
|
|
505
|
+
tutorial.resetProgress()
|
|
506
|
+
expect(tutorial.localRev).toBe(settled)
|
|
507
|
+
expect(tutorial.serverPushNeeded).toBe(false)
|
|
508
|
+
})
|
|
509
|
+
})
|
package/app/stores/tutorial.ts
CHANGED
|
@@ -1,41 +1,46 @@
|
|
|
1
1
|
import { defineStore } from 'pinia'
|
|
2
2
|
import { computed, ref } from 'vue'
|
|
3
|
+
import { createTutorialPrompt } from '~/stores/tutorial.prompt'
|
|
4
|
+
import { createTutorialRecord } from '~/stores/tutorial.record'
|
|
3
5
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
+
// `TutorialDecision` is deliberately NOT re-exported from here even though it used to live here:
|
|
7
|
+
// both store modules are auto-import sources, so two exports of one name make Nuxt drop one of them
|
|
8
|
+
// with a build warning. It is reachable unqualified anywhere in the layer, from `tutorial.record.ts`
|
|
9
|
+
// where it is declared beside the state it describes.
|
|
6
10
|
|
|
7
11
|
/**
|
|
8
12
|
* The in-app tutorial state: the launch-prompt decision, per-tour completion, and the
|
|
9
13
|
* live progress of whichever tour is running.
|
|
10
14
|
*
|
|
11
|
-
* Two tiers of state, split on purpose:
|
|
15
|
+
* Two tiers of state, split on purpose — and the split is now a real seam rather than a comment:
|
|
12
16
|
*
|
|
13
|
-
* - PERSISTED (`decision`, `completedTourIds`) — the
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
17
|
+
* - PERSISTED (`decision`, `completedTourIds`, `nudgedTourIds`) — the standing record of what this
|
|
18
|
+
* PERSON has done, owned by `createTutorialRecord` (`stores/tutorial.record.ts`) along with the
|
|
19
|
+
* server-reconciliation rules that go with it. `null` (never asked) is a real state distinct from
|
|
20
|
+
* `declined`: only an explicit answer stops the launch prompt returning, while closing it without
|
|
21
|
+
* answering merely defers it to the next launch.
|
|
22
|
+
* - SESSION-ONLY (`promptOpen`, `catalogueOpen`, `activeTourId`, `stepIndex`, `pendingNudgeId`) —
|
|
23
|
+
* owned here. A tour is anchored to live DOM, so replaying progress across a reload would point
|
|
24
|
+
* step N at a board that hasn't reached that state; a reloaded tour restarts from its beginning.
|
|
25
|
+
*
|
|
26
|
+
* The persisted half is a per-person fact rather than a per-browser one, so it is MIRRORED to the
|
|
27
|
+
* signed-in user's server row when the deployment has accounts (`useTutorialSync`), with this store
|
|
28
|
+
* staying the local cache the SPA reads. Persisting it here as well is not redundancy: it is what a
|
|
29
|
+
* deployment with auth disabled, and every load before the snapshot lands, runs on.
|
|
22
30
|
*
|
|
23
31
|
* The store deliberately knows nothing about WHICH tours exist: the catalog lives in the
|
|
24
|
-
* `tutorialTours` slot (see `modular/tutorial-tours.ts`), so tours ship and evolve — first-
|
|
25
|
-
*
|
|
26
|
-
*
|
|
32
|
+
* `tutorialTours` slot (see `modular/tutorial-tours.ts`), so tours ship and evolve — first-party and
|
|
33
|
+
* consumer-contributed alike — without this store changing. It tracks ids and a step cursor; the
|
|
34
|
+
* overlay resolves definitions and drives the cursor.
|
|
27
35
|
*/
|
|
28
36
|
export const useTutorialStore = defineStore(
|
|
29
37
|
'tutorial',
|
|
30
38
|
() => {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
const promptOpen = ref(false)
|
|
37
|
-
/** Once-per-session guard for the launch auto-open; later opens are user-driven. */
|
|
38
|
-
const promptAutoOpened = ref(false)
|
|
39
|
+
const record = createTutorialRecord()
|
|
40
|
+
// The launch offer's own four-exit state machine (`stores/tutorial.prompt.ts`), which needs to
|
|
41
|
+
// know only WHETHER an answer exists.
|
|
42
|
+
const prompt = createTutorialPrompt({ hasDecision: () => record.decision.value !== null })
|
|
43
|
+
const { promptOpen } = prompt
|
|
39
44
|
/**
|
|
40
45
|
* The tutorial catalogue (every tour this deployment ships, startable at any time) is
|
|
41
46
|
* open. Always user-driven — nothing auto-opens it — which is why it carries none of the
|
|
@@ -58,6 +63,21 @@ export const useTutorialStore = defineStore(
|
|
|
58
63
|
*/
|
|
59
64
|
const interrupted = ref<{ tourId: string; stepIndex: number } | null>(null)
|
|
60
65
|
|
|
66
|
+
/**
|
|
67
|
+
* The tour the contextual offer is currently holding out, or null.
|
|
68
|
+
*
|
|
69
|
+
* Session state, and separate from `nudgedTourIds` on purpose: the id is marked as SPENT the
|
|
70
|
+
* moment the offer is made, while what is on SCREEN survives being suppressed. The two most
|
|
71
|
+
* valuable moments to offer a tour (a run parked, a run failed) routinely arrive while a
|
|
72
|
+
* tutorial window or another tour is up, and dropping the offer there would lose the one chance
|
|
73
|
+
* this mechanism gets. Holding it means it appears as soon as the way is clear.
|
|
74
|
+
*
|
|
75
|
+
* The trade is deliberate: a reload before it is ever shown burns the offer. That beats the
|
|
76
|
+
* alternative of re-arming it, which turns one missed moment into a prompt that keeps coming
|
|
77
|
+
* back on a board whose gates flip constantly.
|
|
78
|
+
*/
|
|
79
|
+
const pendingNudgeId = ref<string | null>(null)
|
|
80
|
+
|
|
61
81
|
/** A tour is currently running (the overlay mounts off this). */
|
|
62
82
|
const touring = computed(() => activeTourId.value !== null)
|
|
63
83
|
|
|
@@ -77,43 +97,6 @@ export const useTutorialStore = defineStore(
|
|
|
77
97
|
*/
|
|
78
98
|
const ownWindowOpen = computed(() => promptOpen.value || catalogueOpen.value)
|
|
79
99
|
|
|
80
|
-
/**
|
|
81
|
-
* Auto-open the launch prompt, at most once per session and only while the user has
|
|
82
|
-
* never answered it. Callers gate on the rest of the launch context (board ready, no
|
|
83
|
-
* other startup advisory open) — see `pages/index.vue`.
|
|
84
|
-
*/
|
|
85
|
-
function maybeOfferOnLaunch() {
|
|
86
|
-
if (decision.value !== null || promptAutoOpened.value) return
|
|
87
|
-
promptAutoOpened.value = true
|
|
88
|
-
promptOpen.value = true
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** User-driven open (command palette), regardless of any saved decision. */
|
|
92
|
-
function openPrompt() {
|
|
93
|
-
promptOpen.value = true
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Withdraw an offer this store made, because something the user actually has to answer
|
|
98
|
-
* (a startup advisory, the GitHub onboarding gate) opened on top of it — and re-arm, so
|
|
99
|
-
* the offer returns once that surface is gone. Distinct from {@link closePrompt}: no
|
|
100
|
-
* decision is written EITHER way, but a deferral was not the user's doing, so it must
|
|
101
|
-
* not consume this session's one offer.
|
|
102
|
-
*
|
|
103
|
-
* Only ever withdraws the auto-opened prompt; a prompt the user opened themselves from
|
|
104
|
-
* the palette is theirs to close.
|
|
105
|
-
*/
|
|
106
|
-
function deferPrompt() {
|
|
107
|
-
if (!promptAutoOpened.value) return
|
|
108
|
-
promptOpen.value = false
|
|
109
|
-
promptAutoOpened.value = false
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** Close without answering: no decision is written, so the next launch asks again. */
|
|
113
|
-
function closePrompt() {
|
|
114
|
-
promptOpen.value = false
|
|
115
|
-
}
|
|
116
|
-
|
|
117
100
|
/**
|
|
118
101
|
* Open the catalogue. Closes the launch prompt WITHOUT answering it: browsing the full
|
|
119
102
|
* list is not "no thanks" (it is the opposite), and the two are modals that would
|
|
@@ -131,7 +114,7 @@ export const useTutorialStore = defineStore(
|
|
|
131
114
|
|
|
132
115
|
/** The explicit "no thanks": saved, so the launch prompt never auto-opens again. */
|
|
133
116
|
function decline() {
|
|
134
|
-
|
|
117
|
+
record.declineOffer()
|
|
135
118
|
promptOpen.value = false
|
|
136
119
|
}
|
|
137
120
|
|
|
@@ -141,7 +124,7 @@ export const useTutorialStore = defineStore(
|
|
|
141
124
|
* leaving `declined` in place would misdescribe what happened).
|
|
142
125
|
*/
|
|
143
126
|
function startTour(tourId: string) {
|
|
144
|
-
|
|
127
|
+
record.acceptOffer()
|
|
145
128
|
promptOpen.value = false
|
|
146
129
|
catalogueOpen.value = false
|
|
147
130
|
activeTourId.value = tourId
|
|
@@ -171,7 +154,7 @@ export const useTutorialStore = defineStore(
|
|
|
171
154
|
startTour(tourId)
|
|
172
155
|
return
|
|
173
156
|
}
|
|
174
|
-
|
|
157
|
+
record.acceptOffer()
|
|
175
158
|
promptOpen.value = false
|
|
176
159
|
catalogueOpen.value = false
|
|
177
160
|
activeTourId.value = tourId
|
|
@@ -211,9 +194,7 @@ export const useTutorialStore = defineStore(
|
|
|
211
194
|
/** Finish the running tour: record completion (idempotent) and clear the cursor. */
|
|
212
195
|
function completeTour() {
|
|
213
196
|
const id = activeTourId.value
|
|
214
|
-
if (id
|
|
215
|
-
completedTourIds.value = [...completedTourIds.value, id]
|
|
216
|
-
}
|
|
197
|
+
if (id) record.markCompleted(id)
|
|
217
198
|
// A finished tour has no position left to resume, and an offer to resume the walkthrough
|
|
218
199
|
// the user just completed would sit beside its own Completed badge.
|
|
219
200
|
if (id !== null && interrupted.value?.tourId === id) interrupted.value = null
|
|
@@ -225,45 +206,50 @@ export const useTutorialStore = defineStore(
|
|
|
225
206
|
return interrupted.value?.tourId === tourId ? interrupted.value.stepIndex : null
|
|
226
207
|
}
|
|
227
208
|
|
|
228
|
-
|
|
229
|
-
|
|
209
|
+
/**
|
|
210
|
+
* Hold out the contextual offer for a tour that just became takeable.
|
|
211
|
+
*
|
|
212
|
+
* Spending the id and raising the offer are ONE action, so an offer can never be made twice
|
|
213
|
+
* however many times the gates flip: the guard is the persisted list, not the visible state.
|
|
214
|
+
* Idempotent, so a caller re-evaluating the catalogue needs no check of its own.
|
|
215
|
+
*/
|
|
216
|
+
function offerNudge(tourId: string) {
|
|
217
|
+
if (record.wasNudged(tourId)) return
|
|
218
|
+
record.markNudged(tourId)
|
|
219
|
+
pendingNudgeId.value = tourId
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Take the offer off screen. It is already spent, so it does not come back. */
|
|
223
|
+
function dismissNudge() {
|
|
224
|
+
pendingNudgeId.value = null
|
|
230
225
|
}
|
|
231
226
|
|
|
232
227
|
/**
|
|
233
|
-
* Forget everything this browser remembers about the tutorial
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
* The decision goes with it deliberately. "Reset" is asked for by someone handing the app
|
|
237
|
-
* to a colleague, demoing it, or re-walking the product after it changed — and every one
|
|
238
|
-
* of those wants the first-launch experience back, which a cleared completion list alone
|
|
239
|
-
* does not restore. It does NOT re-open the prompt in this session: `promptAutoOpened` is
|
|
240
|
-
* session state and stays spent, so the offer returns at the next launch rather than
|
|
241
|
-
* appearing on top of the catalogue the user is still reading.
|
|
228
|
+
* Forget everything this browser remembers about the tutorial (see `record.reset`), plus the
|
|
229
|
+
* offer currently on screen.
|
|
242
230
|
*
|
|
243
|
-
*
|
|
244
|
-
* the
|
|
231
|
+
* It does NOT re-open the prompt in this session: `promptAutoOpened` is session state and stays
|
|
232
|
+
* spent, so the offer returns at the next launch rather than appearing on top of the catalogue
|
|
233
|
+
* the user is still reading. A running tour is left alone: this clears a record, it does not
|
|
234
|
+
* interrupt a walkthrough the user is in the middle of (which would end it, unrecorded, on a
|
|
235
|
+
* click about history).
|
|
245
236
|
*/
|
|
246
237
|
function resetProgress() {
|
|
247
|
-
|
|
238
|
+
record.reset()
|
|
248
239
|
interrupted.value = null
|
|
249
|
-
|
|
240
|
+
pendingNudgeId.value = null
|
|
250
241
|
}
|
|
251
242
|
|
|
252
243
|
return {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
promptOpen,
|
|
256
|
-
promptAutoOpened,
|
|
244
|
+
...record,
|
|
245
|
+
...prompt,
|
|
257
246
|
catalogueOpen,
|
|
258
247
|
activeTourId,
|
|
259
248
|
stepIndex,
|
|
260
249
|
interrupted,
|
|
250
|
+
pendingNudgeId,
|
|
261
251
|
touring,
|
|
262
252
|
ownWindowOpen,
|
|
263
|
-
maybeOfferOnLaunch,
|
|
264
|
-
openPrompt,
|
|
265
|
-
closePrompt,
|
|
266
|
-
deferPrompt,
|
|
267
253
|
openCatalogue,
|
|
268
254
|
closeCatalogue,
|
|
269
255
|
resetProgress,
|
|
@@ -273,9 +259,10 @@ export const useTutorialStore = defineStore(
|
|
|
273
259
|
setStepIndex,
|
|
274
260
|
stopTour,
|
|
275
261
|
completeTour,
|
|
276
|
-
isCompleted,
|
|
277
262
|
interruptedAt,
|
|
263
|
+
offerNudge,
|
|
264
|
+
dismissNudge,
|
|
278
265
|
}
|
|
279
266
|
},
|
|
280
|
-
{ persist: { pick: ['decision', 'completedTourIds'] } },
|
|
267
|
+
{ persist: { pick: ['decision', 'completedTourIds', 'nudgedTourIds'] } },
|
|
281
268
|
)
|