feat: Add Focus Tools (ADHD-friendly productivity features)
continuous-integration/drone/push Build is passing
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:
@@ -738,6 +738,88 @@ Format:
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Break down a large task into smaller, manageable subtasks for ADHD users.
|
||||
*/
|
||||
async breakdownTask(task: { title: string; description?: string; estimatedDuration?: number }): Promise<{
|
||||
subtasks: { title: string; estimatedMinutes: number; order: number }[];
|
||||
totalEstimatedMinutes: number;
|
||||
encouragement: string;
|
||||
}> {
|
||||
const provider = await this.storage.getSystemSettings("ai_provider") || "openai";
|
||||
const apiKey = await this.storage.getSystemSettings("ai_api_key");
|
||||
const model = await this.storage.getSystemSettings("ai_model") || "gpt-4o";
|
||||
const baseUrl = await this.storage.getSystemSettings("ai_base_url");
|
||||
|
||||
// Default fallback if AI is not configured
|
||||
if (!apiKey && provider !== "ollama") {
|
||||
return {
|
||||
subtasks: [
|
||||
{ title: `Start: ${task.title}`, estimatedMinutes: 5, order: 1 },
|
||||
{ title: `Continue: ${task.title}`, estimatedMinutes: 10, order: 2 },
|
||||
{ title: `Finish: ${task.title}`, estimatedMinutes: 5, order: 3 },
|
||||
],
|
||||
totalEstimatedMinutes: 20,
|
||||
encouragement: "Du schaffst das! Nimm dir einen Schritt nach dem anderen vor.",
|
||||
};
|
||||
}
|
||||
|
||||
const prompt = `Du bist ein ADHS-Coach. Der Nutzer hat eine überwältigende Aufgabe, die in kleinere Schritte zerlegt werden muss.
|
||||
|
||||
Aufgabe: "${task.title}"
|
||||
${task.description ? `Beschreibung: "${task.description}"` : ''}
|
||||
${task.estimatedDuration ? `Geschätzte Dauer: ${task.estimatedDuration} Minuten` : ''}
|
||||
|
||||
Zerlege diese Aufgabe in 3-7 kleine, konkrete Schritte.
|
||||
Jeder Schritt sollte:
|
||||
- In 5-15 Minuten erledigt werden können
|
||||
- Eine klare, aktionsorientierte Beschreibung haben (auf Deutsch)
|
||||
- Einen konkreten Endpunkt haben
|
||||
|
||||
Antworte NUR mit einem JSON-Objekt. Keine Markdown-Formatierung.
|
||||
Format:
|
||||
{
|
||||
"subtasks": [
|
||||
{ "title": "Schritt beschreibung", "estimatedMinutes": 5, "order": 1 },
|
||||
...
|
||||
],
|
||||
"totalEstimatedMinutes": number,
|
||||
"encouragement": "Eine ermutigende Nachricht auf Deutsch"
|
||||
}`;
|
||||
|
||||
const messages: ChatMessage[] = [
|
||||
{ role: "system", content: "Du bist ein hilfreicher ADHS-Coach. Antworte nur mit validem JSON." },
|
||||
{ role: "user", content: prompt }
|
||||
];
|
||||
|
||||
try {
|
||||
let responseText = "";
|
||||
if (provider === "openai" || provider === "ollama") {
|
||||
responseText = await this.chatOpenAI(provider, apiKey || "", model, baseUrl, messages, undefined, []);
|
||||
} else if (provider === "anthropic") {
|
||||
responseText = await this.chatAnthropic(apiKey || "", model, messages);
|
||||
} else if (provider === "google") {
|
||||
responseText = await this.chatGemini(apiKey || "", model, messages);
|
||||
}
|
||||
|
||||
// Clean response
|
||||
const cleanJson = responseText.replace(/```json/g, '').replace(/```/g, '').trim();
|
||||
return JSON.parse(cleanJson);
|
||||
} catch (error) {
|
||||
console.error("AI Breakdown Error:", error);
|
||||
// Fallback
|
||||
return {
|
||||
subtasks: [
|
||||
{ title: `Schritt 1: ${task.title} vorbereiten`, estimatedMinutes: 5, order: 1 },
|
||||
{ title: `Schritt 2: ${task.title} durchführen`, estimatedMinutes: 10, order: 2 },
|
||||
{ title: `Schritt 3: ${task.title} abschließen`, estimatedMinutes: 5, order: 3 },
|
||||
],
|
||||
totalEstimatedMinutes: 20,
|
||||
encouragement: "Jeder kleine Schritt zählt! Du machst das großartig.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async chatAnthropic(apiKey: string, model: string, messages: any[]): Promise<string> {
|
||||
const systemMessage = messages.find(m => m.role === "system")?.content || "";
|
||||
const userAssistantMessages = messages.filter(m => m.role !== "system");
|
||||
|
||||
+349
-1
@@ -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;
|
||||
}
|
||||
|
||||
+256
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user