tududi/backend/modules/habits/habitService.js
Chris 542be2c1e9
Fix bug 366 (#764)
* Optimize DB

* Clean up names

* fixup! Clean up names

* fixup! fixup! Clean up names
2026-01-07 18:18:07 +02:00

280 lines
8.8 KiB
JavaScript

const { RecurringCompletion } = require('../../models');
const { Op } = require('sequelize');
class HabitService {
/**
* Log a habit completion
* Updates streak counters and creates completion record
*/
async logCompletion(task, completedAt = new Date()) {
if (!task.habit_mode) {
throw new Error('Task is not a habit');
}
const completion = await RecurringCompletion.create({
task_id: task.id,
completed_at: completedAt,
original_due_date: completedAt, // For habits, due date = completion date
skipped: false,
});
// Update cached counters and mark as done for today
const updates = await this.calculateStreakUpdates(task, completedAt);
updates.status = 2; // Mark as done
updates.completed_at = completedAt;
await task.update(updates);
return { completion, task };
}
/**
* Calculate streak updates based on completion
* This is the core habit logic
*/
async calculateStreakUpdates(task, completedAt) {
const updates = {
habit_total_completions: task.habit_total_completions + 1,
habit_last_completion_at: completedAt,
};
// Calculate new streak
const newStreak = await this.calculateCurrentStreak(task, completedAt);
updates.habit_current_streak = newStreak;
// Update best streak if needed
if (newStreak > task.habit_best_streak) {
updates.habit_best_streak = newStreak;
}
return updates;
}
/**
* Recalculate all streak values after a completion is deleted
*/
async recalculateStreaks(task) {
const completions = await RecurringCompletion.findAll({
where: {
task_id: task.id,
skipped: false,
},
order: [['completed_at', 'DESC']],
});
const updates = {
habit_total_completions: completions.length,
habit_last_completion_at:
completions.length > 0 ? completions[0].completed_at : null,
};
// Calculate current streak
updates.habit_current_streak =
completions.length > 0
? this.calculateCalendarStreak(completions, new Date())
: 0;
// Calculate best streak (need to check all possible streaks)
updates.habit_best_streak = this.calculateBestStreak(completions);
return updates;
}
/**
* Calculate the best (longest) streak from completion history
*/
calculateBestStreak(completions) {
if (completions.length === 0) return 0;
let bestStreak = 0;
let currentStreak = 0;
let lastDate = null;
// Sort by date ascending for this calculation
const sorted = [...completions].sort(
(a, b) => new Date(a.completed_at) - new Date(b.completed_at)
);
for (const completion of sorted) {
const completedDate = new Date(completion.completed_at);
completedDate.setHours(0, 0, 0, 0);
if (!lastDate) {
currentStreak = 1;
} else {
const diffDays = Math.floor(
(completedDate - lastDate) / (1000 * 60 * 60 * 24)
);
if (diffDays === 1) {
// Consecutive day
currentStreak++;
} else if (diffDays === 0) {
// Same day, don't change streak
continue;
} else {
// Streak broken
bestStreak = Math.max(bestStreak, currentStreak);
currentStreak = 1;
}
}
lastDate = new Date(completedDate);
}
return Math.max(bestStreak, currentStreak);
}
/**
* Calculate current streak based on streak mode
*/
async calculateCurrentStreak(task, asOfDate = new Date()) {
const completions = await RecurringCompletion.findAll({
where: {
task_id: task.id,
skipped: false,
},
order: [['completed_at', 'DESC']],
});
if (completions.length === 0) return 0;
if (task.habit_streak_mode === 'calendar') {
return this.calculateCalendarStreak(completions, asOfDate);
} else {
return this.calculateScheduledStreak(task, completions, asOfDate);
}
}
/**
* Calendar streak: consecutive days with completions
*/
calculateCalendarStreak(completions, asOfDate) {
let streak = 0;
let currentDate = new Date(asOfDate);
currentDate.setHours(0, 0, 0, 0);
const completionDates = completions.map((c) => {
const d = new Date(c.completed_at);
d.setHours(0, 0, 0, 0);
return d.getTime();
});
const uniqueDates = [...new Set(completionDates)].sort((a, b) => b - a);
for (const dateTimestamp of uniqueDates) {
const expectedDate = currentDate.getTime();
if (dateTimestamp === expectedDate) {
streak++;
currentDate.setDate(currentDate.getDate() - 1);
} else if (dateTimestamp < expectedDate) {
break; // Gap in streak
}
}
return streak;
}
/**
* Scheduled streak: consecutive scheduled occurrences completed
* Uses recurrence pattern to determine expected dates
*/
calculateScheduledStreak(task, completions, asOfDate) {
// Implementation depends on recurrence pattern
// For MVP: simplified version - count consecutive scheduled periods with completions
// Full implementation would use recurringTaskService to calculate expected dates
// Simplified: treat as calendar streak for now
// TODO: Implement full scheduled streak logic in Phase 2
return this.calculateCalendarStreak(completions, asOfDate);
}
/**
* Get habit statistics for a period
*/
async getHabitStats(task, startDate, endDate) {
const completions = await RecurringCompletion.findAll({
where: {
task_id: task.id,
completed_at: {
[Op.between]: [startDate, endDate],
},
skipped: false,
},
order: [['completed_at', 'ASC']],
});
const totalCompletions = completions.length;
const currentStreak = task.habit_current_streak;
// Calculate completion rate if target is set
let completionRate = null;
if (task.habit_target_count && task.habit_frequency_period) {
const target = this.calculatePeriodTarget(task, startDate, endDate);
completionRate = target > 0 ? (totalCompletions / target) * 100 : 0;
}
return {
totalCompletions,
currentStreak,
bestStreak: task.habit_best_streak,
completionRate,
completions: completions.map((c) => ({
completed_at: c.completed_at,
id: c.id,
})),
};
}
/**
* Calculate target completions for a date range
*/
calculatePeriodTarget(task, startDate, endDate) {
if (!task.habit_target_count || !task.habit_frequency_period) {
return 0;
}
const days = Math.ceil((endDate - startDate) / (1000 * 60 * 60 * 24));
switch (task.habit_frequency_period) {
case 'daily':
return task.habit_target_count * days;
case 'weekly':
return task.habit_target_count * Math.ceil(days / 7);
case 'monthly':
return task.habit_target_count * Math.ceil(days / 30);
default:
return 0;
}
}
/**
* Check if habit is due today based on flexibility mode
*/
isDueToday(task, today = new Date()) {
if (task.habit_flexibility_mode === 'flexible') {
// Flexible: always available
return true;
} else {
// Strict: check if today matches recurrence pattern
// Leverage existing recurringTaskService logic
const {
calculateNextDueDate,
} = require('../tasks/recurringTaskService');
const nextDue = calculateNextDueDate(
task,
task.habit_last_completion_at || task.created_at
);
if (!nextDue) return false;
const todayStart = new Date(today);
todayStart.setHours(0, 0, 0, 0);
const todayEnd = new Date(today);
todayEnd.setHours(23, 59, 59, 999);
return nextDue >= todayStart && nextDue <= todayEnd;
}
}
}
module.exports = new HabitService();