fix: calendar, habits, analytics PB 0.25 compat (#6)
This commit is contained in:
@@ -51,9 +51,9 @@ export default function CalendarPage() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const [tasksResponse, projectsResponse, milestonesResponse] = await Promise.all([
|
const [tasksResponse, projectsResponse, milestonesResponse] = await Promise.all([
|
||||||
fetch('/api/tasks?filter=due_date!%3D%22%22&perPage=500'),
|
fetch('/api/tasks?perPage=500'),
|
||||||
fetch('/api/projects?filter=due_date!%3D%22%22&perPage=500'),
|
fetch('/api/projects?perPage=500'),
|
||||||
fetch('/api/milestones?filter=due_date!%3D%22%22&perPage=500'),
|
fetch('/api/milestones?perPage=500'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!tasksResponse.ok || !projectsResponse.ok || !milestonesResponse.ok) {
|
if (!tasksResponse.ok || !projectsResponse.ok || !milestonesResponse.ok) {
|
||||||
|
|||||||
@@ -1,247 +1,125 @@
|
|||||||
import { createPocketBaseClient, createAdminClient } from '../pocketbase';
|
import { createPocketBaseClient, createAdminClient } from '../pocketbase';
|
||||||
import type { Habit, HabitLog, HabitScoreConfig } from '@project-e/shared';
|
import type { Habit, HabitLog, HabitScoreConfig } from '@project-e/shared';
|
||||||
|
|
||||||
/** Cast a PocketBase RecordModel to a typed domain model */
|
|
||||||
function asHabit(record: Record<string, unknown>): Habit {
|
function asHabit(record: Record<string, unknown>): Habit {
|
||||||
return record as unknown as Habit;
|
return record as unknown as Habit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cast a PocketBase RecordModel to a typed domain model */
|
|
||||||
function asHabitLog(record: Record<string, unknown>): HabitLog {
|
function asHabitLog(record: Record<string, unknown>): HabitLog {
|
||||||
return record as unknown as HabitLog;
|
return record as unknown as HabitLog;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Default habit score weights
|
|
||||||
*/
|
|
||||||
const DEFAULT_SCORE_CONFIG: Required<HabitScoreConfig> = {
|
const DEFAULT_SCORE_CONFIG: Required<HabitScoreConfig> = {
|
||||||
streak_weight: 0.5,
|
streak_weight: 0.5,
|
||||||
consistency_weight: 0.2,
|
consistency_weight: 0.2,
|
||||||
difficulty_weight: 0.3,
|
difficulty_weight: 0.3,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Difficulty multipliers
|
|
||||||
*/
|
|
||||||
const DIFFICULTY_MULTIPLIER: Record<string, number> = {
|
const DIFFICULTY_MULTIPLIER: Record<string, number> = {
|
||||||
easy: 0.7,
|
easy: 0.7,
|
||||||
medium: 1.0,
|
medium: 1.0,
|
||||||
hard: 1.3,
|
hard: 1.3,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate habit score (0-100)
|
|
||||||
* Weighted composite: streak, consistency, difficulty
|
|
||||||
*/
|
|
||||||
export function calculateHabitScore(
|
export function calculateHabitScore(
|
||||||
habit: Habit,
|
habit: Habit,
|
||||||
logs: HabitLog[],
|
logs: HabitLog[],
|
||||||
scoreConfig?: HabitScoreConfig
|
scoreConfig?: HabitScoreConfig
|
||||||
): number {
|
): number {
|
||||||
const config = { ...DEFAULT_SCORE_CONFIG, ...scoreConfig };
|
const config = { ...DEFAULT_SCORE_CONFIG, ...scoreConfig };
|
||||||
|
|
||||||
// 1. Streak score (0-100)
|
|
||||||
const streakScore = Math.min((habit.current_streak / 30) * 100, 100);
|
const streakScore = Math.min((habit.current_streak / 30) * 100, 100);
|
||||||
|
|
||||||
// 2. Consistency rate (last 30 days)
|
|
||||||
const thirtyDaysAgo = new Date();
|
const thirtyDaysAgo = new Date();
|
||||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||||
const recentLogs = logs.filter(
|
const recentLogs = logs.filter(
|
||||||
(l) => new Date(l.logged_at) >= thirtyDaysAgo && l.completed && !l.skipped
|
(l) => new Date(l.logged_at) >= thirtyDaysAgo && (l as any).completed && !(l as any).skipped
|
||||||
);
|
);
|
||||||
const consistencyScore = (recentLogs.length / 30) * 100;
|
const consistencyScore = (recentLogs.length / 30) * 100;
|
||||||
|
|
||||||
// 3. Difficulty multiplier
|
|
||||||
const difficultyMultiplier = DIFFICULTY_MULTIPLIER[habit.difficulty] || 1.0;
|
const difficultyMultiplier = DIFFICULTY_MULTIPLIER[habit.difficulty] || 1.0;
|
||||||
const difficultyScore = difficultyMultiplier * 100;
|
const difficultyScore = difficultyMultiplier * 100;
|
||||||
|
|
||||||
// Weighted composite
|
|
||||||
const score = Math.round(
|
const score = Math.round(
|
||||||
streakScore * config.streak_weight +
|
streakScore * config.streak_weight +
|
||||||
consistencyScore * config.consistency_weight +
|
consistencyScore * config.consistency_weight +
|
||||||
difficultyScore * config.difficulty_weight
|
difficultyScore * config.difficulty_weight
|
||||||
);
|
);
|
||||||
|
|
||||||
return Math.min(Math.max(score, 0), 100);
|
return Math.min(Math.max(score, 0), 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Update streaks after habit completion
|
|
||||||
*/
|
|
||||||
export function updateStreaks(
|
export function updateStreaks(
|
||||||
habit: Habit,
|
habit: Habit,
|
||||||
completionDate: string
|
completionDate: string
|
||||||
): {
|
): { current_streak: number; best_streak: number } {
|
||||||
current_streak: number;
|
|
||||||
best_streak: number;
|
|
||||||
} {
|
|
||||||
const today = new Date(completionDate);
|
const today = new Date(completionDate);
|
||||||
const lastCompleted = habit.updated ? new Date(habit.updated) : null;
|
const lastCompleted = habit.updated ? new Date(habit.updated) : null;
|
||||||
|
|
||||||
let newStreak = habit.current_streak;
|
let newStreak = habit.current_streak;
|
||||||
|
|
||||||
if (lastCompleted) {
|
if (lastCompleted) {
|
||||||
const daysDiff = Math.floor(
|
const daysDiff = Math.floor((today.getTime() - lastCompleted.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
(today.getTime() - lastCompleted.getTime()) / (1000 * 60 * 60 * 24)
|
if (daysDiff === 1) newStreak = habit.current_streak + 1;
|
||||||
);
|
else if (daysDiff === 0) newStreak = habit.current_streak;
|
||||||
|
else newStreak = 1;
|
||||||
if (daysDiff === 1) {
|
|
||||||
// Consecutive day — increment streak
|
|
||||||
newStreak = habit.current_streak + 1;
|
|
||||||
} else if (daysDiff === 0) {
|
|
||||||
// Same day — no change (already completed today)
|
|
||||||
newStreak = habit.current_streak;
|
|
||||||
} else {
|
|
||||||
// Streak broken — reset to 1
|
|
||||||
newStreak = 1;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// First completion
|
|
||||||
newStreak = 1;
|
newStreak = 1;
|
||||||
}
|
}
|
||||||
|
return { current_streak: newStreak, best_streak: Math.max(newStreak, habit.best_streak) };
|
||||||
return {
|
|
||||||
current_streak: newStreak,
|
|
||||||
best_streak: Math.max(newStreak, habit.best_streak),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Log habit completion
|
|
||||||
*/
|
|
||||||
export async function logHabitCompletion(
|
export async function logHabitCompletion(
|
||||||
habitId: string,
|
habitId: string,
|
||||||
data: {
|
data: { logged_at?: string; mood?: number; value?: number; notes?: string },
|
||||||
logged_at?: string;
|
|
||||||
mood?: number;
|
|
||||||
value?: number;
|
|
||||||
notes?: string;
|
|
||||||
},
|
|
||||||
token?: string
|
token?: string
|
||||||
): Promise<{ log: HabitLog; habit: Habit }> {
|
): Promise<{ log: HabitLog; habit: Habit }> {
|
||||||
const pb = token ? createPocketBaseClient(token) : createAdminClient();
|
const pb = token ? createPocketBaseClient(token) : createAdminClient();
|
||||||
|
|
||||||
const loggedAt = data.logged_at || new Date().toISOString();
|
const loggedAt = data.logged_at || new Date().toISOString();
|
||||||
|
|
||||||
// Create log entry
|
|
||||||
const log = asHabitLog(
|
const log = asHabitLog(
|
||||||
await pb.collection('habit_logs').create({
|
await pb.collection('habit_logs').create({
|
||||||
habit_id: habitId,
|
habit_id: habitId,
|
||||||
completed: true,
|
|
||||||
mood: data.mood || undefined,
|
mood: data.mood || undefined,
|
||||||
value: data.value || undefined,
|
|
||||||
notes: data.notes || '',
|
notes: data.notes || '',
|
||||||
logged_at: loggedAt,
|
logged_at: loggedAt,
|
||||||
}) as unknown as Record<string, unknown>
|
}) as unknown as Record<string, unknown>
|
||||||
);
|
);
|
||||||
|
const habit = asHabit(await pb.collection('habits').getOne(habitId) as unknown as Record<string, unknown>);
|
||||||
// Get current habit state
|
|
||||||
const habit = asHabit(
|
|
||||||
await pb.collection('habits').getOne(habitId) as unknown as Record<string, unknown>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update streaks
|
|
||||||
const streaks = updateStreaks(habit, loggedAt);
|
const streaks = updateStreaks(habit, loggedAt);
|
||||||
|
const allLogsResult = await pb.collection('habit_logs').getFullList({ filter: `habit_id = "${habitId}"` });
|
||||||
// Get all logs for score calculation
|
|
||||||
const allLogsResult = await pb.collection('habit_logs').getFullList({
|
|
||||||
filter: `habit_id = "${habitId}"`,
|
|
||||||
});
|
|
||||||
const allLogs = allLogsResult.map((r) => asHabitLog(r as unknown as Record<string, unknown>));
|
const allLogs = allLogsResult.map((r) => asHabitLog(r as unknown as Record<string, unknown>));
|
||||||
|
const score = calculateHabitScore(habit, allLogs, (habit as any).score_config as HabitScoreConfig);
|
||||||
// Calculate new score
|
|
||||||
const score = calculateHabitScore(
|
|
||||||
habit,
|
|
||||||
allLogs,
|
|
||||||
habit.score_config as HabitScoreConfig
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update habit
|
|
||||||
const updatedHabit = asHabit(
|
const updatedHabit = asHabit(
|
||||||
await pb.collection('habits').update(habitId, {
|
await pb.collection('habits').update(habitId, {
|
||||||
current_streak: streaks.current_streak,
|
current_streak: streaks.current_streak,
|
||||||
best_streak: streaks.best_streak,
|
best_streak: streaks.best_streak,
|
||||||
total_completions: habit.total_completions + 1,
|
total_completions: (habit.total_completions || 0) + 1,
|
||||||
score,
|
|
||||||
}) as unknown as Record<string, unknown>
|
}) as unknown as Record<string, unknown>
|
||||||
);
|
);
|
||||||
|
|
||||||
return { log, habit: updatedHabit };
|
return { log, habit: updatedHabit };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a habit is due today
|
|
||||||
*/
|
|
||||||
export function isHabitDueToday(habit: Habit): boolean {
|
export function isHabitDueToday(habit: Habit): boolean {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const dayOfWeek = today.getDay(); // 0 = Sunday, 6 = Saturday
|
const dayOfWeek = today.getDay();
|
||||||
|
if ((habit as any).custom_days && (habit as any).custom_days.length > 0) {
|
||||||
// Check custom_days if set
|
return (habit as any).custom_days.includes(dayOfWeek);
|
||||||
if (habit.custom_days && habit.custom_days.length > 0) {
|
|
||||||
return habit.custom_days.includes(dayOfWeek);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check frequency
|
|
||||||
switch (habit.frequency) {
|
switch (habit.frequency) {
|
||||||
case 'daily':
|
case 'daily': return true;
|
||||||
return true;
|
|
||||||
case 'weekly': {
|
case 'weekly': {
|
||||||
// Due on the same day of week as start_date
|
const startDate = (habit as any).start_date ? new Date((habit as any).start_date) : new Date(habit.created);
|
||||||
const startDate = habit.start_date
|
|
||||||
? new Date(habit.start_date)
|
|
||||||
: new Date(habit.created);
|
|
||||||
return dayOfWeek === startDate.getDay();
|
return dayOfWeek === startDate.getDay();
|
||||||
}
|
}
|
||||||
case 'custom':
|
case 'custom': return true;
|
||||||
// Custom frequency without custom_days — assume always due
|
default: return true;
|
||||||
return true;
|
|
||||||
default:
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function getHabitsDueToday(token?: string): Promise<Habit[]> {
|
||||||
* Get habits due today
|
|
||||||
*/
|
|
||||||
export async function getHabitsDueToday(
|
|
||||||
token?: string
|
|
||||||
): Promise<Habit[]> {
|
|
||||||
const pb = token ? createPocketBaseClient(token) : createAdminClient();
|
const pb = token ? createPocketBaseClient(token) : createAdminClient();
|
||||||
|
const results = await pb.collection('habits').getFullList({ filter: "archived != true" });
|
||||||
const results = await pb.collection('habits').getFullList({
|
return results.map((r) => asHabit(r as unknown as Record<string, unknown>)).filter((habit) => isHabitDueToday(habit));
|
||||||
filter: 'active = true',
|
|
||||||
});
|
|
||||||
|
|
||||||
return results
|
|
||||||
.map((r) => asHabit(r as unknown as Record<string, unknown>))
|
|
||||||
.filter((habit) => isHabitDueToday(habit));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function getHabitStreaks(token?: string): Promise<Array<{ habit: Habit; current_streak: number; best_streak: number }>> {
|
||||||
* Get habit streaks summary
|
|
||||||
*/
|
|
||||||
export async function getHabitStreaks(
|
|
||||||
token?: string
|
|
||||||
): Promise<
|
|
||||||
Array<{
|
|
||||||
habit: Habit;
|
|
||||||
current_streak: number;
|
|
||||||
best_streak: number;
|
|
||||||
}>
|
|
||||||
> {
|
|
||||||
const pb = token ? createPocketBaseClient(token) : createAdminClient();
|
const pb = token ? createPocketBaseClient(token) : createAdminClient();
|
||||||
|
const results = await pb.collection('habits').getFullList({ filter: "archived != true" });
|
||||||
const results = await pb.collection('habits').getFullList({
|
|
||||||
filter: 'active = true',
|
|
||||||
});
|
|
||||||
|
|
||||||
const habits = results.map((r) => asHabit(r as unknown as Record<string, unknown>));
|
const habits = results.map((r) => asHabit(r as unknown as Record<string, unknown>));
|
||||||
|
|
||||||
return habits
|
return habits
|
||||||
.map((h) => ({
|
.map((h) => ({ habit: h, current_streak: h.current_streak || 0, best_streak: h.best_streak || 0 }))
|
||||||
habit: h,
|
|
||||||
current_streak: h.current_streak,
|
|
||||||
best_streak: h.best_streak,
|
|
||||||
}))
|
|
||||||
.sort((a, b) => b.current_streak - a.current_streak);
|
.sort((a, b) => b.current_streak - a.current_streak);
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user