feat: Add Focus Tools (ADHD-friendly productivity features)
continuous-integration/drone/push Build is passing

- Add Focus Tools dashboard with collapsible help section
- Implement Quick Wins page for tasks under 15 minutes
- Add Single Task Focus mode to reduce overwhelm
- Create Body Doubling page for virtual co-working
- Add visual timer, break reminders, and energy tracking
- Implement hyperfocus protection alerts
- Add ADHD settings panel with customizable options
- Include full English and German translations
- Fix larger touch targets CSS to not break button layouts
- Add Playwright tests for Focus Tools features
This commit is contained in:
Paul Nothaft
2026-01-15 21:20:04 +01:00
parent 74ffef48d3
commit 7ce4f7efdc
31 changed files with 5651 additions and 11 deletions
+349 -1
View File
@@ -1871,6 +1871,354 @@ ${activeTasksWithLabels.slice(0, 5).map(t => `- [${t.priority}] [${t.label}] ${t
console.error("Export Failed:", e);
res.status(500).json({ error: "Failed to export data" });
}
}); // End of routes
});
// ============================================
// ADHD MODE ROUTES
// ============================================
// Update ADHD settings
app.patch("/api/user/adhd-settings", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const userId = (req.user as User).id;
try {
const { adhdMode, adhdSettings } = req.body;
const updated = await storage.updateUser(userId, {
adhdMode,
adhdSettings,
});
await storage.createAuditLog({
userId,
action: "UPDATE",
entityType: "USER",
entityId: userId,
details: { action: "UPDATE_ADHD_SETTINGS", adhdMode },
source: "USER"
});
res.json(updated);
} catch (e) {
console.error("Update ADHD settings failed:", e);
res.status(500).json({ error: "Failed to update ADHD settings" });
}
});
// Log a break
app.post("/api/user/log-break", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const userId = (req.user as User).id;
try {
const { breakType, durationMinutes } = req.body;
// Log the break
await storage.createBreakLog({
userId,
breakType,
durationMinutes: durationMinutes || 5,
});
// Update user's lastBreakAt
await storage.updateUser(userId, {
lastBreakAt: new Date(),
});
// Award XP for taking a break (micro-XP)
await gamificationService.awardXP(userId, 10, 'break_taken', undefined, { breakType });
res.json({ success: true });
} catch (e) {
console.error("Log break failed:", e);
res.status(500).json({ error: "Failed to log break" });
}
});
// Get break stats
app.get("/api/user/break-stats", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const userId = (req.user as User).id;
try {
const stats = await storage.getBreakStats(userId);
res.json(stats);
} catch (e) {
res.status(500).json({ error: "Failed to get break stats" });
}
});
// Log energy level
app.post("/api/energy/log", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const userId = (req.user as User).id;
try {
const { energyLevel, notes } = req.body;
await storage.createEnergyLog({
userId,
energyLevel,
notes,
});
// Update user's current energy level
await storage.updateUser(userId, {
currentEnergyLevel: energyLevel,
todayEnergyCheckedIn: true,
});
// Award XP for checking in
await gamificationService.awardXP(userId, 5, 'energy_checkin', undefined, { energyLevel });
res.json({ success: true });
} catch (e) {
console.error("Log energy failed:", e);
res.status(500).json({ error: "Failed to log energy" });
}
});
// Get energy history
app.get("/api/energy/history", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const userId = (req.user as User).id;
try {
const history = await storage.getEnergyHistory(userId);
res.json(history);
} catch (e) {
res.status(500).json({ error: "Failed to get energy history" });
}
});
// Get energy-based task suggestions
app.get("/api/tasks/energy-based", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const userId = (req.user as User).id;
try {
const user = req.user as User;
const tasks = await storage.getTasksForUser(userId);
const energyLevel = user.currentEnergyLevel || 'medium';
// Filter tasks based on energy level
let recommended = tasks.filter(t => t.status !== 'done');
if (energyLevel === 'low') {
// Prefer low-energy tasks for low energy
recommended = recommended.filter(t =>
t.energyLevel === 'low' ||
(t.estimatedDuration && t.estimatedDuration <= 15) ||
t.priority === 'low'
);
} else if (energyLevel === 'high') {
// Prefer high-energy/challenging tasks
recommended = recommended.filter(t =>
t.energyLevel === 'high' ||
t.priority === 'high' ||
(t.estimatedDuration && t.estimatedDuration > 30)
);
}
res.json(recommended.slice(0, 10));
} catch (e) {
res.status(500).json({ error: "Failed to get energy-based tasks" });
}
});
// Get quick wins
app.get("/api/tasks/quick-wins", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const userId = (req.user as User).id;
try {
const user = req.user as User;
const tasks = await storage.getTasksForUser(userId);
const threshold = (user.adhdSettings as any)?.quickWinThreshold || 10;
const quickWins = tasks
.filter(t => {
if (t.status === 'done') return false;
if (t.estimatedDuration && t.estimatedDuration > threshold * 1.5) return false;
return true;
})
.slice(0, 10);
res.json(quickWins);
} catch (e) {
res.status(500).json({ error: "Failed to get quick wins" });
}
});
// AI Task Breakdown
app.post("/api/ai/breakdown-task", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const userId = (req.user as User).id;
try {
const { taskId, title, description, estimatedDuration } = req.body;
const breakdown = await aiService.breakdownTask({
title,
description,
estimatedDuration,
});
await storage.createAuditLog({
userId,
action: "AI_BREAKDOWN",
entityType: "TASK",
entityId: taskId,
details: { title, subtaskCount: breakdown.subtasks?.length },
source: "AI"
});
res.json(breakdown);
} catch (e) {
console.error("AI breakdown failed:", e);
res.status(500).json({ error: "Failed to break down task" });
}
});
// ============================================
// BODY DOUBLING SESSION ROUTES
// ============================================
// Get all sessions
app.get("/api/sessions", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const userId = (req.user as User).id;
try {
const sessions = await storage.getBodyDoublingSessions(userId);
res.json(sessions);
} catch (e) {
res.status(500).json({ error: "Failed to get sessions" });
}
});
// Create session
app.post("/api/sessions", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const userId = (req.user as User).id;
try {
const session = await storage.createBodyDoublingSession({
hostId: userId,
...req.body,
});
// Auto-join as participant
await storage.joinSession(session.id, userId);
res.json(session);
} catch (e) {
console.error("Create session failed:", e);
res.status(500).json({ error: "Failed to create session" });
}
});
// Join session
app.post("/api/sessions/:id/join", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const userId = (req.user as User).id;
const sessionId = req.params.id;
try {
await storage.joinSession(sessionId, userId);
res.json({ success: true });
} catch (e) {
console.error("Join session failed:", e);
res.status(500).json({ error: "Failed to join session" });
}
});
// Leave session
app.post("/api/sessions/:id/leave", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const userId = (req.user as User).id;
const sessionId = req.params.id;
try {
await storage.leaveSession(sessionId, userId);
res.json({ success: true });
} catch (e) {
console.error("Leave session failed:", e);
res.status(500).json({ error: "Failed to leave session" });
}
});
// ============================================
// FOCUS SESSION ROUTES
// ============================================
// Start focus session
app.post("/api/focus-sessions", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const userId = (req.user as User).id;
try {
const session = await storage.createFocusSession({
userId,
...req.body,
});
res.json(session);
} catch (e) {
res.status(500).json({ error: "Failed to start focus session" });
}
});
// Complete focus session
app.patch("/api/focus-sessions/:id/complete", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const userId = (req.user as User).id;
try {
const { actualMinutes, wasCompleted } = req.body;
const session = await storage.completeFocusSession(req.params.id, actualMinutes, wasCompleted);
// Award XP based on completion
const xpAmount = wasCompleted ? 15 : 5;
await gamificationService.awardXP(userId, xpAmount, 'focus_session', undefined, {
sessionType: session?.sessionType,
actualMinutes,
});
res.json(session);
} catch (e) {
res.status(500).json({ error: "Failed to complete focus session" });
}
});
// ============================================
// DAILY CHALLENGES ROUTES
// ============================================
// Get today's challenges
app.get("/api/challenges/today", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const userId = (req.user as User).id;
try {
let challenges = await storage.getTodaysChallenges(userId);
// Generate new challenges if none exist for today
if (challenges.length === 0) {
challenges = await storage.generateDailyChallenges(userId);
}
res.json(challenges);
} catch (e) {
res.status(500).json({ error: "Failed to get challenges" });
}
});
// Update challenge progress
app.post("/api/challenges/:id/progress", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const userId = (req.user as User).id;
try {
const { increment } = req.body;
const challenge = await storage.updateChallengeProgress(req.params.id, increment || 1);
// Award XP if completed
if (challenge?.completedAt && challenge.xpReward) {
await gamificationService.awardXP(userId, challenge.xpReward, 'daily_challenge', undefined, {
challengeType: challenge.challengeType,
});
}
res.json(challenge);
} catch (e) {
res.status(500).json({ error: "Failed to update challenge progress" });
}
});
// End of routes
return httpServer;
}