280 lines
8.8 KiB
JavaScript
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();
|