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
+256 -1
View File
@@ -1,4 +1,4 @@
import { type User, type InsertUser, type Label, type InsertLabel, type SharedLabel, type Task, type InsertTask, type XpEvent, type InsertXpEvent, type Goal, type InsertGoal, type Reward, type InsertReward, type UserReward, type InsertUserReward, type SystemSettings, type InsertSystemSettings, type SharedTask, type InsertSharedTask, type UserTaskAccess, type InsertUserTaskAccess, type InsertPasswordResetToken, type PasswordResetToken, type Conversation, type InsertConversation, type Message, type InsertMessage, type AuditLog, type InsertAuditLog, type TaskTimeLog, type InsertTaskTimeLog } from "@shared/schema";
import { type User, type InsertUser, type Label, type InsertLabel, type SharedLabel, type Task, type InsertTask, type XpEvent, type InsertXpEvent, type Goal, type InsertGoal, type Reward, type InsertReward, type UserReward, type InsertUserReward, type SystemSettings, type InsertSystemSettings, type SharedTask, type InsertSharedTask, type UserTaskAccess, type InsertUserTaskAccess, type InsertPasswordResetToken, type PasswordResetToken, type Conversation, type InsertConversation, type Message, type InsertMessage, type AuditLog, type InsertAuditLog, type TaskTimeLog, type InsertTaskTimeLog, type BreakLog, type EnergyLog, type BodyDoublingSession, type FocusSession, type DailyChallenge } from "@shared/schema";
import * as schema from "@shared/schema";
import { getDatabase, pool } from "./db";
import { eq, sql, and, desc, asc, gt, ne, or, isNull } from "drizzle-orm";
@@ -109,6 +109,27 @@ export interface IStorage {
// Time Tracking
logTaskTime(log: InsertTaskTimeLog): Promise<TaskTimeLog>;
getAnalyticsTimeDistribution(userId: string, period: 'day' | 'week' | 'month' | 'year'): Promise<{ labelId: string | null, labelName: string | null, labelColor: string | null, timeSpent: number }[]>;
// ADHD Features
createBreakLog(log: { userId: string; breakType: string; durationMinutes: number }): Promise<any>;
getBreakStats(userId: string): Promise<{ todayBreaks: number; totalMinutes: number; lastBreak: Date | null }>;
createEnergyLog(log: { userId: string; energyLevel: string; notes?: string }): Promise<any>;
getEnergyHistory(userId: string): Promise<any[]>;
// Body Doubling Sessions
getBodyDoublingSessions(userId: string): Promise<any[]>;
createBodyDoublingSession(session: any): Promise<any>;
joinSession(sessionId: string, userId: string): Promise<void>;
leaveSession(sessionId: string, userId: string): Promise<void>;
// Focus Sessions
createFocusSession(session: any): Promise<any>;
completeFocusSession(sessionId: string, actualMinutes: number, wasCompleted: boolean): Promise<any>;
// Daily Challenges
getTodaysChallenges(userId: string): Promise<any[]>;
generateDailyChallenges(userId: string): Promise<any[]>;
updateChallengeProgress(challengeId: string, increment: number): Promise<any>;
}
export class MemStorage implements IStorage {
@@ -787,6 +808,21 @@ export class MemStorage implements IStorage {
return Array.from(distribution.values());
}
// ADHD Feature stubs for MemStorage (minimal implementation)
async createBreakLog(_log: any): Promise<any> { return {}; }
async getBreakStats(_userId: string): Promise<any> { return { todayBreaks: 0, totalMinutes: 0, lastBreak: null }; }
async createEnergyLog(_log: any): Promise<any> { return {}; }
async getEnergyHistory(_userId: string): Promise<any[]> { return []; }
async getBodyDoublingSessions(_userId: string): Promise<any[]> { return []; }
async createBodyDoublingSession(_session: any): Promise<any> { return {}; }
async joinSession(_sessionId: string, _userId: string): Promise<void> {}
async leaveSession(_sessionId: string, _userId: string): Promise<void> {}
async createFocusSession(_session: any): Promise<any> { return {}; }
async completeFocusSession(_sessionId: string, _actualMinutes: number, _wasCompleted: boolean): Promise<any> { return undefined; }
async getTodaysChallenges(_userId: string): Promise<any[]> { return []; }
async generateDailyChallenges(_userId: string): Promise<any[]> { return []; }
async updateChallengeProgress(_challengeId: string, _increment: number): Promise<any> { return undefined; }
}
@@ -1369,6 +1405,225 @@ export class DbStorage implements IStorage {
timeSpent: log.timeSpent || 0
}));
}
// ============================================
// ADHD FEATURE IMPLEMENTATIONS
// ============================================
async createBreakLog(log: { userId: string; breakType: string; durationMinutes: number }): Promise<BreakLog> {
const [result] = await this.db.insert(schema.breakLogs).values({
userId: log.userId,
breakType: log.breakType,
durationMinutes: log.durationMinutes,
}).returning();
return result;
}
async getBreakStats(userId: string): Promise<{ todayBreaks: number; totalMinutes: number; lastBreak: Date | null }> {
const today = new Date();
today.setHours(0, 0, 0, 0);
const todayLogs = await this.db.select()
.from(schema.breakLogs)
.where(and(
eq(schema.breakLogs.userId, userId),
gt(schema.breakLogs.createdAt, today)
));
const totalMinutes = todayLogs.reduce((sum, log) => sum + log.durationMinutes, 0);
const lastLog = await this.db.select()
.from(schema.breakLogs)
.where(eq(schema.breakLogs.userId, userId))
.orderBy(desc(schema.breakLogs.createdAt))
.limit(1);
return {
todayBreaks: todayLogs.length,
totalMinutes,
lastBreak: lastLog[0]?.createdAt || null,
};
}
async createEnergyLog(log: { userId: string; energyLevel: string; notes?: string }): Promise<EnergyLog> {
const [result] = await this.db.insert(schema.energyLogs).values({
userId: log.userId,
energyLevel: log.energyLevel,
notes: log.notes,
}).returning();
return result;
}
async getEnergyHistory(userId: string): Promise<EnergyLog[]> {
return await this.db.select()
.from(schema.energyLogs)
.where(eq(schema.energyLogs.userId, userId))
.orderBy(desc(schema.energyLogs.loggedAt))
.limit(30);
}
async getBodyDoublingSessions(userId: string): Promise<any[]> {
const sessions = await this.db.select({
id: schema.bodyDoublingSessions.id,
hostId: schema.bodyDoublingSessions.hostId,
title: schema.bodyDoublingSessions.title,
sessionType: schema.bodyDoublingSessions.sessionType,
startsAt: schema.bodyDoublingSessions.startsAt,
durationMinutes: schema.bodyDoublingSessions.durationMinutes,
maxParticipants: schema.bodyDoublingSessions.maxParticipants,
isPublic: schema.bodyDoublingSessions.isPublic,
status: schema.bodyDoublingSessions.status,
createdAt: schema.bodyDoublingSessions.createdAt,
hostUsername: schema.users.username,
})
.from(schema.bodyDoublingSessions)
.innerJoin(schema.users, eq(schema.bodyDoublingSessions.hostId, schema.users.id))
.where(or(
eq(schema.bodyDoublingSessions.isPublic, true),
eq(schema.bodyDoublingSessions.hostId, userId)
))
.orderBy(asc(schema.bodyDoublingSessions.startsAt));
// Get participant counts
const result = await Promise.all(sessions.map(async (session) => {
const participants = await this.db.select()
.from(schema.sessionParticipants)
.where(eq(schema.sessionParticipants.sessionId, session.id));
const isParticipant = participants.some(p => p.userId === userId);
return {
...session,
host: { username: session.hostUsername },
participantCount: participants.length,
isParticipant,
};
}));
return result;
}
async createBodyDoublingSession(sessionData: any): Promise<BodyDoublingSession> {
const [result] = await this.db.insert(schema.bodyDoublingSessions).values({
hostId: sessionData.hostId,
title: sessionData.title,
sessionType: sessionData.sessionType || 'focus',
startsAt: new Date(sessionData.startsAt),
durationMinutes: sessionData.durationMinutes || 50,
maxParticipants: sessionData.maxParticipants || 5,
isPublic: sessionData.isPublic ?? true,
}).returning();
return result;
}
async joinSession(sessionId: string, userId: string): Promise<void> {
// Check if already a participant
const existing = await this.db.select()
.from(schema.sessionParticipants)
.where(and(
eq(schema.sessionParticipants.sessionId, sessionId),
eq(schema.sessionParticipants.userId, userId)
));
if (existing.length === 0) {
await this.db.insert(schema.sessionParticipants).values({
sessionId,
userId,
});
}
}
async leaveSession(sessionId: string, userId: string): Promise<void> {
await this.db.update(schema.sessionParticipants)
.set({ leftAt: new Date() })
.where(and(
eq(schema.sessionParticipants.sessionId, sessionId),
eq(schema.sessionParticipants.userId, userId)
));
}
async createFocusSession(sessionData: any): Promise<FocusSession> {
const [result] = await this.db.insert(schema.focusSessions).values({
userId: sessionData.userId,
taskId: sessionData.taskId,
sessionType: sessionData.sessionType || 'pomodoro',
plannedMinutes: sessionData.plannedMinutes,
}).returning();
return result;
}
async completeFocusSession(sessionId: string, actualMinutes: number, wasCompleted: boolean): Promise<FocusSession | undefined> {
const [result] = await this.db.update(schema.focusSessions)
.set({
actualMinutes,
wasCompleted,
endedAt: new Date(),
})
.where(eq(schema.focusSessions.id, sessionId))
.returning();
return result;
}
async getTodaysChallenges(userId: string): Promise<DailyChallenge[]> {
const today = new Date().toISOString().split('T')[0];
return await this.db.select()
.from(schema.dailyChallenges)
.where(and(
eq(schema.dailyChallenges.userId, userId),
eq(schema.dailyChallenges.challengeDate, today)
));
}
async generateDailyChallenges(userId: string): Promise<DailyChallenge[]> {
const today = new Date().toISOString().split('T')[0];
const challengeTemplates = [
{ type: 'complete_tasks', title: 'Complete 3 tasks', target: 3, xp: 50 },
{ type: 'take_breaks', title: 'Take 2 breaks', target: 2, xp: 30 },
{ type: 'quick_wins', title: 'Complete 2 quick wins', target: 2, xp: 40 },
{ type: 'focus_time', title: 'Focus for 25 minutes', target: 25, xp: 35 },
{ type: 'use_timer', title: 'Use the visual timer', target: 1, xp: 20 },
];
// Pick 3 random challenges
const shuffled = challengeTemplates.sort(() => Math.random() - 0.5);
const selected = shuffled.slice(0, 3);
const challenges = await Promise.all(selected.map(async (template) => {
const [result] = await this.db.insert(schema.dailyChallenges).values({
userId,
challengeType: template.type,
title: template.title,
target: template.target,
xpReward: template.xp,
challengeDate: today,
}).returning();
return result;
}));
return challenges;
}
async updateChallengeProgress(challengeId: string, increment: number): Promise<DailyChallenge | undefined> {
const challenge = await this.db.select()
.from(schema.dailyChallenges)
.where(eq(schema.dailyChallenges.id, challengeId))
.limit(1);
if (!challenge[0]) return undefined;
const newProgress = Math.min(challenge[0].progress + increment, challenge[0].target);
const isCompleted = newProgress >= challenge[0].target;
const [result] = await this.db.update(schema.dailyChallenges)
.set({
progress: newProgress,
completedAt: isCompleted && !challenge[0].completedAt ? new Date() : challenge[0].completedAt,
})
.where(eq(schema.dailyChallenges.id, challengeId))
.returning();
return result;
}
}
// Export storage based on environment