handzon-core 0.6.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.
Files changed (89) hide show
  1. package/package.json +74 -0
  2. package/src/collections.ts +150 -0
  3. package/src/components/Footer.astro +85 -0
  4. package/src/components/Navbar.astro +74 -0
  5. package/src/components/Progress.tsx +36 -0
  6. package/src/components/Sidebar.astro +162 -0
  7. package/src/components/StepNav.astro +107 -0
  8. package/src/components/ai/ByokSetup.tsx +90 -0
  9. package/src/components/ai/ChatButton.tsx +30 -0
  10. package/src/components/ai/ChatPanel.tsx +244 -0
  11. package/src/components/auth/SignInButton.astro +41 -0
  12. package/src/components/auth/UserMenu.astro +79 -0
  13. package/src/components/auth/UserMenu.tsx +136 -0
  14. package/src/components/home/FilterBar.tsx +152 -0
  15. package/src/components/home/Hero.astro +60 -0
  16. package/src/components/home/Pagination.tsx +89 -0
  17. package/src/components/home/ResumeRail.tsx +50 -0
  18. package/src/components/home/TutorialCard.astro +185 -0
  19. package/src/components/mdx/Callout.astro +77 -0
  20. package/src/components/mdx/Checkpoint.astro +14 -0
  21. package/src/components/mdx/Checkpoint.tsx +49 -0
  22. package/src/components/mdx/Diff.astro +6 -0
  23. package/src/components/mdx/Diff.tsx +100 -0
  24. package/src/components/mdx/Download.astro +37 -0
  25. package/src/components/mdx/Embed.astro +56 -0
  26. package/src/components/mdx/File.astro +28 -0
  27. package/src/components/mdx/FileTree.astro +6 -0
  28. package/src/components/mdx/FileTree.tsx +71 -0
  29. package/src/components/mdx/Hint.astro +51 -0
  30. package/src/components/mdx/Mermaid.astro +6 -0
  31. package/src/components/mdx/Mermaid.tsx +47 -0
  32. package/src/components/mdx/Playground.astro +6 -0
  33. package/src/components/mdx/Playground.tsx +34 -0
  34. package/src/components/mdx/Quiz.astro +6 -0
  35. package/src/components/mdx/Quiz.tsx +102 -0
  36. package/src/components/mdx/Recap.astro +65 -0
  37. package/src/components/mdx/Reveal.astro +7 -0
  38. package/src/components/mdx/Reveal.tsx +25 -0
  39. package/src/components/mdx/Step.astro +12 -0
  40. package/src/components/mdx/Steps.astro +40 -0
  41. package/src/components/mdx/Tab.astro +22 -0
  42. package/src/components/mdx/Tabs.astro +67 -0
  43. package/src/components/mdx/Terminal.astro +6 -0
  44. package/src/components/mdx/Terminal.tsx +47 -0
  45. package/src/index.ts +55 -0
  46. package/src/layouts/BaseLayout.astro +112 -0
  47. package/src/layouts/TutorialLayout.astro +218 -0
  48. package/src/lib/ai/client.ts +92 -0
  49. package/src/lib/ai/context.ts +97 -0
  50. package/src/lib/content.ts +73 -0
  51. package/src/lib/mdx-components.ts +47 -0
  52. package/src/lib/progress/local.ts +89 -0
  53. package/src/lib/progress/remote.ts +199 -0
  54. package/src/lib/progress/types.ts +63 -0
  55. package/src/lib/progress/useProgress.ts +117 -0
  56. package/src/lib/rehype-mermaid-passthrough.ts +31 -0
  57. package/src/pages/Home.astro +408 -0
  58. package/src/pages/TutorialLanding.astro +324 -0
  59. package/src/pages/TutorialStep.astro +67 -0
  60. package/src/pages/paths.ts +36 -0
  61. package/src/server/auth/config.ts +102 -0
  62. package/src/server/auth/schema.ts +66 -0
  63. package/src/server/auth/session.ts +27 -0
  64. package/src/server/auth.ts +127 -0
  65. package/src/server/db/client.ts +14 -0
  66. package/src/server/db/migrate.ts +29 -0
  67. package/src/server/db/schema.ts +65 -0
  68. package/src/server/handlers/healthz.ts +6 -0
  69. package/src/server/handlers/progress.ts +90 -0
  70. package/src/server/handlers/tutorialStats.ts +67 -0
  71. package/src/server/http.ts +33 -0
  72. package/src/types/ai.ts +17 -0
  73. package/styles/base.css +127 -0
  74. package/styles/components/a11y.css +12 -0
  75. package/styles/components/byok.css +50 -0
  76. package/styles/components/chat.css +304 -0
  77. package/styles/components/checkpoint.css +49 -0
  78. package/styles/components/diff.css +44 -0
  79. package/styles/components/expressive-code.css +61 -0
  80. package/styles/components/filetree.css +68 -0
  81. package/styles/components/mermaid.css +19 -0
  82. package/styles/components/modal.css +25 -0
  83. package/styles/components/progress.css +19 -0
  84. package/styles/components/quiz.css +101 -0
  85. package/styles/components/reveal.css +25 -0
  86. package/styles/components/tabs.css +60 -0
  87. package/styles/components/terminal.css +55 -0
  88. package/styles/components.css +28 -0
  89. package/styles/global.css +15 -0
