fix: calendar, habits, analytics PB 0.25 compat (#6)

This commit is contained in:
2026-07-19 19:39:41 -04:00
2 changed files with 29 additions and 151 deletions
+3 -3
View File
@@ -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) {
+26 -148
View File
@@ -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);
} }