Cybersecurity-Panel / frontend /src /utils /advisorPathProgress.js
NeonClary's picture
Welcome path navigator, Practical Next Steps chips (user-voice, diversity, refresh), and distinct advisor personas.
ad7840f verified
Raw
History Blame Contribute Delete
4.15 kB
import { PATH_NODES, getPathNode } from '../data/advisorPaths';
const STORAGE_PREFIX = 'advisor-path-progress::';
export function pathProgressKey(userId) {
const uid = userId || 'anon';
return `${STORAGE_PREFIX}${uid}`;
}
/** @returns {{ completed: string[], activeId: string|null, updatedAt: number }} */
export function loadPathProgress(userId) {
try {
const raw = localStorage.getItem(pathProgressKey(userId));
if (!raw) return { completed: [], activeId: null, updatedAt: 0 };
const parsed = JSON.parse(raw);
const completed = Array.isArray(parsed.completed)
? parsed.completed.filter((id) => getPathNode(id))
: [];
const activeId = parsed.activeId && getPathNode(parsed.activeId) ? parsed.activeId : null;
return {
completed,
activeId,
updatedAt: typeof parsed.updatedAt === 'number' ? parsed.updatedAt : 0,
};
} catch {
return { completed: [], activeId: null, updatedAt: 0 };
}
}
export function savePathProgress(userId, state) {
try {
localStorage.setItem(
pathProgressKey(userId),
JSON.stringify({
completed: state.completed || [],
activeId: state.activeId || null,
updatedAt: Date.now(),
}),
);
} catch {
/* quota / private mode */
}
}
function prerequisitesMet(node, completedSet) {
if (node.requires?.length) {
if (!node.requires.every((id) => completedSet.has(id))) return false;
}
if (node.requiresAny?.length) {
if (!node.requiresAny.some((id) => completedSet.has(id))) return false;
}
return true;
}
export function isNodeVisible(node, { completedSet, isGuest }) {
if (node.guestOnly && !isGuest) return false;
if (node.signedInOnly && isGuest) return false;
if (completedSet.has(node.id)) return false;
return prerequisitesMet(node, completedSet);
}
/**
* Next steps to show (max `limit`), preferring lower tier then definition order.
*/
export function getAvailableSteps({ completed = [], isGuest = false, limit = 4 } = {}) {
const completedSet = new Set(completed);
return PATH_NODES.filter((node) => isNodeVisible(node, { completedSet, isGuest }))
.sort((a, b) => a.tier - b.tier || PATH_NODES.indexOf(a) - PATH_NODES.indexOf(b))
.slice(0, limit);
}
/** Highest tier among completed nodes, or 0 if none. */
export function currentTier(completed = []) {
let max = 0;
completed.forEach((id) => {
const node = getPathNode(id);
if (node && node.tier > max) max = node.tier;
});
return max;
}
/** Most recently completed node id for the “current focus” strip. */
export function latestCompleted(completed = []) {
if (!completed.length) return null;
return completed[completed.length - 1];
}
export function markStepComplete(completed, stepId) {
if (!getPathNode(stepId) || completed.includes(stepId)) return completed;
return [...completed, stepId];
}
/** Auto-detect completions from app state (does not remove manual completions). */
export function mergeAutoCompletions(completed, signals = {}) {
const next = new Set(completed);
const {
hasChats,
hasProfileFacts,
hasStatedGoal,
visitedJourney,
visitedWorkspace,
} = signals;
if (hasChats) next.add('first-question');
if (hasProfileFacts) next.add('your-profile');
if (hasStatedGoal) next.add('stated-goal');
if (visitedJourney) next.add('your-journey');
if (visitedWorkspace) next.add('your-workspace');
return PATH_NODES.map((n) => n.id).filter((id) => next.has(id));
}
export function recordVisit(userId, area) {
const key = `${STORAGE_PREFIX}visits::${userId || 'anon'}`;
try {
const raw = localStorage.getItem(key);
const visits = raw ? JSON.parse(raw) : {};
visits[area] = true;
localStorage.setItem(key, JSON.stringify(visits));
} catch {
/* ignore */
}
}
export function loadVisits(userId) {
try {
const raw = localStorage.getItem(`${STORAGE_PREFIX}visits::${userId || 'anon'}`);
return raw ? JSON.parse(raw) : {};
} catch {
return {};
}
}