@@ -0,0 +1,97 @@
1
+ import type { StepEntry, TutorialEntry } from "../content";
2
+ import { parseStepId } from "../content";
3
+ import type { ProgressState } from "../progress/types";
4
+
5
+ export interface AssistantContext {
6
+ tutorial: {
7
+ slug: string;
8
+ title: string;
9
+ description: string;
10
+ difficulty: string;
11
+ tags: string[];
12
+ };
13
+ outline: Array<{ slug: string; title: string; completed: boolean; current: boolean }>;
14
+ currentStep: {
15
+ slug: string;
16
+ title: string;
17
+ source: string;
18
+ };
19
+ priorSteps: Array<{ slug: string; title: string; source: string }>;
20
+ progress: {
21
+ completed: string[];
22
+ quizzes: Array<{ id: string; correct: boolean }>;
23
+ checkpoints: string[];
24
+ };
25
+ references: Array<{ source: string; content: string }>;
26
+ }
27
+
28
+ interface BuildOptions {
29
+ tutorial: TutorialEntry;
30
+ steps: StepEntry[];
31
+ currentStep: StepEntry;
32
+ progress: ProgressState;
33
+ references?: Array<{ source: string; content: string }>;
34
+ includeFutureSteps?: boolean;
35
+ }
36
+
37
+ /**
38
+ * Assemble the per-request context. Prior-step bodies are inlined verbatim
39
+ * so the assistant has the same view of the work as the learner. Future
40
+ * steps are excluded by default to avoid spoilers.
41
+ */
42
+ export function buildContext({
43
+ tutorial,
44
+ steps,
45
+ currentStep,
46
+ progress,
47
+ references = [],
48
+ includeFutureSteps = false,
49
+ }: BuildOptions): AssistantContext {
50
+ const slug = tutorial.id;
51
+ const currentIdx = steps.findIndex((s) => s.id === currentStep.id);
52
+ const { stepSlug: currentSlug } = parseStepId(currentStep.id);
53
+
54
+ const outline = steps.map((s) => {
55
+ const { stepSlug } = parseStepId(s.id);
56
+ return {
57
+ slug: stepSlug,
58
+ title: s.data.title,
59
+ completed: progress.steps[`${slug}/${stepSlug}`] === "complete",
60
+ current: stepSlug === currentSlug,
61
+ };
62
+ });
63
+
64
+ const priorSteps = steps
65
+ .slice(0, includeFutureSteps ? steps.length : currentIdx)
66
+ .filter((s) => s.id !== currentStep.id)
67
+ .map((s) => ({
68
+ slug: parseStepId(s.id).stepSlug,
69
+ title: s.data.title,
70
+ source: s.body ?? "",
71
+ }));
72
+
73
+ return {
74
+ tutorial: {
75
+ slug,
76
+ title: tutorial.data.title,
77
+ description: tutorial.data.description,
78
+ difficulty: tutorial.data.difficulty,
79
+ tags: tutorial.data.tags,
80
+ },
81
+ outline,
82
+ currentStep: {
83
+ slug: currentSlug,
84
+ title: currentStep.data.title,
85
+ source: currentStep.body ?? "",
86
+ },
87
+ priorSteps,
88
+ progress: {
89
+ completed: Object.entries(progress.steps)
90
+ .filter(([k, v]) => k.startsWith(`${slug}/`) && v === "complete")
91
+ .map(([k]) => k.split("/").slice(1).join("/")),
92
+ quizzes: Object.entries(progress.quizzes).map(([id, q]) => ({ id, correct: q.correct })),
93
+ checkpoints: Object.keys(progress.checkpoints),
94
+ },
95
+ references,
96
+ };
97
+ }
@@ -0,0 +1,73 @@
1
+ import { type CollectionEntry, getCollection } from "astro:content";
2
+
3
+ export type TutorialEntry = CollectionEntry<"tutorials">;
4
+ export type StepEntry = CollectionEntry<"steps">;
5
+
6
+ const STEP_PREFIX = /^(\d+)-(.+)$/;
7
+
8
+ /**
9
+ * Parse a step's collection id ("react-todo/01-setup") into its parts.
10
+ * Tutorial slug is the verbatim first path segment; step ordering comes
11
+ * from the file's numeric prefix.
12
+ */
13
+ export function parseStepId(id: string): { tutorialSlug: string; stepSlug: string; order: number } {
14
+ const slash = id.indexOf("/");
15
+ if (slash < 0) {
16
+ throw new Error(`Unrecognized step id: ${id}`);
17
+ }
18
+ const tutorialSlug = id.slice(0, slash);
19
+ const stepFile = id.slice(slash + 1);
20
+ if (!tutorialSlug || !stepFile) {
21
+ throw new Error(`Unrecognized step id: ${id}`);
22
+ }
23
+ const stepMatch = STEP_PREFIX.exec(stepFile);
24
+ return {
25
+ tutorialSlug,
26
+ stepSlug: stepMatch?.[2] ?? stepFile,
27
+ order: stepMatch?.[1] ? Number.parseInt(stepMatch[1], 10) : 0,
28
+ };
29
+ }
30
+
31
+ export async function getTutorials(): Promise<TutorialEntry[]> {
32
+ const all = await getCollection("tutorials");
33
+ return all.sort((a, b) => {
34
+ const ao = (a.data as { order?: number }).order ?? 0;
35
+ const bo = (b.data as { order?: number }).order ?? 0;
36
+ return ao - bo;
37
+ });
38
+ }
39
+
40
+ export async function getTutorialBySlug(slug: string): Promise<TutorialEntry | undefined> {
41
+ const all = await getCollection("tutorials");
42
+ return all.find((t) => t.id === slug);
43
+ }
44
+
45
+ export async function getStepsForTutorial(slug: string): Promise<StepEntry[]> {
46
+ const all = await getCollection("steps");
47
+ return all
48
+ .filter((s) => parseStepId(s.id).tutorialSlug === slug)
49
+ .sort((a, b) => parseStepId(a.id).order - parseStepId(b.id).order);
50
+ }
51
+
52
+ export async function getStep(
53
+ tutorialSlug: string,
54
+ stepSlug: string,
55
+ ): Promise<StepEntry | undefined> {
56
+ const steps = await getStepsForTutorial(tutorialSlug);
57
+ return steps.find((s) => parseStepId(s.id).stepSlug === stepSlug);
58
+ }
59
+
60
+ /**
61
+ * "5 min" + "3 min" → "8 min". Falls back to undefined if any step is missing duration.
62
+ */
63
+ export function sumDurations(steps: StepEntry[]): string | undefined {
64
+ let total = 0;
65
+ for (const step of steps) {
66
+ const dur = step.data.duration;
67
+ if (!dur) return undefined;
68
+ const match = /(\d+)\s*min/i.exec(dur);
69
+ if (!match?.[1]) return undefined;
70
+ total += Number.parseInt(match[1], 10);
71
+ }
72
+ return total > 0 ? `${total} min` : undefined;
73
+ }
@@ -0,0 +1,47 @@
1
+ import Callout from "../components/mdx/Callout.astro";
2
+ import Checkpoint from "../components/mdx/Checkpoint.astro";
3
+ import Diff from "../components/mdx/Diff.astro";
4
+ import Download from "../components/mdx/Download.astro";
5
+ import Embed from "../components/mdx/Embed.astro";
6
+ import File from "../components/mdx/File.astro";
7
+ import FileTree from "../components/mdx/FileTree.astro";
8
+ import Hint from "../components/mdx/Hint.astro";
9
+ import Mermaid from "../components/mdx/Mermaid.astro";
10
+ import Playground from "../components/mdx/Playground.astro";
11
+ import Quiz from "../components/mdx/Quiz.astro";
12
+ import Recap from "../components/mdx/Recap.astro";
13
+ import Reveal from "../components/mdx/Reveal.astro";
14
+ import StepCmp from "../components/mdx/Step.astro";
15
+ import StepsCmp from "../components/mdx/Steps.astro";
16
+ import Tab from "../components/mdx/Tab.astro";
17
+ import Tabs from "../components/mdx/Tabs.astro";
18
+ import Terminal from "../components/mdx/Terminal.astro";
19
+
20
+ /**
21
+ * The components map passed to <Content components={...} />. Every React
22
+ * island has an .astro wrapper that pre-binds the hydration directive
23
+ * (Astro's MDX render call can't apply client:* itself; the wrapper does).
24
+ * Checkpoint reads its host route from a DOM marker, not props.
25
+ */
26
+ export function mdxComponents() {
27
+ return {
28
+ Callout,
29
+ Hint,
30
+ Steps: StepsCmp,
31
+ Step: StepCmp,
32
+ File,
33
+ Recap,
34
+ Embed,
35
+ Download,
36
+ Tabs,
37
+ Tab,
38
+ FileTree,
39
+ Reveal,
40
+ Terminal,
41
+ Mermaid,
42
+ Diff,
43
+ Quiz,
44
+ Checkpoint,
45
+ Playground,
46
+ };
47
+ }
@@ -0,0 +1,89 @@
1
+ import { createRemoteStore } from "./remote";
2
+ import {
3
+ CHANNEL_NAME,
4
+ emptyState,
5
+ type ProgressState,
6
+ type ProgressStore,
7
+ STORAGE_KEY,
8
+ } from "./types";
9
+
10
+ const isBrowser = typeof window !== "undefined";
11
+
12
+ function readStorage(): ProgressState {
13
+ if (!isBrowser) return emptyState();
14
+ try {
15
+ const raw = window.localStorage.getItem(STORAGE_KEY);
16
+ if (!raw) return emptyState();
17
+ const parsed = JSON.parse(raw) as Partial<ProgressState>;
18
+ return { ...emptyState(), ...parsed };
19
+ } catch {
20
+ return emptyState();
21
+ }
22
+ }
23
+
24
+ function writeStorage(state: ProgressState): void {
25
+ if (!isBrowser) return;
26
+ try {
27
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
28
+ } catch {
29
+ // quota or private mode — silently swallow
30
+ }
31
+ }
32
+
33
+ export function createLocalStore(): ProgressStore {
34
+ let state: ProgressState = readStorage();
35
+ const subscribers = new Set<(s: ProgressState) => void>();
36
+ let channel: BroadcastChannel | null = null;
37
+
38
+ if (isBrowser && typeof BroadcastChannel !== "undefined") {
39
+ channel = new BroadcastChannel(CHANNEL_NAME);
40
+ channel.addEventListener("message", (event: MessageEvent) => {
41
+ if (event.data?.type === "set") {
42
+ state = event.data.state as ProgressState;
43
+ for (const fn of subscribers) fn(state);
44
+ }
45
+ });
46
+ }
47
+
48
+ if (isBrowser) {
49
+ window.addEventListener("storage", (e) => {
50
+ if (e.key !== STORAGE_KEY) return;
51
+ state = readStorage();
52
+ for (const fn of subscribers) fn(state);
53
+ });
54
+ }
55
+
56
+ return {
57
+ get: () => state,
58
+ set: (updater) => {
59
+ state = updater(state);
60
+ writeStorage(state);
61
+ for (const fn of subscribers) fn(state);
62
+ channel?.postMessage({ type: "set", state });
63
+ },
64
+ subscribe: (fn) => {
65
+ subscribers.add(fn);
66
+ return () => {
67
+ subscribers.delete(fn);
68
+ };
69
+ },
70
+ };
71
+ }
72
+
73
+ let singleton: ProgressStore | null = null;
74
+
75
+ /**
76
+ * Pick the right store based on PUBLIC_PROGRESS_BACKEND. Both stores
77
+ * are statically imported because dynamic `require()` doesn't exist in
78
+ * the browser ESM bundle (this file ships to the client) and a dynamic
79
+ * `import()` would force every call site to become async.
80
+ */
81
+ export function getStore(): ProgressStore {
82
+ if (singleton) return singleton;
83
+ if (isBrowser && import.meta.env.PUBLIC_PROGRESS_BACKEND === "remote") {
84
+ singleton = createRemoteStore();
85
+ } else {
86
+ singleton = createLocalStore();
87
+ }
88
+ return singleton;
89
+ }
@@ -0,0 +1,199 @@
1
+ import {
2
+ CHANNEL_NAME,
3
+ emptyState,
4
+ type ProgressState,
5
+ type ProgressStore,
6
+ STORAGE_KEY,
7
+ } from "./types";
8
+
9
+ const isBrowser = typeof window !== "undefined";
10
+
11
+ type ProgressEntry = {
12
+ kind: string;
13
+ scope: string;
14
+ key: string;
15
+ value: unknown;
16
+ updatedAt?: string;
17
+ };
18
+
19
+ const PENDING_KEY = "handzon:pending";
20
+
21
+ function readStorage(): ProgressState {
22
+ if (!isBrowser) return emptyState();
23
+ try {
24
+ const raw = window.localStorage.getItem(STORAGE_KEY);
25
+ if (!raw) return emptyState();
26
+ return { ...emptyState(), ...(JSON.parse(raw) as Partial<ProgressState>) };
27
+ } catch {
28
+ return emptyState();
29
+ }
30
+ }
31
+
32
+ function writeStorage(state: ProgressState): void {
33
+ if (!isBrowser) return;
34
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
35
+ }
36
+
37
+ function readPending(): ProgressEntry[] {
38
+ if (!isBrowser) return [];
39
+ try {
40
+ return JSON.parse(window.localStorage.getItem(PENDING_KEY) ?? "[]") as ProgressEntry[];
41
+ } catch {
42
+ return [];
43
+ }
44
+ }
45
+
46
+ function writePending(entries: ProgressEntry[]): void {
47
+ window.localStorage.setItem(PENDING_KEY, JSON.stringify(entries));
48
+ }
49
+
50
+ function diffState(prev: ProgressState, next: ProgressState): ProgressEntry[] {
51
+ const out: ProgressEntry[] = [];
52
+ for (const [key, value] of Object.entries(next.steps)) {
53
+ if (prev.steps[key as `${string}/${string}`] !== value) {
54
+ const [scope, k] = key.split("/") as [string, string];
55
+ out.push({ kind: "step", scope, key: k, value });
56
+ }
57
+ }
58
+ for (const [id, value] of Object.entries(next.quizzes)) {
59
+ if (JSON.stringify(prev.quizzes[id]) !== JSON.stringify(value)) {
60
+ out.push({ kind: "quiz", scope: "global", key: id, value });
61
+ }
62
+ }
63
+ for (const [id, value] of Object.entries(next.checkpoints)) {
64
+ if (!prev.checkpoints[id]) {
65
+ out.push({ kind: "checkpoint", scope: "global", key: id, value });
66
+ }
67
+ }
68
+ for (const [k, value] of Object.entries(next.prefs)) {
69
+ if ((prev.prefs as Record<string, unknown>)[k] !== value) {
70
+ out.push({ kind: "pref", scope: "global", key: k, value });
71
+ }
72
+ }
73
+ for (const [scope, value] of Object.entries(next.lastVisited)) {
74
+ const prevValue = prev.lastVisited[scope];
75
+ if (!prevValue || prevValue.step !== value.step || prevValue.ts !== value.ts) {
76
+ out.push({ kind: "lastVisited", scope, key: "step", value });
77
+ }
78
+ }
79
+ for (const [scope, marker] of Object.entries(next.tutorials)) {
80
+ const prevMarker = prev.tutorials[scope] ?? {};
81
+ if (marker.started && prevMarker.started !== marker.started) {
82
+ out.push({ kind: "tutorial", scope, key: "started", value: { ts: marker.started } });
83
+ }
84
+ if (marker.completed && prevMarker.completed !== marker.completed) {
85
+ out.push({ kind: "tutorial", scope, key: "completed", value: { ts: marker.completed } });
86
+ }
87
+ }
88
+ return out;
89
+ }
90
+
91
+ /**
92
+ * Same shape as the local store, but mirrors writes to /api/progress with
93
+ * debounced batching and an offline queue.
94
+ */
95
+ export function createRemoteStore(): ProgressStore {
96
+ let state: ProgressState = readStorage();
97
+ const subscribers = new Set<(s: ProgressState) => void>();
98
+ let channel: BroadcastChannel | null = null;
99
+ let flushTimer: ReturnType<typeof setTimeout> | null = null;
100
+
101
+ if (isBrowser && typeof BroadcastChannel !== "undefined") {
102
+ channel = new BroadcastChannel(CHANNEL_NAME);
103
+ channel.addEventListener("message", (event: MessageEvent) => {
104
+ if (event.data?.type === "set") {
105
+ state = event.data.state as ProgressState;
106
+ for (const fn of subscribers) fn(state);
107
+ }
108
+ });
109
+ }
110
+
111
+ async function flush() {
112
+ flushTimer = null;
113
+ const pending = readPending();
114
+ if (pending.length === 0) return;
115
+ try {
116
+ const res = await fetch("/api/progress", {
117
+ method: "POST",
118
+ headers: { "Content-Type": "application/json" },
119
+ body: JSON.stringify(pending),
120
+ credentials: "same-origin",
121
+ });
122
+ if (res.ok) writePending([]);
123
+ } catch {
124
+ // stay offline-queued
125
+ }
126
+ }
127
+
128
+ function scheduleFlush() {
129
+ if (flushTimer) clearTimeout(flushTimer);
130
+ flushTimer = setTimeout(flush, 750);
131
+ }
132
+
133
+ if (isBrowser) {
134
+ window.addEventListener("online", () => void flush());
135
+
136
+ // On first mount, pull the server's snapshot and merge in.
137
+ void (async () => {
138
+ try {
139
+ const res = await fetch("/api/progress", { credentials: "same-origin" });
140
+ if (!res.ok) return;
141
+ const { entries } = (await res.json()) as {
142
+ entries: Array<{ kind: string; scope: string; key: string; value: unknown }>;
143
+ };
144
+ const merged: ProgressState = { ...emptyState(), ...state };
145
+ for (const e of entries) {
146
+ if (e.kind === "step") {
147
+ merged.steps[`${e.scope}/${e.key}` as `${string}/${string}`] = e.value as
148
+ | "incomplete"
149
+ | "complete";
150
+ } else if (e.kind === "quiz") {
151
+ merged.quizzes[e.key] = e.value as ProgressState["quizzes"][string];
152
+ } else if (e.kind === "checkpoint") {
153
+ merged.checkpoints[e.key] = e.value as ProgressState["checkpoints"][string];
154
+ } else if (e.kind === "pref") {
155
+ (merged.prefs as Record<string, unknown>)[e.key] = e.value;
156
+ } else if (e.kind === "lastVisited") {
157
+ // Tolerate both new ({step, ts}) and legacy (string) shapes.
158
+ const v = e.value as unknown;
159
+ merged.lastVisited[e.scope] =
160
+ typeof v === "string" ? { step: v, ts: 0 } : (v as { step: string; ts: number });
161
+ } else if (e.kind === "tutorial") {
162
+ const v = (e.value as { ts?: number }) ?? {};
163
+ const marker = merged.tutorials[e.scope] ?? {};
164
+ if (e.key === "started") marker.started = v.ts;
165
+ else if (e.key === "completed") marker.completed = v.ts;
166
+ merged.tutorials[e.scope] = marker;
167
+ }
168
+ }
169
+ state = merged;
170
+ writeStorage(state);
171
+ for (const fn of subscribers) fn(state);
172
+ } catch {
173
+ // ignore — local data still drives the UI
174
+ }
175
+ })();
176
+ }
177
+
178
+ return {
179
+ get: () => state,
180
+ set: (updater) => {
181
+ const prev = state;
182
+ state = updater(state);
183
+ writeStorage(state);
184
+ const entries = diffState(prev, state);
185
+ if (entries.length > 0) {
186
+ writePending([...readPending(), ...entries]);
187
+ scheduleFlush();
188
+ }
189
+ for (const fn of subscribers) fn(state);
190
+ channel?.postMessage({ type: "set", state });
191
+ },
192
+ subscribe: (fn) => {
193
+ subscribers.add(fn);
194
+ return () => {
195
+ subscribers.delete(fn);
196
+ };
197
+ },
198
+ };
199
+ }
@@ -0,0 +1,63 @@
1
+ export type StepKey = `${string}/${string}`;
2
+
3
+ export interface LastVisitedEntry {
4
+ step: string;
5
+ ts: number;
6
+ }
7
+
8
+ /**
9
+ * Local mirror of the per-learner "I started / finished this tutorial"
10
+ * markers. We keep them locally so the remote diff is idempotent (no
11
+ * duplicate POSTs when the user reloads). The server's composite PK
12
+ * makes re-sends harmless but spamming `/api/progress` is rude.
13
+ */
14
+ export interface TutorialMarker {
15
+ started?: number;
16
+ completed?: number;
17
+ }
18
+
19
+ export type ProgressState = {
20
+ steps: Record<StepKey, "incomplete" | "complete">;
21
+ quizzes: Record<string, { chosen: number[]; correct: boolean; ts: number }>;
22
+ checkpoints: Record<string, { ts: number }>;
23
+ prefs: {
24
+ packageManager?: "npm" | "pnpm" | "yarn" | "bun";
25
+ os?: "macos" | "linux" | "windows";
26
+ theme?: "light" | "dark";
27
+ };
28
+ /**
29
+ * Per-tutorial "where was I last?" marker. Tracks `ts` so consumers
30
+ * (like the ResumeRail) can pick the truly most-recent tutorial
31
+ * instead of relying on insertion order, which lies when an existing
32
+ * key is overwritten.
33
+ */
34
+ lastVisited: Record<string, LastVisitedEntry>;
35
+ /**
36
+ * Per-tutorial popularity-event markers. Cross-learner aggregates
37
+ * live on the server (`/api/tutorials/stats`); this map only tracks
38
+ * what *this* learner has emitted so we can dedupe.
39
+ */
40
+ tutorials: Record<string, TutorialMarker>;
41
+ };
42
+
43
+ export interface ProgressStore {
44
+ get(): ProgressState;
45
+ set(updater: (s: ProgressState) => ProgressState): void;
46
+ subscribe(fn: (s: ProgressState) => void): () => void;
47
+ }
48
+
49
+ export const emptyState = (): ProgressState => ({
50
+ steps: {},
51
+ quizzes: {},
52
+ checkpoints: {},
53
+ prefs: {},
54
+ lastVisited: {},
55
+ tutorials: {},
56
+ });
57
+
58
+ // TODO: when you change ProgressState's shape in a way that's not
59
+ // forward-compatible with the spread-merge in readStorage(), bump the
60
+ // version suffix and add a one-shot migration in `local.ts` that reads
61
+ // the old key, transforms it, and writes the new one.
62
+ export const STORAGE_KEY = "handzon:v1";
63
+ export const CHANNEL_NAME = "handzon:v1";
@@ -0,0 +1,117 @@
1
+ import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
2
+ import { getStore } from "./local";
3
+ import type { ProgressState, StepKey } from "./types";
4
+
5
+ interface ProgressApi {
6
+ state: ProgressState;
7
+ markStepComplete: (tutorial: string, step: string) => void;
8
+ markStepIncomplete: (tutorial: string, step: string) => void;
9
+ recordQuiz: (questionId: string, chosen: number[], correct: boolean) => void;
10
+ recordCheckpoint: (checkpointId: string) => void;
11
+ setPref: <K extends keyof ProgressState["prefs"]>(
12
+ key: K,
13
+ value: ProgressState["prefs"][K],
14
+ ) => void;
15
+ setLastVisited: (tutorial: string, step: string) => void;
16
+ markTutorialStarted: (tutorial: string) => void;
17
+ markTutorialCompleted: (tutorial: string) => void;
18
+ isStepComplete: (tutorial: string, step: string) => boolean;
19
+ }
20
+
21
+ /**
22
+ * Action methods are memoized in a useMemo with an empty dep list so their
23
+ * references stay stable across renders. Without this, every render produced
24
+ * a new `markStepComplete` (etc.) reference; consumers using these as effect
25
+ * deps would re-run effects forever, each call triggering a store update and
26
+ * another render — a freeze-the-tab render storm.
27
+ */
28
+ export function useProgress(): ProgressApi {
29
+ const store = getStore();
30
+ const state = useSyncExternalStore(
31
+ store.subscribe,
32
+ () => store.get(),
33
+ () => store.get(),
34
+ );
35
+
36
+ const actions = useMemo(() => {
37
+ const stepKey = (tutorial: string, step: string): StepKey => `${tutorial}/${step}`;
38
+ return {
39
+ markStepComplete: (tutorial: string, step: string) =>
40
+ store.set((s) => ({
41
+ ...s,
42
+ steps: { ...s.steps, [stepKey(tutorial, step)]: "complete" as const },
43
+ })),
44
+ markStepIncomplete: (tutorial: string, step: string) =>
45
+ store.set((s) => ({
46
+ ...s,
47
+ steps: { ...s.steps, [stepKey(tutorial, step)]: "incomplete" as const },
48
+ })),
49
+ recordQuiz: (questionId: string, chosen: number[], correct: boolean) =>
50
+ store.set((s) => ({
51
+ ...s,
52
+ quizzes: { ...s.quizzes, [questionId]: { chosen, correct, ts: Date.now() } },
53
+ })),
54
+ recordCheckpoint: (checkpointId: string) =>
55
+ store.set((s) => ({
56
+ ...s,
57
+ checkpoints: { ...s.checkpoints, [checkpointId]: { ts: Date.now() } },
58
+ })),
59
+ setPref: <K extends keyof ProgressState["prefs"]>(key: K, value: ProgressState["prefs"][K]) =>
60
+ store.set((s) => ({ ...s, prefs: { ...s.prefs, [key]: value } })),
61
+ setLastVisited: (tutorial: string, step: string) =>
62
+ store.set((s) => ({
63
+ ...s,
64
+ lastVisited: { ...s.lastVisited, [tutorial]: { step, ts: Date.now() } },
65
+ })),
66
+ markTutorialStarted: (tutorial: string) =>
67
+ store.set((s) => {
68
+ // Idempotent: bail if we already recorded this learner's
69
+ // "started" event so we don't spam /api/progress on every
70
+ // navigation.
71
+ if (s.tutorials[tutorial]?.started) return s;
72
+ return {
73
+ ...s,
74
+ tutorials: {
75
+ ...s.tutorials,
76
+ [tutorial]: { ...s.tutorials[tutorial], started: Date.now() },
77
+ },
78
+ };
79
+ }),
80
+ markTutorialCompleted: (tutorial: string) =>
81
+ store.set((s) => {
82
+ if (s.tutorials[tutorial]?.completed) return s;
83
+ return {
84
+ ...s,
85
+ tutorials: {
86
+ ...s.tutorials,
87
+ [tutorial]: { ...s.tutorials[tutorial], completed: Date.now() },
88
+ },
89
+ };
90
+ }),
91
+ };
92
+ }, [store]);
93
+
94
+ return {
95
+ state,
96
+ ...actions,
97
+ isStepComplete: (tutorial, step) =>
98
+ state.steps[`${tutorial}/${step}` as StepKey] === "complete",
99
+ };
100
+ }
101
+
102
+ /**
103
+ * SSR-safe variant that only reads the store after mount. Use this when a
104
+ * component needs to render something only when the value is known (e.g.
105
+ * "Resume" buttons that should not flash on first paint).
106
+ */
107
+ export function useProgressAfterMount(): ProgressState | null {
108
+ const [mounted, setMounted] = useState(false);
109
+ const store = getStore();
110
+ const state = useSyncExternalStore(
111
+ store.subscribe,
112
+ () => store.get(),
113
+ () => store.get(),
114
+ );
115
+ useEffect(() => setMounted(true), []);
116
+ return mounted ? state : null;
117
+ }