Files
fusion/packages/dashboard/app/components/model-onboarding-state.ts
Fusion 8938bdf93a feat(FN-2403): add project setup step to onboarding flow
- Introduce a new "project-setup" onboarding step between GitHub and first-task actions
- Gate first-task navigation on project selection and add contextual setup-wizard guidance
- Centralize ordered onboarding step definitions in shared state and reuse them for navigation/progress labels
- Extract ModelOnboardingModal styles into a dedicated component CSS file and update tests/mocks for the new step order
2026-04-25 07:04:50 -07:00

546 lines
18 KiB
TypeScript

/**
* Persisted onboarding step state for resume functionality.
*
* Stores the current onboarding step in localStorage so users can resume
* from where they left off if they dismiss the modal without completing.
*/
export type OnboardingStep = "ai-setup" | "github" | "project-setup" | "first-task" | "complete";
interface OnboardingState {
currentStep: OnboardingStep | string; // string allows for future unknown steps
updatedAt: string; // ISO-8601 timestamp
/** Steps that have been completed (visited and passed) */
completedSteps: OnboardingStep[];
/** Steps that were intentionally skipped and can be completed later */
skippedSteps: 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;
/** ISO-8601 timestamp when post-onboarding recommendations were dismissed */
postOnboardingDismissedAt?: string;
}
const STORAGE_KEY = "fusion_model_onboarding_state";
/**
* Default values for backward compatibility with partial state objects
*/
const DEFAULT_COMPLETED_STEPS: OnboardingStep[] = [];
const DEFAULT_SKIPPED_STEPS: OnboardingStep[] = [];
const DEFAULT_DISMISSED = false;
const DEFAULT_COMPLETED = false;
const DEFAULT_STEP_DATA: Partial<Record<OnboardingStep, Record<string, unknown>>> = {};
const DEFAULT_POST_ONBOARDING_DISMISSED_AT: string | undefined = undefined;
/**
* Ordered onboarding flow steps before completion.
* Keep this list in sync with ModelOnboardingModal's stepper rendering and navigation.
*/
export const ONBOARDING_FLOW_STEPS = ["ai-setup", "github", "project-setup", "first-task"] as const;
/**
* Step labels for display in the resume card.
* Fallback for unknown step IDs uses the raw key with title-case formatting.
*/
export const ONBOARDING_STEP_LABELS: Record<OnboardingStep, string> = {
"ai-setup": "AI Setup",
github: "GitHub",
"project-setup": "Project",
"first-task": "First Task",
complete: "Complete",
};
/**
* 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;
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as unknown;
if (
parsed &&
typeof parsed === "object" &&
"currentStep" in parsed &&
typeof (parsed as Record<string, unknown>).currentStep === "string"
) {
const state = parsed as OnboardingState;
// Apply defaults for backward compatibility with partial state objects
return applyStateDefaults(state);
}
return null;
} catch {
// Malformed storage - treat as missing
return null;
}
}
/**
* Apply default values for backward compatibility with partial/legacy state objects.
*/
function applyStateDefaults(state: OnboardingState): OnboardingState {
const postOnboardingDismissedAt = state.postOnboardingDismissedAt ?? DEFAULT_POST_ONBOARDING_DISMISSED_AT;
return {
...state,
completedSteps: state.completedSteps ?? DEFAULT_COMPLETED_STEPS,
skippedSteps: state.skippedSteps ?? DEFAULT_SKIPPED_STEPS,
dismissed: state.dismissed ?? DEFAULT_DISMISSED,
completed: state.completed ?? DEFAULT_COMPLETED,
stepData: state.stepData ?? DEFAULT_STEP_DATA,
...(postOnboardingDismissedAt ? { postOnboardingDismissedAt } : {}),
};
}
/**
* 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
* @param options.completedSteps - Array of steps that have been completed (for resume functionality)
* @param options.skippedSteps - Array of steps intentionally skipped (for resume functionality)
* @param options.dismissed - Whether the user explicitly dismissed without finishing
* @param options.completed - Whether the user finished all steps
* @param options.stepData - Per-step data for restoring UI state on reopen
*/
export function saveOnboardingState(
step: OnboardingStep | string,
options?: {
completedSteps?: OnboardingStep[];
skippedSteps?: OnboardingStep[];
dismissed?: boolean;
completed?: boolean;
stepData?: Partial<Record<OnboardingStep, Record<string, unknown>>>;
}
): void {
if (typeof window === "undefined") return;
// If no options provided, use simple overwrite (backward compatible)
if (!options) {
const state: OnboardingState = {
currentStep: step,
updatedAt: new Date().toISOString(),
completedSteps: DEFAULT_COMPLETED_STEPS,
skippedSteps: DEFAULT_SKIPPED_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
const 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;
const skippedSteps = options.skippedSteps ?? existing?.skippedSteps ?? DEFAULT_SKIPPED_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,
skippedSteps,
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],
skippedSteps: DEFAULT_SKIPPED_STEPS,
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,
skippedSteps: existing.skippedSteps.filter((s) => s !== step),
updatedAt: now,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch {
// Storage quota exceeded or private browsing - fail silently
}
}
/**
* Mark a specific step as skipped.
* Reads current state, adds step to skippedSteps (deduped), and saves.
* If no state exists, initializes a fresh state with skippedSteps containing only this step.
* @param step - The step to mark as skipped
*/
export function markStepSkipped(step: OnboardingStep): void {
if (typeof window === "undefined") return;
try {
const existing = getOnboardingState();
const now = new Date().toISOString();
if (!existing) {
const state: OnboardingState = {
currentStep: step,
updatedAt: now,
completedSteps: DEFAULT_COMPLETED_STEPS,
skippedSteps: [step],
dismissed: DEFAULT_DISMISSED,
completed: DEFAULT_COMPLETED,
stepData: DEFAULT_STEP_DATA,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
return;
}
const skippedSteps = existing.skippedSteps.includes(step)
? existing.skippedSteps
: [...existing.skippedSteps, step];
const state: OnboardingState = {
...existing,
skippedSteps,
completedSteps: existing.completedSteps.filter((s) => s !== step),
updatedAt: now,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch {
// Storage quota exceeded or private browsing - fail silently
}
}
/**
* 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(options?: { preserveProgress?: boolean }): void {
if (typeof window === "undefined") return;
try {
// 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,
skippedSteps: DEFAULT_SKIPPED_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
}
}
/**
* Mark onboarding as completed by setting the completedAt timestamp.
* 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: now, completed: true, dismissed: false, updatedAt: now }
: {
currentStep: "complete",
updatedAt: now,
completedAt: now,
completedSteps: DEFAULT_COMPLETED_STEPS,
skippedSteps: DEFAULT_SKIPPED_STEPS,
dismissed: false,
completed: true,
stepData: DEFAULT_STEP_DATA,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch {
// Storage quota exceeded or private browsing - fail silently
}
}
/**
* Check if onboarding has been marked as completed.
* 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;
}
/**
* Mark the post-onboarding recommendations as dismissed.
*/
export function dismissPostOnboardingRecommendations(): void {
if (typeof window === "undefined") return;
try {
const existing = getOnboardingState();
const now = new Date().toISOString();
const state: OnboardingState = existing
? {
...existing,
updatedAt: now,
postOnboardingDismissedAt: now,
}
: {
currentStep: "complete",
updatedAt: now,
completedSteps: DEFAULT_COMPLETED_STEPS,
skippedSteps: DEFAULT_SKIPPED_STEPS,
dismissed: false,
completed: false,
stepData: DEFAULT_STEP_DATA,
postOnboardingDismissedAt: now,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch {
// Storage quota exceeded or private browsing - fail silently
}
}
/**
* Whether post-onboarding recommendations were dismissed.
*/
export function isPostOnboardingDismissed(): boolean {
const state = getOnboardingState();
if (!state) return false;
return typeof state.postOnboardingDismissedAt === "string" && state.postOnboardingDismissedAt.length > 0;
}
/**
* Remove persisted post-onboarding dismissal state.
*/
export function clearPostOnboardingDismissal(): void {
if (typeof window === "undefined") return;
try {
const existing = getOnboardingState();
if (!existing) return;
const { postOnboardingDismissedAt: _postOnboardingDismissedAt, ...stateWithoutDismissal } = existing;
const state: OnboardingState = {
...stateWithoutDismissal,
updatedAt: new Date().toISOString(),
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch {
// Storage quota exceeded or private browsing - fail silently
}
}
/**
* 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();
if (!state || !state.completedAt) return null;
return state.completedAt;
}
/**
* Determine if onboarding can be resumed.
* Returns true only when persisted state exists, currentStep is not "complete",
* 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 (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";
}
/**
* Get the step info needed to display the resume card.
* Returns null if no resumable state exists (including if onboarding was completed).
* @returns Object containing:
* - currentStep: The step where the user left off
* - label: Human-readable step label
* - completedSteps: Array of steps the user has completed (for showing progress)
*/
export function getOnboardingResumeStep(): { currentStep: string; label: string; completedSteps: OnboardingStep[] } | null {
const state = getOnboardingState();
// Return null if no state, completed, or step is "complete"
if (!state || isOnboardingCompleted() || state.currentStep === "complete") {
return null;
}
// Check if it's a known step with a predefined label
const knownStep = state.currentStep as OnboardingStep;
const label = ONBOARDING_STEP_LABELS[knownStep] ?? formatUnknownStepLabel(state.currentStep);
return {
currentStep: state.currentStep,
label,
completedSteps: state.completedSteps,
};
}
/**
* 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 the list of skipped steps from stored state.
* Returns empty array if no state exists or skippedSteps is missing.
*/
export function getSkippedSteps(): OnboardingStep[] {
const state = getOnboardingState();
if (!state) return DEFAULT_SKIPPED_STEPS;
return state.skippedSteps;
}
/**
* 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.
*/
function formatUnknownStepLabel(stepId: string): string {
// Convert kebab-case or snake_case to Title Case
return stepId
.replace(/[-_]/g, " ")
.replace(/\b\w/g, (char) => char.toUpperCase());
}