feat(FN-1860): merge fusion/fn-1860

This commit is contained in:
gsxdsm
2026-04-15 00:15:56 -07:00
parent d8b601c19b
commit 5a9445e350
2 changed files with 770 additions and 87 deletions

View File

@@ -8,6 +8,9 @@ import {
markOnboardingCompleted,
isOnboardingCompleted,
getOnboardingCompletedAt,
markStepCompleted,
getCompletedSteps,
getStepData,
ONBOARDING_STEP_LABELS,
} from "../model-onboarding-state";
@@ -61,17 +64,32 @@ describe("model-onboarding-state", () => {
expect(result).toBeNull();
});
it("returns parsed state for valid data", () => {
it("returns parsed state for valid data with defaults applied", () => {
const state = { currentStep: "ai-setup" as const, updatedAt: "2024-01-01T00:00:00.000Z" };
mockStore[STORAGE_KEY] = JSON.stringify(state);
expect(getOnboardingState()).toEqual(state);
const result = getOnboardingState();
expect(result).toEqual({
currentStep: "ai-setup",
updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: [],
dismissed: false,
completed: false,
stepData: {},
});
});
it("returns parsed state for unknown step IDs", () => {
const state = { currentStep: "unknown-step", updatedAt: "2024-01-01T00:00:00.000Z" };
mockStore[STORAGE_KEY] = JSON.stringify(state);
// Unknown steps are now accepted (fallback label logic handles them)
expect(getOnboardingState()).toEqual(state);
expect(getOnboardingState()).toEqual({
currentStep: "unknown-step",
updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: [],
dismissed: false,
completed: false,
stepData: {},
});
});
it("returns null when currentStep is missing", () => {
@@ -87,33 +105,367 @@ describe("model-onboarding-state", () => {
const result = getOnboardingState();
expect(result).toBeNull();
});
it("returns state with defaults when stored data has only currentStep and updatedAt (pre-FN-1860 format)", () => {
const state = { currentStep: "github" as const, updatedAt: "2024-01-01T00:00:00.000Z" };
mockStore[STORAGE_KEY] = JSON.stringify(state);
const result = getOnboardingState();
expect(result).toEqual({
currentStep: "github",
updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: [],
dismissed: false,
completed: false,
stepData: {},
});
});
it("preserves new fields when present", () => {
const state = {
currentStep: "first-task" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: ["ai-setup", "github"] as const,
dismissed: true,
completed: false,
stepData: { "ai-setup": { someData: "value" } },
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
const result = getOnboardingState();
expect(result).toEqual(state);
});
});
describe("saveOnboardingState", () => {
it("persists state to localStorage", () => {
it("persists state to localStorage without options (backward compatible)", () => {
saveOnboardingState("ai-setup");
const stored = mockStore[STORAGE_KEY];
const parsed = JSON.parse(stored);
expect(parsed.currentStep).toBe("ai-setup");
expect(parsed.updatedAt).toBeDefined();
expect(parsed.completedSteps).toEqual([]);
expect(parsed.dismissed).toBe(false);
expect(parsed.completed).toBe(false);
expect(parsed.stepData).toEqual({});
});
it("overwrites existing state", () => {
saveOnboardingState("github");
it("overwrites existing state when called without options", () => {
saveOnboardingState("ai-setup");
saveOnboardingState("first-task");
const stored = mockStore[STORAGE_KEY];
const parsed = JSON.parse(stored);
expect(parsed.currentStep).toBe("first-task");
expect(parsed.completedSteps).toEqual([]);
});
it("saves completedSteps when provided", () => {
saveOnboardingState("github", { completedSteps: ["ai-setup"] });
const stored = mockStore[STORAGE_KEY];
const parsed = JSON.parse(stored);
expect(parsed.completedSteps).toEqual(["ai-setup"]);
expect(parsed.currentStep).toBe("github");
});
it("sets dismissed: true when options.dismissed is true", () => {
saveOnboardingState("github", { dismissed: true });
const stored = mockStore[STORAGE_KEY];
const parsed = JSON.parse(stored);
expect(parsed.dismissed).toBe(true);
expect(parsed.completed).toBe(false);
});
it("sets completed: true when options.completed is true", () => {
saveOnboardingState("complete", { completed: true });
const stored = mockStore[STORAGE_KEY];
const parsed = JSON.parse(stored);
expect(parsed.completed).toBe(true);
expect(parsed.dismissed).toBe(false);
});
it("completed takes precedence over dismissed when both are true", () => {
saveOnboardingState("complete", { dismissed: true, completed: true });
const stored = mockStore[STORAGE_KEY];
const parsed = JSON.parse(stored);
expect(parsed.completed).toBe(true);
expect(parsed.dismissed).toBe(false);
});
it("merges stepData per-step without overwriting other steps", () => {
// Set initial state with step data
saveOnboardingState("ai-setup", { stepData: { "ai-setup": { key1: "value1" } } });
// Update with new step data
saveOnboardingState("github", { stepData: { github: { key2: "value2" } } });
const stored = mockStore[STORAGE_KEY];
const parsed = JSON.parse(stored);
expect(parsed.stepData).toEqual({
"ai-setup": { key1: "value1" },
github: { key2: "value2" },
});
});
it("merges with existing state when options provided", () => {
// Set initial state
saveOnboardingState("ai-setup", { completedSteps: ["ai-setup"] });
// Update current step while preserving completedSteps
saveOnboardingState("github", { completedSteps: ["ai-setup"] });
const stored = mockStore[STORAGE_KEY];
const parsed = JSON.parse(stored);
expect(parsed.currentStep).toBe("github");
expect(parsed.completedSteps).toEqual(["ai-setup"]);
});
it("works with empty options object (backward compatible)", () => {
saveOnboardingState("ai-setup", {});
const stored = mockStore[STORAGE_KEY];
const parsed = JSON.parse(stored);
expect(parsed.currentStep).toBe("ai-setup");
expect(parsed.completedSteps).toEqual([]);
});
});
describe("markStepCompleted", () => {
it("creates fresh state with step when no state exists", () => {
markStepCompleted("ai-setup");
const stored = mockStore[STORAGE_KEY];
const parsed = JSON.parse(stored);
expect(parsed.currentStep).toBe("ai-setup");
expect(parsed.completedSteps).toEqual(["ai-setup"]);
expect(parsed.dismissed).toBe(false);
expect(parsed.completed).toBe(false);
});
it("appends step to existing completedSteps without duplicates", () => {
saveOnboardingState("github", { completedSteps: ["ai-setup"] });
markStepCompleted("github");
const stored = mockStore[STORAGE_KEY];
const parsed = JSON.parse(stored);
expect(parsed.completedSteps).toEqual(["ai-setup", "github"]);
});
it("does not duplicate step if already in completedSteps", () => {
saveOnboardingState("github", { completedSteps: ["ai-setup", "github"] });
markStepCompleted("github");
const stored = mockStore[STORAGE_KEY];
const parsed = JSON.parse(stored);
expect(parsed.completedSteps).toEqual(["ai-setup", "github"]);
});
it("works when state already has other completed steps", () => {
saveOnboardingState("first-task", { completedSteps: ["ai-setup", "github"] });
markStepCompleted("first-task");
const stored = mockStore[STORAGE_KEY];
const parsed = JSON.parse(stored);
expect(parsed.completedSteps).toEqual(["ai-setup", "github", "first-task"]);
});
});
describe("clearOnboardingState", () => {
it("removes state from localStorage", () => {
const state = { currentStep: "ai-setup" as const, updatedAt: "2024-01-01T00:00:00.000Z" };
it("removes state from localStorage when called without options (backward compatible)", () => {
const state = {
currentStep: "ai-setup" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: ["ai-setup"],
dismissed: false,
completed: false,
stepData: {},
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
clearOnboardingState();
expect(mockStore[STORAGE_KEY]).toBeUndefined();
});
it("sets completed: true with preserveProgress while preserving completedSteps and stepData", () => {
const state = {
currentStep: "github" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: ["ai-setup", "github"] as const,
dismissed: true,
completed: false,
stepData: { "ai-setup": { someData: "value" } },
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
clearOnboardingState({ preserveProgress: true });
const stored = mockStore[STORAGE_KEY];
const parsed = JSON.parse(stored);
expect(parsed.currentStep).toBe("complete");
expect(parsed.completed).toBe(true);
expect(parsed.dismissed).toBe(false);
expect(parsed.completedSteps).toEqual(["ai-setup", "github"]);
expect(parsed.stepData).toEqual({ "ai-setup": { someData: "value" } });
});
it("creates minimal completed state when no existing state with preserveProgress", () => {
clearOnboardingState({ preserveProgress: true });
const stored = mockStore[STORAGE_KEY];
const parsed = JSON.parse(stored);
expect(parsed.currentStep).toBe("complete");
expect(parsed.completed).toBe(true);
expect(parsed.dismissed).toBe(false);
expect(parsed.completedSteps).toEqual([]);
expect(parsed.stepData).toEqual({});
});
it("preserveProgress: false removes key (explicit option)", () => {
const state = {
currentStep: "ai-setup" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: ["ai-setup"],
dismissed: false,
completed: false,
stepData: {},
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
clearOnboardingState({ preserveProgress: false });
expect(mockStore[STORAGE_KEY]).toBeUndefined();
});
});
describe("isOnboardingCompleted", () => {
it("returns false when no state exists", () => {
expect(isOnboardingCompleted()).toBe(false);
});
it("returns false when completed field is false", () => {
const state = {
currentStep: "first-task" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: [],
dismissed: false,
completed: false,
stepData: {},
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
expect(isOnboardingCompleted()).toBe(false);
});
it("returns true when completed field is true", () => {
const state = {
currentStep: "complete" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: ["ai-setup", "github", "first-task"],
dismissed: false,
completed: true,
stepData: {},
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
expect(isOnboardingCompleted()).toBe(true);
});
it("returns true when completedAt is set (legacy format)", () => {
const state = {
currentStep: "first-task" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
completedAt: "2024-01-02T00:00:00.000Z",
completedSteps: [],
dismissed: false,
completed: false,
stepData: {},
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
expect(isOnboardingCompleted()).toBe(true);
});
it("returns true when completedAt is set even if completed is false", () => {
const state = {
currentStep: "complete" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
completedAt: "2024-01-02T00:00:00.000Z",
completedSteps: [],
dismissed: false,
completed: false,
stepData: {},
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
expect(isOnboardingCompleted()).toBe(true);
});
it("returns false when state exists but completed field is missing (legacy state)", () => {
const state = {
currentStep: "first-task" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: [],
dismissed: false,
stepData: {},
// Note: no completed field
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
expect(isOnboardingCompleted()).toBe(false);
});
it("returns false when completedAt is empty string", () => {
const state = {
currentStep: "first-task" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
completedAt: "",
completedSteps: [],
dismissed: false,
completed: false,
stepData: {},
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
expect(isOnboardingCompleted()).toBe(false);
});
});
describe("getCompletedSteps", () => {
it("returns empty array when no state exists", () => {
expect(getCompletedSteps()).toEqual([]);
});
it("returns stored completedSteps array", () => {
saveOnboardingState("first-task", { completedSteps: ["ai-setup", "github"] });
expect(getCompletedSteps()).toEqual(["ai-setup", "github"]);
});
it("returns empty array when state exists but completedSteps is missing", () => {
// Legacy state without completedSteps
const state = {
currentStep: "first-task" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
dismissed: false,
completed: false,
stepData: {},
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
expect(getCompletedSteps()).toEqual([]);
});
});
describe("getStepData", () => {
it("returns null when no state exists", () => {
expect(getStepData("ai-setup")).toBeNull();
});
it("returns null when step has no data", () => {
saveOnboardingState("github", { completedSteps: ["ai-setup"] });
expect(getStepData("github")).toBeNull();
});
it("returns stored data for the step", () => {
saveOnboardingState("github", {
stepData: { "ai-setup": { selectedProvider: "anthropic", selectedModel: "claude-3" } },
});
expect(getStepData("ai-setup")).toEqual({ selectedProvider: "anthropic", selectedModel: "claude-3" });
});
it("returns null when state exists but stepData is missing (legacy state)", () => {
const state = {
currentStep: "first-task" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: [],
dismissed: false,
completed: false,
// Note: no stepData field
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
expect(getStepData("ai-setup")).toBeNull();
});
});
describe("isOnboardingResumable", () => {
@@ -122,7 +474,14 @@ describe("model-onboarding-state", () => {
});
it("returns false when step is 'complete'", () => {
const state = { currentStep: "complete" as const, updatedAt: "2024-01-01T00:00:00.000Z" };
const state = {
currentStep: "complete" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: [],
dismissed: false,
completed: false,
stepData: {},
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
expect(isOnboardingResumable()).toBe(false);
});
@@ -130,11 +489,59 @@ describe("model-onboarding-state", () => {
it("returns true for non-terminal steps", () => {
const steps: Array<"ai-setup" | "github" | "first-task"> = ["ai-setup", "github", "first-task"];
for (const step of steps) {
const state = { currentStep: step, updatedAt: "2024-01-01T00:00:00.000Z" };
const state = {
currentStep: step,
updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: [],
dismissed: false,
completed: false,
stepData: {},
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
expect(isOnboardingResumable()).toBe(true);
}
});
it("returns false when completed: true (new format)", () => {
const state = {
currentStep: "first-task" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: ["ai-setup", "github"],
dismissed: false,
completed: true,
stepData: {},
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
expect(isOnboardingResumable()).toBe(false);
});
it("returns false when completedAt is set (legacy format)", () => {
const state = {
currentStep: "first-task" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
completedAt: "2024-01-02T00:00:00.000Z",
completedSteps: [],
dismissed: false,
completed: false,
stepData: {},
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
expect(isOnboardingResumable()).toBe(false);
});
it("returns false when dismissed: true", () => {
const state = {
currentStep: "github" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: ["ai-setup"],
dismissed: true,
completed: false,
stepData: {},
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
// Dismissed onboarding should still show resume card (user can restart)
expect(isOnboardingResumable()).toBe(true);
});
});
describe("getOnboardingResumeStep", () => {
@@ -143,7 +550,14 @@ describe("model-onboarding-state", () => {
});
it("returns null when step is 'complete'", () => {
const state = { currentStep: "complete" as const, updatedAt: "2024-01-01T00:00:00.000Z" };
const state = {
currentStep: "complete" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: [],
dismissed: false,
completed: false,
stepData: {},
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
expect(getOnboardingResumeStep()).toBeNull();
});
@@ -151,7 +565,14 @@ describe("model-onboarding-state", () => {
it("returns step info for known steps", () => {
const steps: Array<"ai-setup" | "github" | "first-task"> = ["ai-setup", "github", "first-task"];
for (const step of steps) {
const state = { currentStep: step, updatedAt: "2024-01-01T00:00:00.000Z" };
const state = {
currentStep: step,
updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: [],
dismissed: false,
completed: false,
stepData: {},
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
const result = getOnboardingResumeStep();
expect(result).toEqual({
@@ -161,8 +582,42 @@ describe("model-onboarding-state", () => {
}
});
it("returns null when completed: true", () => {
const state = {
currentStep: "first-task" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: ["ai-setup", "github"],
dismissed: false,
completed: true,
stepData: {},
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
expect(getOnboardingResumeStep()).toBeNull();
});
it("returns null when completedAt is set (legacy format)", () => {
const state = {
currentStep: "first-task" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
completedAt: "2024-01-02T00:00:00.000Z",
completedSteps: [],
dismissed: false,
completed: false,
stepData: {},
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
expect(getOnboardingResumeStep()).toBeNull();
});
it("returns fallback label for unknown future step IDs", () => {
const state = { currentStep: "custom-step" as const, updatedAt: "2024-01-01T00:00:00.000Z" };
const state = {
currentStep: "custom-step" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: [],
dismissed: false,
completed: false,
stepData: {},
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
const result = getOnboardingResumeStep();
expect(result).toEqual({
@@ -172,14 +627,28 @@ describe("model-onboarding-state", () => {
});
it("handles kebab-case unknown steps", () => {
const state = { currentStep: "my-custom-step" as const, updatedAt: "2024-01-01T00:00:00.000Z" };
const state = {
currentStep: "my-custom-step" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: [],
dismissed: false,
completed: false,
stepData: {},
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
const result = getOnboardingResumeStep();
expect(result?.label).toBe("My Custom Step");
});
it("handles snake_case unknown steps", () => {
const state = { currentStep: "my_custom_step" as const, updatedAt: "2024-01-01T00:00:00.000Z" };
const state = {
currentStep: "my_custom_step" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: [],
dismissed: false,
completed: false,
stepData: {},
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
const result = getOnboardingResumeStep();
expect(result?.label).toBe("My Custom Step");
@@ -196,15 +665,15 @@ describe("model-onboarding-state", () => {
});
describe("markOnboardingCompleted", () => {
it("sets completedAt on existing state", () => {
// Set up existing state
saveOnboardingState("first-task");
it("sets completed: true and completedAt on existing state", () => {
saveOnboardingState("first-task", { completedSteps: ["ai-setup"] });
markOnboardingCompleted();
const stored = mockStore[STORAGE_KEY];
const parsed = JSON.parse(stored);
expect(parsed.currentStep).toBe("first-task");
expect(parsed.completed).toBe(true);
expect(parsed.completedAt).toBeDefined();
expect(typeof parsed.completedAt).toBe("string");
expect(parsed.dismissed).toBe(false);
});
it("creates minimal state when no state exists", () => {
@@ -212,8 +681,11 @@ describe("model-onboarding-state", () => {
const stored = mockStore[STORAGE_KEY];
const parsed = JSON.parse(stored);
expect(parsed.currentStep).toBe("complete");
expect(parsed.completed).toBe(true);
expect(parsed.completedAt).toBeDefined();
expect(parsed.updatedAt).toBeDefined();
expect(parsed.completedSteps).toEqual([]);
expect(parsed.dismissed).toBe(false);
});
it("does not change currentStep if one is already set", () => {
@@ -222,11 +694,12 @@ describe("model-onboarding-state", () => {
const stored = mockStore[STORAGE_KEY];
const parsed = JSON.parse(stored);
expect(parsed.currentStep).toBe("github");
expect(parsed.completed).toBe(true);
expect(parsed.completedAt).toBeDefined();
});
it("updates completedAt timestamp on subsequent calls", async () => {
saveOnboardingState("ai-setup");
saveOnboardingState("ai-setup", { completedSteps: ["ai-setup"] });
markOnboardingCompleted();
const firstStored = mockStore[STORAGE_KEY];
const firstParsed = JSON.parse(firstStored);
@@ -241,52 +714,31 @@ describe("model-onboarding-state", () => {
});
});
describe("isOnboardingCompleted", () => {
it("returns true when completedAt is set", () => {
const state = {
currentStep: "first-task" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
completedAt: "2024-01-02T00:00:00.000Z"
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
expect(isOnboardingCompleted()).toBe(true);
});
it("returns false when state has no completedAt", () => {
const state = { currentStep: "first-task" as const, updatedAt: "2024-01-01T00:00:00.000Z" };
mockStore[STORAGE_KEY] = JSON.stringify(state);
expect(isOnboardingCompleted()).toBe(false);
});
it("returns false when completedAt is empty string", () => {
const state = {
currentStep: "first-task" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
completedAt: ""
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
expect(isOnboardingCompleted()).toBe(false);
});
it("returns false when no state exists", () => {
expect(isOnboardingCompleted()).toBe(false);
});
});
describe("getOnboardingCompletedAt", () => {
it("returns timestamp when completed", () => {
it("returns timestamp when completedAt is set", () => {
const timestamp = "2024-01-02T00:00:00.000Z";
const state = {
currentStep: "first-task" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
completedAt: timestamp
completedAt: timestamp,
completedSteps: [],
dismissed: false,
completed: true,
stepData: {},
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
expect(getOnboardingCompletedAt()).toBe(timestamp);
});
it("returns null when not completed (no completedAt)", () => {
const state = { currentStep: "first-task" as const, updatedAt: "2024-01-01T00:00:00.000Z" };
const state = {
currentStep: "first-task" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: [],
dismissed: false,
completed: false,
stepData: {},
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
expect(getOnboardingCompletedAt()).toBeNull();
});
@@ -296,27 +748,44 @@ describe("model-onboarding-state", () => {
});
});
describe("isOnboardingResumable with completedAt", () => {
it("returns false when completedAt is set (completed onboarding is not resumable)", () => {
const state = {
currentStep: "first-task" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
completedAt: "2024-01-02T00:00:00.000Z"
};
describe("backward compatibility with legacy state", () => {
it("getOnboardingState returns defaults for legacy state", () => {
// Pre-FN-1860 format: only currentStep and updatedAt
const state = { currentStep: "github" as const, updatedAt: "2024-01-01T00:00:00.000Z" };
mockStore[STORAGE_KEY] = JSON.stringify(state);
expect(isOnboardingResumable()).toBe(false);
const result = getOnboardingState();
expect(result).toEqual({
currentStep: "github",
updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: [],
dismissed: false,
completed: false,
stepData: {},
});
});
});
describe("getOnboardingResumeStep with completedAt", () => {
it("returns null when completedAt is set", () => {
const state = {
currentStep: "first-task" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
completedAt: "2024-01-02T00:00:00.000Z"
};
it("isOnboardingCompleted returns false for legacy state without completedAt", () => {
const state = { currentStep: "first-task" as const, updatedAt: "2024-01-01T00:00:00.000Z" };
mockStore[STORAGE_KEY] = JSON.stringify(state);
expect(getOnboardingResumeStep()).toBeNull();
expect(isOnboardingCompleted()).toBe(false);
});
it("isOnboardingResumable works with legacy state", () => {
const state = { currentStep: "github" as const, updatedAt: "2024-01-01T00:00:00.000Z" };
mockStore[STORAGE_KEY] = JSON.stringify(state);
expect(isOnboardingResumable()).toBe(true);
});
it("getCompletedSteps returns empty array for legacy state", () => {
const state = { currentStep: "first-task" as const, updatedAt: "2024-01-01T00:00:00.000Z" };
mockStore[STORAGE_KEY] = JSON.stringify(state);
expect(getCompletedSteps()).toEqual([]);
});
it("getStepData returns null for legacy state", () => {
const state = { currentStep: "first-task" as const, updatedAt: "2024-01-01T00:00:00.000Z" };
mockStore[STORAGE_KEY] = JSON.stringify(state);
expect(getStepData("ai-setup")).toBeNull();
});
});
});

View File

@@ -10,11 +10,28 @@ export type OnboardingStep = "ai-setup" | "github" | "first-task" | "complete";
interface OnboardingState {
currentStep: OnboardingStep | string; // string allows for future unknown steps
updatedAt: string; // ISO-8601 timestamp
completedAt?: string; // ISO-8601 timestamp when onboarding was marked complete (distinct from dismissed)
/** Steps that have been completed (visited and passed) */
completedSteps: OnboardingStep[];
/** Whether the user explicitly dismissed the modal without finishing */
dismissed: boolean;
/** Whether the user finished all steps and completed onboarding */
completed: boolean;
/** Per-step data for restoring UI state on reopen */
stepData: Partial<Record<OnboardingStep, Record<string, unknown>>>;
/** Legacy field: ISO-8601 timestamp when onboarding was marked complete */
completedAt?: string;
}
const STORAGE_KEY = "fusion_model_onboarding_state";
/**
* Default values for backward compatibility with partial state objects
*/
const DEFAULT_COMPLETED_STEPS: OnboardingStep[] = [];
const DEFAULT_DISMISSED = false;
const DEFAULT_COMPLETED = false;
const DEFAULT_STEP_DATA: Partial<Record<OnboardingStep, Record<string, unknown>>> = {};
/**
* Step labels for display in the resume card.
* Fallback for unknown step IDs uses the raw key with title-case formatting.
@@ -28,6 +45,7 @@ export const ONBOARDING_STEP_LABELS: Record<OnboardingStep, string> = {
/**
* Get the currently persisted onboarding state, or null if none exists.
* Applies defaults for backward compatibility with partial/legacy state objects.
*/
export function getOnboardingState(): OnboardingState | null {
if (typeof window === "undefined") return null;
@@ -44,8 +62,8 @@ export function getOnboardingState(): OnboardingState | null {
typeof (parsed as Record<string, unknown>).currentStep === "string"
) {
const state = parsed as OnboardingState;
// Return state as-is; getOnboardingResumeStep handles fallback labels for unknown steps
return state;
// Apply defaults for backward compatibility with partial state objects
return applyStateDefaults(state);
}
return null;
} catch {
@@ -54,20 +72,140 @@ export function getOnboardingState(): OnboardingState | null {
}
}
/**
* Apply default values for backward compatibility with partial/legacy state objects.
*/
function applyStateDefaults(state: OnboardingState): OnboardingState {
return {
...state,
completedSteps: state.completedSteps ?? DEFAULT_COMPLETED_STEPS,
dismissed: state.dismissed ?? DEFAULT_DISMISSED,
completed: state.completed ?? DEFAULT_COMPLETED,
stepData: state.stepData ?? DEFAULT_STEP_DATA,
};
}
/**
* Persist the current onboarding step state.
* Call this when the user dismisses the modal without completing.
* @param step - The current step (known OnboardingStep or unknown string for future steps)
* @param options - Optional rich payload for extended state tracking
*/
export function saveOnboardingState(step: OnboardingStep | string): void {
export function saveOnboardingState(
step: OnboardingStep | string,
options?: {
completedSteps?: OnboardingStep[];
dismissed?: boolean;
completed?: boolean;
stepData?: Partial<Record<OnboardingStep, Record<string, unknown>>>;
}
): void {
if (typeof window === "undefined") return;
const state: OnboardingState = {
currentStep: step,
updatedAt: new Date().toISOString(),
};
// If no options provided, use simple overwrite (backward compatible)
if (!options) {
const state: OnboardingState = {
currentStep: step,
updatedAt: new Date().toISOString(),
completedSteps: DEFAULT_COMPLETED_STEPS,
dismissed: DEFAULT_DISMISSED,
completed: DEFAULT_COMPLETED,
stepData: DEFAULT_STEP_DATA,
};
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch {
// Storage quota exceeded or private browsing - fail silently
}
return;
}
// With options, merge with existing state
try {
const existing = getOnboardingState();
const now = new Date().toISOString();
// Determine completed and dismissed flags
// completed takes precedence over dismissed
let completed = options.completed ?? DEFAULT_COMPLETED;
let dismissed = options.dismissed ?? DEFAULT_DISMISSED;
// If completed is true, dismissed should be false
if (completed) {
dismissed = false;
}
// Merge completedSteps
const completedSteps = options.completedSteps ?? existing?.completedSteps ?? DEFAULT_COMPLETED_STEPS;
// Merge stepData per-step key
const stepData: Partial<Record<OnboardingStep, Record<string, unknown>>> = {
...(existing?.stepData ?? DEFAULT_STEP_DATA),
};
if (options.stepData) {
for (const [stepKey, data] of Object.entries(options.stepData)) {
if (data !== undefined) {
stepData[stepKey as OnboardingStep] = {
...(stepData[stepKey as OnboardingStep] ?? {}),
...data,
};
}
}
}
const state: OnboardingState = {
currentStep: step,
updatedAt: now,
completedSteps,
dismissed,
completed,
stepData,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch {
// Storage quota exceeded or private browsing - fail silently
}
}
/**
* Mark a specific step as completed.
* Reads current state, adds step to completedSteps (deduped), and saves.
* If no state exists, initializes a fresh state with completedSteps containing only this step.
* @param step - The step to mark as completed
*/
export function markStepCompleted(step: OnboardingStep): void {
if (typeof window === "undefined") return;
try {
const existing = getOnboardingState();
const now = new Date().toISOString();
if (!existing) {
// Initialize fresh state with this step completed
const state: OnboardingState = {
currentStep: step,
updatedAt: now,
completedSteps: [step],
dismissed: DEFAULT_DISMISSED,
completed: DEFAULT_COMPLETED,
stepData: DEFAULT_STEP_DATA,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
return;
}
// Add step to completedSteps (deduped)
const completedSteps = existing.completedSteps.includes(step)
? existing.completedSteps
: [...existing.completedSteps, step];
const state: OnboardingState = {
...existing,
completedSteps,
updatedAt: now,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch {
// Storage quota exceeded or private browsing - fail silently
@@ -77,12 +215,47 @@ export function saveOnboardingState(step: OnboardingStep | string): void {
/**
* Clear the persisted onboarding state.
* Call this when onboarding is fully completed.
* @param options - Optional options for clearing behavior
* @param options.preserveProgress - If true, sets completed=true while preserving completedSteps and stepData
*/
export function clearOnboardingState(): void {
export function clearOnboardingState(options?: { preserveProgress?: boolean }): void {
if (typeof window === "undefined") return;
try {
localStorage.removeItem(STORAGE_KEY);
// Default behavior: remove the key entirely (backward compatible)
if (!options?.preserveProgress) {
localStorage.removeItem(STORAGE_KEY);
return;
}
// With preserveProgress: set completed=true while preserving progress data
const existing = getOnboardingState();
const now = new Date().toISOString();
if (!existing) {
// No existing state - create minimal completed state
const state: OnboardingState = {
currentStep: "complete",
updatedAt: now,
completedSteps: DEFAULT_COMPLETED_STEPS,
dismissed: false,
completed: true,
stepData: DEFAULT_STEP_DATA,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
return;
}
// Preserve existing progress data but mark as completed
const state: OnboardingState = {
...existing,
currentStep: "complete",
updatedAt: now,
dismissed: false,
completed: true,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch {
// Fail silently
}
@@ -93,15 +266,25 @@ export function clearOnboardingState(): void {
* Unlike clearOnboardingState(), this preserves the state so the completion
* timestamp can be queried later. Call this when user completes onboarding
* (via Finish Setup, Create Task, or Import from GitHub).
* @deprecated Use clearOnboardingState({ preserveProgress: true }) for new code
*/
export function markOnboardingCompleted(): void {
if (typeof window === "undefined") return;
try {
const existing = getOnboardingState();
const now = new Date().toISOString();
const state: OnboardingState = existing
? { ...existing, completedAt: new Date().toISOString() }
: { currentStep: "complete", updatedAt: new Date().toISOString(), completedAt: new Date().toISOString() };
? { ...existing, completedAt: now, completed: true, dismissed: false, updatedAt: now }
: {
currentStep: "complete",
updatedAt: now,
completedAt: now,
completedSteps: DEFAULT_COMPLETED_STEPS,
dismissed: false,
completed: true,
stepData: DEFAULT_STEP_DATA,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch {
@@ -111,18 +294,26 @@ export function markOnboardingCompleted(): void {
/**
* Check if onboarding has been marked as completed.
* Returns true only when the persisted state exists AND completedAt is set.
* Returns true when either:
* - The `completed` boolean is true (new format)
* - The `completedAt` timestamp is set (legacy format)
* A dismissed (but not completed) onboarding returns false.
*/
export function isOnboardingCompleted(): boolean {
const state = getOnboardingState();
if (!state) return false;
// Check new boolean field first
if (state.completed === true) return true;
// Fall back to legacy timestamp field
return typeof state.completedAt === "string" && state.completedAt.length > 0;
}
/**
* Get the ISO-8601 timestamp when onboarding was marked complete.
* Returns null if onboarding has not been completed or no state exists.
* @deprecated Use isOnboardingCompleted() and getOnboardingState().completed instead
*/
export function getOnboardingCompletedAt(): string | null {
const state = getOnboardingState();
@@ -133,13 +324,15 @@ export function getOnboardingCompletedAt(): string | null {
/**
* Determine if onboarding can be resumed.
* Returns true only when persisted state exists, currentStep is not "complete",
* and onboarding has not been marked as completed.
* and onboarding has not been completed (either via `completed` boolean or `completedAt` timestamp).
*/
export function isOnboardingResumable(): boolean {
const state = getOnboardingState();
if (!state) return false;
// Reject if completed (completed onboarding is not resumable, only revisitable)
// Reject if completed (either new boolean or legacy timestamp)
if (isOnboardingCompleted()) return false;
// Reject if currentStep is "complete" or not a valid step identifier
return state.currentStep !== "complete";
}
@@ -165,6 +358,27 @@ export function getOnboardingResumeStep(): { currentStep: string; label: string
};
}
/**
* Get the list of completed steps from stored state.
* Returns empty array if no state exists or completedSteps is missing.
*/
export function getCompletedSteps(): OnboardingStep[] {
const state = getOnboardingState();
if (!state) return DEFAULT_COMPLETED_STEPS;
return state.completedSteps;
}
/**
* Get stored per-step data for a specific step.
* Returns null if no state exists or the step has no data.
* @param step - The step to get data for
*/
export function getStepData(step: OnboardingStep): Record<string, unknown> | null {
const state = getOnboardingState();
if (!state || !state.stepData) return null;
return state.stepData[step] ?? null;
}
/**
* Generate a human-readable label for an unknown step ID.
* This handles future step IDs that may be added after this code was written.