diff --git a/package-lock.json b/package-lock.json index c1f8a402..ca202071 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@sentry/react": "8.54.0", "@stellar/stellar-sdk": "^12.3.0", "@tensorflow/tfjs": "^4.22.0", - "@tensorflow/tfjs-node": "4.22.0", + "@tensorflow/tfjs-node": "^4.22.0", "d3-array": "^3.2.4", "d3-force-3d": "^3.0.6", "d3-scale": "^4.0.2", @@ -22,6 +22,7 @@ "d3-shape": "^3.2.0", "d3-time-format": "^4.1.0", "d3-zoom": "^3.0.0", + "date-fns": "^3.6.0", "express": "^4.18.2", "i18next": "^23.15.1", "i18next-browser-languagedetector": "^8.0.0", @@ -74,7 +75,7 @@ "prettier": "^3.3.3", "rollup-plugin-visualizer": "^5.12.0", "storybook": "^8.5.0", - "typescript": "^5.9.0", + "typescript": "^5.9.3", "vite": "^5.4.0", "vitest": "^2.1.9" }, @@ -5924,14 +5925,14 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.31", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -7094,7 +7095,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.1.tgz", "integrity": "sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0" }, "node_modules/bare-semver": { @@ -7136,7 +7137,7 @@ "version": "2.4.6", "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.6.tgz", "integrity": "sha512-iQxPClE07hETVpbRoX7JXX3v/ZQViCxe/SYCxylRLzdEx1xJAufPptfiOqR8tqiCtmbtMDANKWszzjLu1PMAZQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "bare-path": "^3.0.0" @@ -8330,6 +8331,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -16697,7 +16708,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/package.json b/package.json index de30340c..322327c6 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,6 @@ "d3-time-format": "^4.1.0", "d3-zoom": "^3.0.0", "date-fns": "^3.6.0", - "express": "^4.18.2", "i18next": "^23.15.1", "i18next-browser-languagedetector": "^8.0.0", "idb": "^8.0.3", diff --git a/src/components/dashboard/PersonalizationPanel.tsx b/src/components/dashboard/PersonalizationPanel.tsx index b0c6817d..031da78c 100644 --- a/src/components/dashboard/PersonalizationPanel.tsx +++ b/src/components/dashboard/PersonalizationPanel.tsx @@ -474,7 +474,6 @@ export default function PersonalizationPanel() { style={{ padding: '8px 16px', borderRadius: '999px', - border: 'none', background: profile.learningEnabled ? 'var(--green-glow)' : 'var(--bg-elevated)', color: profile.learningEnabled ? 'var(--green)' : 'var(--text-muted)', cursor: 'pointer', diff --git a/src/components/dashboard/TimeAnalysis.tsx b/src/components/dashboard/TimeAnalysis.tsx index 6cbc6d4a..7e92dca3 100644 --- a/src/components/dashboard/TimeAnalysis.tsx +++ b/src/components/dashboard/TimeAnalysis.tsx @@ -300,6 +300,7 @@ export default function TimeAnalysis() { [v, 'Operations']} /> + { + it('exposes initial service statuses', () => { + const statuses = selfHealingManager.getStatuses() + + expect(statuses.size).toBeGreaterThan(0) + expect(statuses.get('horizon:testnet')?.health).toBe('unknown') + }) + + it('supports subscriptions and recovery updates', async () => { + const updates: string[] = [] + const unsubscribe = selfHealingManager.subscribe(() => { + updates.push('changed') + }) + + await selfHealingManager.healNow('horizon:testnet') + + expect(selfHealingManager.getStatuses().get('horizon:testnet')?.health).toBe('healthy') + expect(updates.length).toBeGreaterThan(0) + + unsubscribe() + }) +}) diff --git a/src/lib/personalizationEngine.ts b/src/lib/personalizationEngine.ts index 7b1d3903..83db8d31 100644 --- a/src/lib/personalizationEngine.ts +++ b/src/lib/personalizationEngine.ts @@ -322,3 +322,200 @@ export async function clearPersonalizationData(userId: string): Promise { resetProfile(userId) resetSettings(userId) } + +// ─── Profile-based personalization API (used by PersonalizationPanel) ───────── + +const PROFILE_STORAGE_KEY = 'stellar_personalization_profile' + +export interface PersonalizationProfile { + /** Total number of recorded interactions */ + interactionCount: number + /** Map of tab name → visit count */ + tabVisits: Record + /** Map of widget type → usage count */ + widgetUsage: Record + /** Dismissed widget suggestions */ + dismissedWidgets: string[] + /** Accepted widget suggestions */ + acceptedWidgets: string[] + /** Hourly activity counts (index = hour 0–23) */ + hourlyActivity: number[] + /** Whether learning mode is active */ + learningEnabled: boolean + /** How much transparency to show: full / summary / minimal */ + transparencyLevel: 'full' | 'summary' | 'minimal' + /** ISO timestamp of last update */ + lastUpdated: string +} + +export interface PersonalizationStats { + totalInteractions: number + uniqueTabsVisited: number + uniqueWidgetsUsed: number + suggestionsAccepted: number + estimatedEfficiencyGain: number + topTabs: Array<{ tab: string; count: number }> + topWidgets: Array<{ widget: string; count: number }> +} + +export interface WidgetScore { + widgetType: string + score: number + reason: string +} + +function defaultProfile(): PersonalizationProfile { + return { + interactionCount: 0, + tabVisits: {}, + widgetUsage: {}, + dismissedWidgets: [], + acceptedWidgets: [], + hourlyActivity: new Array(24).fill(0), + learningEnabled: true, + transparencyLevel: 'full', + lastUpdated: new Date().toISOString(), + } +} + +export async function loadPersonalizationProfile(): Promise { + try { + const raw = localStorage.getItem(PROFILE_STORAGE_KEY) + if (raw) return { ...defaultProfile(), ...JSON.parse(raw) } + } catch { + // ignore parse errors + } + return defaultProfile() +} + +export async function savePersonalizationProfile(profile: PersonalizationProfile): Promise { + try { + localStorage.setItem(PROFILE_STORAGE_KEY, JSON.stringify({ ...profile, lastUpdated: new Date().toISOString() })) + } catch { + // ignore storage errors + } +} + +export async function resetPersonalization(): Promise { + const fresh = defaultProfile() + await savePersonalizationProfile(fresh) + return fresh +} + +export async function recordInteraction( + profile: PersonalizationProfile, + event: { type: string; target: string; metadata?: Record }, +): Promise { + if (!profile.learningEnabled) return profile + const updated: PersonalizationProfile = { + ...profile, + interactionCount: profile.interactionCount + 1, + tabVisits: + event.type === 'tab_visit' + ? { ...profile.tabVisits, [event.target]: (profile.tabVisits[event.target] ?? 0) + 1 } + : profile.tabVisits, + widgetUsage: + event.type === 'widget_use' + ? { ...profile.widgetUsage, [event.target]: (profile.widgetUsage[event.target] ?? 0) + 1 } + : profile.widgetUsage, + } + const hour = new Date().getHours() + const hourly = [...updated.hourlyActivity] + hourly[hour] = (hourly[hour] ?? 0) + 1 + updated.hourlyActivity = hourly + await savePersonalizationProfile(updated) + return updated +} + +export async function recordSuggestionAccepted( + profile: PersonalizationProfile, + widgetType: string, +): Promise { + const updated: PersonalizationProfile = { + ...profile, + acceptedWidgets: [...new Set([...profile.acceptedWidgets, widgetType])], + dismissedWidgets: profile.dismissedWidgets.filter(w => w !== widgetType), + } + await savePersonalizationProfile(updated) + return updated +} + +export async function recordSuggestionDismissed( + profile: PersonalizationProfile, + widgetType: string, +): Promise { + const updated: PersonalizationProfile = { + ...profile, + dismissedWidgets: [...new Set([...profile.dismissedWidgets, widgetType])], + acceptedWidgets: profile.acceptedWidgets.filter(w => w !== widgetType), + } + await savePersonalizationProfile(updated) + return updated +} + +export function computePersonalizationStats(profile: PersonalizationProfile): PersonalizationStats { + const topTabs = Object.entries(profile.tabVisits) + .sort((a, b) => b[1] - a[1]) + .map(([tab, count]) => ({ tab, count })) + + const topWidgets = Object.entries(profile.widgetUsage) + .sort((a, b) => b[1] - a[1]) + .map(([widget, count]) => ({ widget, count })) + + const uniqueTabsVisited = Object.keys(profile.tabVisits).length + const uniqueWidgetsUsed = Object.keys(profile.widgetUsage).length + + return { + totalInteractions: profile.interactionCount, + uniqueTabsVisited, + uniqueWidgetsUsed, + suggestionsAccepted: profile.acceptedWidgets.length, + estimatedEfficiencyGain: Math.min(95, Math.round(profile.interactionCount / 10)), + topTabs, + topWidgets, + } +} + +export function computeWidgetRecommendations( + profile: PersonalizationProfile, + availableTypes: string[], + _currentWidgets: string[], +): WidgetScore[] { + return availableTypes + .filter(type => !profile.dismissedWidgets.includes(type)) + .map(type => { + const usage = profile.widgetUsage[type] ?? 0 + const accepted = profile.acceptedWidgets.includes(type) + const score = Math.min(100, (usage * 10) + (accepted ? 30 : 0) + Math.random() * 20) + const reason = + usage > 5 + ? `You've used this widget ${usage} times — it fits your workflow well.` + : accepted + ? 'Previously added to your layout.' + : 'Might complement your existing setup.' + return { widgetType: type, score, reason } + }) + .sort((a, b) => b.score - a.score) +} + +export function identifyPeakUsageHours(profile: PersonalizationProfile): number[] { + return profile.hourlyActivity + .map((count, hour) => ({ hour, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 3) + .filter(e => e.count > 0) + .map(e => e.hour) +} + +export function detectPowerUser(profile: PersonalizationProfile): boolean { + return profile.interactionCount > 100 || Object.keys(profile.tabVisits).length > 6 +} + +export function detectCasualUser(profile: PersonalizationProfile): boolean { + return profile.interactionCount < 20 +} + +export function computeLayoutCompactnessScore(profile: PersonalizationProfile): number { + const widgetCount = Object.keys(profile.widgetUsage).length + return Math.min(1, widgetCount / 8) +} diff --git a/src/utils/monitoring.ts b/src/utils/monitoring.ts index 38d733a6..c70ff1e1 100644 --- a/src/utils/monitoring.ts +++ b/src/utils/monitoring.ts @@ -254,8 +254,6 @@ export function initMonitoring(userConfig: Partial = {}): void logger.info('Monitoring stack initialised', { env: cfg.environment }); } -// Re-export lightweight runtime helpers for useMonitoring.js and other consumers. -export { collectHealthSnapshot, collectSystemHealthSnapshot, computeHealthScore, watchErrors } from './monitoring.js' // ─── Sentry user context helpers ───────────────────────────────────────────── /** @@ -398,20 +396,3 @@ export default { captureError, SentryErrorBoundary, }; - -// ─── Stubs for hooks/useMonitoring.js compatibility ────────────────────────── -export function collectHealthSnapshot() { - return { cpu: 0, memory: 0, latency: 0, errors: 0, timestamp: Date.now() } -} - -export function collectSystemHealthSnapshot() { - return collectHealthSnapshot() -} - -export function computeHealthScore(_snapshot: ReturnType): number { - return 100 -} - -export function watchErrors(_handler: (err: unknown) => void): () => void { - return () => { /* no-op */ } -}