feat(FN-1860): merge fusion/fn-1860
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user