🚀 Major Enhancement: Complete AI-Powered LifeRPG Platform with Git LFS

 New Features:
- AI-powered habit creation with natural language processing
- HuggingFace transformers integration for sentiment analysis (tracked via Git LFS)
- Advanced predictive analytics and behavioral insights
- Voice & image input capabilities for hands-free habit tracking
- Real-time notifications and community features
- Plugin system with extensible architecture

🔧 Technical Improvements:
- Comprehensive FastAPI backend with 30+ endpoints
- React frontend with PWA capabilities
- Advanced authentication with 2FA support
- RBAC authorization system
- Comprehensive security features (CSRF, rate limiting, audit logging)
- Database migrations and health monitoring
- Docker containerization support
- Git LFS configured for large AI model files (2+ GB)

📚 Documentation & DevOps:
- Complete deployment guides for multiple platforms
- Professional README with feature highlights
- GitHub Actions CI/CD workflows
- Comprehensive API documentation
- Security audit roadmap and compliance framework
- Setup scripts for development environment

🧪 Testing & Quality:
- Comprehensive test suite with 20+ test modules
- Setup verification scripts
- Working development environment with both backend and frontend
- Health checks and monitoring systems

🌟 Ready for:
- Portfolio showcasing
- Community contributions
- Production deployment
- Professional presentation
This commit is contained in:
TLimoges33
2025-09-28 21:29:19 +00:00
committed by GitHub
parent 7fe4ae5365
commit 2b961611fd
131 changed files with 29938 additions and 1450 deletions
+11 -1
View File
@@ -7,8 +7,18 @@ COPY modern/frontend /app
RUN npm run build
FROM node:20-alpine
# Create non-root user for security
RUN addgroup -g 1001 -S appuser && \
adduser -S appuser -u 1001 -G appuser
WORKDIR /app
COPY --from=build /app/dist /app/dist
COPY --from=build --chown=appuser:appuser /app/dist /app/dist
# Switch to non-root user
USER appuser
RUN npm i -g serve
EXPOSE 5173
CMD ["serve", "-s", "dist", "-l", "5173"]
+149 -128
View File
@@ -1,135 +1,156 @@
{
"name": "The Wizard's Grimoire",
"short_name": "Grimoire",
"description": "Track your magical habits and build powerful routines with The Wizard's Grimoire",
"start_url": "/",
"display": "standalone",
"theme_color": "#7c3aed",
"background_color": "#0f172a",
"orientation": "portrait-primary",
"scope": "/",
"categories": [
"productivity",
"lifestyle",
"health"
],
"lang": "en",
"dir": "ltr",
"icons": [
"name": "LifeRPG - Habit Tracker",
"short_name": "LifeRPG",
"description": "Gamified habit tracking with RPG mechanics and real-time progress",
"start_url": "/",
"display": "standalone",
"theme_color": "#8b5cf6",
"background_color": "#1a1a2e",
"orientation": "portrait-primary",
"scope": "/",
"categories": ["productivity", "lifestyle", "health", "games"],
"lang": "en",
"dir": "ltr",
"shortcuts": [
{
"name": "Quick Add Habit",
"short_name": "Add Habit",
"description": "Quickly add a new habit",
"url": "/habits/add",
"icons": [
{
"src": "/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icon-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icon-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icon-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icon-152x152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icon-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable any"
"src": "/icon-96x96.png",
"sizes": "96x96",
"type": "image/png"
}
],
"shortcuts": [
]
},
{
"name": "Today's Habits",
"short_name": "Today",
"description": "View today's habit list",
"url": "/habits/today",
"icons": [
{
"name": "Quick Add Habit",
"short_name": "Add Habit",
"description": "Quickly add a new magical habit",
"url": "/habits/new",
"icons": [
{
"src": "/icon-96x96.png",
"sizes": "96x96"
}
]
},
{
"name": "Today's Progress",
"short_name": "Today",
"description": "View today's habit progress",
"url": "/today",
"icons": [
{
"src": "/icon-96x96.png",
"sizes": "96x96"
}
]
},
{
"name": "Analytics",
"short_name": "Stats",
"description": "View your magical progress analytics",
"url": "/analytics",
"icons": [
{
"src": "/icon-96x96.png",
"sizes": "96x96"
}
]
"src": "/icon-96x96.png",
"sizes": "96x96",
"type": "image/png"
}
],
"screenshots": [
]
},
{
"name": "Analytics",
"short_name": "Stats",
"description": "View your progress analytics",
"url": "/analytics",
"icons": [
{
"src": "/screenshot-wide.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide",
"label": "The Wizard's Grimoire desktop interface"
},
{
"src": "/screenshot-narrow.png",
"sizes": "375x812",
"type": "image/png",
"form_factor": "narrow",
"label": "The Wizard's Grimoire mobile interface"
"src": "/icon-96x96.png",
"sizes": "96x96",
"type": "image/png"
}
],
"prefer_related_applications": false,
"related_applications": [
{
"platform": "webapp",
"url": "https://wizards-grimoire.app"
}
],
"protocol_handlers": [
{
"protocol": "web+grimoire",
"url": "/share?habit=%s"
}
]
}
]
}
],
"icons": [
{
"src": "/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icon-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icon-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icon-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icon-152x152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icon-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable any"
}
],
"screenshots": [
{
"src": "/screenshots/mobile-habits-list.png",
"sizes": "390x844",
"type": "image/png",
"platform": "narrow",
"label": "Habit tracking interface on mobile"
},
{
"src": "/screenshots/mobile-analytics.png",
"sizes": "390x844",
"type": "image/png",
"platform": "narrow",
"label": "Analytics dashboard on mobile"
},
{
"src": "/screenshots/desktop-dashboard.png",
"sizes": "1280x800",
"type": "image/png",
"platform": "wide",
"label": "Full dashboard on desktop"
}
],
"prefer_related_applications": false,
"related_applications": [
{
"platform": "play",
"url": "https://play.google.com/store/apps/details?id=com.liferpg.app",
"id": "com.liferpg.app"
},
{
"platform": "itunes",
"url": "https://apps.apple.com/app/liferpg/id123456789",
"id": "123456789"
}
],
"protocol_handlers": [
{
"protocol": "liferpg",
"url": "/habit?id=%s"
}
],
"file_handlers": [
{
"action": "/import",
"accept": {
"application/json": [".json"],
"text/csv": [".csv"]
}
}
]
}
+236 -233
View File
@@ -1,264 +1,267 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Offline - The Wizard's Grimoire</title>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>LifeRPG - Offline</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #1e1b4b, #312e81, #1e1b4b);
color: #e2e8f0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
text-align: center;
max-width: 500px;
padding: 2rem;
background: rgba(30, 27, 75, 0.8);
border-radius: 20px;
border: 1px solid rgba(124, 58, 237, 0.3);
backdrop-filter: blur(10px);
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
}
.icon {
font-size: 4rem;
margin-bottom: 1rem;
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%,
100% {
transform: translateY(0px);
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #1e1b4b, #312e81, #1e1b4b);
color: #e2e8f0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
50% {
transform: translateY(-10px);
}
}
h1 {
color: #c084fc;
margin-bottom: 1rem;
font-size: 2rem;
font-weight: 600;
}
p {
color: #cbd5e1;
margin-bottom: 2rem;
line-height: 1.6;
}
.button {
display: inline-block;
background: linear-gradient(135deg, #7c3aed, #a855f7);
color: white;
padding: 12px 24px;
border-radius: 12px;
text-decoration: none;
font-weight: 500;
transition: all 0.3s ease;
border: none;
cursor: pointer;
margin: 0 8px;
}
.button:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(124, 58, 237, 0.4);
}
.button.secondary {
background: transparent;
border: 2px solid #7c3aed;
color: #c084fc;
}
.status {
margin-top: 2rem;
padding: 1rem;
background: rgba(249, 115, 22, 0.1);
border: 1px solid rgba(249, 115, 22, 0.3);
border-radius: 12px;
color: #fed7aa;
}
.features {
margin-top: 2rem;
text-align: left;
}
.features h3 {
color: #c084fc;
margin-bottom: 1rem;
font-size: 1.2rem;
}
.features ul {
list-style: none;
padding: 0;
}
.features li {
padding: 8px 0;
border-bottom: 1px solid rgba(124, 58, 237, 0.2);
color: #cbd5e1;
}
.features li:last-child {
border-bottom: none;
}
.features li::before {
content: "✨ ";
color: #a855f7;
margin-right: 8px;
}
.network-status {
position: fixed;
top: 20px;
right: 20px;
padding: 8px 16px;
background: #ef4444;
color: white;
border-radius: 20px;
font-size: 0.9rem;
font-weight: 500;
}
.network-status.online {
background: #10b981;
}
@media (max-width: 600px) {
.container {
text-align: center;
max-width: 500px;
padding: 2rem;
background: rgba(30, 27, 75, 0.8);
border-radius: 20px;
border: 1px solid rgba(124, 58, 237, 0.3);
backdrop-filter: blur(10px);
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
}
.icon {
font-size: 4rem;
margin-bottom: 1rem;
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%,
100% {
transform: translateY(0px);
}
50% {
transform: translateY(-10px);
}
padding: 1.5rem;
margin: 10px;
}
h1 {
color: #c084fc;
margin-bottom: 1rem;
font-size: 2rem;
font-weight: 600;
font-size: 1.5rem;
}
p {
color: #cbd5e1;
margin-bottom: 2rem;
line-height: 1.6;
}
.button {
display: inline-block;
background: linear-gradient(135deg, #7c3aed, #a855f7);
color: white;
padding: 12px 24px;
border-radius: 12px;
text-decoration: none;
font-weight: 500;
transition: all 0.3s ease;
border: none;
cursor: pointer;
margin: 0 8px;
}
.button:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(124, 58, 237, 0.4);
}
.button.secondary {
background: transparent;
border: 2px solid #7c3aed;
color: #c084fc;
}
.status {
margin-top: 2rem;
padding: 1rem;
background: rgba(249, 115, 22, 0.1);
border: 1px solid rgba(249, 115, 22, 0.3);
border-radius: 12px;
color: #fed7aa;
}
.features {
margin-top: 2rem;
text-align: left;
}
.features h3 {
color: #c084fc;
margin-bottom: 1rem;
font-size: 1.2rem;
}
.features ul {
list-style: none;
padding: 0;
}
.features li {
padding: 8px 0;
border-bottom: 1px solid rgba(124, 58, 237, 0.2);
color: #cbd5e1;
}
.features li:last-child {
border-bottom: none;
}
.features li::before {
content: "✨ ";
color: #a855f7;
margin-right: 8px;
}
.network-status {
position: fixed;
top: 20px;
right: 20px;
padding: 8px 16px;
background: #ef4444;
color: white;
border-radius: 20px;
font-size: 0.9rem;
font-weight: 500;
}
.network-status.online {
background: #10b981;
}
@media (max-width: 600px) {
.container {
padding: 1.5rem;
margin: 10px;
}
h1 {
font-size: 1.5rem;
}
.icon {
font-size: 3rem;
}
.icon {
font-size: 3rem;
}
}
</style>
</head>
</head>
<body>
<body>
<div id="networkStatus" class="network-status">📡 Offline</div>
<div class="container">
<div class="icon">🧙‍♂️</div>
<h1>You're Offline</h1>
<p>Your magical connection has been temporarily disrupted, but your grimoire is still accessible!</p>
<div class="icon">🧙‍♂️</div>
<h1>You're Offline</h1>
<p>
Your magical connection has been temporarily disrupted, but your
grimoire is still accessible!
</p>
<div style="margin-bottom: 2rem;">
<button class="button" onclick="tryReconnect()">🔄 Try Reconnecting</button>
<a href="/" class="button secondary">📖 Open Grimoire</a>
</div>
<div style="margin-bottom: 2rem">
<button class="button" onclick="tryReconnect()">
🔄 Try Reconnecting
</button>
<a href="/" class="button secondary">📖 Open Grimoire</a>
</div>
<div class="status">
<strong>🛡️ Offline Mode Active</strong><br>
Your data is safely stored locally and will sync when connection is restored.
</div>
<div class="status">
<strong>🛡️ Offline Mode Active</strong><br />
Your data is safely stored locally and will sync when connection is
restored.
</div>
<div class="features">
<h3>✨ What You Can Still Do</h3>
<ul>
<li>View your existing habits and progress</li>
<li>Mark habits as complete (will sync later)</li>
<li>Browse cached analytics and insights</li>
<li>Access previously loaded content</li>
<li>Create new habits (will sync when online)</li>
</ul>
</div>
<div class="features">
<h3>✨ What You Can Still Do</h3>
<ul>
<li>View your existing habits and progress</li>
<li>Mark habits as complete (will sync later)</li>
<li>Browse cached analytics and insights</li>
<li>Access previously loaded content</li>
<li>Create new habits (will sync when online)</li>
</ul>
</div>
</div>
<script>
// Check network status
function updateNetworkStatus() {
const statusEl = document.getElementById('networkStatus');
if (navigator.onLine) {
statusEl.textContent = '🌐 Back Online!';
statusEl.className = 'network-status online';
setTimeout(() => {
window.location.href = '/';
}, 2000);
} else {
statusEl.textContent = '📡 Offline';
statusEl.className = 'network-status';
}
// Check network status
function updateNetworkStatus() {
const statusEl = document.getElementById("networkStatus");
if (navigator.onLine) {
statusEl.textContent = "🌐 Back Online!";
statusEl.className = "network-status online";
setTimeout(() => {
window.location.href = "/";
}, 2000);
} else {
statusEl.textContent = "📡 Offline";
statusEl.className = "network-status";
}
}
// Try to reconnect
function tryReconnect() {
const button = event.target;
button.textContent = '🔄 Checking...';
button.disabled = true;
// Try to reconnect
function tryReconnect() {
const button = event.target;
button.textContent = "🔄 Checking...";
button.disabled = true;
// Simple connectivity test
fetch('/', {
method: 'HEAD',
cache: 'no-cache'
// Simple connectivity test
fetch("/", {
method: "HEAD",
cache: "no-cache",
})
.then(() => {
button.textContent = "✅ Connected!";
setTimeout(() => {
window.location.href = "/";
}, 1000);
})
.catch(() => {
button.textContent = "❌ Still Offline";
setTimeout(() => {
button.textContent = "🔄 Try Reconnecting";
button.disabled = false;
}, 2000);
});
}
// Listen for network changes
window.addEventListener("online", updateNetworkStatus);
window.addEventListener("offline", updateNetworkStatus);
// Initial status check
updateNetworkStatus();
// Auto-retry connection every 30 seconds
setInterval(() => {
if (!navigator.onLine) {
fetch("/", {
method: "HEAD",
cache: "no-cache",
})
.then(() => {
updateNetworkStatus();
})
.then(() => {
button.textContent = '✅ Connected!';
setTimeout(() => {
window.location.href = '/';
}, 1000);
})
.catch(() => {
button.textContent = '❌ Still Offline';
setTimeout(() => {
button.textContent = '🔄 Try Reconnecting';
button.disabled = false;
}, 2000);
});
.catch(() => {
// Still offline
});
}
// Listen for network changes
window.addEventListener('online', updateNetworkStatus);
window.addEventListener('offline', updateNetworkStatus);
// Initial status check
updateNetworkStatus();
// Auto-retry connection every 30 seconds
setInterval(() => {
if (!navigator.onLine) {
fetch('/', {
method: 'HEAD',
cache: 'no-cache'
})
.then(() => {
updateNetworkStatus();
})
.catch(() => {
// Still offline
});
}
}, 30000);
}, 30000);
</script>
</body>
</html>
</body>
</html>
+385
View File
@@ -0,0 +1,385 @@
const CACHE_NAME = "wizards-grimoire-v1.0.0";
const OFFLINE_URL = "/offline.html";
const API_CACHE_NAME = "api-cache-v1";
const SECURE_CACHE_NAME = "secure-cache-v1";
// Security configuration
const SECURITY_CONFIG = {
// Cache security policies
cacheSecurityHeaders: true,
encryptSensitiveData: true,
maxCacheAge: 24 * 60 * 60 * 1000, // 24 hours
// CSP configuration
contentSecurityPolicy:
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https:; font-src 'self'; object-src 'none'; media-src 'self'; frame-src 'none';",
// Allowed origins for API requests
allowedOrigins: [
"https://wizardsgrimoire.com",
"https://api.wizardsgrimoire.com",
"http://localhost:3000", // Development only
"http://localhost:8000", // Development only
],
// Sensitive data patterns (never cache these)
sensitivePatterns: [
/\/api\/.*\/auth/,
/\/api\/.*\/login/,
/\/api\/.*\/2fa/,
/\/api\/.*\/token/,
/\/api\/.*\/password/,
/\/api\/.*\/gdpr/,
],
// Cacheable patterns with encryption
secureCachePatterns: [
/\/api\/v1\/habits$/,
/\/api\/v1\/user\/profile$/,
/\/api\/v1\/analytics/,
],
};
// Resources to cache immediately (only non-sensitive)
const STATIC_CACHE_URLS = [
"/",
"/static/js/bundle.js",
"/static/css/main.css",
"/manifest.json",
"/icon-192x192.png",
"/icon-512x512.png",
OFFLINE_URL,
];
// Simple encryption for cached sensitive data
class CacheEncryption {
static async encrypt(data, key = "wizards-grimoire-cache-key") {
try {
const encoder = new TextEncoder();
const keyData = encoder.encode(key);
const cryptoKey = await crypto.subtle.importKey(
"raw",
keyData,
{ name: "AES-GCM" },
false,
["encrypt"]
);
const iv = crypto.getRandomValues(new Uint8Array(12));
const encodedData = encoder.encode(JSON.stringify(data));
const encrypted = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: iv },
cryptoKey,
encodedData
);
return {
encrypted: Array.from(new Uint8Array(encrypted)),
iv: Array.from(iv),
timestamp: Date.now(),
};
} catch (error) {
console.error("Cache encryption failed:", error);
return null;
}
}
static async decrypt(encryptedData, key = "wizards-grimoire-cache-key") {
try {
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const keyData = encoder.encode(key);
const cryptoKey = await crypto.subtle.importKey(
"raw",
keyData,
{ name: "AES-GCM" },
false,
["decrypt"]
);
const decrypted = await crypto.subtle.decrypt(
{
name: "AES-GCM",
iv: new Uint8Array(encryptedData.iv),
},
cryptoKey,
new Uint8Array(encryptedData.encrypted)
);
return JSON.parse(decoder.decode(decrypted));
} catch (error) {
console.error("Cache decryption failed:", error);
return null;
}
}
}
// Security utilities
function isSensitiveRequest(url) {
return SECURITY_CONFIG.sensitivePatterns.some((pattern) => pattern.test(url));
}
function isAllowedOrigin(origin) {
return SECURITY_CONFIG.allowedOrigins.includes(origin);
}
function shouldEncryptCache(url) {
return SECURITY_CONFIG.secureCachePatterns.some((pattern) =>
pattern.test(url)
);
}
function sanitizeResponse(response, url) {
// Remove sensitive headers from cached responses
const sanitizedHeaders = new Headers();
// Copy safe headers only
const safeHeaders = [
"content-type",
"cache-control",
"expires",
"last-modified",
"etag",
];
safeHeaders.forEach((header) => {
if (response.headers.has(header)) {
sanitizedHeaders.set(header, response.headers.get(header));
}
});
// Add security headers
sanitizedHeaders.set("X-Cached-By", "ServiceWorker");
sanitizedHeaders.set("X-Cache-Timestamp", new Date().toISOString());
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: sanitizedHeaders,
});
}
// Install event - cache static resources securely
self.addEventListener("install", (event) => {
console.log("Secure Service Worker: Installing...");
event.waitUntil(
(async () => {
try {
const cache = await caches.open(CACHE_NAME);
console.log("Service Worker: Caching static resources");
await cache.addAll(STATIC_CACHE_URLS);
// Force activation of the new service worker
await self.skipWaiting();
} catch (error) {
console.error(
"Service Worker: Failed to cache static resources",
error
);
}
})()
);
});
// Activate event - clean up old caches
self.addEventListener("activate", (event) => {
console.log("Secure Service Worker: Activating...");
event.waitUntil(
(async () => {
try {
const cacheNames = await caches.keys();
// Delete old caches
await Promise.all(
cacheNames.map(async (cacheName) => {
if (
cacheName !== CACHE_NAME &&
cacheName !== API_CACHE_NAME &&
cacheName !== SECURE_CACHE_NAME
) {
console.log("Service Worker: Deleting old cache", cacheName);
await caches.delete(cacheName);
}
})
);
// Take control of all clients
await self.clients.claim();
} catch (error) {
console.error("Service Worker: Activation failed", error);
}
})()
);
});
// Fetch event - secure request handling
self.addEventListener("fetch", (event) => {
const request = event.request;
const url = new URL(request.url);
// Security checks
if (!isAllowedOrigin(url.origin) && url.origin !== self.location.origin) {
console.warn(
"Service Worker: Blocked request to unauthorized origin",
url.origin
);
return;
}
// Never cache sensitive requests
if (isSensitiveRequest(url.pathname)) {
console.log(
"Service Worker: Bypassing cache for sensitive request",
url.pathname
);
event.respondWith(fetch(request));
return;
}
event.respondWith(
(async () => {
try {
// Try cache first for static resources
if (request.method === "GET") {
const cachedResponse = await caches.match(request);
if (cachedResponse) {
// Check cache age
const cacheTimestamp =
cachedResponse.headers.get("X-Cache-Timestamp");
if (cacheTimestamp) {
const age = Date.now() - new Date(cacheTimestamp).getTime();
if (age > SECURITY_CONFIG.maxCacheAge) {
console.log(
"Service Worker: Cache expired, fetching fresh",
url.pathname
);
// Cache expired, fetch fresh
} else {
console.log("Service Worker: Serving from cache", url.pathname);
return cachedResponse;
}
} else {
return cachedResponse;
}
}
}
// Fetch from network
const response = await fetch(request);
// Cache successful GET responses
if (request.method === "GET" && response.status === 200) {
try {
const cache = await caches.open(
shouldEncryptCache(url.pathname) ? SECURE_CACHE_NAME : CACHE_NAME
);
// Sanitize response before caching
const sanitizedResponse = sanitizeResponse(
response.clone(),
url.pathname
);
// For secure endpoints, encrypt the data
if (
shouldEncryptCache(url.pathname) &&
SECURITY_CONFIG.encryptSensitiveData
) {
const responseData = await response.clone().json();
const encryptedData = await CacheEncryption.encrypt(responseData);
if (encryptedData) {
const encryptedResponse = new Response(
JSON.stringify({
encrypted: true,
data: encryptedData,
}),
{
headers: sanitizedResponse.headers,
}
);
await cache.put(request, encryptedResponse);
}
} else {
await cache.put(request, sanitizedResponse);
}
} catch (cacheError) {
console.warn(
"Service Worker: Failed to cache response",
cacheError
);
}
}
return response;
} catch (error) {
console.error("Service Worker: Fetch failed", error);
// Return offline page for navigation requests
if (request.mode === "navigate") {
const offlineResponse = await caches.match(OFFLINE_URL);
if (offlineResponse) {
return offlineResponse;
}
}
// Return basic error response
return new Response(
JSON.stringify({
error: "Network error",
message: "Unable to fetch resource",
offline: true,
}),
{
status: 503,
headers: {
"Content-Type": "application/json",
"X-Error-Source": "ServiceWorker",
},
}
);
}
})()
);
});
// Message handling for cache management
self.addEventListener("message", (event) => {
if (event.data && event.data.type) {
switch (event.data.type) {
case "CLEAR_CACHE":
clearCache();
break;
case "CLEAR_SENSITIVE_CACHE":
clearSensitiveCache();
break;
case "UPDATE_SECURITY_CONFIG":
// In a real implementation, validate and update security config
console.log("Service Worker: Security config update requested");
break;
}
}
});
async function clearCache() {
try {
const cacheNames = await caches.keys();
await Promise.all(cacheNames.map((name) => caches.delete(name)));
console.log("Service Worker: All caches cleared");
} catch (error) {
console.error("Service Worker: Failed to clear cache", error);
}
}
async function clearSensitiveCache() {
try {
await caches.delete(SECURE_CACHE_NAME);
console.log("Service Worker: Sensitive cache cleared");
} catch (error) {
console.error("Service Worker: Failed to clear sensitive cache", error);
}
}
+318 -307
View File
@@ -1,407 +1,418 @@
const CACHE_NAME = 'wizards-grimoire-v1.0.0';
const OFFLINE_URL = '/offline.html';
const API_CACHE_NAME = 'api-cache-v1';
const CACHE_NAME = "liferpg-v1.0.0";
const OFFLINE_URL = "/offline.html";
const API_CACHE_NAME = "api-cache-v1";
// Resources to cache immediately
const STATIC_CACHE_URLS = [
'/',
'/static/js/bundle.js',
'/static/css/main.css',
'/manifest.json',
'/icon-192x192.png',
'/icon-512x512.png',
OFFLINE_URL
"/",
"/static/js/bundle.js",
"/static/css/main.css",
"/manifest.json",
"/icon-72x72.png",
"/icon-96x96.png",
"/icon-128x128.png",
"/icon-144x144.png",
"/icon-152x152.png",
"/icon-192x192.png",
"/icon-384x384.png",
"/icon-512x512.png",
OFFLINE_URL,
];
// API endpoints to cache
const API_CACHE_PATTERNS = [
/\/api\/v1\/habits$/,
/\/api\/v1\/user\/profile$/,
/\/api\/v1\/analytics/
/\/api\/v1\/habits$/,
/\/api\/v1\/habits\/today$/,
/\/api\/v1\/user\/profile$/,
/\/api\/v1\/analytics/,
/\/api\/v1\/gamification/,
];
// Install event - cache static resources
self.addEventListener('install', (event) => {
console.log('Service Worker: Installing...');
self.addEventListener("install", (event) => {
console.log("Service Worker: Installing...");
event.waitUntil(
(async () => {
try {
const cache = await caches.open(CACHE_NAME);
console.log('Service Worker: Caching static resources');
await cache.addAll(STATIC_CACHE_URLS);
event.waitUntil(
(async () => {
try {
const cache = await caches.open(CACHE_NAME);
console.log("Service Worker: Caching static resources");
await cache.addAll(STATIC_CACHE_URLS);
// Force activation of the new service worker
await self.skipWaiting();
} catch (error) {
console.error('Service Worker: Failed to cache static resources', error);
}
})()
);
// Force activation of the new service worker
await self.skipWaiting();
} catch (error) {
console.error(
"Service Worker: Failed to cache static resources",
error
);
}
})()
);
});
// Activate event - clean up old caches
self.addEventListener('activate', (event) => {
console.log('Service Worker: Activating...');
self.addEventListener("activate", (event) => {
console.log("Service Worker: Activating...");
event.waitUntil(
(async () => {
try {
const cacheNames = await caches.keys();
await Promise.all(
cacheNames
.filter(cacheName =>
cacheName !== CACHE_NAME &&
cacheName !== API_CACHE_NAME
)
.map(cacheName => {
console.log('Service Worker: Deleting old cache', cacheName);
return caches.delete(cacheName);
})
);
event.waitUntil(
(async () => {
try {
const cacheNames = await caches.keys();
await Promise.all(
cacheNames
.filter(
(cacheName) =>
cacheName !== CACHE_NAME && cacheName !== API_CACHE_NAME
)
.map((cacheName) => {
console.log("Service Worker: Deleting old cache", cacheName);
return caches.delete(cacheName);
})
);
// Take control of all clients
await self.clients.claim();
} catch (error) {
console.error('Service Worker: Failed to activate', error);
}
})()
);
// Take control of all clients
await self.clients.claim();
} catch (error) {
console.error("Service Worker: Failed to activate", error);
}
})()
);
});
// Fetch event - serve cached content when offline
self.addEventListener('fetch', (event) => {
// Skip non-GET requests for modification requests
const url = new URL(event.request.url);
self.addEventListener("fetch", (event) => {
// Skip non-GET requests for modification requests
const url = new URL(event.request.url);
// Skip chrome-extension requests
if (event.request.url.startsWith('chrome-extension://')) return;
// Skip chrome-extension requests
if (event.request.url.startsWith("chrome-extension://")) return;
event.respondWith(
(async () => {
try {
// Handle API requests
if (url.pathname.startsWith('/api/')) {
return await handleApiRequest(event.request);
}
event.respondWith(
(async () => {
try {
// Handle API requests
if (url.pathname.startsWith("/api/")) {
return await handleApiRequest(event.request);
}
// Handle navigation requests
if (event.request.mode === 'navigate') {
return await handleNavigationRequest(event.request);
}
// Handle navigation requests
if (event.request.mode === "navigate") {
return await handleNavigationRequest(event.request);
}
// Handle static resource requests
return await handleStaticRequest(event.request);
} catch (error) {
console.error('Service Worker: Fetch error', error);
return await handleFallback(event.request);
}
})()
);
// Handle static resource requests
return await handleStaticRequest(event.request);
} catch (error) {
console.error("Service Worker: Fetch error", error);
return await handleFallback(event.request);
}
})()
);
});
// Handle API requests with cache-first strategy for GET requests
async function handleApiRequest(request) {
const url = new URL(request.url);
const shouldCache = API_CACHE_PATTERNS.some(pattern => pattern.test(url.pathname));
const url = new URL(request.url);
const shouldCache = API_CACHE_PATTERNS.some((pattern) =>
pattern.test(url.pathname)
);
if (shouldCache && request.method === 'GET') {
try {
// Try cache first for API requests
const cachedResponse = await caches.match(request);
if (cachedResponse) {
// Return cached response and update in background
updateApiCache(request);
return cachedResponse;
}
// Fetch from network and cache
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(API_CACHE_NAME);
cache.put(request, response.clone());
}
return response;
} catch (error) {
// Return cached version if network fails
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
throw error;
}
}
// For non-cached API requests, try network first
if (shouldCache && request.method === "GET") {
try {
const response = await fetch(request);
// Try cache first for API requests
const cachedResponse = await caches.match(request);
if (cachedResponse) {
// Return cached response and update in background
updateApiCache(request);
return cachedResponse;
}
// If it's a POST/PUT/DELETE request that modifies data, store it for sync
if (['POST', 'PUT', 'DELETE'].includes(request.method)) {
await storeOfflineAction(request);
}
return response;
// Fetch from network and cache
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(API_CACHE_NAME);
cache.put(request, response.clone());
}
return response;
} catch (error) {
// Store the action for later sync
if (['POST', 'PUT', 'DELETE'].includes(request.method)) {
await storeOfflineAction(request);
return new Response(JSON.stringify({ success: true, offline: true }), {
headers: { 'Content-Type': 'application/json' }
});
}
throw error;
// Return cached version if network fails
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
throw error;
}
}
// For non-cached API requests, try network first
try {
const response = await fetch(request);
// If it's a POST/PUT/DELETE request that modifies data, store it for sync
if (["POST", "PUT", "DELETE"].includes(request.method)) {
await storeOfflineAction(request);
}
return response;
} catch (error) {
// Store the action for later sync
if (["POST", "PUT", "DELETE"].includes(request.method)) {
await storeOfflineAction(request);
return new Response(JSON.stringify({ success: true, offline: true }), {
headers: { "Content-Type": "application/json" },
});
}
throw error;
}
}
// Handle navigation requests
async function handleNavigationRequest(request) {
try {
const response = await fetch(request);
return response;
} catch (error) {
// Return cached version or offline page
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
const offlineResponse = await caches.match(OFFLINE_URL);
return offlineResponse || new Response('Offline', { status: 200 });
try {
const response = await fetch(request);
return response;
} catch (error) {
// Return cached version or offline page
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
const offlineResponse = await caches.match(OFFLINE_URL);
return offlineResponse || new Response("Offline", { status: 200 });
}
}
// Handle static resource requests
async function handleStaticRequest(request) {
// Try cache first for static resources
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
// Try cache first for static resources
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
// If not in cache, fetch from network
try {
const response = await fetch(request);
// Cache successful responses
if (response.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
}
// If not in cache, fetch from network
try {
const response = await fetch(request);
// Cache successful responses
if (response.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
}
return response;
} catch (error) {
throw error;
}
return response;
} catch (error) {
throw error;
}
}
// Fallback handler
async function handleFallback(request) {
if (request.mode === 'navigate') {
const offlineResponse = await caches.match(OFFLINE_URL);
return offlineResponse || new Response('Offline', { status: 200 });
}
if (request.mode === "navigate") {
const offlineResponse = await caches.match(OFFLINE_URL);
return offlineResponse || new Response("Offline", { status: 200 });
}
return new Response('Resource not available offline', { status: 503 });
return new Response("Resource not available offline", { status: 503 });
}
// Update API cache in background
async function updateApiCache(request) {
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(API_CACHE_NAME);
await cache.put(request, response);
}
} catch (error) {
console.log('Background update failed:', error);
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(API_CACHE_NAME);
await cache.put(request, response);
}
} catch (error) {
console.log("Background update failed:", error);
}
}
// Store offline actions for later sync
async function storeOfflineAction(request) {
try {
const action = {
url: request.url,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
body: await request.text(),
timestamp: Date.now()
};
try {
const action = {
url: request.url,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
body: await request.text(),
timestamp: Date.now(),
};
const existingActions = await getStoredActions();
existingActions.push(action);
const existingActions = await getStoredActions();
existingActions.push(action);
// Store in IndexedDB or localStorage fallback
const storage = await getOfflineStorage();
await storage.setItem('offline-actions', JSON.stringify(existingActions));
} catch (error) {
console.error('Failed to store offline action:', error);
}
// Store in IndexedDB or localStorage fallback
const storage = await getOfflineStorage();
await storage.setItem("offline-actions", JSON.stringify(existingActions));
} catch (error) {
console.error("Failed to store offline action:", error);
}
}
// Get stored offline actions
async function getStoredActions() {
try {
const storage = await getOfflineStorage();
const actions = await storage.getItem('offline-actions');
return actions ? JSON.parse(actions) : [];
} catch (error) {
console.error('Failed to get stored actions:', error);
return [];
}
try {
const storage = await getOfflineStorage();
const actions = await storage.getItem("offline-actions");
return actions ? JSON.parse(actions) : [];
} catch (error) {
console.error("Failed to get stored actions:", error);
return [];
}
}
// Simple storage abstraction
async function getOfflineStorage() {
// Try to use IndexedDB, fallback to cache storage
return {
async getItem(key) {
const cache = await caches.open('offline-storage');
const response = await cache.match(`/${key}`);
return response ? await response.text() : null;
},
// Try to use IndexedDB, fallback to cache storage
return {
async getItem(key) {
const cache = await caches.open("offline-storage");
const response = await cache.match(`/${key}`);
return response ? await response.text() : null;
},
async setItem(key, value) {
const cache = await caches.open('offline-storage');
await cache.put(`/${key}`, new Response(value));
}
};
async setItem(key, value) {
const cache = await caches.open("offline-storage");
await cache.put(`/${key}`, new Response(value));
},
};
}
// Background sync event
self.addEventListener('sync', (event) => {
if (event.tag === 'background-sync') {
event.waitUntil(syncOfflineActions());
}
self.addEventListener("sync", (event) => {
if (event.tag === "background-sync") {
event.waitUntil(syncOfflineActions());
}
});
// Sync offline actions when back online
async function syncOfflineActions() {
try {
const actions = await getStoredActions();
const successfulSyncs = [];
try {
const actions = await getStoredActions();
const successfulSyncs = [];
for (const action of actions) {
try {
const request = new Request(action.url, {
method: action.method,
headers: action.headers,
body: action.body || undefined
});
for (const action of actions) {
try {
const request = new Request(action.url, {
method: action.method,
headers: action.headers,
body: action.body || undefined,
});
const response = await fetch(request);
const response = await fetch(request);
if (response.ok) {
successfulSyncs.push(action);
}
} catch (error) {
console.error('Failed to sync action:', error);
}
if (response.ok) {
successfulSyncs.push(action);
}
// Remove successfully synced actions
if (successfulSyncs.length > 0) {
const remainingActions = actions.filter(
action => !successfulSyncs.includes(action)
);
const storage = await getOfflineStorage();
await storage.setItem('offline-actions', JSON.stringify(remainingActions));
}
} catch (error) {
console.error('Background sync failed:', error);
} catch (error) {
console.error("Failed to sync action:", error);
}
}
// Remove successfully synced actions
if (successfulSyncs.length > 0) {
const remainingActions = actions.filter(
(action) => !successfulSyncs.includes(action)
);
const storage = await getOfflineStorage();
await storage.setItem(
"offline-actions",
JSON.stringify(remainingActions)
);
}
} catch (error) {
console.error("Background sync failed:", error);
}
}
// Push notification event
self.addEventListener('push', (event) => {
if (!event.data) return;
self.addEventListener("push", (event) => {
if (!event.data) return;
try {
const data = event.data.json();
try {
const data = event.data.json();
const options = {
body: data.body || 'Time to practice your magical habits!',
icon: '/icon-192x192.png',
badge: '/icon-72x72.png',
image: data.image,
vibrate: [200, 100, 200],
data: {
url: data.url || '/',
action: data.action || 'open'
},
actions: [
{
action: 'complete',
title: '✓ Mark Complete',
icon: '/icon-72x72.png'
},
{
action: 'view',
title: '👁 View Details',
icon: '/icon-72x72.png'
}
],
requireInteraction: true,
tag: data.tag || 'habit-reminder'
};
const options = {
body: data.body || "Time to practice your magical habits!",
icon: "/icon-192x192.png",
badge: "/icon-72x72.png",
image: data.image,
vibrate: [200, 100, 200],
data: {
url: data.url || "/",
action: data.action || "open",
},
actions: [
{
action: "complete",
title: "✓ Mark Complete",
icon: "/icon-72x72.png",
},
{
action: "view",
title: "👁 View Details",
icon: "/icon-72x72.png",
},
],
requireInteraction: true,
tag: data.tag || "habit-reminder",
};
event.waitUntil(
self.registration.showNotification(
data.title || '🧙‍♂️ Grimoire Reminder',
options
)
);
} catch (error) {
console.error('Push notification error:', error);
}
event.waitUntil(
self.registration.showNotification(
data.title || "🧙‍♂️ Grimoire Reminder",
options
)
);
} catch (error) {
console.error("Push notification error:", error);
}
});
// Notification click event
self.addEventListener('notificationclick', (event) => {
event.notification.close();
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const action = event.action;
const data = event.notification.data;
const action = event.action;
const data = event.notification.data;
if (action === 'complete') {
// Handle habit completion
event.waitUntil(handleHabitCompletion(data));
} else {
// Open the app
event.waitUntil(
clients.openWindow(data.url || '/')
);
}
if (action === "complete") {
// Handle habit completion
event.waitUntil(handleHabitCompletion(data));
} else {
// Open the app
event.waitUntil(clients.openWindow(data.url || "/"));
}
});
// Handle habit completion from notification
async function handleHabitCompletion(data) {
try {
if (data.habitId) {
// Store completion for sync
await storeOfflineAction(new Request(`/api/v1/habits/${data.habitId}/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ completedAt: new Date().toISOString() })
}));
try {
if (data.habitId) {
// Store completion for sync
await storeOfflineAction(
new Request(`/api/v1/habits/${data.habitId}/complete`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ completedAt: new Date().toISOString() }),
})
);
// Show success notification
await self.registration.showNotification('✅ Habit Completed!', {
body: 'Great job! Your progress has been recorded.',
icon: '/icon-192x192.png',
tag: 'completion-success'
});
}
} catch (error) {
console.error('Failed to complete habit:', error);
// Show success notification
await self.registration.showNotification("✅ Habit Completed!", {
body: "Great job! Your progress has been recorded.",
icon: "/icon-192x192.png",
tag: "completion-success",
});
}
} catch (error) {
console.error("Failed to complete habit:", error);
}
}
console.log('Service Worker: Loaded');
console.log("Service Worker: Loaded");
+480 -290
View File
@@ -1,310 +1,500 @@
import React, { useState, useEffect } from 'react';
import MainDashboard from './components/MainDashboard';
import ScryingPortal from './components/ScryingPortal';
import SocialFeatures from './components/SocialFeatures';
import NotificationSettings from './components/NotificationSettings';
import PerformanceOptimization from './components/PerformanceOptimization';
import MobileAppEnhancement from './components/MobileAppEnhancement';
import { Card, CardHeader, CardTitle, CardContent } from './components/ui/card';
import { Button } from './components/ui/button';
import { Input } from './components/ui/input';
import { User, Lock, Mail, BarChart3, Users, Settings, Zap, Smartphone, Home } from 'lucide-react';
import React, { useState, useEffect } from "react";
import MainDashboard from "./components/MainDashboard";
import ScryingPortal from "./components/ScryingPortal";
import SocialFeatures from "./components/SocialFeatures";
import NotificationSettings from "./components/NotificationSettings";
import PerformanceOptimization from "./components/PerformanceOptimization";
import MobileAppEnhancement from "./components/MobileAppEnhancement";
import FloatingHUD from "./components/FloatingHUD";
import NotificationSystem from "./components/NotificationSystem";
import PredictiveAnalyticsUI from "./components/PredictiveAnalyticsUI";
import VoiceImageInput from "./components/VoiceImageInput";
import {
KeyboardShortcutsProvider,
useKeyboardActions,
KeyboardShortcutsHelp,
} from "./hooks/useKeyboardShortcuts";
import { useNotifications } from "./components/NotificationSystem";
import { useHUDData } from "./components/FloatingHUD";
import { Card, CardHeader, CardTitle, CardContent } from "./components/ui/card";
import { Button } from "./components/ui/button";
import { Input } from "./components/ui/input";
import {
User,
Lock,
Mail,
BarChart3,
Users,
Settings,
Zap,
Smartphone,
Home,
Sparkles,
Brain,
Mic,
Camera,
} from "lucide-react";
import NaturalLanguageHabitCreator from "./components/NaturalLanguageHabitCreator";
const App = () => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [loginForm, setLoginForm] = useState({ email: '', password: '' });
const [registering, setRegistering] = useState(false);
const [currentView, setCurrentView] = useState('dashboard');
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [loginForm, setLoginForm] = useState({ email: "", password: "" });
const [registering, setRegistering] = useState(false);
const [currentView, setCurrentView] = useState("dashboard");
const [hudVisible, setHudVisible] = useState(true);
const [showShortcutsHelp, setShowShortcutsHelp] = useState(false);
const [showNLPHabitCreator, setShowNLPHabitCreator] = useState(false);
useEffect(() => {
checkAuth();
registerServiceWorker();
}, []);
// Initialize new systems
const {
showAchievement,
showXPGain,
showLevelUp,
showStreak,
showHabitComplete,
} = useNotifications();
const { lastAction } = useKeyboardActions();
const { hudData, loading: hudLoading } = useHUDData(user?.id);
const registerServiceWorker = async () => {
if ('serviceWorker' in navigator) {
try {
const registration = await navigator.serviceWorker.register('/sw.js');
console.log('Service Worker registered:', registration);
} catch (error) {
console.error('Service Worker registration failed:', error);
}
}
};
useEffect(() => {
checkAuth();
registerServiceWorker();
}, []);
const checkAuth = async () => {
const token = localStorage.getItem('token');
if (!token) {
setLoading(false);
return;
}
const registerServiceWorker = async () => {
if ("serviceWorker" in navigator) {
try {
const registration = await navigator.serviceWorker.register("/sw.js");
console.log("Service Worker registered:", registration);
} catch (error) {
console.error("Service Worker registration failed:", error);
}
}
};
try {
const response = await fetch('/api/v1/me', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
const userData = await response.json();
setUser(userData);
} else {
localStorage.removeItem('token');
}
} catch (error) {
console.error('Auth check failed:', error);
localStorage.removeItem('token');
} finally {
setLoading(false);
}
};
const handleLogin = async (e) => {
e.preventDefault();
setLoading(true);
try {
const response = await fetch('/api/v1/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(loginForm)
});
if (response.ok) {
const data = await response.json();
localStorage.setItem('token', data.token);
setUser(data.user);
} else {
const error = await response.json();
alert(error.detail || 'Login failed');
}
} catch (error) {
console.error('Login failed:', error);
alert('Login failed. Please try again.');
} finally {
setLoading(false);
}
};
const handleRegister = async (e) => {
e.preventDefault();
setLoading(true);
try {
const response = await fetch('/api/v1/auth/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
...loginForm,
display_name: loginForm.email.split('@')[0] // Simple display name
})
});
if (response.ok) {
const data = await response.json();
localStorage.setItem('token', data.token);
setUser(data.user);
} else {
const error = await response.json();
alert(error.detail || 'Registration failed');
}
} catch (error) {
console.error('Registration failed:', error);
alert('Registration failed. Please try again.');
} finally {
setLoading(false);
}
};
const handleLogout = () => {
localStorage.removeItem('token');
setUser(null);
};
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 mx-auto mb-4"></div>
<p className="text-gray-600">Loading...</p>
</div>
</div>
);
const checkAuth = async () => {
const token = localStorage.getItem("token");
if (!token) {
setLoading(false);
return;
}
if (!user) {
return (
<div className="min-h-screen bg-gradient-to-br from-purple-900 via-blue-900 to-indigo-900 flex items-center justify-center px-4">
<Card className="w-full max-w-md bg-gradient-to-b from-purple-100 to-blue-50 border-2 border-gold-400 shadow-2xl">
<CardHeader className="text-center">
<div className="w-16 h-16 bg-gradient-to-br from-purple-600 to-indigo-600 rounded-full flex items-center justify-center mx-auto mb-4 shadow-lg">
<span className="text-2xl">🔮</span>
</div>
<CardTitle className="text-2xl text-purple-900">
{registering ? 'Join the Academy' : 'Welcome to The Wizard\'s Grimoire'}
</CardTitle>
<p className="text-purple-700 mt-2">
{registering
? 'Begin your magical journey and master daily spells'
: 'Enter your sanctum to practice spells and unlock mystical powers'
}
</p>
</CardHeader>
<CardContent>
<form onSubmit={registering ? handleRegister : handleLogin} className="space-y-4">
<div>
<label className="text-sm font-medium text-purple-800">Mystic Email</label>
<div className="relative">
<Mail className="absolute left-3 top-3 h-4 w-4 text-purple-500" />
<Input
type="email"
placeholder="Enter your mystical contact"
value={loginForm.email}
onChange={(e) => setLoginForm({ ...loginForm, email: e.target.value })}
className="pl-10 border-purple-300 focus:border-purple-500"
required
/>
</div>
</div>
try {
const response = await fetch("/api/v1/me", {
headers: {
Authorization: `Bearer ${token}`,
},
});
<div>
<label className="text-sm font-medium text-purple-800">Arcane Password</label>
<div className="relative">
<Lock className="absolute left-3 top-3 h-4 w-4 text-purple-500" />
<Input
type="password"
placeholder="Speak the secret incantation"
value={loginForm.password}
onChange={(e) => setLoginForm({ ...loginForm, password: e.target.value })}
className="pl-10 border-purple-300 focus:border-purple-500"
required
/>
</div>
</div>
<Button
type="submit"
className="w-full bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 text-white shadow-lg"
disabled={loading}
>
{loading ? 'Summoning...' : registering ? 'Begin Journey' : 'Enter Sanctum'}
</Button>
</form>
<div className="mt-6 text-center">
<button
type="button"
onClick={() => setRegistering(!registering)}
className="text-purple-600 hover:text-purple-700 text-sm font-medium"
>
{registering
? 'Already initiated? Enter your sanctum'
: "New to magic? Join the Academy"
}
</button>
</div>
{!registering && (
<div className="mt-4 text-center">
<p className="text-xs text-purple-600">
🧙 Demo: Use any incantation to create a practice realm
</p>
</div>
)}
</CardContent>
</Card>
</div>
);
if (response.ok) {
const userData = await response.json();
setUser(userData);
} else {
localStorage.removeItem("token");
}
} catch (error) {
console.error("Auth check failed:", error);
localStorage.removeItem("token");
} finally {
setLoading(false);
}
};
// Navigation component
const Navigation = () => (
<div className="bg-slate-800 border-b border-purple-500/30 p-4 mb-6">
<div className="flex flex-wrap gap-2">
<Button
onClick={() => setCurrentView('dashboard')}
variant={currentView === 'dashboard' ? 'default' : 'outline'}
className="flex items-center gap-2"
>
<Home className="w-4 h-4" />
Dashboard
</Button>
<Button
onClick={() => setCurrentView('analytics')}
variant={currentView === 'analytics' ? 'default' : 'outline'}
className="flex items-center gap-2"
>
<BarChart3 className="w-4 h-4" />
Analytics
</Button>
<Button
onClick={() => setCurrentView('social')}
variant={currentView === 'social' ? 'default' : 'outline'}
className="flex items-center gap-2"
>
<Users className="w-4 h-4" />
Social
</Button>
<Button
onClick={() => setCurrentView('notifications')}
variant={currentView === 'notifications' ? 'default' : 'outline'}
className="flex items-center gap-2"
>
<Settings className="w-4 h-4" />
Notifications
</Button>
<Button
onClick={() => setCurrentView('performance')}
variant={currentView === 'performance' ? 'default' : 'outline'}
className="flex items-center gap-2"
>
<Zap className="w-4 h-4" />
Performance
</Button>
<Button
onClick={() => setCurrentView('mobile')}
variant={currentView === 'mobile' ? 'default' : 'outline'}
className="flex items-center gap-2"
>
<Smartphone className="w-4 h-4" />
Mobile
</Button>
</div>
</div>
);
const handleLogin = async (e) => {
e.preventDefault();
setLoading(true);
// Render current view
const renderCurrentView = () => {
switch (currentView) {
case 'analytics':
return <ScryingPortal />;
case 'social':
return <SocialFeatures />;
case 'notifications':
return <NotificationSettings />;
case 'performance':
return <PerformanceOptimization />;
case 'mobile':
return <MobileAppEnhancement />;
default:
return <MainDashboard user={user} onLogout={handleLogout} />;
}
try {
const response = await fetch("/api/v1/auth/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(loginForm),
});
if (response.ok) {
const data = await response.json();
localStorage.setItem("token", data.token);
setUser(data.user);
} else {
const error = await response.json();
alert(error.detail || "Login failed");
}
} catch (error) {
console.error("Login failed:", error);
alert("Login failed. Please try again.");
} finally {
setLoading(false);
}
};
const handleRegister = async (e) => {
e.preventDefault();
setLoading(true);
try {
const response = await fetch("/api/v1/auth/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
...loginForm,
display_name: loginForm.email.split("@")[0], // Simple display name
}),
});
if (response.ok) {
const data = await response.json();
localStorage.setItem("token", data.token);
setUser(data.user);
} else {
const error = await response.json();
alert(error.detail || "Registration failed");
}
} catch (error) {
console.error("Registration failed:", error);
alert("Registration failed. Please try again.");
} finally {
setLoading(false);
}
};
const handleLogout = () => {
localStorage.removeItem("token");
setUser(null);
};
// Handle keyboard actions
useEffect(() => {
if (!lastAction) return;
switch (lastAction) {
case "toggle-hud":
setHudVisible(!hudVisible);
break;
case "show-shortcuts":
setShowShortcutsHelp(true);
break;
case "focus-search":
// This will be handled by the search component
break;
case "add-habit":
setCurrentView("dashboard");
// Trigger add habit modal
break;
case "show-analytics":
setCurrentView("analytics");
break;
case "show-skills":
// Show skills view (could be a new view)
break;
}
}, [lastAction, hudVisible]);
// Listen for habit completion events to show notifications
useEffect(() => {
const handleHabitComplete = (event) => {
const { habitName, xpAwarded, streakCount } = event.detail;
showHabitComplete(habitName, xpAwarded);
if (streakCount && streakCount % 5 === 0) {
showStreak(habitName, streakCount);
}
};
const handleAchievementUnlock = (event) => {
const { name, description, xpAwarded } = event.detail;
showAchievement(name, description, xpAwarded);
};
const handleLevelUp = (event) => {
const { newLevel, xpAwarded } = event.detail;
showLevelUp(newLevel, xpAwarded);
};
const handleXPGain = (event) => {
const { amount, source } = event.detail;
showXPGain(amount, source);
};
window.addEventListener("habit-completed", handleHabitComplete);
window.addEventListener("achievement-unlocked", handleAchievementUnlock);
window.addEventListener("level-up", handleLevelUp);
window.addEventListener("xp-gained", handleXPGain);
return () => {
window.removeEventListener("habit-completed", handleHabitComplete);
window.removeEventListener(
"achievement-unlocked",
handleAchievementUnlock
);
window.removeEventListener("level-up", handleLevelUp);
window.removeEventListener("xp-gained", handleXPGain);
};
}, [showHabitComplete, showStreak, showAchievement, showLevelUp, showXPGain]);
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900">
<Navigation />
<div className="container mx-auto px-4">
{renderCurrentView()}
</div>
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 mx-auto mb-4"></div>
<p className="text-gray-600">Loading...</p>
</div>
</div>
);
}
if (!user) {
return (
<div className="min-h-screen bg-gradient-to-br from-purple-900 via-blue-900 to-indigo-900 flex items-center justify-center px-4">
<Card className="w-full max-w-md bg-gradient-to-b from-purple-100 to-blue-50 border-2 border-gold-400 shadow-2xl">
<CardHeader className="text-center">
<div className="w-16 h-16 bg-gradient-to-br from-purple-600 to-indigo-600 rounded-full flex items-center justify-center mx-auto mb-4 shadow-lg">
<span className="text-2xl">🔮</span>
</div>
<CardTitle className="text-2xl text-purple-900">
{registering
? "Join the Academy"
: "Welcome to The Wizard's Grimoire"}
</CardTitle>
<p className="text-purple-700 mt-2">
{registering
? "Begin your magical journey and master daily spells"
: "Enter your sanctum to practice spells and unlock mystical powers"}
</p>
</CardHeader>
<CardContent>
<form
onSubmit={registering ? handleRegister : handleLogin}
className="space-y-4"
>
<div>
<label className="text-sm font-medium text-purple-800">
Mystic Email
</label>
<div className="relative">
<Mail className="absolute left-3 top-3 h-4 w-4 text-purple-500" />
<Input
type="email"
placeholder="Enter your mystical contact"
value={loginForm.email}
onChange={(e) =>
setLoginForm({ ...loginForm, email: e.target.value })
}
className="pl-10 border-purple-300 focus:border-purple-500"
required
/>
</div>
</div>
<div>
<label className="text-sm font-medium text-purple-800">
Arcane Password
</label>
<div className="relative">
<Lock className="absolute left-3 top-3 h-4 w-4 text-purple-500" />
<Input
type="password"
placeholder="Speak the secret incantation"
value={loginForm.password}
onChange={(e) =>
setLoginForm({ ...loginForm, password: e.target.value })
}
className="pl-10 border-purple-300 focus:border-purple-500"
required
/>
</div>
</div>
<Button
type="submit"
className="w-full bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 text-white shadow-lg"
disabled={loading}
>
{loading
? "Summoning..."
: registering
? "Begin Journey"
: "Enter Sanctum"}
</Button>
</form>
<div className="mt-6 text-center">
<button
type="button"
onClick={() => setRegistering(!registering)}
className="text-purple-600 hover:text-purple-700 text-sm font-medium"
>
{registering
? "Already initiated? Enter your sanctum"
: "New to magic? Join the Academy"}
</button>
</div>
{!registering && (
<div className="mt-4 text-center">
<p className="text-xs text-purple-600">
🧙 Demo: Use any incantation to create a practice realm
</p>
</div>
)}
</CardContent>
</Card>
</div>
);
}
// Navigation component
const Navigation = () => (
<div className="bg-slate-800 border-b border-purple-500/30 p-4 mb-6">
<div className="flex flex-wrap gap-2">
<Button
onClick={() => setCurrentView("dashboard")}
variant={currentView === "dashboard" ? "default" : "outline"}
className="flex items-center gap-2"
>
<Home className="w-4 h-4" />
Dashboard
</Button>
<Button
onClick={() => setCurrentView("analytics")}
variant={currentView === "analytics" ? "default" : "outline"}
className="flex items-center gap-2"
>
<BarChart3 className="w-4 h-4" />
Analytics
</Button>
<Button
onClick={() => setCurrentView("social")}
variant={currentView === "social" ? "default" : "outline"}
className="flex items-center gap-2"
>
<Users className="w-4 h-4" />
Social
</Button>
<Button
onClick={() => setCurrentView("notifications")}
variant={currentView === "notifications" ? "default" : "outline"}
className="flex items-center gap-2"
>
<Settings className="w-4 h-4" />
Notifications
</Button>
<Button
onClick={() => setCurrentView("performance")}
variant={currentView === "performance" ? "default" : "outline"}
className="flex items-center gap-2"
>
<Zap className="w-4 h-4" />
Performance
</Button>
<Button
onClick={() => setCurrentView("mobile")}
variant={currentView === "mobile" ? "default" : "outline"}
className="flex items-center gap-2"
>
<Smartphone className="w-4 h-4" />
Mobile
</Button>
<Button
onClick={() => setCurrentView("predictive")}
variant={currentView === "predictive" ? "default" : "outline"}
className="flex items-center gap-2"
>
<Brain className="w-4 h-4" />
AI Analytics
</Button>
<Button
onClick={() => setCurrentView("voice-image")}
variant={currentView === "voice-image" ? "default" : "outline"}
className="flex items-center gap-2"
>
<Mic className="w-4 h-4" />
<Camera className="w-4 h-4" />
Voice & Image
</Button>
</div>
</div>
);
// Render current view
const renderCurrentView = () => {
switch (currentView) {
case "analytics":
return <ScryingPortal />;
case "social":
return <SocialFeatures />;
case "notifications":
return <NotificationSettings />;
case "performance":
return <PerformanceOptimization />;
case "mobile":
return <MobileAppEnhancement />;
case "predictive":
return <PredictiveAnalyticsUI user={user} />;
case "voice-image":
return <VoiceImageInput user={user} />;
default:
return <MainDashboard user={user} onLogout={handleLogout} />;
}
};
return (
<KeyboardShortcutsProvider>
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900">
<Navigation />
<div className="container mx-auto px-4">
{showNLPHabitCreator ? (
<>
<Button
onClick={() => setShowNLPHabitCreator(false)}
className="m-4"
>
Back to Dashboard
</Button>
<NaturalLanguageHabitCreator
onHabitCreated={() => setShowNLPHabitCreator(false)}
/>
</>
) : (
<>
{renderCurrentView()}
<Button
onClick={() => setShowNLPHabitCreator(true)}
className="fixed bottom-6 right-6 z-50 bg-purple-600 text-white shadow-lg hover:bg-purple-700"
>
<Sparkles className="h-5 w-5 mr-2" />
Create Habit with AI
</Button>
</>
)}
</div>
{/* Modern HUD System - like AHK overlay */}
{user && hudVisible && !hudLoading && hudData && (
<FloatingHUD data={hudData} position="top-left" theme="wizard" />
)}
{/* Real-time Notification System */}
<NotificationSystem
maxNotifications={5}
defaultDuration={5000}
position="top-right"
/>
{/* Keyboard Shortcuts Help Modal */}
{showShortcutsHelp && (
<KeyboardShortcutsHelp
isOpen={showShortcutsHelp}
onClose={() => setShowShortcutsHelp(false)}
/>
)}
</div>
</KeyboardShortcutsProvider>
);
};
export default App;
+96 -69
View File
@@ -1,80 +1,107 @@
import React, { useState } from 'react'
import { api } from './api'
import React, { useState } from "react";
import { api } from "./api";
export default function TwoFASetup() {
const [otpauth, setOtpauth] = useState(null)
const [codes, setCodes] = useState([])
const [code, setCode] = useState('')
const [status, setStatus] = useState('idle')
const [error, setError] = useState(null)
const [otpauth, setOtpauth] = useState(null);
const [codes, setCodes] = useState([]);
const [code, setCode] = useState("");
const [status, setStatus] = useState("idle");
const [error, setError] = useState(null);
const [qrCode, setQrCode] = useState(null);
async function beginSetup() {
setError(null)
setStatus('loading')
try {
const res = await api('/v1/auth/2fa/setup', { method: 'POST' })
setOtpauth(res.otpauth_uri)
setCodes(res.recovery_codes || [])
setStatus('ready')
} catch (e) {
setError(String(e))
setStatus('idle')
}
async function beginSetup() {
setError(null);
setStatus("loading");
try {
const res = await api("/v1/auth/2fa/setup", { method: "POST" });
setOtpauth(res.otpauth_uri);
setCodes(res.recovery_codes || []);
// Get secure QR code from server
const qrRes = await api("/v1/auth/2fa/qr", { method: "GET" });
setQrCode(qrRes.qr_code);
setStatus("ready");
} catch (e) {
setError(String(e));
setStatus("idle");
}
}
async function enable2fa(e) {
e.preventDefault()
setError(null)
try {
await api('/v1/auth/2fa/enable', { method: 'POST', body: JSON.stringify({ code }) })
setStatus('enabled')
} catch (e) {
setError(String(e))
}
async function enable2fa(e) {
e.preventDefault();
setError(null);
try {
await api("/v1/auth/2fa/enable", {
method: "POST",
body: JSON.stringify({ code }),
});
setStatus("enabled");
} catch (e) {
setError(String(e));
}
}
// Convert otpauth URI to QR: use a public QR service for demo purposes
const qrUrl = otpauth ? `https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(otpauth)}` : null
return (
<div style={{ marginTop: 20 }}>
<h2>Two-Factor Auth (TOTP) Setup</h2>
<p>Step 1: Begin setup to get a QR and recovery codes.</p>
<button onClick={beginSetup} disabled={status === "loading"}>
Begin Setup
</button>
{status === "loading" && <div>Loading</div>}
{error && <div style={{ color: "crimson", marginTop: 8 }}>{error}</div>}
return (
<div style={{ marginTop: 20 }}>
<h2>Two-Factor Auth (TOTP) Setup</h2>
<p>Step 1: Begin setup to get a QR and recovery codes.</p>
<button onClick={beginSetup} disabled={status === 'loading'}>Begin Setup</button>
{status === 'loading' && <div>Loading</div>}
{error && <div style={{ color: 'crimson', marginTop: 8 }}>{error}</div>}
{otpauth && (
<div style={{ marginTop: 16, display: 'flex', gap: 24 }}>
<div>
<div><strong>Scan this QR in your authenticator</strong></div>
{qrUrl && <img src={qrUrl} alt="TOTP QR" width={180} height={180} />}
<div style={{ fontSize: 12, color: '#555', marginTop: 8 }}>If QR fails, use URI:<br />
<code style={{ wordBreak: 'break-all' }}>{otpauth}</code>
</div>
</div>
<div>
<div><strong>Recovery codes</strong> (save these now they won't be shown again)</div>
<ul>
{codes.map((c, i) => <li key={i}><code>{c}</code></li>)}
</ul>
</div>
</div>
)}
{otpauth && status !== 'enabled' && (
<form onSubmit={enable2fa} style={{ marginTop: 16 }}>
<div>Step 2: Enter the 6-digit code from your authenticator</div>
<input value={code} onChange={e => setCode(e.target.value)} placeholder="123456" />
<button type="submit" style={{ marginLeft: 8 }}>Enable 2FA</button>
</form>
)}
{status === 'enabled' && (
<div style={{ marginTop: 16, color: 'green' }}>
2FA enabled. Keep your recovery codes somewhere safe.
</div>
{otpauth && (
<div style={{ marginTop: 16, display: "flex", gap: 24 }}>
<div>
<div>
<strong>Scan this QR in your authenticator</strong>
</div>
{qrCode && (
<img src={qrCode} alt="TOTP QR" width={180} height={180} />
)}
<div style={{ fontSize: 12, color: "#555", marginTop: 8 }}>
If QR fails, use URI:
<br />
<code style={{ wordBreak: "break-all" }}>{otpauth}</code>
</div>
</div>
<div>
<div>
<strong>Recovery codes</strong> (save these now they won't be
shown again)
</div>
<ul>
{codes.map((c, i) => (
<li key={i}>
<code>{c}</code>
</li>
))}
</ul>
</div>
</div>
)
)}
{otpauth && status !== "enabled" && (
<form onSubmit={enable2fa} style={{ marginTop: 16 }}>
<div>Step 2: Enter the 6-digit code from your authenticator</div>
<input
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="123456"
/>
<button type="submit" style={{ marginLeft: 8 }}>
Enable 2FA
</button>
</form>
)}
{status === "enabled" && (
<div style={{ marginTop: 16, color: "green" }}>
2FA enabled. Keep your recovery codes somewhere safe.
</div>
)}
</div>
);
}
@@ -0,0 +1,594 @@
import React, { useState, useEffect, useMemo, useCallback } from "react";
import { Card, CardHeader, CardTitle, CardContent } from "./ui/card";
import { Button } from "./ui/button";
import { LoadingSpinner } from "./ui/loading";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
BarChart,
Bar,
PieChart,
Pie,
Cell,
ResponsiveContainer,
AreaChart,
Area,
RadarChart,
PolarGrid,
PolarAngleAxis,
PolarRadiusAxis,
Radar,
HeatMap,
ScatterChart,
Scatter,
} from "recharts";
import {
Calendar,
TrendingUp,
Award,
Target,
Clock,
BarChart3,
PieChart as PieChartIcon,
Activity,
Filter,
Download,
RefreshCw,
Zap,
} from "lucide-react";
const COLORS = {
primary: "#8B5CF6",
secondary: "#06B6D4",
success: "#10B981",
warning: "#F59E0B",
error: "#EF4444",
info: "#3B82F6",
};
const CHART_COLORS = [
"#8B5CF6",
"#06B6D4",
"#10B981",
"#F59E0B",
"#EF4444",
"#3B82F6",
"#8B5A2B",
"#EC4899",
];
// Advanced Analytics Dashboard Component
const AdvancedAnalyticsDashboard = ({ userId }) => {
const [analyticsData, setAnalyticsData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [timeRange, setTimeRange] = useState("30d");
const [selectedMetrics, setSelectedMetrics] = useState([
"completion_rate",
"streaks",
"categories",
"difficulty",
]);
const [refreshing, setRefreshing] = useState(false);
// Fetch analytics data
const fetchAnalytics = useCallback(async () => {
try {
setLoading(true);
const token = localStorage.getItem("token");
const response = await fetch(
`/api/v1/analytics/advanced?time_range=${timeRange}&metrics=${selectedMetrics.join(
","
)}`,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
if (response.ok) {
const data = await response.json();
setAnalyticsData(data);
} else {
throw new Error("Failed to fetch analytics data");
}
} catch (error) {
console.error("Analytics fetch error:", error);
setError(error.message);
} finally {
setLoading(false);
setRefreshing(false);
}
}, [timeRange, selectedMetrics]);
useEffect(() => {
fetchAnalytics();
}, [fetchAnalytics]);
const handleRefresh = useCallback(async () => {
setRefreshing(true);
await fetchAnalytics();
}, [fetchAnalytics]);
// Export analytics data
const exportData = useCallback(
async (format = "json") => {
try {
const token = localStorage.getItem("token");
const response = await fetch(
`/api/v1/analytics/export?format=${format}&time_range=${timeRange}`,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
if (response.ok) {
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `habit-analytics-${timeRange}.${format}`;
a.click();
window.URL.revokeObjectURL(url);
}
} catch (error) {
console.error("Export error:", error);
}
},
[timeRange]
);
if (loading) {
return (
<div className="flex items-center justify-center py-8">
<LoadingSpinner />
<span className="ml-2">Loading analytics dashboard...</span>
</div>
);
}
if (error) {
return (
<Card>
<CardContent className="p-8 text-center">
<div className="text-red-500 mb-2"> Error loading analytics</div>
<p className="text-gray-600">{error}</p>
<Button onClick={handleRefresh} className="mt-4">
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</Button>
</CardContent>
</Card>
);
}
return (
<div className="space-y-6">
{/* Header with controls */}
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-2xl font-bold text-gray-900">
📊 Advanced Analytics
</h2>
<p className="text-gray-600">
Deep insights into your habit patterns and progress
</p>
</div>
<div className="flex items-center space-x-2">
<Button
onClick={handleRefresh}
variant="outline"
size="sm"
disabled={refreshing}
>
<RefreshCw
className={`h-4 w-4 mr-2 ${refreshing ? "animate-spin" : ""}`}
/>
Refresh
</Button>
<Button
onClick={() => exportData("csv")}
variant="outline"
size="sm"
>
<Download className="h-4 w-4 mr-2" />
Export CSV
</Button>
</div>
</div>
{/* Time range and metric filters */}
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<Calendar className="h-4 w-4" />
<select
value={timeRange}
onChange={(e) => setTimeRange(e.target.value)}
className="px-3 py-1 border rounded-md text-sm"
>
<option value="7d">Last 7 days</option>
<option value="30d">Last 30 days</option>
<option value="90d">Last 90 days</option>
<option value="1y">Last year</option>
<option value="all">All time</option>
</select>
</div>
<div className="flex items-center space-x-2">
<Filter className="h-4 w-4" />
<span className="text-sm">Metrics:</span>
{[
{ key: "completion_rate", label: "Completion" },
{ key: "streaks", label: "Streaks" },
{ key: "categories", label: "Categories" },
{ key: "difficulty", label: "Difficulty" },
].map((metric) => (
<label key={metric.key} className="flex items-center text-sm">
<input
type="checkbox"
checked={selectedMetrics.includes(metric.key)}
onChange={(e) => {
if (e.target.checked) {
setSelectedMetrics([...selectedMetrics, metric.key]);
} else {
setSelectedMetrics(
selectedMetrics.filter((m) => m !== metric.key)
);
}
}}
className="mr-1"
/>
{metric.label}
</label>
))}
</div>
</div>
</CardContent>
</Card>
{/* Key Performance Indicators */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<MetricCard
title="Overall Progress"
value={`${
analyticsData?.kpis?.overall_completion_rate?.toFixed(1) || 0
}%`}
change={analyticsData?.kpis?.completion_rate_change}
icon={<TrendingUp className="h-5 w-5" />}
color="success"
/>
<MetricCard
title="Active Streaks"
value={analyticsData?.kpis?.active_streaks || 0}
change={analyticsData?.kpis?.streak_change}
icon={<Zap className="h-5 w-5" />}
color="warning"
/>
<MetricCard
title="Achievements"
value={analyticsData?.kpis?.total_achievements || 0}
change={analyticsData?.kpis?.achievement_change}
icon={<Award className="h-5 w-5" />}
color="primary"
/>
<MetricCard
title="Categories Active"
value={analyticsData?.kpis?.active_categories || 0}
change={analyticsData?.kpis?.category_change}
icon={<Target className="h-5 w-5" />}
color="info"
/>
</div>
{/* Charts Grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Completion Rate Trend */}
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<TrendingUp className="h-5 w-5 mr-2" />
Completion Rate Trend
</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={analyticsData?.completion_trend || []}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis />
<Tooltip />
<Legend />
<Line
type="monotone"
dataKey="completion_rate"
stroke={COLORS.primary}
strokeWidth={2}
dot={{ fill: COLORS.primary, strokeWidth: 2, r: 4 }}
/>
<Line
type="monotone"
dataKey="target_rate"
stroke={COLORS.secondary}
strokeDasharray="5 5"
/>
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
{/* Habit Category Distribution */}
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<PieChartIcon className="h-5 w-5 mr-2" />
Habit Categories
</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={analyticsData?.category_distribution || []}
cx="50%"
cy="50%"
labelLine={false}
label={({ name, percent }) =>
`${name} ${(percent * 100).toFixed(0)}%`
}
outerRadius={100}
fill="#8884d8"
dataKey="count"
>
{(analyticsData?.category_distribution || []).map(
(entry, index) => (
<Cell
key={`cell-${index}`}
fill={CHART_COLORS[index % CHART_COLORS.length]}
/>
)
)}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</CardContent>
</Card>
{/* Weekly Activity Heatmap */}
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<Activity className="h-5 w-5 mr-2" />
Weekly Activity Pattern
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{(analyticsData?.weekly_heatmap || []).map((week, weekIndex) => (
<div key={weekIndex} className="flex space-x-1">
{week.map((day, dayIndex) => (
<div
key={dayIndex}
className="w-4 h-4 rounded-sm"
style={{
backgroundColor:
day.intensity > 0
? `rgba(139, 92, 246, ${day.intensity})`
: "#f3f4f6",
}}
title={`${day.date}: ${day.completions} completions`}
/>
))}
</div>
))}
<div className="flex items-center justify-between text-xs text-gray-500 mt-2">
<span>Less</span>
<div className="flex space-x-1">
{[0, 0.25, 0.5, 0.75, 1].map((intensity) => (
<div
key={intensity}
className="w-3 h-3 rounded-sm"
style={{
backgroundColor:
intensity > 0
? `rgba(139, 92, 246, ${intensity})`
: "#f3f4f6",
}}
/>
))}
</div>
<span>More</span>
</div>
</div>
</CardContent>
</Card>
{/* Difficulty vs Success Rate */}
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<BarChart3 className="h-5 w-5 mr-2" />
Difficulty vs Success Rate
</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={analyticsData?.difficulty_analysis || []}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="difficulty" />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="success_rate" fill={COLORS.success} />
<Bar dataKey="habit_count" fill={COLORS.info} />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
{/* Time of Day Analysis */}
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<Clock className="h-5 w-5 mr-2" />
Peak Performance Times
</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={analyticsData?.hourly_performance || []}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="hour" tickFormatter={(hour) => `${hour}:00`} />
<YAxis />
<Tooltip
labelFormatter={(hour) => `${hour}:00 - ${hour + 1}:00`}
/>
<Area
type="monotone"
dataKey="completions"
stroke={COLORS.secondary}
fill={COLORS.secondary}
fillOpacity={0.6}
/>
</AreaChart>
</ResponsiveContainer>
</CardContent>
</Card>
{/* Streak Analysis */}
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<Zap className="h-5 w-5 mr-2" />
Streak Performance
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{(analyticsData?.streak_analysis || []).map((habit, index) => (
<div key={index} className="space-y-2">
<div className="flex justify-between items-center">
<span className="font-medium text-sm">{habit.title}</span>
<span className="text-sm text-gray-500">
{habit.current_streak} days
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-gradient-to-r from-yellow-400 to-orange-500 h-2 rounded-full"
style={{
width: `${Math.min(
(habit.current_streak / habit.best_streak) * 100,
100
)}%`,
}}
/>
</div>
<div className="flex justify-between text-xs text-gray-500">
<span>Best: {habit.best_streak} days</span>
<span>Average: {habit.average_streak} days</span>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
{/* AI Insights Section */}
{analyticsData?.ai_insights && (
<Card>
<CardHeader>
<CardTitle className="flex items-center">
🤖 AI-Powered Insights
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{analyticsData.ai_insights.map((insight, index) => (
<div
key={index}
className="p-4 bg-gradient-to-r from-purple-50 to-pink-50 rounded-lg border border-purple-200"
>
<h4 className="font-semibold text-purple-900 mb-2">
{insight.title}
</h4>
<p className="text-sm text-purple-700 mb-3">
{insight.description}
</p>
<div className="space-y-1">
{insight.recommendations.map((rec, recIndex) => (
<div
key={recIndex}
className="text-xs text-purple-600 flex items-start"
>
<span className="mr-1"></span>
<span>{rec}</span>
</div>
))}
</div>
<div className="mt-3 text-xs text-purple-500">
Confidence: {(insight.confidence * 100).toFixed(0)}%
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
);
};
// Metric card component
const MetricCard = ({ title, value, change, icon, color }) => {
const colorClasses = {
primary: "text-purple-600 bg-purple-100",
success: "text-green-600 bg-green-100",
warning: "text-yellow-600 bg-yellow-100",
error: "text-red-600 bg-red-100",
info: "text-blue-600 bg-blue-100",
};
const changeColor =
change > 0
? "text-green-600"
: change < 0
? "text-red-600"
: "text-gray-500";
return (
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">{title}</p>
<p className="text-2xl font-bold text-gray-900">{value}</p>
{change !== undefined && (
<p className={`text-sm ${changeColor}`}>
{change > 0 ? "+" : ""}
{change}%
<span className="text-gray-500 ml-1">vs last period</span>
</p>
)}
</div>
<div className={`p-3 rounded-full ${colorClasses[color]}`}>
{icon}
</div>
</div>
</CardContent>
</Card>
);
};
export default AdvancedAnalyticsDashboard;
@@ -0,0 +1,434 @@
// Advanced Filter UI Component - Matching AHK's search and filter interface
import React, { useState, useRef, useEffect } from "react";
import {
Search,
X,
Filter,
SortAsc,
SortDesc,
Calendar,
Target,
Zap,
} from "lucide-react";
import {
FilterState,
useAdvancedFiltering,
useFilterStats,
FilterableItem,
} from "../hooks/useAdvancedFiltering";
interface AdvancedFilterBarProps<T extends FilterableItem> {
items: T[];
onFilteredItemsChange: (items: T[]) => void;
className?: string;
showQuickFilters?: boolean;
initialFilters?: Partial<FilterState>;
}
export const AdvancedFilterBar = <T extends FilterableItem>({
items,
onFilteredItemsChange,
className = "",
showQuickFilters = true,
initialFilters,
}: AdvancedFilterBarProps<T>) => {
const {
filters,
filteredItems,
updateFilter,
updateFilters,
resetFilters,
clearSearch,
quickFilters,
getSearchSuggestions,
getFilterOptions,
totalCount,
filteredCount,
} = useAdvancedFiltering(items, initialFilters);
const { activeFilters, hasActiveFilters, filteredPercentage } =
useFilterStats(filters, totalCount, filteredCount);
const [showAdvanced, setShowAdvanced] = useState(false);
const [searchSuggestions, setSearchSuggestions] = useState<string[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
const filterOptions = getFilterOptions();
// Update parent component with filtered items
useEffect(() => {
onFilteredItemsChange(filteredItems);
}, [filteredItems, onFilteredItemsChange]);
// Handle search input changes
const handleSearchChange = (value: string) => {
updateFilter("searchQuery", value);
if (value.trim()) {
const suggestions = getSearchSuggestions(value);
setSearchSuggestions(suggestions);
setShowSuggestions(true);
} else {
setShowSuggestions(false);
}
};
// Handle keyboard shortcuts from the keyboard shortcuts system
useEffect(() => {
const handleKeyboardAction = (event: CustomEvent) => {
const action = event.detail.actionType;
switch (action) {
case "focus-search":
searchInputRef.current?.focus();
break;
case "clear-filters":
resetFilters();
break;
case "filter-high":
updateFilter("importance", "High");
break;
case "filter-medium":
updateFilter("importance", "Medium");
break;
case "filter-low":
updateFilter("importance", "Low");
break;
}
};
window.addEventListener(
"keyboard-action",
handleKeyboardAction as EventListener
);
return () =>
window.removeEventListener(
"keyboard-action",
handleKeyboardAction as EventListener
);
}, [updateFilter, resetFilters]);
const ImportanceIndicator = ({
level,
}: {
level: "High" | "Medium" | "Low";
}) => {
const colors = {
High: "bg-red-500",
Medium: "bg-yellow-500",
Low: "bg-green-500",
};
return <div className={`w-3 h-3 rounded-full ${colors[level]}`} />;
};
return (
<div className={`space-y-4 ${className}`}>
{/* Main Filter Bar */}
<div className="flex flex-wrap items-center gap-4 p-4 bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 shadow-sm">
{/* Search Box (like AHK) */}
<div className="relative flex-1 min-w-[300px]">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<input
ref={searchInputRef}
type="text"
placeholder="Search habits, projects, categories... (Alt+C)"
value={filters.searchQuery}
onChange={(e) => handleSearchChange(e.target.value)}
onFocus={() =>
filters.searchQuery.trim() && setShowSuggestions(true)
}
onBlur={() => setTimeout(() => setShowSuggestions(false), 200)}
className="w-full pl-10 pr-10 py-2 border border-gray-300 dark:border-slate-600 rounded-md
bg-white dark:bg-slate-700 text-gray-900 dark:text-white
focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
{filters.searchQuery && (
<button
onClick={clearSearch}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
<X className="w-4 h-4" />
</button>
)}
</div>
{/* Search Suggestions */}
{showSuggestions && searchSuggestions.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-md shadow-lg z-50">
{searchSuggestions.map((suggestion, index) => (
<button
key={index}
onClick={() => {
updateFilter("searchQuery", suggestion);
setShowSuggestions(false);
}}
className="w-full px-4 py-2 text-left hover:bg-gray-50 dark:hover:bg-slate-700 first:rounded-t-md last:rounded-b-md"
>
{suggestion}
</button>
))}
</div>
)}
</div>
{/* Importance Filter (like AHK dropdown) */}
<div className="flex items-center space-x-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
Importance:
</label>
<select
value={filters.importance}
onChange={(e) => updateFilter("importance", e.target.value as any)}
className="px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-md
bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm
focus:ring-2 focus:ring-blue-500"
>
<option value="All">All</option>
<option value="High">🔴 High</option>
<option value="Medium">🟡 Medium</option>
<option value="Low">🟢 Low</option>
</select>
</div>
{/* Skill Filter (like AHK skill dropdown) */}
<div className="flex items-center space-x-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
Skill:
</label>
<select
value={filters.skill}
onChange={(e) => updateFilter("skill", e.target.value)}
className="px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-md
bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm
focus:ring-2 focus:ring-blue-500"
>
{filterOptions.skills.map((skill) => (
<option key={skill} value={skill}>
{skill}
</option>
))}
</select>
</div>
{/* Show Completed Checkbox (like AHK) */}
<label className="flex items-center space-x-2 text-sm">
<input
type="checkbox"
checked={filters.showCompleted}
onChange={(e) => updateFilter("showCompleted", e.target.checked)}
className="rounded border-gray-300 dark:border-slate-600 text-blue-600
focus:ring-blue-500 dark:bg-slate-700"
/>
<span className="text-gray-700 dark:text-gray-300">
Show completed
</span>
</label>
{/* Advanced Filters Toggle */}
<button
onClick={() => setShowAdvanced(!showAdvanced)}
className="flex items-center space-x-1 px-3 py-2 text-sm text-gray-700 dark:text-gray-300
hover:bg-gray-100 dark:hover:bg-slate-700 rounded-md transition-colors"
>
<Filter className="w-4 h-4" />
<span>Advanced</span>
</button>
{/* Clear Filters */}
{hasActiveFilters && (
<button
onClick={resetFilters}
className="flex items-center space-x-1 px-3 py-2 text-sm text-red-600 dark:text-red-400
hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md transition-colors"
>
<X className="w-4 h-4" />
<span>Clear</span>
</button>
)}
</div>
{/* Quick Filters (like AHK quick buttons) */}
{showQuickFilters && (
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
Quick:
</span>
<button
onClick={quickFilters.todayActive}
className="px-3 py-1 text-xs bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300
rounded-full hover:bg-blue-200 dark:hover:bg-blue-900/50 transition-colors"
>
<Target className="w-3 h-3 inline mr-1" />
Today Active
</button>
<button
onClick={quickFilters.highPriority}
className="px-3 py-1 text-xs bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300
rounded-full hover:bg-red-200 dark:hover:bg-red-900/50 transition-colors"
>
High Priority
</button>
<button
onClick={quickFilters.longStreaks}
className="px-3 py-1 text-xs bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300
rounded-full hover:bg-green-200 dark:hover:bg-green-900/50 transition-colors"
>
<Zap className="w-3 h-3 inline mr-1" />
Long Streaks
</button>
<button
onClick={quickFilters.difficultTasks}
className="px-3 py-1 text-xs bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300
rounded-full hover:bg-purple-200 dark:hover:bg-purple-900/50 transition-colors"
>
Difficult
</button>
</div>
)}
{/* Advanced Filter Panel */}
{showAdvanced && (
<div className="p-4 bg-gray-50 dark:bg-slate-700 rounded-lg border border-gray-200 dark:border-slate-600">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{/* Status Filters */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Status
</label>
<div className="space-y-2">
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={filters.showActive}
onChange={(e) =>
updateFilter("showActive", e.target.checked)
}
className="rounded border-gray-300 dark:border-slate-600 text-blue-600"
/>
<span className="text-sm">Active</span>
</label>
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={filters.showPaused}
onChange={(e) =>
updateFilter("showPaused", e.target.checked)
}
className="rounded border-gray-300 dark:border-slate-600 text-blue-600"
/>
<span className="text-sm">Paused</span>
</label>
</div>
</div>
{/* Difficulty Range */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Difficulty: {filters.difficultyRange[0]} -{" "}
{filters.difficultyRange[1]}
</label>
<div className="space-y-2">
<input
type="range"
min={filterOptions.difficultyRange[0]}
max={filterOptions.difficultyRange[1]}
value={filters.difficultyRange[0]}
onChange={(e) =>
updateFilter("difficultyRange", [
parseInt(e.target.value),
filters.difficultyRange[1],
])
}
className="w-full"
/>
<input
type="range"
min={filterOptions.difficultyRange[0]}
max={filterOptions.difficultyRange[1]}
value={filters.difficultyRange[1]}
onChange={(e) =>
updateFilter("difficultyRange", [
filters.difficultyRange[0],
parseInt(e.target.value),
])
}
className="w-full"
/>
</div>
</div>
{/* Sorting */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Sort By
</label>
<div className="flex space-x-2">
<select
value={filters.sortBy}
onChange={(e) =>
updateFilter("sortBy", e.target.value as any)
}
className="flex-1 px-2 py-1 text-sm border border-gray-300 dark:border-slate-600 rounded
bg-white dark:bg-slate-700"
>
<option value="name">Name</option>
<option value="difficulty">Difficulty</option>
<option value="importance">Importance</option>
<option value="created">Created</option>
<option value="streak">Streak</option>
<option value="completion">Completion</option>
</select>
<button
onClick={() =>
updateFilter(
"sortOrder",
filters.sortOrder === "asc" ? "desc" : "asc"
)
}
className="px-2 py-1 border border-gray-300 dark:border-slate-600 rounded
hover:bg-gray-100 dark:hover:bg-slate-600 transition-colors"
>
{filters.sortOrder === "asc" ? (
<SortAsc className="w-4 h-4" />
) : (
<SortDesc className="w-4 h-4" />
)}
</button>
</div>
</div>
</div>
</div>
)}
{/* Filter Stats */}
<div className="flex items-center justify-between text-sm text-gray-600 dark:text-gray-400">
<div className="flex items-center space-x-4">
<span>
Showing {filteredCount.toLocaleString()} of{" "}
{totalCount.toLocaleString()} items
{filteredPercentage < 100 && (
<span className="ml-1">({filteredPercentage.toFixed(1)}%)</span>
)}
</span>
{hasActiveFilters && (
<div className="flex flex-wrap items-center gap-1">
<span>Filters:</span>
{activeFilters.map((filter, index) => (
<span
key={index}
className="px-2 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300
rounded text-xs"
>
{filter}
</span>
))}
</div>
)}
</div>
</div>
</div>
);
};
export default AdvancedFilterBar;
@@ -0,0 +1,323 @@
// Modern HUD System - Floating Progress Indicators
import React, { useState, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Trophy, Zap, Target, Star, TrendingUp } from "lucide-react";
interface HUDData {
level: number;
xp: number;
xpToNext: number;
momentum: number;
title: string;
achievements: Achievement[];
streak: number;
todayCompleted: number;
todayTotal: number;
}
interface Achievement {
id: string;
name: string;
description: string;
xpAwarded: number;
unlockedAt: Date;
}
interface FloatingHUDProps {
data: HUDData;
position?: "top-left" | "top-right" | "bottom-left" | "bottom-right";
autoHide?: boolean;
theme?: "dark" | "light" | "wizard";
}
export const FloatingHUD: React.FC<FloatingHUDProps> = ({
data,
position = "top-left",
autoHide = true,
theme = "wizard",
}) => {
const [isVisible, setIsVisible] = useState(true);
const [isMinimized, setIsMinimized] = useState(false);
const [recentAchievements, setRecentAchievements] = useState<Achievement[]>(
[]
);
// Auto-hide on mouse hover (like legacy AHK)
useEffect(() => {
if (!autoHide) return;
let hideTimer: NodeJS.Timeout;
const handleMouseMove = (e: MouseEvent) => {
const rect = document
.getElementById("floating-hud")
?.getBoundingClientRect();
if (!rect) return;
const isHovering =
e.clientX >= rect.left &&
e.clientX <= rect.right &&
e.clientY >= rect.top &&
e.clientY <= rect.bottom;
if (isHovering) {
setIsVisible(false);
clearTimeout(hideTimer);
} else {
clearTimeout(hideTimer);
hideTimer = setTimeout(() => setIsVisible(true), 1000);
}
};
document.addEventListener("mousemove", handleMouseMove);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
clearTimeout(hideTimer);
};
}, [autoHide]);
// Show recent achievements
useEffect(() => {
const recent = data.achievements
.filter((a) => {
const timeSince = Date.now() - a.unlockedAt.getTime();
return timeSince < 5000; // Show for 5 seconds
})
.slice(0, 3);
setRecentAchievements(recent);
}, [data.achievements]);
const positionClasses = {
"top-left": "top-4 left-4",
"top-right": "top-4 right-4",
"bottom-left": "bottom-4 left-4",
"bottom-right": "bottom-4 right-4",
};
const themeClasses = {
dark: "bg-slate-900/90 border-slate-700 text-white",
light: "bg-white/90 border-gray-300 text-gray-900",
wizard:
"bg-gradient-to-br from-purple-900/90 to-slate-900/90 border-purple-500 text-white",
};
const xpProgress = (data.xp / (data.xp + data.xpToNext)) * 100;
const momentumColor =
data.momentum >= 70
? "bg-green-500"
: data.momentum >= 40
? "bg-yellow-500"
: "bg-red-500";
return (
<>
<AnimatePresence>
{isVisible && (
<motion.div
id="floating-hud"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
className={`fixed ${positionClasses[position]} z-50 ${themeClasses[theme]}
backdrop-blur-md rounded-lg border shadow-2xl min-w-[300px]`}
>
{/* Header with minimize button */}
<div className="flex items-center justify-between p-3 border-b border-white/10">
<div className="flex items-center space-x-2">
<div className="text-2xl">🧙</div>
<div className="text-sm font-bold">{data.title}</div>
</div>
<button
onClick={() => setIsMinimized(!isMinimized)}
className="text-white/70 hover:text-white transition-colors"
>
{isMinimized ? "◢" : "◤"}
</button>
</div>
{!isMinimized && (
<div className="p-4 space-y-3">
{/* Level Progress */}
<div>
<div className="flex justify-between text-sm mb-1">
<span>LEVEL {data.level}</span>
<span>
{data.xp}/{data.xp + data.xpToNext} XP
</span>
</div>
<div className="w-full bg-white/20 rounded-full h-2">
<motion.div
className="bg-gradient-to-r from-blue-400 to-purple-500 h-2 rounded-full"
initial={{ width: 0 }}
animate={{ width: `${xpProgress}%` }}
transition={{ duration: 1, ease: "easeOut" }}
/>
</div>
</div>
{/* Momentum Bar */}
<div>
<div className="flex items-center justify-between text-sm mb-1">
<div className="flex items-center space-x-1">
<Zap className="w-3 h-3" />
<span>MMT</span>
</div>
<span>{data.momentum}%</span>
</div>
<div className="w-full bg-white/20 rounded-full h-2">
<motion.div
className={`${momentumColor} h-2 rounded-full`}
initial={{ width: 0 }}
animate={{ width: `${data.momentum}%` }}
transition={{ duration: 0.8 }}
/>
</div>
</div>
{/* Daily Progress */}
<div className="flex items-center justify-between text-sm">
<div className="flex items-center space-x-1">
<Target className="w-3 h-3" />
<span>Today</span>
</div>
<span>
{data.todayCompleted}/{data.todayTotal}
</span>
</div>
{/* Streak */}
<div className="flex items-center justify-between text-sm">
<div className="flex items-center space-x-1">
<TrendingUp className="w-3 h-3" />
<span>Streak</span>
</div>
<span>{data.streak} days</span>
</div>
</div>
)}
</motion.div>
)}
</AnimatePresence>
{/* Recent Achievements Notifications */}
<AnimatePresence>
{recentAchievements.map((achievement, index) => (
<motion.div
key={achievement.id}
initial={{
opacity: 0,
x: position.includes("right") ? 100 : -100,
scale: 0.8,
}}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{
opacity: 0,
x: position.includes("right") ? 100 : -100,
scale: 0.8,
}}
className={`fixed ${positionClasses[position]} z-[60] ${themeClasses[theme]}
backdrop-blur-md rounded-lg border shadow-2xl p-4 min-w-[280px]`}
style={{
[position.includes("top") ? "top" : "bottom"]: position.includes(
"top"
)
? `${120 + index * 80}px`
: `${120 + index * 80}px`,
}}
>
<div className="flex items-center space-x-3">
<div className="text-2xl">🏆</div>
<div className="flex-1">
<div className="font-bold text-sm">{achievement.name}</div>
<div className="text-xs opacity-80">
{achievement.description}
</div>
<div className="text-xs text-yellow-400">
+{achievement.xpAwarded} XP
</div>
</div>
</div>
</motion.div>
))}
</AnimatePresence>
</>
);
};
// Hook for managing HUD data
export const useHUDData = (userId: string) => {
const [hudData, setHudData] = useState<HUDData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchHUDData = async () => {
try {
const [profile, stats, habits] = await Promise.all([
fetch(`/api/v1/users/${userId}/profile`).then((r) => r.json()),
fetch(`/api/v1/gamification/stats`).then((r) => r.json()),
fetch(`/api/v1/habits/today`).then((r) => r.json()),
]);
setHudData({
level: stats.level,
xp: stats.xp,
xpToNext: stats.xp_to_next,
momentum: stats.momentum,
title: profile.title,
achievements: stats.recent_achievements || [],
streak: stats.current_streak,
todayCompleted: habits.completed.length,
todayTotal: habits.total.length,
});
} catch (error) {
console.error("Failed to fetch HUD data:", error);
} finally {
setLoading(false);
}
};
fetchHUDData();
const interval = setInterval(fetchHUDData, 30000); // Update every 30s
return () => clearInterval(interval);
}, [userId]);
return { hudData, loading };
};
// Achievement Notification System
export const useAchievementNotifications = () => {
const [notifications, setNotifications] = useState<Achievement[]>([]);
const showAchievement = (achievement: Achievement) => {
setNotifications((prev) => [
...prev,
{ ...achievement, unlockedAt: new Date() },
]);
// Auto-remove after 5 seconds
setTimeout(() => {
setNotifications((prev) => prev.filter((n) => n.id !== achievement.id));
}, 5000);
};
useEffect(() => {
// Listen for achievement events from WebSocket or EventSource
const handleAchievementEvent = (event: CustomEvent<Achievement>) => {
showAchievement(event.detail);
};
window.addEventListener(
"achievement-unlocked",
handleAchievementEvent as EventListener
);
return () =>
window.removeEventListener(
"achievement-unlocked",
handleAchievementEvent as EventListener
);
}, []);
return { notifications, showAchievement };
};
export default FloatingHUD;
@@ -0,0 +1,351 @@
import React, { useState, useEffect } from 'react';
import { Card, CardHeader, CardTitle, CardContent } from './ui/card';
import { Button } from './ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
import { Home, Target, BarChart3, Trophy, Settings, Shield, Zap, Puzzle, BookOpen, Sparkles, Gem, ScrollText, Plus, Search, Filter } from 'lucide-react';
import AdvancedFilterBar from './AdvancedFilterBar';
import { useAdvancedFiltering, FilterableItem } from '../hooks/useAdvancedFiltering';
// Import our dashboard components
import GamificationDashboard from './GamificationDashboard';
import HabitsDashboard from './HabitsDashboard';
import AnalyticsDashboard from './AnalyticsDashboard';
import Leaderboard from './Leaderboard';
import TelemetrySettings from './TelemetrySettings';
import AdminTelemetryDashboard from './AdminTelemetryDashboard';
import PluginAdmin from '../plugins/PluginAdmin';
import PluginExtensionContainer from '../plugins/PluginExtensions';
// Define the habit interface for filtering
interface HabitItem extends FilterableItem {
id: string;
title: string;
notes?: string;
importance: 'High' | 'Medium' | 'Low';
difficulty: number;
skill?: string;
categories: string[];
status: 'active' | 'completed' | 'paused';
createdAt: Date;
completedAt?: Date;
streak: number;
completionRate: number;
}
const MainDashboard = ({ user }) => {
const [activeTab, setActiveTab] = useState('overview');
const [habits, setHabits] = useState<HabitItem[]>([]);
const [filteredHabits, setFilteredHabits] = useState<HabitItem[]>([]);
const [loading, setLoading] = useState(true);
const [showAddHabit, setShowAddHabit] = useState(false);
// Load habits data
useEffect(() => {
const loadHabits = async () => {
try {
const response = await fetch('/api/v1/habits', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
if (response.ok) {
const data = await response.json();
const formattedHabits: HabitItem[] = data.map(habit => ({
id: habit.id.toString(),
title: habit.title,
notes: habit.notes,
importance: habit.importance || 'Medium',
difficulty: habit.difficulty || 1,
skill: habit.skill,
categories: habit.labels || [],
status: habit.status || 'active',
createdAt: new Date(habit.created_at),
completedAt: habit.completed_at ? new Date(habit.completed_at) : undefined,
streak: habit.streak || 0,
completionRate: habit.completion_rate || 0
}));
setHabits(formattedHabits);
}
} catch (error) {
console.error('Failed to load habits:', error);
} finally {
setLoading(false);
}
};
if (user) {
loadHabits();
}
}, [user]);
// Handle habit completion
const handleHabitComplete = async (habitId: string) => {
try {
const response = await fetch(`/api/v1/habits/${habitId}/complete`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
if (response.ok) {
const result = await response.json();
// Update local state
setHabits(prev => prev.map(habit =>
habit.id === habitId
? { ...habit, streak: (habit.streak || 0) + 1 }
: habit
));
// Trigger achievement notifications
window.dispatchEvent(new CustomEvent('habit-completed', {
detail: {
habitName: habits.find(h => h.id === habitId)?.title || 'Unknown Habit',
xpAwarded: result.xp_earned || 10,
streakCount: (habits.find(h => h.id === habitId)?.streak || 0) + 1
}
}));
// Check for achievements
if (result.achievement_unlocked) {
window.dispatchEvent(new CustomEvent('achievement-unlocked', {
detail: result.achievement_unlocked
}));
}
// Check for level up
if (result.level_up) {
window.dispatchEvent(new CustomEvent('level-up', {
detail: {
newLevel: result.new_level,
xpAwarded: result.xp_earned
}
}));
}
}
} catch (error) {
console.error('Failed to complete habit:', error);
}
};
const OverviewTab = () => (
<div className="space-y-6">
{/* Advanced Filtering System - Like AHK's powerful search */}
<AdvancedFilterBar
items={habits}
onFilteredItemsChange={setFilteredHabits}
showQuickFilters={true}
/>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Gamification Stats - Takes 2 columns */}
<div className="lg:col-span-2">
<GamificationDashboard />
</div>
{/* Leaderboard - Takes 1 column */}
<div>
<Leaderboard />
</div>
</div>
{/* Filtered Habits Display */}
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Target className="h-5 w-5 text-purple-600" />
<span>Your Magical Practices</span>
</div>
<Button
onClick={() => setShowAddHabit(true)}
className="flex items-center space-x-2"
>
<Plus className="h-4 w-4" />
<span>Add Habit</span>
</Button>
</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600 mx-auto"></div>
<p className="text-gray-600 mt-2">Loading your magical practices...</p>
</div>
) : filteredHabits.length === 0 ? (
<div className="text-center py-8">
<BookOpen className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<p className="text-gray-600 mb-4">
{habits.length === 0
? "Begin your magical journey by creating your first habit!"
: "No habits match your current filters."
}
</p>
<Button onClick={() => setShowAddHabit(true)}>
<Plus className="h-4 w-4 mr-2" />
Create Your First Habit
</Button>
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredHabits.slice(0, 6).map((habit) => (
<Card key={habit.id} className="hover:shadow-md transition-shadow">
<CardContent className="p-4">
<div className="flex items-start justify-between mb-2">
<h3 className="font-semibold text-sm truncate flex-1">
{habit.title}
</h3>
<span className={`px-2 py-1 rounded text-xs font-medium ${
habit.importance === 'High' ? 'bg-red-100 text-red-800' :
habit.importance === 'Medium' ? 'bg-yellow-100 text-yellow-800' :
'bg-green-100 text-green-800'
}`}>
{habit.importance}
</span>
</div>
{habit.notes && (
<p className="text-xs text-gray-600 mb-2 line-clamp-2">
{habit.notes}
</p>
)}
<div className="flex items-center justify-between text-xs text-gray-500 mb-3">
<span>Difficulty: {habit.difficulty}/10</span>
<span>Streak: {habit.streak}</span>
</div>
<Button
onClick={() => handleHabitComplete(habit.id)}
className="w-full text-xs"
size="sm"
>
<Zap className="h-3 w-3 mr-1" />
Complete Today
</Button>
</CardContent>
</Card>
))}
</div>
)}
</CardContent>
</Card>
{/* Plugin Dashboard Widgets */}
<PluginExtensionContainer extensionPoint="dashboard" />
{/* Quick Actions */}
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Sparkles className="h-5 w-5 text-purple-600" />
<span>Quick Enchantments</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Button
variant="outline"
className="h-20 flex flex-col space-y-2 border-purple-200 hover:border-purple-400 hover:bg-purple-50"
onClick={() => setActiveTab('habits')}
>
<Target className="h-6 w-6 text-purple-600" />
<span className="text-xs">Habits</span>
</Button>
<Button
variant="outline"
className="h-20 flex flex-col space-y-2 border-blue-200 hover:border-blue-400 hover:bg-blue-50"
onClick={() => setActiveTab('analytics')}
>
<BarChart3 className="h-6 w-6 text-blue-600" />
<span className="text-xs">Analytics</span>
</Button>
<Button
variant="outline"
className="h-20 flex flex-col space-y-2 border-green-200 hover:border-green-400 hover:bg-green-50"
onClick={() => setActiveTab('gamification')}
>
<Trophy className="h-6 w-6 text-green-600" />
<span className="text-xs">Achievements</span>
</Button>
<Button
variant="outline"
className="h-20 flex flex-col space-y-2 border-orange-200 hover:border-orange-400 hover:bg-orange-50"
onClick={() => setActiveTab('plugins')}
>
<Puzzle className="h-6 w-6 text-orange-600" />
<span className="text-xs">Plugins</span>
</Button>
</div>
</CardContent>
</Card>
</div>
);
return (
<div className="space-y-6">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-6">
<TabsTrigger value="overview" className="flex items-center space-x-2">
<Home className="h-4 w-4" />
<span className="hidden sm:inline">Overview</span>
</TabsTrigger>
<TabsTrigger value="habits" className="flex items-center space-x-2">
<Target className="h-4 w-4" />
<span className="hidden sm:inline">Habits</span>
</TabsTrigger>
<TabsTrigger value="analytics" className="flex items-center space-x-2">
<BarChart3 className="h-4 w-4" />
<span className="hidden sm:inline">Analytics</span>
</TabsTrigger>
<TabsTrigger value="gamification" className="flex items-center space-x-2">
<Trophy className="h-4 w-4" />
<span className="hidden sm:inline">Achievements</span>
</TabsTrigger>
<TabsTrigger value="plugins" className="flex items-center space-x-2">
<Puzzle className="h-4 w-4" />
<span className="hidden sm:inline">Plugins</span>
</TabsTrigger>
<TabsTrigger value="settings" className="flex items-center space-x-2">
<Settings className="h-4 w-4" />
<span className="hidden sm:inline">Settings</span>
</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-6">
<OverviewTab />
</TabsContent>
<TabsContent value="habits" className="space-y-6">
<HabitsDashboard />
</TabsContent>
<TabsContent value="analytics" className="space-y-6">
<AnalyticsDashboard />
</TabsContent>
<TabsContent value="gamification" className="space-y-6">
<GamificationDashboard />
</TabsContent>
<TabsContent value="plugins" className="space-y-6">
<PluginAdmin />
</TabsContent>
<TabsContent value="settings" className="space-y-6">
<div className="grid gap-6 md:grid-cols-2">
<TelemetrySettings />
{user?.is_admin && <AdminTelemetryDashboard />}
</div>
</TabsContent>
</Tabs>
</div>
);
};
export default MainDashboard;
@@ -0,0 +1,507 @@
import React, { useState, useEffect, useCallback } from "react";
import {
BrowserRouter as Router,
Routes,
Route,
Navigate,
} from "react-router-dom";
import MobileHabitTracker from "./MobileHabitTracker";
import AdvancedAnalyticsDashboard from "./AdvancedAnalyticsDashboard";
import { Card, CardContent } from "./ui/card";
import { Button } from "./ui/button";
import {
Home,
BarChart3,
Settings,
User,
Plus,
Menu,
X,
Wifi,
WifiOff,
Download,
Smartphone,
Monitor,
Bell,
} from "lucide-react";
// Mobile app shell component
const MobileAppShell = () => {
const [isMobile, setIsMobile] = useState(false);
const [isOnline, setIsOnline] = useState(navigator.onLine);
const [installPrompt, setInstallPrompt] = useState(null);
const [isInstalled, setIsInstalled] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [activeTab, setActiveTab] = useState("habits");
const [notificationPermission, setNotificationPermission] =
useState("default");
// Detect mobile device and screen size
useEffect(() => {
const checkMobile = () => {
const isMobileDevice =
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent
);
const isSmallScreen = window.innerWidth <= 768;
setIsMobile(isMobileDevice || isSmallScreen);
};
checkMobile();
window.addEventListener("resize", checkMobile);
return () => window.removeEventListener("resize", checkMobile);
}, []);
// Network status monitoring
useEffect(() => {
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);
window.addEventListener("online", handleOnline);
window.addEventListener("offline", handleOffline);
return () => {
window.removeEventListener("online", handleOnline);
window.removeEventListener("offline", handleOffline);
};
}, []);
// PWA install prompt handling
useEffect(() => {
const handleBeforeInstallPrompt = (e) => {
e.preventDefault();
setInstallPrompt(e);
};
const handleAppInstalled = () => {
setIsInstalled(true);
setInstallPrompt(null);
};
window.addEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
window.addEventListener("appinstalled", handleAppInstalled);
// Check if already installed
if (
window.matchMedia &&
window.matchMedia("(display-mode: standalone)").matches
) {
setIsInstalled(true);
}
return () => {
window.removeEventListener(
"beforeinstallprompt",
handleBeforeInstallPrompt
);
window.removeEventListener("appinstalled", handleAppInstalled);
};
}, []);
// Service worker registration
useEffect(() => {
if ("serviceWorker" in navigator) {
navigator.serviceWorker
.register("/sw.js")
.then((registration) => {
console.log("SW registered:", registration);
})
.catch((error) => {
console.log("SW registration failed:", error);
});
}
}, []);
// Notification permission handling
useEffect(() => {
if ("Notification" in window) {
setNotificationPermission(Notification.permission);
}
}, []);
const handleInstallApp = useCallback(async () => {
if (installPrompt) {
installPrompt.prompt();
const choiceResult = await installPrompt.userChoice;
if (choiceResult.outcome === "accepted") {
console.log("User accepted the install prompt");
}
setInstallPrompt(null);
}
}, [installPrompt]);
const requestNotificationPermission = useCallback(async () => {
if ("Notification" in window) {
const permission = await Notification.requestPermission();
setNotificationPermission(permission);
}
}, []);
const navigationItems = [
{ id: "habits", label: "Habits", icon: Home },
{ id: "analytics", label: "Analytics", icon: BarChart3 },
{ id: "settings", label: "Settings", icon: Settings },
{ id: "profile", label: "Profile", icon: User },
];
const renderContent = () => {
switch (activeTab) {
case "habits":
return <MobileHabitTracker userId={1} isMobile={isMobile} />;
case "analytics":
return <AdvancedAnalyticsDashboard userId={1} />;
case "settings":
return (
<SettingsPanel
isMobile={isMobile}
isOnline={isOnline}
installPrompt={installPrompt}
onInstall={handleInstallApp}
notificationPermission={notificationPermission}
onRequestNotifications={requestNotificationPermission}
/>
);
case "profile":
return <ProfilePanel isMobile={isMobile} />;
default:
return <MobileHabitTracker userId={1} isMobile={isMobile} />;
}
};
return (
<div className={`min-h-screen bg-gray-50 ${isMobile ? "pb-16" : ""}`}>
{/* Header */}
<header className="bg-white shadow-sm border-b sticky top-0 z-40">
<div className="flex items-center justify-between px-4 py-3">
<div className="flex items-center space-x-3">
{isMobile && (
<Button
variant="ghost"
size="sm"
onClick={() => setSidebarOpen(!sidebarOpen)}
>
{sidebarOpen ? (
<X className="h-5 w-5" />
) : (
<Menu className="h-5 w-5" />
)}
</Button>
)}
<h1 className="text-xl font-bold text-purple-600">LifeRPG</h1>
</div>
<div className="flex items-center space-x-2">
{/* Network status */}
<div
className={`flex items-center space-x-1 px-2 py-1 rounded-full text-xs ${
isOnline
? "bg-green-100 text-green-800"
: "bg-red-100 text-red-800"
}`}
>
{isOnline ? (
<Wifi className="h-3 w-3" />
) : (
<WifiOff className="h-3 w-3" />
)}
<span>{isOnline ? "Online" : "Offline"}</span>
</div>
{/* Install button */}
{installPrompt && !isInstalled && (
<Button
size="sm"
onClick={handleInstallApp}
className="hidden sm:flex"
>
<Download className="h-4 w-4 mr-1" />
Install
</Button>
)}
{/* Device indicator */}
<div className="flex items-center space-x-1 text-gray-500">
{isMobile ? (
<Smartphone className="h-4 w-4" />
) : (
<Monitor className="h-4 w-4" />
)}
</div>
</div>
</div>
</header>
{/* Mobile sidebar overlay */}
{isMobile && sidebarOpen && (
<div
className="fixed inset-0 bg-black bg-opacity-50 z-30"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Sidebar for mobile */}
{isMobile && (
<div
className={`fixed left-0 top-0 h-full w-64 bg-white shadow-lg transform transition-transform duration-300 z-40 ${
sidebarOpen ? "translate-x-0" : "-translate-x-full"
}`}
>
<div className="p-4 border-b">
<h2 className="text-lg font-semibold">Menu</h2>
</div>
<nav className="p-4 space-y-2">
{navigationItems.map((item) => {
const Icon = item.icon;
return (
<button
key={item.id}
onClick={() => {
setActiveTab(item.id);
setSidebarOpen(false);
}}
className={`w-full flex items-center space-x-3 px-3 py-2 rounded-lg text-left ${
activeTab === item.id
? "bg-purple-100 text-purple-600"
: "hover:bg-gray-100"
}`}
>
<Icon className="h-5 w-5" />
<span>{item.label}</span>
</button>
);
})}
{/* PWA actions */}
<div className="pt-4 border-t">
{installPrompt && !isInstalled && (
<button
onClick={handleInstallApp}
className="w-full flex items-center space-x-3 px-3 py-2 rounded-lg text-left hover:bg-gray-100"
>
<Download className="h-5 w-5" />
<span>Install App</span>
</button>
)}
{notificationPermission === "default" && (
<button
onClick={requestNotificationPermission}
className="w-full flex items-center space-x-3 px-3 py-2 rounded-lg text-left hover:bg-gray-100"
>
<Bell className="h-5 w-5" />
<span>Enable Notifications</span>
</button>
)}
</div>
</nav>
</div>
)}
{/* Main content */}
<main className="max-w-7xl mx-auto px-4 py-6">
{/* Desktop navigation tabs */}
{!isMobile && (
<div className="flex space-x-1 mb-6 bg-white rounded-lg p-1 shadow-sm">
{navigationItems.map((item) => {
const Icon = item.icon;
return (
<button
key={item.id}
onClick={() => setActiveTab(item.id)}
className={`flex items-center space-x-2 px-4 py-2 rounded-md transition-all ${
activeTab === item.id
? "bg-purple-100 text-purple-600 shadow-sm"
: "hover:bg-gray-100"
}`}
>
<Icon className="h-4 w-4" />
<span>{item.label}</span>
</button>
);
})}
</div>
)}
{/* Content */}
{renderContent()}
</main>
{/* Mobile bottom navigation */}
{isMobile && (
<nav className="fixed bottom-0 left-0 right-0 bg-white border-t shadow-lg">
<div className="flex items-center justify-around py-2">
{navigationItems.slice(0, 4).map((item) => {
const Icon = item.icon;
return (
<button
key={item.id}
onClick={() => setActiveTab(item.id)}
className={`flex flex-col items-center space-y-1 p-2 rounded-lg ${
activeTab === item.id ? "text-purple-600" : "text-gray-600"
}`}
>
<Icon className="h-5 w-5" />
<span className="text-xs">{item.label}</span>
</button>
);
})}
</div>
</nav>
)}
</div>
);
};
// Settings panel component
const SettingsPanel = ({
isMobile,
isOnline,
installPrompt,
onInstall,
notificationPermission,
onRequestNotifications,
}) => {
return (
<div className="space-y-6">
<Card>
<CardContent className="p-6">
<h3 className="text-lg font-semibold mb-4">App Settings</h3>
<div className="space-y-4">
{/* PWA Install */}
{installPrompt && (
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium">Install App</h4>
<p className="text-sm text-gray-600">
Install LifeRPG for better performance and offline access
</p>
</div>
<Button onClick={onInstall}>
<Download className="h-4 w-4 mr-2" />
Install
</Button>
</div>
)}
{/* Notifications */}
{notificationPermission === "default" && (
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium">Push Notifications</h4>
<p className="text-sm text-gray-600">
Get reminded about your habits and achievements
</p>
</div>
<Button onClick={onRequestNotifications}>
<Bell className="h-4 w-4 mr-2" />
Enable
</Button>
</div>
)}
{/* Status indicators */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-4 border-t">
<div className="flex items-center space-x-3">
<div
className={`w-3 h-3 rounded-full ${
isOnline ? "bg-green-500" : "bg-red-500"
}`}
/>
<span className="text-sm">
{isOnline ? "Online" : "Offline"}
</span>
</div>
<div className="flex items-center space-x-3">
<div
className={`w-3 h-3 rounded-full ${
notificationPermission === "granted"
? "bg-green-500"
: "bg-gray-500"
}`}
/>
<span className="text-sm">
Notifications {notificationPermission}
</span>
</div>
<div className="flex items-center space-x-3">
{isMobile ? (
<Smartphone className="h-4 w-4" />
) : (
<Monitor className="h-4 w-4" />
)}
<span className="text-sm">
{isMobile ? "Mobile" : "Desktop"}
</span>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
};
// Profile panel component
const ProfilePanel = ({ isMobile }) => {
const [profile, setProfile] = useState({
name: "Habit Warrior",
level: 12,
totalHabits: 45,
streakRecord: 30,
achievements: 18,
});
return (
<div className="space-y-6">
<Card>
<CardContent className="p-6">
<div className="flex items-center space-x-4 mb-6">
<div className="w-16 h-16 bg-purple-100 rounded-full flex items-center justify-center">
<User className="h-8 w-8 text-purple-600" />
</div>
<div>
<h2 className="text-2xl font-bold">{profile.name}</h2>
<p className="text-gray-600">Level {profile.level} Adventurer</p>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-purple-600">
{profile.totalHabits}
</div>
<div className="text-sm text-gray-600">Total Habits</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-orange-600">
{profile.streakRecord}
</div>
<div className="text-sm text-gray-600">Best Streak</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-green-600">
{profile.achievements}
</div>
<div className="text-sm text-gray-600">Achievements</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-blue-600">
{profile.level}
</div>
<div className="text-sm text-gray-600">Current Level</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
};
export default MobileAppShell;
@@ -0,0 +1,644 @@
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Card, CardHeader, CardTitle, CardContent } from "./ui/card";
import { Button } from "./ui/button";
import { Badge } from "./ui/badge";
import {
CheckCircle2,
Clock,
Flame,
Star,
Target,
TrendingUp,
Calendar,
Plus,
Settings,
Smartphone,
Bell,
Zap,
Award,
} from "lucide-react";
// Mobile-first habit tracker component
const MobileHabitTracker = ({ userId, isMobile = false }) => {
const [habits, setHabits] = useState([]);
const [todayStats, setTodayStats] = useState({});
const [loading, setLoading] = useState(true);
const [selectedHabit, setSelectedHabit] = useState(null);
const [showQuickAdd, setShowQuickAdd] = useState(false);
const [notifications, setNotifications] = useState([]);
// Touch/swipe handling for mobile
const [touchStart, setTouchStart] = useState(null);
const [touchEnd, setTouchEnd] = useState(null);
// Load data
useEffect(() => {
loadHabits();
loadTodayStats();
if (isMobile) {
setupNotifications();
}
}, [userId, isMobile]);
const loadHabits = useCallback(async () => {
try {
const token = localStorage.getItem("token");
const response = await fetch("/api/v1/habits/today", {
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
const data = await response.json();
setHabits(data);
}
} catch (error) {
console.error("Failed to load habits:", error);
} finally {
setLoading(false);
}
}, []);
const loadTodayStats = useCallback(async () => {
try {
const token = localStorage.getItem("token");
const response = await fetch("/api/v1/analytics/today", {
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
const data = await response.json();
setTodayStats(data);
}
} catch (error) {
console.error("Failed to load today stats:", error);
}
}, []);
const setupNotifications = useCallback(() => {
if ("serviceWorker" in navigator && "Notification" in window) {
// Request notification permissions
Notification.requestPermission().then((permission) => {
if (permission === "granted") {
// Setup WebSocket for real-time notifications
const ws = new WebSocket(`ws://localhost:8000/ws/${userId}`);
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === "notification") {
showNotification(data.notification);
}
};
}
});
}
}, [userId]);
const showNotification = useCallback((notification) => {
if (Notification.permission === "granted") {
new Notification(notification.title, {
body: notification.message,
icon: "/icon-192.png",
badge: "/badge-72.png",
tag: notification.id,
requireInteraction: notification.priority === "high",
});
}
// Add to in-app notifications
setNotifications((prev) => [notification, ...prev.slice(0, 4)]);
}, []);
// Swipe gesture handling
const onTouchStart = useCallback((e) => {
setTouchEnd(null);
setTouchStart(e.targetTouches[0].clientX);
}, []);
const onTouchMove = useCallback((e) => {
setTouchEnd(e.targetTouches[0].clientX);
}, []);
const onTouchEnd = useCallback(
(habitId) => {
if (!touchStart || !touchEnd) return;
const distance = touchStart - touchEnd;
const isLeftSwipe = distance > 50;
const isRightSwipe = distance < -50;
if (isLeftSwipe) {
// Swipe left to complete habit
completeHabit(habitId);
} else if (isRightSwipe) {
// Swipe right to snooze/skip
snoozeHabit(habitId);
}
},
[touchStart, touchEnd]
);
const completeHabit = useCallback(async (habitId) => {
try {
const token = localStorage.getItem("token");
const response = await fetch(`/api/v1/habits/${habitId}/complete`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
const result = await response.json();
// Update local state
setHabits((prev) =>
prev.map((habit) =>
habit.id === habitId
? {
...habit,
completed_today: true,
streak: (habit.streak || 0) + 1,
}
: habit
)
);
// Show celebration if streak milestone
if (result.streak_milestone) {
showCelebration(result.streak_milestone);
}
// Update today's stats
setTodayStats((prev) => ({
...prev,
completed: (prev.completed || 0) + 1,
completion_rate: ((prev.completed + 1) / prev.total_habits) * 100,
}));
}
} catch (error) {
console.error("Failed to complete habit:", error);
}
}, []);
const snoozeHabit = useCallback(async (habitId) => {
try {
const token = localStorage.getItem("token");
await fetch(`/api/v1/habits/${habitId}/snooze`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: JSON.stringify({ snooze_minutes: 60 }),
});
// Update local state
setHabits((prev) =>
prev.map((habit) =>
habit.id === habitId
? {
...habit,
snoozed_until: new Date(Date.now() + 60 * 60000).toISOString(),
}
: habit
)
);
} catch (error) {
console.error("Failed to snooze habit:", error);
}
}, []);
const showCelebration = useCallback((milestone) => {
// Mobile-friendly celebration animation
const celebration = document.createElement("div");
celebration.className =
"fixed inset-0 flex items-center justify-center z-50 pointer-events-none";
celebration.innerHTML = `
<div class="bg-gradient-to-r from-yellow-400 to-orange-500 text-white rounded-full p-8 animate-bounce shadow-2xl">
<div class="text-4xl">🔥</div>
<div class="text-xl font-bold mt-2">${milestone} Day Streak!</div>
</div>
`;
document.body.appendChild(celebration);
setTimeout(() => {
document.body.removeChild(celebration);
}, 3000);
}, []);
// Quick add habit
const quickAddHabit = useCallback(async (habitTitle) => {
try {
const token = localStorage.getItem("token");
const response = await fetch("/api/v1/habits", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
title: habitTitle,
difficulty: 1,
cadence: "daily",
quick_add: true,
}),
});
if (response.ok) {
const newHabit = await response.json();
setHabits((prev) => [...prev, newHabit]);
setShowQuickAdd(false);
}
} catch (error) {
console.error("Failed to add habit:", error);
}
}, []);
// Calculated stats
const todayProgress = useMemo(() => {
const completed = habits.filter((h) => h.completed_today).length;
const total = habits.length;
return {
completed,
total,
percentage: total > 0 ? (completed / total) * 100 : 0,
};
}, [habits]);
const streakHabits = useMemo(() => {
return habits
.filter((h) => h.streak > 0)
.sort((a, b) => b.streak - a.streak)
.slice(0, 3);
}, [habits]);
if (loading) {
return (
<div className="flex items-center justify-center py-8">
<div className="animate-pulse">
<Smartphone className="h-8 w-8 text-purple-500 animate-bounce" />
</div>
<span className="ml-2">Loading your habits...</span>
</div>
);
}
return (
<div className={`space-y-4 ${isMobile ? "pb-20" : ""}`}>
{/* Header with notifications */}
<Card className="bg-gradient-to-r from-purple-500 to-pink-500 text-white">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold">Today's Quest</h2>
<p className="text-purple-100">
{new Date().toLocaleDateString("en-US", {
weekday: "long",
month: "long",
day: "numeric",
})}
</p>
</div>
<div className="relative">
<Bell className="h-6 w-6" />
{notifications.length > 0 && (
<Badge className="absolute -top-2 -right-2 bg-red-500 text-white text-xs px-1">
{notifications.length}
</Badge>
)}
</div>
</div>
{/* Progress Ring */}
<div className="mt-4 flex items-center justify-center">
<div className="relative w-32 h-32">
<svg className="w-32 h-32 transform -rotate-90">
<circle
cx="64"
cy="64"
r="56"
stroke="rgba(255,255,255,0.2)"
strokeWidth="8"
fill="none"
/>
<circle
cx="64"
cy="64"
r="56"
stroke="white"
strokeWidth="8"
fill="none"
strokeDasharray={`${2 * Math.PI * 56}`}
strokeDashoffset={`${
2 * Math.PI * 56 * (1 - todayProgress.percentage / 100)
}`}
className="transition-all duration-500 ease-in-out"
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center">
<div className="text-2xl font-bold">
{todayProgress.completed}/{todayProgress.total}
</div>
<div className="text-sm text-purple-100">
{Math.round(todayProgress.percentage)}%
</div>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Quick Stats */}
<div className="grid grid-cols-3 gap-3">
<Card className="text-center">
<CardContent className="p-3">
<Flame className="h-6 w-6 mx-auto mb-1 text-orange-500" />
<div className="text-lg font-bold">
{streakHabits[0]?.streak || 0}
</div>
<div className="text-xs text-gray-600">Best Streak</div>
</CardContent>
</Card>
<Card className="text-center">
<CardContent className="p-3">
<Award className="h-6 w-6 mx-auto mb-1 text-yellow-500" />
<div className="text-lg font-bold">
{todayStats.achievements || 0}
</div>
<div className="text-xs text-gray-600">Achievements</div>
</CardContent>
</Card>
<Card className="text-center">
<CardContent className="p-3">
<TrendingUp className="h-6 w-6 mx-auto mb-1 text-green-500" />
<div className="text-lg font-bold">
{Math.round(todayStats.weekly_avg || 0)}%
</div>
<div className="text-xs text-gray-600">Weekly Avg</div>
</CardContent>
</Card>
</div>
{/* Today's Habits */}
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="flex items-center">
<Target className="h-5 w-5 mr-2" />
Today's Habits
</CardTitle>
<Button
size="sm"
variant="outline"
onClick={() => setShowQuickAdd(true)}
>
<Plus className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent className="space-y-3">
{habits.map((habit) => (
<HabitCard
key={habit.id}
habit={habit}
onComplete={() => completeHabit(habit.id)}
onSnooze={() => snoozeHabit(habit.id)}
isMobile={isMobile}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={() => onTouchEnd(habit.id)}
/>
))}
{habits.length === 0 && (
<div className="text-center py-8 text-gray-500">
<Target className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>No habits for today</p>
<Button
onClick={() => setShowQuickAdd(true)}
className="mt-2"
size="sm"
>
Add your first habit
</Button>
</div>
)}
</CardContent>
</Card>
{/* Streak Highlights */}
{streakHabits.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center">
<Flame className="h-5 w-5 mr-2 text-orange-500" />
Streak Leaders
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{streakHabits.map((habit, index) => (
<div
key={habit.id}
className="flex items-center justify-between p-2 bg-orange-50 rounded-lg"
>
<div className="flex items-center space-x-3">
<div className="text-2xl">
{index === 0 ? "🥇" : index === 1 ? "🥈" : "🥉"}
</div>
<div>
<div className="font-medium text-sm">{habit.title}</div>
<div className="text-xs text-gray-600">
{habit.streak} days
</div>
</div>
</div>
<Flame className="h-5 w-5 text-orange-500" />
</div>
))}
</CardContent>
</Card>
)}
{/* Quick Add Modal */}
{showQuickAdd && (
<QuickAddModal
onAdd={quickAddHabit}
onClose={() => setShowQuickAdd(false)}
/>
)}
{/* Mobile Bottom Navigation Spacer */}
{isMobile && <div className="h-16" />}
</div>
);
};
// Individual habit card component
const HabitCard = ({
habit,
onComplete,
onSnooze,
isMobile,
onTouchStart,
onTouchMove,
onTouchEnd,
}) => {
const isCompleted = habit.completed_today;
const isSnoozed =
habit.snoozed_until && new Date(habit.snoozed_until) > new Date();
const difficultyColors = {
1: "bg-green-100 text-green-800",
2: "bg-yellow-100 text-yellow-800",
3: "bg-orange-100 text-orange-800",
4: "bg-red-100 text-red-800",
5: "bg-purple-100 text-purple-800",
};
const touchProps = isMobile
? {
onTouchStart,
onTouchMove,
onTouchEnd,
}
: {};
return (
<div
className={`flex items-center space-x-3 p-3 rounded-lg border transition-all duration-200 ${
isCompleted
? "bg-green-50 border-green-200"
: isSnoozed
? "bg-gray-50 border-gray-200"
: "bg-white border-gray-200 hover:shadow-md"
}`}
{...touchProps}
>
{/* Completion Button */}
<button
onClick={onComplete}
disabled={isCompleted}
className={`flex-shrink-0 w-8 h-8 rounded-full border-2 flex items-center justify-center transition-all ${
isCompleted
? "bg-green-500 border-green-500 text-white"
: "border-gray-300 hover:border-purple-500 hover:bg-purple-50"
}`}
>
{isCompleted && <CheckCircle2 className="h-5 w-5" />}
</button>
{/* Habit Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-2 mb-1">
<h4
className={`font-medium truncate ${
isCompleted ? "line-through text-gray-500" : ""
}`}
>
{habit.title}
</h4>
<Badge
variant="secondary"
className={`text-xs ${
difficultyColors[habit.difficulty] || difficultyColors[1]
}`}
>
L{habit.difficulty}
</Badge>
</div>
{habit.streak > 0 && (
<div className="flex items-center space-x-1 text-xs text-orange-600">
<Flame className="h-3 w-3" />
<span>{habit.streak} day streak</span>
</div>
)}
{isSnoozed && (
<div className="flex items-center space-x-1 text-xs text-gray-500">
<Clock className="h-3 w-3" />
<span>
Snoozed until {new Date(habit.snoozed_until).toLocaleTimeString()}
</span>
</div>
)}
</div>
{/* Quick Actions */}
{!isCompleted && !isSnoozed && isMobile && (
<div className="text-xs text-gray-400 text-center">
<div> Complete</div>
<div>Snooze </div>
</div>
)}
{!isMobile && !isCompleted && (
<div className="flex space-x-1">
<Button size="sm" variant="outline" onClick={onSnooze}>
<Clock className="h-3 w-3" />
</Button>
<Button size="sm" onClick={onComplete}>
<CheckCircle2 className="h-3 w-3" />
</Button>
</div>
)}
</div>
);
};
// Quick add habit modal
const QuickAddModal = ({ onAdd, onClose }) => {
const [title, setTitle] = useState("");
const [isAdding, setIsAdding] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
if (!title.trim()) return;
setIsAdding(true);
await onAdd(title.trim());
setIsAdding(false);
setTitle("");
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>Quick Add Habit</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="What habit do you want to build?"
className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500"
autoFocus
/>
<div className="flex space-x-2">
<Button
type="button"
variant="outline"
onClick={onClose}
className="flex-1"
>
Cancel
</Button>
<Button
type="submit"
disabled={!title.trim() || isAdding}
className="flex-1"
>
{isAdding ? "Adding..." : "Add Habit"}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
);
};
export default MobileHabitTracker;
@@ -0,0 +1,94 @@
import React, { useState } from "react";
import { Card, CardHeader, CardTitle, CardContent } from "./ui/card";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Sparkles, Loader2 } from "lucide-react";
const NaturalLanguageHabitCreator = ({ onHabitCreated }) => {
const [prompt, setPrompt] = useState("");
const [loading, setLoading] = useState(false);
const [result, setResult] = useState(null);
const [error, setError] = useState(null);
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setError(null);
setResult(null);
try {
const token = localStorage.getItem("token");
const res = await fetch("/api/v1/ai/habits/nlp-create", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ prompt }),
});
const data = await res.json();
if (res.ok && data.success) {
setResult(data.habit);
setPrompt("");
if (onHabitCreated) onHabitCreated(data.habit);
} else {
setError(data.error || "Could not create habit");
}
} catch (err) {
setError("Network error");
} finally {
setLoading(false);
}
};
return (
<Card className="max-w-xl mx-auto my-8">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-purple-500" />
Create a Habit with AI
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<Input
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="e.g. Remind me to meditate every morning at 7am"
disabled={loading}
required
className="text-lg"
/>
<Button type="submit" disabled={loading || !prompt.trim()}>
{loading ? (
<Loader2 className="animate-spin h-4 w-4 mr-2" />
) : (
<Sparkles className="h-4 w-4 mr-2" />
)}
Create Habit
</Button>
</form>
{result && (
<div className="mt-4 p-3 bg-green-50 rounded text-green-700">
<div className="font-semibold">Habit Created:</div>
<div>
Title: <span className="font-mono">{result.title}</span>
</div>
<div>
Cadence: <span className="font-mono">{result.cadence}</span>
</div>
{result.due_time && (
<div>
Time: <span className="font-mono">{result.due_time}</span>
</div>
)}
</div>
)}
{error && (
<div className="mt-4 p-3 bg-red-50 rounded text-red-700">{error}</div>
)}
</CardContent>
</Card>
);
};
export default NaturalLanguageHabitCreator;
@@ -0,0 +1,440 @@
// Real-time Notification System - AHK-style popups for achievements and updates
import React, { useState, useEffect, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
X,
Trophy,
Zap,
Target,
Star,
CheckCircle,
AlertCircle,
} from "lucide-react";
export interface NotificationData {
id: string;
type:
| "achievement"
| "xp_gain"
| "level_up"
| "streak"
| "habit_complete"
| "warning"
| "info";
title: string;
message: string;
xpAwarded?: number;
icon?: React.ReactNode;
duration?: number; // ms, 0 = persistent
action?: {
label: string;
onClick: () => void;
};
sound?: boolean;
position?: "top-right" | "top-left" | "bottom-right" | "bottom-left";
}
interface NotificationSystemProps {
maxNotifications?: number;
defaultDuration?: number;
playSound?: boolean;
position?: "top-right" | "top-left" | "bottom-right" | "bottom-left";
}
export const NotificationSystem: React.FC<NotificationSystemProps> = ({
maxNotifications = 5,
defaultDuration = 5000,
playSound = true,
position = "top-right",
}) => {
const [notifications, setNotifications] = useState<NotificationData[]>([]);
// Auto-remove notifications after duration
useEffect(() => {
const timers = notifications
.filter((n) => n.duration !== 0)
.map((notification) => {
const duration = notification.duration || defaultDuration;
return setTimeout(() => {
removeNotification(notification.id);
}, duration);
});
return () => timers.forEach(clearTimeout);
}, [notifications, defaultDuration]);
const addNotification = useCallback(
(notification: Omit<NotificationData, "id">) => {
const id = `notification-${Date.now()}-${Math.random()}`;
const newNotification: NotificationData = {
...notification,
id,
position: notification.position || position,
};
setNotifications((prev) => {
const updated = [newNotification, ...prev];
// Keep only max notifications
return updated.slice(0, maxNotifications);
});
// Play notification sound (like AHK notification sounds)
if (playSound && notification.sound !== false) {
playNotificationSound(notification.type);
}
},
[maxNotifications, playSound, position]
);
const removeNotification = useCallback((id: string) => {
setNotifications((prev) => prev.filter((n) => n.id !== id));
}, []);
const clearAll = useCallback(() => {
setNotifications([]);
}, []);
// Listen for global notification events
useEffect(() => {
const handleGlobalNotification = (event: CustomEvent<NotificationData>) => {
addNotification(event.detail);
};
window.addEventListener(
"show-notification",
handleGlobalNotification as EventListener
);
return () =>
window.removeEventListener(
"show-notification",
handleGlobalNotification as EventListener
);
}, [addNotification]);
const getNotificationIcon = (type: NotificationData["type"]) => {
switch (type) {
case "achievement":
return <Trophy className="w-5 h-5 text-yellow-500" />;
case "xp_gain":
return <Zap className="w-5 h-5 text-blue-500" />;
case "level_up":
return <Star className="w-5 h-5 text-purple-500" />;
case "streak":
return <Target className="w-5 h-5 text-green-500" />;
case "habit_complete":
return <CheckCircle className="w-5 h-5 text-green-600" />;
case "warning":
return <AlertCircle className="w-5 h-5 text-orange-500" />;
case "info":
default:
return <AlertCircle className="w-5 h-5 text-blue-500" />;
}
};
const getNotificationColors = (type: NotificationData["type"]) => {
switch (type) {
case "achievement":
return "border-yellow-300 bg-gradient-to-r from-yellow-50 to-amber-50 dark:from-yellow-900/20 dark:to-amber-900/20";
case "xp_gain":
return "border-blue-300 bg-gradient-to-r from-blue-50 to-cyan-50 dark:from-blue-900/20 dark:to-cyan-900/20";
case "level_up":
return "border-purple-300 bg-gradient-to-r from-purple-50 to-pink-50 dark:from-purple-900/20 dark:to-pink-900/20";
case "streak":
return "border-green-300 bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20";
case "habit_complete":
return "border-green-300 bg-gradient-to-r from-green-50 to-teal-50 dark:from-green-900/20 dark:to-teal-900/20";
case "warning":
return "border-orange-300 bg-gradient-to-r from-orange-50 to-red-50 dark:from-orange-900/20 dark:to-red-900/20";
case "info":
default:
return "border-gray-300 bg-gradient-to-r from-gray-50 to-slate-50 dark:from-gray-800 dark:to-slate-800";
}
};
const positionClasses = {
"top-right": "top-4 right-4",
"top-left": "top-4 left-4",
"bottom-right": "bottom-4 right-4",
"bottom-left": "bottom-4 left-4",
};
const getAnimationProps = (index: number, position: string) => {
const fromRight = position.includes("right");
const fromTop = position.includes("top");
return {
initial: {
opacity: 0,
x: fromRight ? 100 : -100,
y: 0,
scale: 0.8,
},
animate: {
opacity: 1,
x: 0,
y: fromTop ? index * 10 : -index * 10,
scale: 1,
},
exit: {
opacity: 0,
x: fromRight ? 100 : -100,
scale: 0.8,
},
};
};
return (
<div
className={`fixed ${positionClasses[position]} z-[200] space-y-2 max-w-sm`}
>
<AnimatePresence mode="popLayout">
{notifications.map((notification, index) => (
<motion.div
key={notification.id}
className={`rounded-lg border shadow-lg backdrop-blur-sm p-4
${getNotificationColors(notification.type)}
min-w-[320px] max-w-sm`}
{...getAnimationProps(index, notification.position || position)}
transition={{
type: "spring",
stiffness: 300,
damping: 30,
mass: 0.8,
}}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<div className="flex items-start space-x-3">
{/* Icon */}
<div className="flex-shrink-0 mt-0.5">
{notification.icon || getNotificationIcon(notification.type)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-gray-900 dark:text-white truncate">
{notification.title}
</p>
<p className="text-sm text-gray-600 dark:text-gray-300 mt-1">
{notification.message}
</p>
{/* XP Award Display */}
{notification.xpAwarded && (
<div className="flex items-center space-x-1 mt-2">
<Zap className="w-4 h-4 text-yellow-500" />
<span className="text-xs font-bold text-yellow-600 dark:text-yellow-400">
+{notification.xpAwarded} XP
</span>
</div>
)}
{/* Action Button */}
{notification.action && (
<button
onClick={notification.action.onClick}
className="mt-2 text-xs bg-blue-100 dark:bg-blue-900/30
text-blue-700 dark:text-blue-300 px-2 py-1 rounded
hover:bg-blue-200 dark:hover:bg-blue-900/50 transition-colors"
>
{notification.action.label}
</button>
)}
</div>
{/* Close Button */}
<button
onClick={() => removeNotification(notification.id)}
className="flex-shrink-0 ml-2 text-gray-400 hover:text-gray-600
dark:text-gray-500 dark:hover:text-gray-300 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
</div>
</motion.div>
))}
</AnimatePresence>
{/* Clear All Button (when multiple notifications) */}
{notifications.length > 1 && (
<motion.button
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={clearAll}
className="w-full text-xs text-center py-1 px-2 bg-gray-200 dark:bg-gray-700
text-gray-600 dark:text-gray-400 rounded hover:bg-gray-300
dark:hover:bg-gray-600 transition-colors"
>
Clear All ({notifications.length})
</motion.button>
)}
</div>
);
};
// Sound effects for notifications (like AHK notification sounds)
const playNotificationSound = (type: NotificationData["type"]) => {
// Create audio context for sound effects
const audioContext = new (window.AudioContext ||
(window as any).webkitAudioContext)();
const playTone = (
frequency: number,
duration: number,
type: OscillatorType = "sine"
) => {
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.frequency.setValueAtTime(frequency, audioContext.currentTime);
oscillator.type = type;
gainNode.gain.setValueAtTime(0.1, audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(
0.01,
audioContext.currentTime + duration
);
oscillator.start(audioContext.currentTime);
oscillator.stop(audioContext.currentTime + duration);
};
switch (type) {
case "achievement":
// Triumphant ascending tones
playTone(523, 0.15); // C5
setTimeout(() => playTone(659, 0.15), 100); // E5
setTimeout(() => playTone(784, 0.3), 200); // G5
break;
case "xp_gain":
// Quick positive chirp
playTone(800, 0.1, "square");
setTimeout(() => playTone(1000, 0.1, "square"), 50);
break;
case "level_up":
// Major chord progression
playTone(523, 0.2); // C5
setTimeout(() => playTone(659, 0.2), 100); // E5
setTimeout(() => playTone(784, 0.2), 200); // G5
setTimeout(() => playTone(1047, 0.4), 300); // C6
break;
case "streak":
// Bouncy rhythm
playTone(700, 0.08);
setTimeout(() => playTone(800, 0.08), 80);
setTimeout(() => playTone(900, 0.15), 160);
break;
case "habit_complete":
// Simple completion sound
playTone(600, 0.1);
setTimeout(() => playTone(800, 0.15), 80);
break;
case "warning":
// Alert tone
playTone(400, 0.15, "triangle");
setTimeout(() => playTone(300, 0.15, "triangle"), 150);
break;
case "info":
default:
// Gentle notification
playTone(600, 0.12);
break;
}
};
// Hook for easy notification management
export const useNotifications = () => {
const showNotification = useCallback(
(notification: Omit<NotificationData, "id">) => {
window.dispatchEvent(
new CustomEvent("show-notification", { detail: notification })
);
},
[]
);
const showAchievement = useCallback(
(title: string, description: string, xpAwarded: number) => {
showNotification({
type: "achievement",
title,
message: description,
xpAwarded,
duration: 8000, // Longer for achievements
sound: true,
});
},
[showNotification]
);
const showXPGain = useCallback(
(xp: number, source: string) => {
showNotification({
type: "xp_gain",
title: `+${xp} XP Gained!`,
message: `From: ${source}`,
xpAwarded: xp,
duration: 3000,
});
},
[showNotification]
);
const showLevelUp = useCallback(
(newLevel: number, xpAwarded: number) => {
showNotification({
type: "level_up",
title: `🎉 Level Up!`,
message: `You've reached Level ${newLevel}!`,
xpAwarded,
duration: 10000, // Even longer for level ups
sound: true,
});
},
[showNotification]
);
const showStreak = useCallback(
(habitName: string, streakCount: number) => {
showNotification({
type: "streak",
title: `🔥 Streak Achievement!`,
message: `${habitName}: ${streakCount} days in a row!`,
duration: 6000,
});
},
[showNotification]
);
const showHabitComplete = useCallback(
(habitName: string, xpAwarded: number) => {
showNotification({
type: "habit_complete",
title: "Habit Completed!",
message: habitName,
xpAwarded,
duration: 3000,
});
},
[showNotification]
);
return {
showNotification,
showAchievement,
showXPGain,
showLevelUp,
showStreak,
showHabitComplete,
};
};
export default NotificationSystem;
@@ -0,0 +1,520 @@
import React, { useState, useEffect, useMemo, useCallback, memo } from "react";
import { FixedSizeList as List } from "react-window";
import { Card, CardHeader, CardTitle, CardContent } from "./ui/card";
import { Button } from "./ui/button";
import { LoadingSpinner } from "./ui/loading";
import {
Zap,
Target,
Calendar,
TrendingUp,
CheckCircle2,
Clock,
Star,
Filter,
} from "lucide-react";
// Memoized habit card component for performance
const HabitCard = memo(({ habit, onComplete, onEdit }) => {
const handleComplete = useCallback(() => {
onComplete(habit.id);
}, [habit.id, onComplete]);
const handleEdit = useCallback(() => {
onEdit(habit);
}, [habit, onEdit]);
const difficultyColor = useMemo(() => {
switch (habit.difficulty) {
case 1:
return "text-green-600 bg-green-50";
case 2:
return "text-yellow-600 bg-yellow-50";
case 3:
return "text-orange-600 bg-orange-50";
case 4:
return "text-red-600 bg-red-50";
case 5:
return "text-purple-600 bg-purple-50";
default:
return "text-gray-600 bg-gray-50";
}
}, [habit.difficulty]);
const statusColor = useMemo(() => {
switch (habit.status) {
case "active":
return "border-l-green-500";
case "completed":
return "border-l-blue-500";
case "paused":
return "border-l-yellow-500";
default:
return "border-l-gray-500";
}
}, [habit.status]);
return (
<Card
className={`hover:shadow-md transition-all duration-200 border-l-4 ${statusColor}`}
>
<CardContent className="p-4">
<div className="flex items-start justify-between mb-2">
<h3 className="font-semibold text-sm truncate flex-1">
{habit.title}
</h3>
<div
className={`px-2 py-1 rounded-full text-xs font-medium ${difficultyColor}`}
>
Level {habit.difficulty}
</div>
</div>
{habit.notes && (
<p className="text-xs text-gray-600 mb-2 line-clamp-2">
{habit.notes}
</p>
)}
<div className="flex items-center justify-between text-xs text-gray-500 mb-3">
<span className="flex items-center">
<Target className="h-3 w-3 mr-1" />
{habit.cadence || "Daily"}
</span>
<span className="flex items-center">
<Star className="h-3 w-3 mr-1" />
{habit.streak || 0} streak
</span>
</div>
<div className="flex space-x-2">
<Button
onClick={handleComplete}
className="flex-1 text-xs h-8"
size="sm"
disabled={habit.status === "completed"}
>
<CheckCircle2 className="h-3 w-3 mr-1" />
{habit.status === "completed" ? "Completed" : "Complete"}
</Button>
<Button
onClick={handleEdit}
variant="outline"
size="sm"
className="text-xs h-8 px-2"
>
Edit
</Button>
</div>
</CardContent>
</Card>
);
});
HabitCard.displayName = "HabitCard";
// Virtualized habit list for performance with large datasets
const VirtualizedHabitList = memo(
({ habits, onComplete, onEdit, height = 600 }) => {
const Row = useCallback(
({ index, style }) => (
<div style={style} className="pr-2 pb-2">
<HabitCard
habit={habits[index]}
onComplete={onComplete}
onEdit={onEdit}
/>
</div>
),
[habits, onComplete, onEdit]
);
if (habits.length === 0) {
return (
<div className="text-center py-8">
<Target className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<p className="text-gray-600">No habits found</p>
</div>
);
}
return (
<List
height={height}
itemCount={habits.length}
itemSize={160} // Height of each habit card
overscanCount={5} // Pre-render 5 items above/below viewport
>
{Row}
</List>
);
}
);
VirtualizedHabitList.displayName = "VirtualizedHabitList";
// Advanced filtering hook with performance optimizations
const useAdvancedHabitFiltering = (habits) => {
const [filters, setFilters] = useState({
search: "",
status: "all",
difficulty: "all",
sortBy: "created_at",
sortOrder: "desc",
});
// Memoized filtered and sorted habits
const filteredHabits = useMemo(() => {
let result = [...habits];
// Search filter
if (filters.search) {
const searchLower = filters.search.toLowerCase();
result = result.filter(
(habit) =>
habit.title.toLowerCase().includes(searchLower) ||
(habit.notes && habit.notes.toLowerCase().includes(searchLower))
);
}
// Status filter
if (filters.status !== "all") {
result = result.filter((habit) => habit.status === filters.status);
}
// Difficulty filter
if (filters.difficulty !== "all") {
result = result.filter(
(habit) => habit.difficulty === parseInt(filters.difficulty)
);
}
// Sorting
result.sort((a, b) => {
let aValue = a[filters.sortBy];
let bValue = b[filters.sortBy];
// Handle date sorting
if (filters.sortBy === "created_at" || filters.sortBy === "due_date") {
aValue = new Date(aValue);
bValue = new Date(bValue);
}
if (filters.sortOrder === "desc") {
return bValue > aValue ? 1 : -1;
} else {
return aValue > bValue ? 1 : -1;
}
});
return result;
}, [habits, filters]);
const updateFilter = useCallback((key, value) => {
setFilters((prev) => ({ ...prev, [key]: value }));
}, []);
const resetFilters = useCallback(() => {
setFilters({
search: "",
status: "all",
difficulty: "all",
sortBy: "created_at",
sortOrder: "desc",
});
}, []);
return {
filters,
filteredHabits,
updateFilter,
resetFilters,
totalCount: habits.length,
filteredCount: filteredHabits.length,
};
};
// Advanced filter bar component
const AdvancedFilterBar = memo(
({ filters, updateFilter, resetFilters, totalCount, filteredCount }) => {
const [showAdvanced, setShowAdvanced] = useState(false);
return (
<Card className="mb-4">
<CardContent className="p-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-2">
<Filter className="h-4 w-4" />
<span className="text-sm font-medium">
Filters ({filteredCount}/{totalCount})
</span>
</div>
<Button
onClick={() => setShowAdvanced(!showAdvanced)}
variant="ghost"
size="sm"
>
{showAdvanced ? "Simple" : "Advanced"}
</Button>
</div>
{/* Basic filters */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div>
<label className="block text-xs font-medium mb-1">Search</label>
<input
type="text"
placeholder="Search habits..."
value={filters.search}
onChange={(e) => updateFilter("search", e.target.value)}
className="w-full px-3 py-1 text-sm border rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500"
/>
</div>
<div>
<label className="block text-xs font-medium mb-1">Status</label>
<select
value={filters.status}
onChange={(e) => updateFilter("status", e.target.value)}
className="w-full px-3 py-1 text-sm border rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500"
>
<option value="all">All Status</option>
<option value="active">Active</option>
<option value="completed">Completed</option>
<option value="paused">Paused</option>
</select>
</div>
<div>
<label className="block text-xs font-medium mb-1">Sort By</label>
<select
value={filters.sortBy}
onChange={(e) => updateFilter("sortBy", e.target.value)}
className="w-full px-3 py-1 text-sm border rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500"
>
<option value="created_at">Created Date</option>
<option value="title">Title</option>
<option value="difficulty">Difficulty</option>
<option value="streak">Streak</option>
<option value="due_date">Due Date</option>
</select>
</div>
</div>
{/* Advanced filters */}
{showAdvanced && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-4 border-t">
<div>
<label className="block text-xs font-medium mb-1">
Difficulty Level
</label>
<select
value={filters.difficulty}
onChange={(e) => updateFilter("difficulty", e.target.value)}
className="w-full px-3 py-1 text-sm border rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500"
>
<option value="all">All Difficulties</option>
<option value="1">Level 1 (Beginner)</option>
<option value="2">Level 2 (Easy)</option>
<option value="3">Level 3 (Medium)</option>
<option value="4">Level 4 (Hard)</option>
<option value="5">Level 5 (Expert)</option>
</select>
</div>
<div>
<label className="block text-xs font-medium mb-1">
Sort Order
</label>
<select
value={filters.sortOrder}
onChange={(e) => updateFilter("sortOrder", e.target.value)}
className="w-full px-3 py-1 text-sm border rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500"
>
<option value="desc">Descending</option>
<option value="asc">Ascending</option>
</select>
</div>
</div>
)}
{/* Filter actions */}
<div className="flex justify-end mt-4 space-x-2">
<Button onClick={resetFilters} variant="outline" size="sm">
Reset Filters
</Button>
</div>
</CardContent>
</Card>
);
}
);
AdvancedFilterBar.displayName = "AdvancedFilterBar";
// Optimized main habits dashboard
const OptimizedHabitsView = () => {
const [habits, setHabits] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Use advanced filtering hook
const {
filters,
filteredHabits,
updateFilter,
resetFilters,
totalCount,
filteredCount,
} = useAdvancedHabitFiltering(habits);
// Memoized event handlers
const handleComplete = useCallback(async (habitId) => {
try {
const token = localStorage.getItem("token");
const response = await fetch(`/api/v1/habits/${habitId}/complete`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
},
});
if (response.ok) {
const result = await response.json();
// Update local state optimistically
setHabits((prev) =>
prev.map((habit) =>
habit.id === habitId
? {
...habit,
status: "completed",
streak: (habit.streak || 0) + 1,
}
: habit
)
);
// Trigger celebrations if needed
if (result.achievement_unlocked) {
window.dispatchEvent(
new CustomEvent("achievement-unlocked", {
detail: result.achievement_unlocked,
})
);
}
}
} catch (error) {
console.error("Failed to complete habit:", error);
setError("Failed to complete habit");
}
}, []);
const handleEdit = useCallback((habit) => {
// Open edit modal (implementation depends on your modal system)
console.log("Edit habit:", habit);
}, []);
// Load habits with error handling
useEffect(() => {
const loadHabits = async () => {
try {
const token = localStorage.getItem("token");
const response = await fetch("/api/v1/habits", {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (response.ok) {
const data = await response.json();
setHabits(data);
} else {
throw new Error("Failed to fetch habits");
}
} catch (error) {
console.error("Failed to load habits:", error);
setError(error.message);
} finally {
setLoading(false);
}
};
loadHabits();
}, []);
// Performance monitoring (development only)
useEffect(() => {
if (process.env.NODE_ENV === "development") {
const startTime = performance.now();
const endTime = performance.now();
console.log(`Habits render time: ${endTime - startTime}ms`);
}
}, [filteredHabits]);
if (loading) {
return (
<div className="flex items-center justify-center py-8">
<LoadingSpinner />
<span className="ml-2">Loading your magical practices...</span>
</div>
);
}
if (error) {
return (
<Card>
<CardContent className="p-8 text-center">
<div className="text-red-500 mb-2"> Error loading habits</div>
<p className="text-gray-600">{error}</p>
<Button onClick={() => window.location.reload()} className="mt-4">
Retry
</Button>
</CardContent>
</Card>
);
}
return (
<div className="space-y-4">
<AdvancedFilterBar
filters={filters}
updateFilter={updateFilter}
resetFilters={resetFilters}
totalCount={totalCount}
filteredCount={filteredCount}
/>
{/* Performance stats in development */}
{process.env.NODE_ENV === "development" && (
<Card>
<CardContent className="p-4">
<div className="text-xs text-gray-500">
Performance: {filteredHabits.length} habits rendered
{filteredHabits.length > 50 && " (using virtualization)"}
</div>
</CardContent>
</Card>
)}
{/* Use virtualized list for large datasets */}
{filteredHabits.length > 50 ? (
<VirtualizedHabitList
habits={filteredHabits}
onComplete={handleComplete}
onEdit={handleEdit}
/>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredHabits.map((habit) => (
<HabitCard
key={habit.id}
habit={habit}
onComplete={handleComplete}
onEdit={handleEdit}
/>
))}
</div>
)}
</div>
);
};
export default OptimizedHabitsView;
@@ -0,0 +1,362 @@
import React, { useState, useEffect } from "react";
import { Card, CardHeader, CardTitle, CardContent } from "./ui/card";
import { Button } from "./ui/button";
import { Progress } from "./ui/progress";
import {
Brain,
TrendingUp,
TrendingDown,
AlertTriangle,
CheckCircle,
Clock,
Target,
Lightbulb,
BarChart3,
} from "lucide-react";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
const PredictiveAnalyticsUI = ({ userId }) => {
const [predictions, setPredictions] = useState([]);
const [patterns, setPatterns] = useState(null);
const [suggestions, setSuggestions] = useState([]);
const [loading, setLoading] = useState(true);
const [selectedHabit, setSelectedHabit] = useState(null);
useEffect(() => {
loadAnalytics();
}, [userId]);
const loadAnalytics = async () => {
setLoading(true);
try {
const token = localStorage.getItem("token");
const headers = { Authorization: `Bearer ${token}` };
// Load pattern analysis
const patternsRes = await fetch("/api/v1/ai/habits/analyze-patterns", {
headers,
});
if (patternsRes.ok) {
const patternsData = await patternsRes.json();
setPatterns(patternsData);
}
// Load AI suggestions
const suggestionsRes = await fetch("/api/v1/ai/habits/suggestions", {
headers,
});
if (suggestionsRes.ok) {
const suggestionsData = await suggestionsRes.json();
setSuggestions(suggestionsData.suggestions || []);
}
} catch (error) {
console.error("Failed to load analytics:", error);
} finally {
setLoading(false);
}
};
const predictHabitSuccess = async (habitId) => {
try {
const token = localStorage.getItem("token");
const res = await fetch(
`/api/v1/ai/habits/predict-success?habit_id=${habitId}`,
{
headers: { Authorization: `Bearer ${token}` },
}
);
if (res.ok) {
const data = await res.json();
setSelectedHabit({ id: habitId, prediction: data.prediction });
}
} catch (error) {
console.error("Failed to predict habit success:", error);
}
};
const getSuccessProbabilityColor = (probability) => {
if (probability >= 0.8) return "text-green-600";
if (probability >= 0.6) return "text-yellow-600";
return "text-red-600";
};
const getSuccessProbabilityIcon = (probability) => {
if (probability >= 0.8)
return <CheckCircle className="h-5 w-5 text-green-600" />;
if (probability >= 0.6)
return <Clock className="h-5 w-5 text-yellow-600" />;
return <AlertTriangle className="h-5 w-5 text-red-600" />;
};
if (loading) {
return (
<div className="flex items-center justify-center py-8">
<div className="animate-pulse">
<Brain className="h-8 w-8 text-purple-500 animate-bounce" />
</div>
<span className="ml-2">Analyzing your habits with AI...</span>
</div>
);
}
return (
<div className="space-y-6">
{/* AI Insights Header */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Brain className="h-6 w-6 text-purple-600" />
AI-Powered Habit Analytics
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-600">
Using advanced AI models to analyze your habit patterns and predict
success rates.
</p>
</CardContent>
</Card>
{/* Pattern Analysis */}
{patterns && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BarChart3 className="h-5 w-5 text-blue-600" />
Your Habit Patterns
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Best Time Pattern */}
{patterns.patterns?.best_time_of_day?.best_hour && (
<div className="p-4 bg-blue-50 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<Clock className="h-4 w-4 text-blue-600" />
<span className="font-semibold text-blue-800">
Optimal Time
</span>
</div>
<p className="text-blue-700">
You're most successful at{" "}
{patterns.patterns.best_time_of_day.best_hour}:00 (
{patterns.patterns.best_time_of_day.success_count}{" "}
completions)
</p>
</div>
)}
{/* Success by Difficulty */}
{patterns.patterns?.success_by_difficulty && (
<div className="p-4 bg-green-50 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<Target className="h-4 w-4 text-green-600" />
<span className="font-semibold text-green-800">
Difficulty Performance
</span>
</div>
<div className="space-y-2">
{Object.entries(patterns.patterns.success_by_difficulty).map(
([difficulty, rate]) => (
<div
key={difficulty}
className="flex items-center justify-between"
>
<span className="text-green-700 capitalize">
{difficulty.replace("_", " ")}
</span>
<div className="flex items-center gap-2">
<Progress value={rate * 100} className="w-20" />
<span className="text-sm font-mono">
{Math.round(rate * 100)}%
</span>
</div>
</div>
)
)}
</div>
</div>
)}
{/* AI Insights */}
{patterns.insights && patterns.insights.length > 0 && (
<div className="p-4 bg-purple-50 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<Lightbulb className="h-4 w-4 text-purple-600" />
<span className="font-semibold text-purple-800">
AI Insights
</span>
</div>
<ul className="space-y-1">
{patterns.insights.map((insight, index) => (
<li key={index} className="text-purple-700 text-sm">
{insight}
</li>
))}
</ul>
</div>
)}
{/* Recommendations */}
{patterns.recommendations &&
patterns.recommendations.length > 0 && (
<div className="p-4 bg-orange-50 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<TrendingUp className="h-4 w-4 text-orange-600" />
<span className="font-semibold text-orange-800">
AI Recommendations
</span>
</div>
<ul className="space-y-1">
{patterns.recommendations.map((rec, index) => (
<li key={index} className="text-orange-700 text-sm">
{rec}
</li>
))}
</ul>
</div>
)}
</CardContent>
</Card>
)}
{/* AI Habit Suggestions */}
{suggestions.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Lightbulb className="h-5 w-5 text-yellow-600" />
AI Habit Suggestions
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-3">
{suggestions.map((suggestion, index) => (
<div
key={index}
className="p-3 border rounded-lg hover:bg-gray-50"
>
<div className="flex items-center justify-between">
<span className="font-medium">{suggestion}</span>
<Button
size="sm"
variant="outline"
onClick={() => {
// Could integrate with habit creation
console.log("Create habit:", suggestion);
}}
>
Add Habit
</Button>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Habit Success Prediction */}
{selectedHabit && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Brain className="h-5 w-5 text-purple-600" />
Success Prediction
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-4">
{getSuccessProbabilityIcon(
selectedHabit.prediction.success_probability
)}
<div className="flex-1">
<div className="flex items-center justify-between mb-2">
<span className="font-semibold">Success Probability</span>
<span
className={`font-mono text-lg ${getSuccessProbabilityColor(
selectedHabit.prediction.success_probability
)}`}
>
{Math.round(
selectedHabit.prediction.success_probability * 100
)}
%
</span>
</div>
<Progress
value={selectedHabit.prediction.success_probability * 100}
className="h-3"
/>
</div>
</div>
{selectedHabit.prediction.insights &&
selectedHabit.prediction.insights.length > 0 && (
<div className="p-3 bg-blue-50 rounded-lg">
<h4 className="font-semibold text-blue-800 mb-2">
AI Insights
</h4>
<ul className="space-y-1">
{selectedHabit.prediction.insights.map((insight, index) => (
<li key={index} className="text-blue-700 text-sm">
{insight}
</li>
))}
</ul>
</div>
)}
{selectedHabit.prediction.recommended_adjustments &&
selectedHabit.prediction.recommended_adjustments.length > 0 && (
<div className="p-3 bg-green-50 rounded-lg">
<h4 className="font-semibold text-green-800 mb-2">
Recommended Improvements
</h4>
<ul className="space-y-1">
{selectedHabit.prediction.recommended_adjustments.map(
(adjustment, index) => (
<li key={index} className="text-green-700 text-sm">
{adjustment}
</li>
)
)}
</ul>
</div>
)}
</CardContent>
</Card>
)}
{/* Test Prediction Button */}
<Card>
<CardHeader>
<CardTitle>Test Habit Prediction</CardTitle>
</CardHeader>
<CardContent>
<div className="flex gap-2">
<Button onClick={() => predictHabitSuccess(1)} variant="outline">
Predict Habit #1
</Button>
<Button onClick={() => predictHabitSuccess(2)} variant="outline">
Predict Habit #2
</Button>
</div>
<p className="text-sm text-gray-600 mt-2">
Click to see AI predictions for your habits
</p>
</CardContent>
</Card>
</div>
);
};
export default PredictiveAnalyticsUI;
@@ -0,0 +1,464 @@
import React, { useState, useRef, useEffect } from "react";
import { Card, CardHeader, CardTitle, CardContent } from "./ui/card";
import { Button } from "./ui/button";
import {
Mic,
MicOff,
Camera,
Upload,
Play,
Pause,
Square,
Volume2,
ImageIcon,
CheckCircle,
AlertCircle,
} from "lucide-react";
const VoiceImageInput = ({ onHabitCreated, onHabitCompleted }) => {
// Voice recording state
const [isRecording, setIsRecording] = useState(false);
const [audioBlob, setAudioBlob] = useState(null);
const [mediaRecorder, setMediaRecorder] = useState(null);
const [audioUrl, setAudioUrl] = useState(null);
const [voiceTranscript, setVoiceTranscript] = useState("");
const [voiceLoading, setVoiceLoading] = useState(false);
// Image capture/upload state
const [imageFile, setImageFile] = useState(null);
const [imagePreview, setImagePreview] = useState(null);
const [cameraStream, setCameraStream] = useState(null);
const [imageLoading, setImageLoading] = useState(false);
const [imageResult, setImageResult] = useState(null);
// Refs
const videoRef = useRef(null);
const canvasRef = useRef(null);
const fileInputRef = useRef(null);
// Cleanup camera stream on unmount
useEffect(() => {
return () => {
if (cameraStream) {
cameraStream.getTracks().forEach((track) => track.stop());
}
};
}, [cameraStream]);
// Voice Recording Functions
const startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const recorder = new MediaRecorder(stream);
const chunks = [];
recorder.ondataavailable = (e) => chunks.push(e.data);
recorder.onstop = () => {
const blob = new Blob(chunks, { type: "audio/wav" });
setAudioBlob(blob);
setAudioUrl(URL.createObjectURL(blob));
stream.getTracks().forEach((track) => track.stop());
};
recorder.start();
setMediaRecorder(recorder);
setIsRecording(true);
} catch (error) {
console.error("Error starting recording:", error);
alert("Could not access microphone. Please check permissions.");
}
};
const stopRecording = () => {
if (mediaRecorder && isRecording) {
mediaRecorder.stop();
setIsRecording(false);
setMediaRecorder(null);
}
};
const processVoiceCommand = async () => {
if (!audioBlob) return;
setVoiceLoading(true);
try {
// For now, simulate voice processing
// In a real implementation, you'd send the audio to your backend
// which would use speech-to-text and then NLP processing
const formData = new FormData();
formData.append("audio", audioBlob);
const token = localStorage.getItem("token");
const response = await fetch("/api/v1/ai/habits/voice-command", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
},
body: formData,
});
if (response.ok) {
const result = await response.json();
setVoiceTranscript(result.transcript || "Voice command processed!");
if (result.action === "create_habit" && onHabitCreated) {
onHabitCreated(result.habit);
} else if (result.action === "complete_habit" && onHabitCompleted) {
onHabitCompleted(result.habit_id);
}
} else {
// Simulate processing for demo
setVoiceTranscript(
"Voice processing coming soon! Audio recorded successfully."
);
}
} catch (error) {
console.error("Voice processing error:", error);
setVoiceTranscript("Voice processing temporarily unavailable.");
} finally {
setVoiceLoading(false);
}
};
// Camera Functions
const startCamera = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: "environment" }, // Use back camera on mobile
});
setCameraStream(stream);
if (videoRef.current) {
videoRef.current.srcObject = stream;
}
} catch (error) {
console.error("Error accessing camera:", error);
alert("Could not access camera. Please check permissions.");
}
};
const stopCamera = () => {
if (cameraStream) {
cameraStream.getTracks().forEach((track) => track.stop());
setCameraStream(null);
}
};
const capturePhoto = () => {
if (!videoRef.current || !canvasRef.current) return;
const video = videoRef.current;
const canvas = canvasRef.current;
const context = canvas.getContext("2d");
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
context.drawImage(video, 0, 0);
canvas.toBlob((blob) => {
setImageFile(blob);
setImagePreview(URL.createObjectURL(blob));
stopCamera();
});
};
const handleImageUpload = (e) => {
const file = e.target.files[0];
if (file) {
setImageFile(file);
setImagePreview(URL.createObjectURL(file));
}
};
const processImage = async () => {
if (!imageFile) return;
setImageLoading(true);
try {
const formData = new FormData();
formData.append("image", imageFile);
const token = localStorage.getItem("token");
const response = await fetch("/api/v1/ai/habits/image-checkin", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
},
body: formData,
});
if (response.ok) {
const result = await response.json();
setImageResult(result);
if (result.habit_completed && onHabitCompleted) {
onHabitCompleted(result.habit_id);
}
} else {
// Simulate processing for demo
setImageResult({
message:
"Image recognition coming soon! Image uploaded successfully.",
confidence: 0.95,
detected_items: ["workout equipment", "healthy food", "book"],
});
}
} catch (error) {
console.error("Image processing error:", error);
setImageResult({
message: "Image processing temporarily unavailable.",
error: true,
});
} finally {
setImageLoading(false);
}
};
return (
<div className="space-y-6">
{/* Voice Input Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Mic className="h-5 w-5 text-blue-600" />
Voice Commands
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-gray-600 text-sm">
Try saying: "Create a habit to drink water every morning" or "Mark
my meditation habit as complete"
</p>
<div className="flex items-center gap-4">
{!isRecording ? (
<Button
onClick={startRecording}
className="bg-blue-600 hover:bg-blue-700"
>
<Mic className="h-4 w-4 mr-2" />
Start Recording
</Button>
) : (
<Button onClick={stopRecording} variant="destructive">
<Square className="h-4 w-4 mr-2" />
Stop Recording
</Button>
)}
{audioUrl && (
<div className="flex items-center gap-2">
<audio controls src={audioUrl} className="h-8" />
<Button
onClick={processVoiceCommand}
disabled={voiceLoading}
variant="outline"
>
{voiceLoading ? "Processing..." : "Process Voice"}
</Button>
</div>
)}
</div>
{isRecording && (
<div className="flex items-center gap-2 text-red-600">
<div className="w-3 h-3 bg-red-600 rounded-full animate-pulse" />
<span className="text-sm">Recording... Speak your command</span>
</div>
)}
{voiceTranscript && (
<div className="p-3 bg-blue-50 rounded-lg">
<div className="flex items-start gap-2">
<Volume2 className="h-4 w-4 text-blue-600 mt-0.5" />
<div>
<div className="font-semibold text-blue-800">
Voice Command Result
</div>
<div className="text-blue-700">{voiceTranscript}</div>
</div>
</div>
</div>
)}
</CardContent>
</Card>
{/* Image Input Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Camera className="h-5 w-5 text-green-600" />
Image Check-in
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-gray-600 text-sm">
Take a photo or upload an image to check-in for habits like
workouts, meals, or reading.
</p>
{/* Camera Controls */}
{!cameraStream ? (
<div className="flex gap-2">
<Button
onClick={startCamera}
className="bg-green-600 hover:bg-green-700"
>
<Camera className="h-4 w-4 mr-2" />
Open Camera
</Button>
<Button
onClick={() => fileInputRef.current?.click()}
variant="outline"
>
<Upload className="h-4 w-4 mr-2" />
Upload Image
</Button>
</div>
) : (
<div className="space-y-2">
<div className="relative">
<video
ref={videoRef}
autoPlay
playsInline
className="w-full max-w-md rounded-lg"
/>
</div>
<div className="flex gap-2">
<Button
onClick={capturePhoto}
className="bg-green-600 hover:bg-green-700"
>
<Camera className="h-4 w-4 mr-2" />
Capture Photo
</Button>
<Button onClick={stopCamera} variant="outline">
Cancel
</Button>
</div>
</div>
)}
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleImageUpload}
className="hidden"
/>
{/* Hidden canvas for photo capture */}
<canvas ref={canvasRef} className="hidden" />
{/* Image Preview */}
{imagePreview && (
<div className="space-y-2">
<div className="relative">
<img
src={imagePreview}
alt="Preview"
className="w-full max-w-md rounded-lg border"
/>
</div>
<div className="flex gap-2">
<Button
onClick={processImage}
disabled={imageLoading}
className="bg-green-600 hover:bg-green-700"
>
{imageLoading ? "Processing..." : "Analyze Image"}
</Button>
<Button
onClick={() => {
setImageFile(null);
setImagePreview(null);
setImageResult(null);
}}
variant="outline"
>
Clear
</Button>
</div>
</div>
)}
{/* Image Analysis Result */}
{imageResult && (
<div className="p-3 bg-green-50 rounded-lg">
<div className="flex items-start gap-2">
{imageResult.error ? (
<AlertCircle className="h-4 w-4 text-red-600 mt-0.5" />
) : (
<CheckCircle className="h-4 w-4 text-green-600 mt-0.5" />
)}
<div>
<div className="font-semibold text-green-800">
Analysis Result
</div>
<div className="text-green-700">{imageResult.message}</div>
{imageResult.detected_items && (
<div className="mt-2">
<div className="text-sm font-medium text-green-800">
Detected Items:
</div>
<div className="text-sm text-green-700">
{imageResult.detected_items.join(", ")}
</div>
</div>
)}
{imageResult.confidence && (
<div className="text-sm text-green-600 mt-1">
Confidence: {Math.round(imageResult.confidence * 100)}%
</div>
)}
</div>
</div>
</div>
)}
</CardContent>
</Card>
{/* Usage Tips */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ImageIcon className="h-5 w-5 text-purple-600" />
Usage Tips
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid md:grid-cols-2 gap-4 text-sm">
<div>
<h4 className="font-semibold text-purple-800 mb-2">
Voice Commands
</h4>
<ul className="space-y-1 text-gray-600">
<li> "Create habit to exercise daily"</li>
<li> "Mark meditation as complete"</li>
<li> "Show my habit progress"</li>
<li> "Remind me to drink water at 9am"</li>
</ul>
</div>
<div>
<h4 className="font-semibold text-purple-800 mb-2">
Image Check-ins
</h4>
<ul className="space-y-1 text-gray-600">
<li> Photo of your workout equipment</li>
<li> Picture of healthy meal</li>
<li> Book you're reading</li>
<li> Clean workspace/room</li>
</ul>
</div>
</div>
</CardContent>
</Card>
</div>
);
};
export default VoiceImageInput;
@@ -0,0 +1,384 @@
// Advanced Filtering System - Matching AHK's powerful search and filter capabilities
import { useMemo, useState, useCallback } from "react";
export interface FilterState {
// Text search (like AHK search box)
searchQuery: string;
// Importance filter (like AHK importance dropdown)
importance: "All" | "High" | "Medium" | "Low";
// Skill filter (like AHK skill dropdown)
skill: string | "All" | "None";
// Status filters
showCompleted: boolean;
showActive: boolean;
showPaused: boolean;
// Difficulty range
difficultyRange: [number, number];
// Date filters
dateRange: {
start: Date | null;
end: Date | null;
};
// Category filter
categories: string[];
// Streak filters
minStreak: number;
maxStreak: number;
// Sorting
sortBy:
| "name"
| "difficulty"
| "importance"
| "created"
| "streak"
| "completion";
sortOrder: "asc" | "desc";
}
export interface FilterableItem {
id: string;
title: string;
notes?: string;
importance: "High" | "Medium" | "Low";
difficulty: number;
skill?: string;
categories: string[];
status: "active" | "completed" | "paused";
createdAt: Date;
completedAt?: Date;
streak: number;
completionRate: number;
}
const defaultFilterState: FilterState = {
searchQuery: "",
importance: "All",
skill: "All",
showCompleted: true,
showActive: true,
showPaused: true,
difficultyRange: [1, 10],
dateRange: { start: null, end: null },
categories: [],
minStreak: 0,
maxStreak: 999,
sortBy: "created",
sortOrder: "desc",
};
export const useAdvancedFiltering = <T extends FilterableItem>(
items: T[],
initialFilters?: Partial<FilterState>
) => {
const [filters, setFilters] = useState<FilterState>({
...defaultFilterState,
...initialFilters,
});
// Memoized filtering logic for performance
const filteredItems = useMemo(() => {
let filtered = [...items];
// Text search - search in title, notes, and categories
if (filters.searchQuery.trim()) {
const query = filters.searchQuery.toLowerCase();
filtered = filtered.filter(
(item) =>
item.title.toLowerCase().includes(query) ||
(item.notes && item.notes.toLowerCase().includes(query)) ||
item.categories.some((cat) => cat.toLowerCase().includes(query)) ||
(item.skill && item.skill.toLowerCase().includes(query))
);
}
// Importance filter
if (filters.importance !== "All") {
filtered = filtered.filter(
(item) => item.importance === filters.importance
);
}
// Skill filter
if (filters.skill !== "All") {
if (filters.skill === "None") {
filtered = filtered.filter((item) => !item.skill);
} else {
filtered = filtered.filter((item) => item.skill === filters.skill);
}
}
// Status filters
const statusFilters = [];
if (filters.showActive) statusFilters.push("active");
if (filters.showCompleted) statusFilters.push("completed");
if (filters.showPaused) statusFilters.push("paused");
filtered = filtered.filter((item) => statusFilters.includes(item.status));
// Difficulty range
filtered = filtered.filter(
(item) =>
item.difficulty >= filters.difficultyRange[0] &&
item.difficulty <= filters.difficultyRange[1]
);
// Date range filter
if (filters.dateRange.start || filters.dateRange.end) {
filtered = filtered.filter((item) => {
const itemDate = item.completedAt || item.createdAt;
if (filters.dateRange.start && itemDate < filters.dateRange.start)
return false;
if (filters.dateRange.end && itemDate > filters.dateRange.end)
return false;
return true;
});
}
// Categories filter
if (filters.categories.length > 0) {
filtered = filtered.filter((item) =>
filters.categories.every((cat) => item.categories.includes(cat))
);
}
// Streak range filter
filtered = filtered.filter(
(item) =>
item.streak >= filters.minStreak && item.streak <= filters.maxStreak
);
// Sorting
filtered.sort((a, b) => {
let aValue: any;
let bValue: any;
switch (filters.sortBy) {
case "name":
aValue = a.title.toLowerCase();
bValue = b.title.toLowerCase();
break;
case "difficulty":
aValue = a.difficulty;
bValue = b.difficulty;
break;
case "importance":
const importanceOrder = { High: 3, Medium: 2, Low: 1 };
aValue = importanceOrder[a.importance];
bValue = importanceOrder[b.importance];
break;
case "created":
aValue = a.createdAt;
bValue = b.createdAt;
break;
case "streak":
aValue = a.streak;
bValue = b.streak;
break;
case "completion":
aValue = a.completionRate;
bValue = b.completionRate;
break;
default:
return 0;
}
if (aValue < bValue) return filters.sortOrder === "asc" ? -1 : 1;
if (aValue > bValue) return filters.sortOrder === "asc" ? 1 : -1;
return 0;
});
return filtered;
}, [items, filters]);
// Update filter functions
const updateFilter = useCallback(
<K extends keyof FilterState>(key: K, value: FilterState[K]) => {
setFilters((prev) => ({ ...prev, [key]: value }));
},
[]
);
const updateFilters = useCallback((newFilters: Partial<FilterState>) => {
setFilters((prev) => ({ ...prev, ...newFilters }));
}, []);
const resetFilters = useCallback(() => {
setFilters(defaultFilterState);
}, []);
const clearSearch = useCallback(() => {
updateFilter("searchQuery", "");
}, [updateFilter]);
// Quick filter presets (like AHK's quick buttons)
const quickFilters = {
todayActive: () => {
const today = new Date();
today.setHours(0, 0, 0, 0);
updateFilters({
showActive: true,
showCompleted: false,
showPaused: false,
dateRange: { start: today, end: null },
});
},
highPriority: () => {
updateFilters({
importance: "High",
showActive: true,
showCompleted: false,
});
},
longStreaks: () => {
updateFilters({
minStreak: 7,
sortBy: "streak",
sortOrder: "desc",
});
},
recentlyCompleted: () => {
const weekAgo = new Date();
weekAgo.setDate(weekAgo.getDate() - 7);
updateFilters({
showCompleted: true,
showActive: false,
dateRange: { start: weekAgo, end: null },
sortBy: "created",
sortOrder: "desc",
});
},
difficultTasks: () => {
updateFilters({
difficultyRange: [7, 10],
sortBy: "difficulty",
sortOrder: "desc",
});
},
};
// Search suggestions based on current data
const getSearchSuggestions = useCallback(
(query: string): string[] => {
if (!query.trim()) return [];
const suggestions = new Set<string>();
const queryLower = query.toLowerCase();
items.forEach((item) => {
// Title suggestions
if (item.title.toLowerCase().includes(queryLower)) {
suggestions.add(item.title);
}
// Category suggestions
item.categories.forEach((cat) => {
if (cat.toLowerCase().includes(queryLower)) {
suggestions.add(cat);
}
});
// Skill suggestions
if (item.skill && item.skill.toLowerCase().includes(queryLower)) {
suggestions.add(item.skill);
}
});
return Array.from(suggestions).slice(0, 10);
},
[items]
);
// Get available filter options from data
const getFilterOptions = useCallback(() => {
const skills = new Set<string>();
const categories = new Set<string>();
let minDifficulty = 10;
let maxDifficulty = 1;
let minStreak = 999;
let maxStreak = 0;
items.forEach((item) => {
if (item.skill) skills.add(item.skill);
item.categories.forEach((cat) => categories.add(cat));
minDifficulty = Math.min(minDifficulty, item.difficulty);
maxDifficulty = Math.max(maxDifficulty, item.difficulty);
minStreak = Math.min(minStreak, item.streak);
maxStreak = Math.max(maxStreak, item.streak);
});
return {
skills: ["All", "None", ...Array.from(skills)],
categories: Array.from(categories),
difficultyRange: [minDifficulty, maxDifficulty] as [number, number],
streakRange: [minStreak, maxStreak] as [number, number],
};
}, [items]);
return {
filters,
filteredItems,
updateFilter,
updateFilters,
resetFilters,
clearSearch,
quickFilters,
getSearchSuggestions,
getFilterOptions,
totalCount: items.length,
filteredCount: filteredItems.length,
};
};
// Filter statistics for UI display
export const useFilterStats = (
filters: FilterState,
totalItems: number,
filteredItems: number
) => {
return useMemo(() => {
const activeFilters = [];
if (filters.searchQuery.trim()) {
activeFilters.push(`Search: "${filters.searchQuery}"`);
}
if (filters.importance !== "All") {
activeFilters.push(`Importance: ${filters.importance}`);
}
if (filters.skill !== "All") {
activeFilters.push(`Skill: ${filters.skill}`);
}
if (!filters.showCompleted || !filters.showActive || !filters.showPaused) {
const statuses = [];
if (filters.showActive) statuses.push("Active");
if (filters.showCompleted) statuses.push("Completed");
if (filters.showPaused) statuses.push("Paused");
activeFilters.push(`Status: ${statuses.join(", ")}`);
}
if (filters.categories.length > 0) {
activeFilters.push(`Categories: ${filters.categories.join(", ")}`);
}
return {
activeFilters,
hasActiveFilters: activeFilters.length > 0,
filteredPercentage:
totalItems > 0 ? (filteredItems / totalItems) * 100 : 0,
};
}, [filters, totalItems, filteredItems]);
};
@@ -0,0 +1,473 @@
import { useEffect, useRef, useCallback } from "react";
/**
* Effect cleanup utilities for preventing memory leaks in React components
* Provides comprehensive cleanup for common sources of memory leaks
*/
// Debounced effect hook with automatic cleanup
export const useDebouncedEffect = (effect, dependencies, delay = 300) => {
const timeoutRef = useRef(null);
const cleanupRef = useRef(null);
useEffect(() => {
// Clear existing timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// Set new timeout
timeoutRef.current = setTimeout(() => {
// Run the effect and store cleanup function if returned
const cleanup = effect();
if (typeof cleanup === "function") {
cleanupRef.current = cleanup;
}
}, delay);
// Cleanup function
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
if (cleanupRef.current) {
cleanupRef.current();
cleanupRef.current = null;
}
};
}, [...dependencies, delay]);
};
// AbortController hook for cancelling fetch requests
export const useAbortController = () => {
const controllerRef = useRef(null);
const createController = useCallback(() => {
// Abort existing controller
if (controllerRef.current) {
controllerRef.current.abort();
}
// Create new controller
controllerRef.current = new AbortController();
return controllerRef.current;
}, []);
const abort = useCallback(() => {
if (controllerRef.current) {
controllerRef.current.abort();
controllerRef.current = null;
}
}, []);
// Cleanup on unmount
useEffect(() => {
return () => {
if (controllerRef.current) {
controllerRef.current.abort();
}
};
}, []);
return { createController, abort, controller: controllerRef.current };
};
// WebSocket hook with automatic cleanup
export const useWebSocket = (url, options = {}) => {
const wsRef = useRef(null);
const reconnectTimeoutRef = useRef(null);
const isUnmountedRef = useRef(false);
const {
onMessage,
onOpen,
onClose,
onError,
reconnectDelay = 3000,
maxReconnectAttempts = 5,
...wsOptions
} = options;
const connect = useCallback(() => {
if (isUnmountedRef.current) return;
try {
wsRef.current = new WebSocket(url);
wsRef.current.onopen = (event) => {
if (onOpen && !isUnmountedRef.current) {
onOpen(event);
}
};
wsRef.current.onmessage = (event) => {
if (onMessage && !isUnmountedRef.current) {
onMessage(event);
}
};
wsRef.current.onclose = (event) => {
if (onClose && !isUnmountedRef.current) {
onClose(event);
}
// Auto-reconnect logic
if (!isUnmountedRef.current && !event.wasClean) {
reconnectTimeoutRef.current = setTimeout(() => {
connect();
}, reconnectDelay);
}
};
wsRef.current.onerror = (event) => {
if (onError && !isUnmountedRef.current) {
onError(event);
}
};
} catch (error) {
if (onError && !isUnmountedRef.current) {
onError(error);
}
}
}, [url, onMessage, onOpen, onClose, onError, reconnectDelay]);
const disconnect = useCallback(() => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
if (wsRef.current) {
wsRef.current.close(1000, "Component unmounting");
wsRef.current = null;
}
}, []);
const sendMessage = useCallback((data) => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(
typeof data === "string" ? data : JSON.stringify(data)
);
}
}, []);
useEffect(() => {
connect();
return () => {
isUnmountedRef.current = true;
disconnect();
};
}, [connect, disconnect]);
return {
sendMessage,
disconnect,
reconnect: connect,
readyState: wsRef.current?.readyState || WebSocket.CONNECTING,
};
};
// Event listener hook with automatic cleanup
export const useEventListener = (
eventName,
handler,
element = window,
options = {}
) => {
const savedHandler = useRef();
// Update ref.current value if handler changes
useEffect(() => {
savedHandler.current = handler;
}, [handler]);
useEffect(() => {
// Make sure element supports addEventListener
const isSupported = element && element.addEventListener;
if (!isSupported) return;
// Create event listener that calls handler function stored in ref
const eventListener = (event) => savedHandler.current(event);
// Add event listener
element.addEventListener(eventName, eventListener, options);
// Remove event listener on cleanup
return () => {
element.removeEventListener(eventName, eventListener, options);
};
}, [eventName, element, options]);
};
// Intersection Observer hook with cleanup
export const useIntersectionObserver = (callback, options = {}) => {
const observerRef = useRef(null);
const elementsRef = useRef(new Set());
const observe = useCallback(
(element) => {
if (!element) return;
if (!observerRef.current) {
observerRef.current = new IntersectionObserver(callback, options);
}
observerRef.current.observe(element);
elementsRef.current.add(element);
},
[callback, options]
);
const unobserve = useCallback((element) => {
if (observerRef.current && element) {
observerRef.current.unobserve(element);
elementsRef.current.delete(element);
}
}, []);
const disconnect = useCallback(() => {
if (observerRef.current) {
observerRef.current.disconnect();
elementsRef.current.clear();
}
}, []);
useEffect(() => {
return () => {
disconnect();
};
}, [disconnect]);
return { observe, unobserve, disconnect };
};
// Async effect hook with cancellation support
export const useAsyncEffect = (asyncEffect, dependencies, onError) => {
const { createController } = useAbortController();
useEffect(() => {
const controller = createController();
const runAsync = async () => {
try {
await asyncEffect(controller.signal);
} catch (error) {
// Only handle error if not aborted
if (error.name !== "AbortError" && onError) {
onError(error);
}
}
};
runAsync();
return () => {
controller.abort();
};
}, dependencies);
};
// Timer hook with automatic cleanup
export const useTimer = (callback, delay, immediate = false) => {
const callbackRef = useRef(callback);
const timeoutRef = useRef(null);
const isRunningRef = useRef(false);
// Update callback ref when callback changes
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
const start = useCallback(() => {
if (isRunningRef.current) return;
isRunningRef.current = true;
timeoutRef.current = setTimeout(() => {
callbackRef.current();
isRunningRef.current = false;
}, delay);
}, [delay]);
const stop = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
isRunningRef.current = false;
}
}, []);
const reset = useCallback(() => {
stop();
start();
}, [start, stop]);
useEffect(() => {
if (immediate) {
start();
}
return stop;
}, [immediate, start, stop]);
return { start, stop, reset, isRunning: isRunningRef.current };
};
// Interval hook with automatic cleanup
export const useInterval = (callback, delay, immediate = false) => {
const callbackRef = useRef(callback);
const intervalRef = useRef(null);
// Update callback ref when callback changes
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
const start = useCallback(() => {
if (intervalRef.current) return; // Already running
if (immediate) {
callbackRef.current();
}
intervalRef.current = setInterval(() => {
callbackRef.current();
}, delay);
}, [delay, immediate]);
const stop = useCallback(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
}, []);
useEffect(() => {
start();
return stop;
}, [start, stop]);
return { start, stop };
};
// Media query hook with cleanup
export const useMediaQuery = (query) => {
const [matches, setMatches] = useState(false);
const mediaQueryRef = useRef(null);
useEffect(() => {
mediaQueryRef.current = window.matchMedia(query);
setMatches(mediaQueryRef.current.matches);
const handler = (event) => setMatches(event.matches);
mediaQueryRef.current.addEventListener("change", handler);
return () => {
if (mediaQueryRef.current) {
mediaQueryRef.current.removeEventListener("change", handler);
}
};
}, [query]);
return matches;
};
// Local storage hook with cleanup
export const useLocalStorage = (key, initialValue) => {
const [storedValue, setStoredValue] = useState(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error(`Error reading localStorage key "${key}":`, error);
return initialValue;
}
});
const setValue = useCallback(
(value) => {
try {
const valueToStore =
value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
// Dispatch storage event for cross-tab synchronization
window.dispatchEvent(
new StorageEvent("storage", {
key,
newValue: JSON.stringify(valueToStore),
oldValue: JSON.stringify(storedValue),
storageArea: window.localStorage,
url: window.location.href,
})
);
} catch (error) {
console.error(`Error setting localStorage key "${key}":`, error);
}
},
[key, storedValue]
);
// Listen for external storage changes
useEventListener("storage", (event) => {
if (event.key === key && event.newValue !== null) {
try {
setStoredValue(JSON.parse(event.newValue));
} catch (error) {
console.error(`Error parsing localStorage key "${key}":`, error);
}
}
});
return [storedValue, setValue];
};
// Component unmount detector
export const useUnmountDetector = () => {
const isUnmountedRef = useRef(false);
useEffect(() => {
return () => {
isUnmountedRef.current = true;
};
}, []);
return isUnmountedRef;
};
// Performance monitoring hook
export const usePerformanceMonitor = (componentName, dependencies = []) => {
const renderCountRef = useRef(0);
const lastRenderTimeRef = useRef(0);
useEffect(() => {
if (process.env.NODE_ENV === "development") {
renderCountRef.current += 1;
const now = performance.now();
const renderTime = now - lastRenderTimeRef.current;
console.log(`[Performance] ${componentName}:`, {
renderCount: renderCountRef.current,
renderTime: renderTime.toFixed(2) + "ms",
dependencies,
});
lastRenderTimeRef.current = now;
}
}, dependencies);
};
export default {
useDebouncedEffect,
useAbortController,
useWebSocket,
useEventListener,
useIntersectionObserver,
useAsyncEffect,
useTimer,
useInterval,
useMediaQuery,
useLocalStorage,
useUnmountDetector,
usePerformanceMonitor,
};
@@ -0,0 +1,367 @@
// Keyboard Shortcuts System - AHK-inspired hotkeys for power users
import { useEffect, useCallback, useState } from "react";
interface KeyboardShortcut {
key: string;
description: string;
action: () => void;
category: string;
}
interface ShortcutCategory {
name: string;
shortcuts: KeyboardShortcut[];
}
export const useKeyboardShortcuts = () => {
const [shortcuts] = useState<KeyboardShortcut[]>([
// Navigation shortcuts (like AHK Alt+ combinations)
{
key: "Alt+A",
description: "Add new habit/project",
action: () => triggerAction("add-habit"),
category: "Navigation",
},
{
key: "Alt+E",
description: "Edit selected item",
action: () => triggerAction("edit-item"),
category: "Navigation",
},
{
key: "Alt+C",
description: "Focus search box",
action: () => triggerAction("focus-search"),
category: "Navigation",
},
{
key: "Alt+R",
description: "Remove/delete selected",
action: () => triggerAction("remove-item"),
category: "Navigation",
},
{
key: "Alt+D",
description: "Mark as done",
action: () => triggerAction("mark-done"),
category: "Actions",
},
// View shortcuts (like AHK function keys)
{
key: "F2",
description: "Toggle HUD visibility",
action: () => triggerAction("toggle-hud"),
category: "View",
},
{
key: "F3",
description: "Show skills view",
action: () => triggerAction("show-skills"),
category: "View",
},
{
key: "F4",
description: "Show analytics",
action: () => triggerAction("show-analytics"),
category: "View",
},
{
key: "Ctrl+/",
description: "Show keyboard shortcuts",
action: () => triggerAction("show-shortcuts"),
category: "Help",
},
// Quick actions
{
key: "Space",
description: "Quick complete selected habit",
action: () => triggerAction("quick-complete"),
category: "Actions",
},
{
key: "Escape",
description: "Close dialogs/cancel",
action: () => triggerAction("cancel"),
category: "Actions",
},
{
key: "Enter",
description: "Confirm/submit",
action: () => triggerAction("confirm"),
category: "Actions",
},
// List navigation
{
key: "ArrowUp",
description: "Select previous item",
action: () => triggerAction("select-previous"),
category: "Navigation",
},
{
key: "ArrowDown",
description: "Select next item",
action: () => triggerAction("select-next"),
category: "Navigation",
},
// Filters (like AHK dropdown shortcuts)
{
key: "Ctrl+1",
description: "Filter by high importance",
action: () => triggerAction("filter-high"),
category: "Filters",
},
{
key: "Ctrl+2",
description: "Filter by medium importance",
action: () => triggerAction("filter-medium"),
category: "Filters",
},
{
key: "Ctrl+3",
description: "Filter by low importance",
action: () => triggerAction("filter-low"),
category: "Filters",
},
{
key: "Ctrl+0",
description: "Clear all filters",
action: () => triggerAction("clear-filters"),
category: "Filters",
},
]);
const triggerAction = (actionType: string) => {
// Dispatch custom events that components can listen to
window.dispatchEvent(
new CustomEvent("keyboard-action", {
detail: { actionType },
})
);
};
const buildKeyCombo = (e: KeyboardEvent): string => {
const parts: string[] = [];
if (e.ctrlKey) parts.push("Ctrl");
if (e.altKey) parts.push("Alt");
if (e.shiftKey) parts.push("Shift");
if (e.metaKey) parts.push("Cmd");
// Handle special keys
const specialKeys: Record<string, string> = {
ArrowUp: "ArrowUp",
ArrowDown: "ArrowDown",
ArrowLeft: "ArrowLeft",
ArrowRight: "ArrowRight",
Enter: "Enter",
Escape: "Escape",
" ": "Space",
F1: "F1",
F2: "F2",
F3: "F3",
F4: "F4",
F5: "F5",
F6: "F6",
F7: "F7",
F8: "F8",
F9: "F9",
F10: "F10",
F11: "F11",
F12: "F12",
"/": "/",
};
const key = specialKeys[e.key] || e.key.toUpperCase();
parts.push(key);
return parts.join("+");
};
useEffect(() => {
const handleKeyPress = (e: KeyboardEvent) => {
// Don't trigger shortcuts when typing in inputs
const activeElement = document.activeElement;
const isInputFocused =
activeElement &&
(activeElement.tagName === "INPUT" ||
activeElement.tagName === "TEXTAREA" ||
activeElement.hasAttribute("contenteditable"));
// Allow some shortcuts even in inputs
const allowedInInputs = ["Escape", "Enter", "F2", "Ctrl+/", "Alt+C"];
const combo = buildKeyCombo(e);
if (isInputFocused && !allowedInInputs.includes(combo)) {
return;
}
const shortcut = shortcuts.find((s) => s.key === combo);
if (shortcut) {
e.preventDefault();
e.stopPropagation();
shortcut.action();
}
};
document.addEventListener("keydown", handleKeyPress);
return () => document.removeEventListener("keydown", handleKeyPress);
}, [shortcuts]);
const getShortcutsByCategory = (): ShortcutCategory[] => {
const categories = [...new Set(shortcuts.map((s) => s.category))];
return categories.map((category) => ({
name: category,
shortcuts: shortcuts.filter((s) => s.category === category),
}));
};
return { shortcuts, getShortcutsByCategory };
};
// Keyboard Shortcuts Help Modal
export const KeyboardShortcutsHelp: React.FC<{
isOpen: boolean;
onClose: () => void;
}> = ({ isOpen, onClose }) => {
const { getShortcutsByCategory } = useKeyboardShortcuts();
const categories = getShortcutsByCategory();
useEffect(() => {
const handleKeyPress = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
};
if (isOpen) {
document.addEventListener("keydown", handleKeyPress);
return () => document.removeEventListener("keydown", handleKeyPress);
}
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[100] flex items-center justify-center p-4">
<div className="bg-white dark:bg-slate-800 rounded-lg shadow-2xl max-w-2xl w-full max-h-[80vh] overflow-y-auto">
<div className="p-6 border-b border-gray-200 dark:border-slate-700">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
Keyboard Shortcuts
</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl"
>
×
</button>
</div>
<p className="text-gray-600 dark:text-gray-400 mt-2">
Power-user shortcuts inspired by the legacy AutoHotkey version
</p>
</div>
<div className="p-6 space-y-6">
{categories.map((category) => (
<div key={category.name}>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">
{category.name}
</h3>
<div className="grid gap-2">
{category.shortcuts.map((shortcut) => (
<div
key={shortcut.key}
className="flex items-center justify-between p-2 rounded hover:bg-gray-50 dark:hover:bg-slate-700"
>
<span className="text-gray-700 dark:text-gray-300">
{shortcut.description}
</span>
<kbd className="px-2 py-1 bg-gray-100 dark:bg-slate-600 text-gray-800 dark:text-gray-200 rounded text-sm font-mono">
{shortcut.key}
</kbd>
</div>
))}
</div>
</div>
))}
</div>
<div className="p-6 border-t border-gray-200 dark:border-slate-700 text-center">
<p className="text-sm text-gray-500 dark:text-gray-400">
Press{" "}
<kbd className="px-1 py-0.5 bg-gray-100 dark:bg-slate-600 rounded text-xs font-mono">
Escape
</kbd>{" "}
to close
</p>
</div>
</div>
</div>
);
};
// Hook to listen for keyboard actions in components
export const useKeyboardActions = () => {
const [lastAction, setLastAction] = useState<string | null>(null);
useEffect(() => {
const handleKeyboardAction = (event: CustomEvent) => {
setLastAction(event.detail.actionType);
// Clear the action after a brief moment so components can react
setTimeout(() => setLastAction(null), 100);
};
window.addEventListener(
"keyboard-action",
handleKeyboardAction as EventListener
);
return () =>
window.removeEventListener(
"keyboard-action",
handleKeyboardAction as EventListener
);
}, []);
return { lastAction };
};
// Context provider for keyboard shortcuts
import React, { createContext, useContext, ReactNode } from "react";
interface KeyboardShortcutsContextType {
shortcuts: KeyboardShortcut[];
getShortcutsByCategory: () => ShortcutCategory[];
}
const KeyboardShortcutsContext = createContext<
KeyboardShortcutsContextType | undefined
>(undefined);
export const KeyboardShortcutsProvider: React.FC<{ children: ReactNode }> = ({
children,
}) => {
const { shortcuts, getShortcutsByCategory } = useKeyboardShortcuts();
return (
<KeyboardShortcutsContext.Provider
value={{ shortcuts, getShortcutsByCategory }}
>
{children}
</KeyboardShortcutsContext.Provider>
);
};
export const useKeyboardShortcutsContext = () => {
const context = useContext(KeyboardShortcutsContext);
if (!context) {
throw new Error(
"useKeyboardShortcutsContext must be used within KeyboardShortcutsProvider"
);
}
return context;
};
+237 -210
View File
@@ -1,220 +1,247 @@
import { create } from 'zustand';
import { create } from "zustand";
import { tokenManager } from "../utils/secureAuth";
const useAppStore = create((set, get) => ({
// User state
user: null,
isAuthenticated: false,
loading: false,
// User state
user: null,
isAuthenticated: false,
loading: false,
// Habits state
habits: [],
habitsLoading: false,
habitsError: null,
// Habits state
habits: [],
habitsLoading: false,
habitsError: null,
// Analytics state
analytics: null,
analyticsLoading: false,
// Analytics state
analytics: null,
analyticsLoading: false,
// UI state
activeTab: 'overview',
theme: 'dark',
// UI state
activeTab: "overview",
theme: "dark",
// Actions
setUser: (user) => set({ user, isAuthenticated: !!user }),
setLoading: (loading) => set({ loading }),
setActiveTab: (activeTab) => set({ activeTab }),
// Habit actions
setHabits: (habits) => set({ habits, habitsLoading: false, habitsError: null }),
setHabitsLoading: (habitsLoading) => set({ habitsLoading }),
setHabitsError: (habitsError) => set({ habitsError, habitsLoading: false }),
addHabit: (habit) => set((state) => ({
habits: [...state.habits, habit]
})),
updateHabit: (habitId, updates) => set((state) => ({
habits: state.habits.map(habit =>
habit.id === habitId ? { ...habit, ...updates } : habit
)
})),
deleteHabit: (habitId) => set((state) => ({
habits: state.habits.filter(habit => habit.id !== habitId)
})),
// Analytics actions
setAnalytics: (analytics) => set({ analytics, analyticsLoading: false }),
setAnalyticsLoading: (analyticsLoading) => set({ analyticsLoading }),
// Auth actions
login: async (credentials) => {
set({ loading: true });
try {
const response = await fetch('/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials)
});
if (response.ok) {
const data = await response.json();
localStorage.setItem('token', data.access_token);
set({ user: data.user, isAuthenticated: true, loading: false });
return { success: true };
} else {
const error = await response.json();
set({ loading: false });
return { success: false, error: error.detail || 'Login failed' };
}
} catch (error) {
set({ loading: false });
return { success: false, error: 'Network error' };
}
},
register: async (userData) => {
set({ loading: true });
try {
const response = await fetch('/api/v1/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userData)
});
if (response.ok) {
const data = await response.json();
localStorage.setItem('token', data.access_token);
set({ user: data.user, isAuthenticated: true, loading: false });
return { success: true };
} else {
const error = await response.json();
set({ loading: false });
return { success: false, error: error.detail || 'Registration failed' };
}
} catch (error) {
set({ loading: false });
return { success: false, error: 'Network error' };
}
},
logout: () => {
localStorage.removeItem('token');
set({
user: null,
isAuthenticated: false,
habits: [],
analytics: null
});
},
checkAuth: async () => {
const token = localStorage.getItem('token');
if (!token) {
set({ loading: false });
return;
}
try {
const response = await fetch('/api/v1/me', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.ok) {
const user = await response.json();
set({ user, isAuthenticated: true, loading: false });
} else {
localStorage.removeItem('token');
set({ loading: false });
}
} catch (error) {
localStorage.removeItem('token');
set({ loading: false });
}
},
// Fetch habits
fetchHabits: async () => {
const token = localStorage.getItem('token');
if (!token) return;
set({ habitsLoading: true });
try {
const response = await fetch('/api/v1/habits', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.ok) {
const habits = await response.json();
set({ habits, habitsLoading: false, habitsError: null });
} else {
set({ habitsError: 'Failed to fetch habits', habitsLoading: false });
}
} catch (error) {
set({ habitsError: 'Network error', habitsLoading: false });
}
},
// Create habit
createHabit: async (habitData) => {
const token = localStorage.getItem('token');
if (!token) return { success: false, error: 'Not authenticated' };
try {
const response = await fetch('/api/v1/habits', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(habitData)
});
if (response.ok) {
const habit = await response.json();
get().addHabit(habit);
return { success: true, habit };
} else {
const error = await response.json();
return { success: false, error: error.detail || 'Failed to create habit' };
}
} catch (error) {
return { success: false, error: 'Network error' };
}
},
// Mark habit complete
markHabitComplete: async (habitId) => {
const token = localStorage.getItem('token');
if (!token) return { success: false, error: 'Not authenticated' };
try {
const response = await fetch(`/api/v1/habits/${habitId}/complete`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.ok) {
const result = await response.json();
// Update the habit in state with new completion data
get().updateHabit(habitId, {
completed_today: true,
last_completed: new Date().toISOString()
});
return { success: true, result };
} else {
const error = await response.json();
return { success: false, error: error.detail || 'Failed to mark complete' };
}
} catch (error) {
return { success: false, error: 'Network error' };
}
// Actions
setUser: (user) => {
set({ user, isAuthenticated: !!user });
// Don't store user data in localStorage for security
if (!user) {
tokenManager.clearToken();
}
},
setLoading: (loading) => set({ loading }),
logout: () => {
tokenManager.clearToken();
set({
user: null,
isAuthenticated: false,
habits: [],
analytics: null,
});
},
setActiveTab: (activeTab) => set({ activeTab }),
// Habit actions
setHabits: (habits) =>
set({ habits, habitsLoading: false, habitsError: null }),
setHabitsLoading: (habitsLoading) => set({ habitsLoading }),
setHabitsError: (habitsError) => set({ habitsError, habitsLoading: false }),
addHabit: (habit) =>
set((state) => ({
habits: [...state.habits, habit],
})),
updateHabit: (habitId, updates) =>
set((state) => ({
habits: state.habits.map((habit) =>
habit.id === habitId ? { ...habit, ...updates } : habit
),
})),
deleteHabit: (habitId) =>
set((state) => ({
habits: state.habits.filter((habit) => habit.id !== habitId),
})),
// Analytics actions
setAnalytics: (analytics) => set({ analytics, analyticsLoading: false }),
setAnalyticsLoading: (analyticsLoading) => set({ analyticsLoading }),
// Auth actions
login: async (credentials) => {
set({ loading: true });
try {
const response = await fetch("/api/v1/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(credentials),
});
if (response.ok) {
const data = await response.json();
localStorage.setItem("token", data.access_token);
set({ user: data.user, isAuthenticated: true, loading: false });
return { success: true };
} else {
const error = await response.json();
set({ loading: false });
return { success: false, error: error.detail || "Login failed" };
}
} catch (error) {
set({ loading: false });
return { success: false, error: "Network error" };
}
},
register: async (userData) => {
set({ loading: true });
try {
const response = await fetch("/api/v1/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(userData),
});
if (response.ok) {
const data = await response.json();
localStorage.setItem("token", data.access_token);
set({ user: data.user, isAuthenticated: true, loading: false });
return { success: true };
} else {
const error = await response.json();
set({ loading: false });
return { success: false, error: error.detail || "Registration failed" };
}
} catch (error) {
set({ loading: false });
return { success: false, error: "Network error" };
}
},
logout: () => {
localStorage.removeItem("token");
set({
user: null,
isAuthenticated: false,
habits: [],
analytics: null,
});
},
checkAuth: async () => {
const token = localStorage.getItem("token");
if (!token) {
set({ loading: false });
return;
}
try {
const response = await fetch("/api/v1/me", {
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
const user = await response.json();
set({ user, isAuthenticated: true, loading: false });
} else {
localStorage.removeItem("token");
set({ loading: false });
}
} catch (error) {
localStorage.removeItem("token");
set({ loading: false });
}
},
// Fetch habits
fetchHabits: async () => {
const token = localStorage.getItem("token");
if (!token) return;
set({ habitsLoading: true });
try {
const response = await fetch("/api/v1/habits", {
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
const habits = await response.json();
set({ habits, habitsLoading: false, habitsError: null });
} else {
set({ habitsError: "Failed to fetch habits", habitsLoading: false });
}
} catch (error) {
set({ habitsError: "Network error", habitsLoading: false });
}
},
// Create habit
createHabit: async (habitData) => {
const token = localStorage.getItem("token");
if (!token) return { success: false, error: "Not authenticated" };
try {
const response = await fetch("/api/v1/habits", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(habitData),
});
if (response.ok) {
const habit = await response.json();
get().addHabit(habit);
return { success: true, habit };
} else {
const error = await response.json();
return {
success: false,
error: error.detail || "Failed to create habit",
};
}
} catch (error) {
return { success: false, error: "Network error" };
}
},
// Mark habit complete
markHabitComplete: async (habitId) => {
const token = localStorage.getItem("token");
if (!token) return { success: false, error: "Not authenticated" };
try {
const response = await fetch(`/api/v1/habits/${habitId}/complete`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
const result = await response.json();
// Update the habit in state with new completion data
get().updateHabit(habitId, {
completed_today: true,
last_completed: new Date().toISOString(),
});
return { success: true, result };
} else {
const error = await response.json();
return {
success: false,
error: error.detail || "Failed to mark complete",
};
}
} catch (error) {
return { success: false, error: "Network error" };
}
},
}));
export default useAppStore;
+353
View File
@@ -0,0 +1,353 @@
/**
* Enhanced secure app store with data classification and encryption
*/
import { create } from "zustand";
import { tokenManager } from "../utils/secureAuth";
import {
secureStorage,
StateSanitizer,
STATE_SECURITY_CONFIG,
} from "../utils/secureState";
// State schema with security classifications
const STATE_SCHEMA = {
// User state (confidential)
user: { classification: STATE_SECURITY_CONFIG.CONFIDENTIAL },
isAuthenticated: { classification: STATE_SECURITY_CONFIG.INTERNAL },
userPreferences: { classification: STATE_SECURITY_CONFIG.INTERNAL },
// Application data (internal)
habits: { classification: STATE_SECURITY_CONFIG.INTERNAL },
projects: { classification: STATE_SECURITY_CONFIG.INTERNAL },
analytics: { classification: STATE_SECURITY_CONFIG.INTERNAL },
// UI state (public - but still limit storage)
activeTab: { classification: STATE_SECURITY_CONFIG.PUBLIC },
theme: { classification: STATE_SECURITY_CONFIG.PUBLIC },
// Loading states (never persist)
loading: { classification: STATE_SECURITY_CONFIG.RESTRICTED },
habitsLoading: { classification: STATE_SECURITY_CONFIG.RESTRICTED },
analyticsLoading: { classification: STATE_SECURITY_CONFIG.RESTRICTED },
// Error states (never persist - may contain sensitive info)
error: { classification: STATE_SECURITY_CONFIG.RESTRICTED },
habitsError: { classification: STATE_SECURITY_CONFIG.RESTRICTED },
};
// Secure persistence middleware
const securePerist = (config) => (set, get, api) => {
// Override set to handle secure persistence
const secureSet = (partial, replace) => {
const result = set(partial, replace);
// Persist state securely after updates
const currentState = get();
persistSecureState(currentState);
return result;
};
// Load initial state from secure storage
loadSecureState().then((savedState) => {
if (savedState && Object.keys(savedState).length > 0) {
set(savedState, false);
}
});
return config(secureSet, get, api);
};
// Persist state based on security classification
async function persistSecureState(state) {
try {
for (const [key, value] of Object.entries(state)) {
const schema = STATE_SCHEMA[key];
if (!schema) continue;
const classification = schema.classification;
// Only persist non-restricted data
if (classification !== STATE_SECURITY_CONFIG.RESTRICTED) {
let sanitizedValue = value;
// Sanitize data before storage
switch (key) {
case "user":
sanitizedValue = StateSanitizer.sanitizeUserData(value);
break;
case "habits":
sanitizedValue = StateSanitizer.sanitizeHabits(value);
break;
case "analytics":
sanitizedValue = StateSanitizer.sanitizeAnalytics(value);
break;
}
secureStorage.setItem(`app_${key}`, sanitizedValue, classification);
}
}
} catch (error) {
console.error("Failed to persist secure state:", error);
}
}
// Load state from secure storage
async function loadSecureState() {
const savedState = {};
try {
for (const [key, schema] of Object.entries(STATE_SCHEMA)) {
if (schema.classification !== STATE_SECURITY_CONFIG.RESTRICTED) {
const value = await secureStorage.getItem(
`app_${key}`,
schema.classification
);
if (value !== null) {
savedState[key] = value;
}
}
}
} catch (error) {
console.error("Failed to load secure state:", error);
}
return savedState;
}
// Create secure app store
const useSecureAppStore = create(
securePerist((set, get) => ({
// User state
user: null,
isAuthenticated: false,
userPreferences: {},
// Application data
habits: [],
projects: [],
analytics: null,
// UI state
activeTab: "overview",
theme: "dark",
// Loading states (not persisted)
loading: false,
habitsLoading: false,
analyticsLoading: false,
// Error states (not persisted)
error: null,
habitsError: null,
// Secure actions
setUser: (user) => {
const sanitizedUser = StateSanitizer.sanitizeUserData(user);
set({
user: sanitizedUser,
isAuthenticated: !!user,
});
if (!user) {
tokenManager.clearToken();
// Clear all user-related state
get().clearUserData();
}
},
setUserPreferences: (preferences) => {
// Sanitize preferences to remove any sensitive data
const sanitizedPreferences = {
theme: preferences.theme,
language: preferences.language,
notifications: preferences.notifications,
privacy: {
analytics: preferences.privacy?.analytics || false,
marketing: preferences.privacy?.marketing || false,
},
};
set({ userPreferences: sanitizedPreferences });
},
setHabits: (habits) => {
const sanitizedHabits = StateSanitizer.sanitizeHabits(habits);
set({
habits: sanitizedHabits,
habitsLoading: false,
habitsError: null,
});
},
setAnalytics: (analytics) => {
const sanitizedAnalytics = StateSanitizer.sanitizeAnalytics(analytics);
set({
analytics: sanitizedAnalytics,
analyticsLoading: false,
});
},
setTheme: (theme) => {
// Validate theme value
const validThemes = ["light", "dark", "auto"];
if (validThemes.includes(theme)) {
set({ theme });
}
},
setActiveTab: (tab) => {
// Validate tab value to prevent XSS
const validTabs = [
"overview",
"habits",
"projects",
"analytics",
"settings",
];
if (validTabs.includes(tab)) {
set({ activeTab: tab });
}
},
// Loading state actions
setLoading: (loading) => set({ loading }),
setHabitsLoading: (loading) => set({ habitsLoading: loading }),
setAnalyticsLoading: (loading) => set({ analyticsLoading: loading }),
// Error state actions
setError: (error) => {
console.error("App error:", error);
set({ error: error?.message || "An error occurred" });
},
setHabitsError: (error) => {
console.error("Habits error:", error);
set({ habitsError: error?.message || "Failed to load habits" });
},
// Utility actions
clearErrors: () =>
set({
error: null,
habitsError: null,
}),
clearUserData: () => {
// Clear all user-related data
set({
user: null,
isAuthenticated: false,
userPreferences: {},
habits: [],
projects: [],
analytics: null,
});
// Clear from secure storage
secureStorage.clearAll();
},
// Emergency state reset
resetState: () => {
secureStorage.clearAll();
set({
user: null,
isAuthenticated: false,
userPreferences: {},
habits: [],
projects: [],
analytics: null,
activeTab: "overview",
theme: "dark",
loading: false,
habitsLoading: false,
analyticsLoading: false,
error: null,
habitsError: null,
});
},
// State validation
validateState: () => {
const state = get();
const issues = [];
// Check for data consistency
if (state.isAuthenticated && !state.user) {
issues.push("Authenticated but no user data");
}
if (state.user && !state.isAuthenticated) {
issues.push("User data present but not authenticated");
}
// Check for stale data
const maxAge = 60 * 60 * 1000; // 1 hour
if (state.user && state.user.lastLogin) {
if (Date.now() - state.user.lastLogin > maxAge) {
issues.push("User session may be stale");
}
}
if (issues.length > 0) {
console.warn("State validation issues:", issues);
return false;
}
return true;
},
}))
);
// State monitoring and cleanup
let stateMonitorInterval;
// Start state monitoring
function startStateMonitoring() {
stateMonitorInterval = setInterval(() => {
const store = useSecureAppStore.getState();
// Validate state periodically
if (!store.validateState()) {
console.warn("State validation failed, considering cleanup");
}
// Auto-cleanup old data
secureStorage.cleanup();
}, 5 * 60 * 1000); // Every 5 minutes
}
// Stop state monitoring
function stopStateMonitoring() {
if (stateMonitorInterval) {
clearInterval(stateMonitorInterval);
stateMonitorInterval = null;
}
}
// Initialize monitoring
startStateMonitoring();
// Cleanup on page unload
window.addEventListener("beforeunload", () => {
stopStateMonitoring();
secureStorage.cleanup();
});
// Development helpers
if (process.env.NODE_ENV === "development") {
window.debugSecureStore = {
getState: () => useSecureAppStore.getState(),
clearAll: () => useSecureAppStore.getState().resetState(),
validateState: () => useSecureAppStore.getState().validateState(),
getStoredData: async () => {
const data = {};
for (const key of Object.keys(STATE_SCHEMA)) {
data[key] = await secureStorage.getItem(`app_${key}`);
}
return data;
},
};
}
export default useSecureAppStore;
+150
View File
@@ -0,0 +1,150 @@
/**
* Secure token storage utilities
* Uses httpOnly cookies when possible, encrypted localStorage as fallback
*/
// Simple encryption for localStorage fallback (not cryptographically secure, but better than plaintext)
const STORAGE_KEY = "app_session_encrypted";
const ENCRYPTION_KEY = "wizards_grimoire_key_v1";
function simpleEncrypt(text) {
if (!text) return text;
const encoded = btoa(text);
return encoded.split("").reverse().join("");
}
function simpleDecrypt(encrypted) {
if (!encrypted) return encrypted;
try {
const reversed = encrypted.split("").reverse().join("");
return atob(reversed);
} catch (e) {
console.warn("Failed to decrypt token, clearing storage");
localStorage.removeItem(STORAGE_KEY);
return null;
}
}
class SecureTokenManager {
constructor() {
this.isSecureContext = window.isSecureContext;
this.supportsCredentials = "credentials" in fetch;
}
// Try to get token from httpOnly cookie first, then encrypted localStorage
getToken() {
// If we're using httpOnly cookies, the server will handle authentication
// We don't need to explicitly manage tokens in JS
if (this.supportsCredentials) {
return null; // Let fetch handle cookie authentication
}
// Fallback to encrypted localStorage for non-secure contexts
const encrypted = localStorage.getItem(STORAGE_KEY);
return simpleDecrypt(encrypted);
}
// Store token securely (only for fallback scenarios)
setToken(token) {
if (!token) {
this.clearToken();
return;
}
// In secure contexts, prefer httpOnly cookies (handled by server)
if (this.supportsCredentials && this.isSecureContext) {
// Token will be set as httpOnly cookie by server
return;
}
// Fallback: encrypted localStorage
const encrypted = simpleEncrypt(token);
localStorage.setItem(STORAGE_KEY, encrypted);
}
clearToken() {
localStorage.removeItem(STORAGE_KEY);
// Clear any potential old unencrypted tokens
localStorage.removeItem("token");
localStorage.removeItem("authToken");
localStorage.removeItem("jwt");
}
// Check if we have authentication (for UI state)
hasAuth() {
// Check for httpOnly cookie by making an authenticated request
if (this.supportsCredentials) {
return this.checkAuthStatus();
}
// Fallback: check localStorage
return !!this.getToken();
}
async checkAuthStatus() {
try {
const response = await fetch("/api/v1/auth/me", {
method: "GET",
credentials: "include",
headers: {
"Content-Type": "application/json",
},
});
return response.ok;
} catch (e) {
return false;
}
}
}
export const tokenManager = new SecureTokenManager();
// Enhanced fetch wrapper with secure authentication
export async function secureApiCall(url, options = {}) {
const defaultOptions = {
credentials: "include", // Include httpOnly cookies
headers: {
"Content-Type": "application/json",
...options.headers,
},
};
// Add CSRF token if available
const csrfToken = getCsrfToken();
if (csrfToken) {
defaultOptions.headers["X-CSRF-Token"] = csrfToken;
}
// Fallback token for non-secure contexts
if (!tokenManager.supportsCredentials) {
const token = tokenManager.getToken();
if (token) {
defaultOptions.headers["Authorization"] = `Bearer ${token}`;
}
}
const response = await fetch(url, { ...defaultOptions, ...options });
// Handle authentication errors
if (response.status === 401) {
tokenManager.clearToken();
// Trigger logout in app state
window.dispatchEvent(new CustomEvent("auth:logout"));
}
return response;
}
function getCsrfToken() {
// Get CSRF token from cookie
const cookies = document.cookie.split(";");
for (let cookie of cookies) {
const [name, value] = cookie.trim().split("=");
if (name === "csrf_token") {
return decodeURIComponent(value);
}
}
return null;
}
export default tokenManager;
+401
View File
@@ -0,0 +1,401 @@
/**
* Secure state management utilities for client-side data
*/
// Security configuration for state management
const STATE_SECURITY_CONFIG = {
// Data classification levels
PUBLIC: "public",
INTERNAL: "internal",
CONFIDENTIAL: "confidential",
RESTRICTED: "restricted",
// Storage policies
MAX_STORAGE_AGE: 15 * 60 * 1000, // 15 minutes
ENCRYPTION_KEY_ROTATION: 24 * 60 * 60 * 1000, // 24 hours
// Sensitive data patterns
SENSITIVE_PATTERNS: [
/password/i,
/token/i,
/secret/i,
/key/i,
/auth/i,
/session/i,
/credit/i,
/payment/i,
/personal/i,
],
};
// Data classification utility
class DataClassifier {
static classify(key, value) {
const keyLower = key.toLowerCase();
const valueStr = typeof value === "string" ? value : JSON.stringify(value);
// Check for restricted data (never store)
if (
STATE_SECURITY_CONFIG.SENSITIVE_PATTERNS.some(
(pattern) => pattern.test(keyLower) || pattern.test(valueStr)
)
) {
return STATE_SECURITY_CONFIG.RESTRICTED;
}
// Classify based on data type and content
if (
keyLower.includes("user") &&
(keyLower.includes("id") || keyLower.includes("email"))
) {
return STATE_SECURITY_CONFIG.CONFIDENTIAL;
}
if (keyLower.includes("preference") || keyLower.includes("setting")) {
return STATE_SECURITY_CONFIG.INTERNAL;
}
return STATE_SECURITY_CONFIG.PUBLIC;
}
}
// Secure state storage with encryption
class SecureStateStorage {
constructor() {
this.encryptionKey = null;
this.keyRotationInterval = null;
this.initEncryption();
}
async initEncryption() {
await this.rotateEncryptionKey();
// Set up key rotation
this.keyRotationInterval = setInterval(() => {
this.rotateEncryptionKey();
}, STATE_SECURITY_CONFIG.ENCRYPTION_KEY_ROTATION);
}
async rotateEncryptionKey() {
try {
const keyMaterial = await window.crypto.subtle.generateKey(
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"]
);
this.encryptionKey = keyMaterial;
console.log("State encryption key rotated");
} catch (error) {
console.error("Failed to rotate encryption key:", error);
}
}
async encrypt(data) {
if (!this.encryptionKey) {
throw new Error("Encryption key not initialized");
}
try {
const encoder = new TextEncoder();
const iv = crypto.getRandomValues(new Uint8Array(12));
const encodedData = encoder.encode(JSON.stringify(data));
const encrypted = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: iv },
this.encryptionKey,
encodedData
);
return {
encrypted: Array.from(new Uint8Array(encrypted)),
iv: Array.from(iv),
timestamp: Date.now(),
};
} catch (error) {
console.error("State encryption failed:", error);
throw error;
}
}
async decrypt(encryptedData) {
if (!this.encryptionKey) {
throw new Error("Encryption key not initialized");
}
try {
const decoder = new TextDecoder();
const decrypted = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: new Uint8Array(encryptedData.iv) },
this.encryptionKey,
new Uint8Array(encryptedData.encrypted)
);
return JSON.parse(decoder.decode(decrypted));
} catch (error) {
console.error("State decryption failed:", error);
throw error;
}
}
// Store state securely based on classification
setItem(key, value, classification = null) {
const dataClass = classification || DataClassifier.classify(key, value);
switch (dataClass) {
case STATE_SECURITY_CONFIG.RESTRICTED:
// Never store restricted data
console.warn(`Attempted to store restricted data: ${key}`);
return false;
case STATE_SECURITY_CONFIG.CONFIDENTIAL:
// Encrypt confidential data in sessionStorage
this.encrypt({ value, timestamp: Date.now() })
.then((encrypted) => {
sessionStorage.setItem(`secure_${key}`, JSON.stringify(encrypted));
})
.catch((error) => {
console.error(`Failed to store confidential data ${key}:`, error);
});
break;
case STATE_SECURITY_CONFIG.INTERNAL:
// Store internal data with timestamp in sessionStorage
sessionStorage.setItem(
key,
JSON.stringify({
value,
timestamp: Date.now(),
classification: dataClass,
})
);
break;
case STATE_SECURITY_CONFIG.PUBLIC:
// Store public data normally
localStorage.setItem(
key,
JSON.stringify({
value,
timestamp: Date.now(),
classification: dataClass,
})
);
break;
}
return true;
}
async getItem(key, classification = null) {
const dataClass = classification || STATE_SECURITY_CONFIG.CONFIDENTIAL; // Assume confidential if unknown
try {
let stored;
if (dataClass === STATE_SECURITY_CONFIG.CONFIDENTIAL) {
// Try to get encrypted data
stored = sessionStorage.getItem(`secure_${key}`);
if (stored) {
const encryptedData = JSON.parse(stored);
const decrypted = await this.decrypt(encryptedData);
// Check expiration
if (
Date.now() - decrypted.timestamp >
STATE_SECURITY_CONFIG.MAX_STORAGE_AGE
) {
this.removeItem(key, dataClass);
return null;
}
return decrypted.value;
}
} else {
// Get regular data
stored = sessionStorage.getItem(key) || localStorage.getItem(key);
if (stored) {
const data = JSON.parse(stored);
// Check expiration for internal data
if (dataClass === STATE_SECURITY_CONFIG.INTERNAL) {
if (
Date.now() - data.timestamp >
STATE_SECURITY_CONFIG.MAX_STORAGE_AGE
) {
this.removeItem(key, dataClass);
return null;
}
}
return data.value;
}
}
return null;
} catch (error) {
console.error(`Failed to retrieve data ${key}:`, error);
this.removeItem(key, dataClass); // Clean up corrupted data
return null;
}
}
removeItem(key, classification = null) {
const dataClass = classification || STATE_SECURITY_CONFIG.CONFIDENTIAL;
if (dataClass === STATE_SECURITY_CONFIG.CONFIDENTIAL) {
sessionStorage.removeItem(`secure_${key}`);
}
sessionStorage.removeItem(key);
localStorage.removeItem(key);
}
// Clear all stored state
clearAll() {
const sessionKeys = Object.keys(sessionStorage);
const localKeys = Object.keys(localStorage);
sessionKeys.forEach((key) => {
if (key.startsWith("secure_")) {
sessionStorage.removeItem(key);
}
});
[...sessionKeys, ...localKeys].forEach((key) => {
if (
key.includes("app_") ||
key.includes("user_") ||
key.includes("habit_")
) {
sessionStorage.removeItem(key);
localStorage.removeItem(key);
}
});
console.log("All application state cleared");
}
// Cleanup expired data
cleanup() {
const now = Date.now();
[sessionStorage, localStorage].forEach((storage) => {
const keys = Object.keys(storage);
keys.forEach((key) => {
try {
const item = storage.getItem(key);
if (item) {
const data = JSON.parse(item);
if (
data.timestamp &&
now - data.timestamp > STATE_SECURITY_CONFIG.MAX_STORAGE_AGE
) {
storage.removeItem(key);
}
}
} catch (error) {
// Remove corrupted items
storage.removeItem(key);
}
});
});
}
destroy() {
if (this.keyRotationInterval) {
clearInterval(this.keyRotationInterval);
}
this.clearAll();
}
}
// State sanitization utilities
class StateSanitizer {
static sanitizeUserData(userData) {
if (!userData || typeof userData !== "object") {
return null;
}
// Remove sensitive fields
const sanitized = { ...userData };
const sensitiveFields = [
"password",
"passwordHash",
"salt",
"totpSecret",
"backupCodes",
"privateKey",
"creditCard",
"ssn",
"personalId",
];
sensitiveFields.forEach((field) => {
delete sanitized[field];
});
// Limit stored user data
return {
id: sanitized.id,
email: sanitized.email,
displayName: sanitized.displayName,
role: sanitized.role,
preferences: sanitized.preferences,
lastLogin: Date.now(),
};
}
static sanitizeHabits(habits) {
if (!Array.isArray(habits)) {
return [];
}
return habits.map((habit) => ({
id: habit.id,
title: habit.title,
description: habit.description,
category: habit.category,
difficulty: habit.difficulty,
progress: habit.progress,
lastUpdate: Date.now(),
}));
}
static sanitizeAnalytics(analytics) {
if (!analytics || typeof analytics !== "object") {
return null;
}
// Remove any personally identifiable analytics
const sanitized = { ...analytics };
delete sanitized.userId;
delete sanitized.userAgent;
delete sanitized.ipAddress;
delete sanitized.deviceId;
return sanitized;
}
}
// Global secure storage instance
const secureStorage = new SecureStateStorage();
// Set up periodic cleanup
setInterval(() => {
secureStorage.cleanup();
}, 5 * 60 * 1000); // Every 5 minutes
// Cleanup on page unload
window.addEventListener("beforeunload", () => {
secureStorage.cleanup();
});
export {
SecureStateStorage,
DataClassifier,
StateSanitizer,
secureStorage,
STATE_SECURITY_CONFIG,
};