add HabitService context class\

\
- Manages habit state across the application\
- Real-time subscriptions for habits and completions data with triplit\
- Contains CRUD and other operations for use by components
This commit is contained in:
hiperman
2026-02-25 01:11:31 -05:00
parent 781bf239b4
commit f0891779fd
+379
View File
@@ -0,0 +1,379 @@
import { getContext, onDestroy, setContext } from 'svelte';
import { SvelteMap } from 'svelte/reactivity';
import type { Habit, HabitCompletion } from '../triplit/schema';
import type { CreateHabitData, UpdateHabitData } from '../api/habits.triplit';
import * as habitApi from '../api/habits.triplit';
import { triplit } from '../triplit/client';
import { today, getLocalTimeZone, CalendarDate } from '@internationalized/date';
export type HabitWithCompletions = Habit & {
habitCompletions: HabitCompletion[];
};
export type HabitCell = {
value: number;
date: string;
span: number;
gridColumn: number;
isEmpty: boolean;
isFailed: boolean;
periodDates: string[];
isTruncatedLeft: boolean;
isTruncatedRight: boolean;
};
export class HabitService {
habits = $state<Habit[]>([]);
completionsMap = new SvelteMap<string, SvelteMap<string, HabitCompletion>>();
loadedRanges = new SvelteMap<string, { start: Date; end: Date }[]>();
private habitsUnsubscribe?: () => void;
private completionsUnsubscribe?: () => void;
get todaysDate() {
return today(getLocalTimeZone());
}
constructor(private userId: string) {
// Set up reactive subscriptions
this.setupSubscriptions();
// Setup cleanup for any resources if needed
onDestroy(() => {
// Cleanup subscriptions
this.habitsUnsubscribe?.();
this.completionsUnsubscribe?.();
// Cleanup any pending operations, timers, etc.
this.completionsMap.clear();
this.loadedRanges.clear();
});
}
private setupSubscriptions() {
// Subscribe to habits changes
this.habitsUnsubscribe = triplit.subscribe(
habitApi.getHabits(this.userId),
(results) => {
this.habits = Array.from(results);
},
(error) => {
console.error('Error in habits subscription:', error);
}
);
// Subscribe to all completions - this should catch all completion changes
this.completionsUnsubscribe = triplit.subscribe(
triplit.query('habit_completions'),
(results) => {
// Update completions map when completions change
this.updateCompletionsFromResults(Array.from(results));
},
(error) => {
console.error('Error in completions subscription:', error);
}
);
}
private updateCompletionsFromResults(completions: HabitCompletion[]) {
// Clear and rebuild completions map
this.completionsMap.clear();
for (const completion of completions) {
// Only include completions for habits belonging to this user
const habit = this.habits.find(h => h.id === completion.habitId);
if (!habit) continue;
if (!this.completionsMap.has(completion.habitId)) {
this.completionsMap.set(completion.habitId, new SvelteMap<string, HabitCompletion>());
}
const dateKey = this.getDateKey(completion.completedAt);
this.completionsMap.get(completion.habitId)!.set(dateKey, completion);
}
}
async addHabit(data: CreateHabitData) {
try {
return await habitApi.createHabit({ ...data, userId: this.userId });
} catch (err) {
console.error('Failed to create habit:', err);
throw err;
}
}
async removeHabit(id: string) {
try {
await habitApi.deleteHabit(id);
// Clean up local caches for this habit
this.completionsMap.delete(id);
this.loadedRanges.delete(id);
} catch (err) {
console.error('Failed to delete habit:', err);
throw err;
}
}
async updateHabit(id: string, data: UpdateHabitData) {
try {
const habit = this.habits.find(h => h.id === id);
const habitName = habit?.name || 'Unknown habit';
console.log('Updating habit:', { id, data, habitName });
const result = await habitApi.updateHabit(id, data);
// Log specific actions based on what was updated
if (data.active !== undefined) {
console.log(`Habit "${habitName}" ${data.active ? 'activated' : 'archived'}`);
}
if (data.name) {
console.log(`Habit "${habitName}" renamed to "${data.name}"`);
}
console.log('Habit update successful');
return result;
} catch (err) {
console.error('Failed to update habit:', err);
throw err;
}
}
async listHabits() {
try {
const habits = await triplit.fetch(habitApi.getHabits(this.userId));
this.habits = habits;
return habits;
} catch (err) {
console.error('Failed to load habits:', err);
throw err;
}
}
async listHabitsWithCompletions(startDate: Date, endDate: Date) {
try {
const habits = await triplit.fetch(habitApi.getHabits(this.userId));
this.habits = habits;
for (const habit of habits) {
await this.loadCompletions(habit.id, startDate, endDate);
}
return habits;
} catch (err) {
console.error('Failed to load habits with completions:', err);
throw err;
}
}
async loadMoreCompletions(habitId: string, startDate: Date, endDate: Date) {
return this.loadCompletions(habitId, startDate, endDate);
}
private async loadCompletions(habitId: string, startDate: Date, endDate: Date) {
const completions = await triplit.fetch(
habitApi.getCompletions(habitId, startDate, endDate)
);
this.mergeCompletions(habitId, completions);
this.updateLoadedRange(habitId, startDate, endDate);
}
private mergeCompletions(habitId: string, newCompletions: HabitCompletion[]) {
if (!this.completionsMap.has(habitId)) {
this.completionsMap.set(habitId, new SvelteMap<string, HabitCompletion>());
}
const habitCompletions = this.completionsMap.get(habitId)!;
for (const completion of newCompletions) {
const dateKey = this.getDateKey(completion.completedAt);
habitCompletions.set(dateKey, completion);
}
}
private updateLoadedRange(habitId: string, startDate: Date, endDate: Date) {
if (!this.loadedRanges.has(habitId)) {
this.loadedRanges.set(habitId, []);
}
this.loadedRanges.get(habitId)!.push({ start: startDate, end: endDate });
}
private getDateKey(date: Date): string {
return date.toISOString().split('T')[0];
}
async upsertCompletion(habitId: string, date: Date, value: number, failed: boolean = false) {
try {
const completion = await habitApi.upsertCompletion(habitId, date, value, failed);
if (completion) {
const dateKey = this.getDateKey(date);
if (!this.completionsMap.has(habitId)) {
this.completionsMap.set(habitId, new SvelteMap<string, HabitCompletion>());
}
this.completionsMap.get(habitId)!.set(dateKey, completion);
}
return completion;
} catch (err) {
console.error('Failed to save completion:', err);
throw err;
}
}
async resetPeriod(habitId: string, startDate: Date, endDate: Date) {
try {
return await habitApi.resetCompletions(habitId, startDate, endDate);
} catch (err) {
console.error('Failed to reset period:', err);
throw err;
}
}
getCompletionsForHabit(habitId: string): SvelteMap<string, HabitCompletion> {
return this.completionsMap.get(habitId) || new SvelteMap<string, HabitCompletion>();
}
getCompletionForDate(habitId: string, date: Date): HabitCompletion | null {
const dateKey = this.getDateKey(date);
return this.completionsMap.get(habitId)?.get(dateKey) || null;
}
toHabitWithCompletions(habit: Habit): HabitWithCompletions {
const completions = Array.from(this.getCompletionsForHabit(habit.id).values());
return {
...habit,
habitCompletions: completions
};
}
async checkAndLoadMoreData(viewStart: CalendarDate, viewEnd: CalendarDate) {
const startDate = viewStart.toDate(getLocalTimeZone());
const endDate = viewEnd.toDate(getLocalTimeZone());
for (const habit of this.habits) {
await this.loadCompletions(habit.id, startDate, endDate);
}
}
async setCompletionValue(habitId: string, value: number, dateStr: string): Promise<boolean> {
const date = new Date(dateStr + 'T00:00:00.000Z');
console.log('Setting habit completion:', { habitId, value, date: dateStr });
// Check current completion value to avoid unnecessary database calls
const currentCompletion = this.getCompletionForDate(habitId, date);
const currentValue = currentCompletion?.value || 0;
// Skip database call if trying to set same value
if (currentValue === value) {
console.log('Database update skipped (no change needed)');
return false; // No database update performed
}
try {
await this.upsertCompletion(habitId, date, value);
console.log('Database update successful');
return true; // Database update performed
} catch (error) {
console.error('Database update failed:', error);
throw error; // Re-throw so caller can handle if needed
}
}
async resetPeriodByDates(habitId: string, periodDates: string[]) {
const startDate = new Date(periodDates[0] + 'T00:00:00.000Z');
const endDate = new Date(periodDates[periodDates.length - 1] + 'T23:59:59.999Z');
return this.resetPeriod(habitId, startDate, endDate);
}
async setPeriodValue(habitId: string, periodDates: string[], totalValue: number, dailyValues?: Record<string, number> | number[]) {
if (!dailyValues) {
// If no daily values provided, distribute total value across the last date
if (periodDates.length > 0 && totalValue > 0) {
const lastDate = new Date(periodDates[periodDates.length - 1] + 'T00:00:00.000Z');
await this.upsertCompletion(habitId, lastDate, totalValue);
}
return;
}
if (Array.isArray(dailyValues)) {
// Handle array format (legacy)
for (let i = 0; i < periodDates.length && i < dailyValues.length; i++) {
const date = new Date(periodDates[i] + 'T00:00:00.000Z');
await this.upsertCompletion(habitId, date, dailyValues[i]);
}
} else {
// Handle Record<string, number> format
for (const [dateStr, value] of Object.entries(dailyValues)) {
if (value > 0) {
const date = new Date(dateStr + 'T00:00:00.000Z');
await this.upsertCompletion(habitId, date, value);
}
}
}
}
async togglePeriodFailed(habitId: string, periodDates: string[], currentFailed: boolean) {
for (const dateStr of periodDates) {
const date = new Date(dateStr + 'T00:00:00.000Z');
// Get the current completion to preserve the existing value
const currentCompletion = this.getCompletionForDate(habitId, date);
const currentValue = currentCompletion?.value || 0;
await this.upsertCompletion(habitId, date, currentValue, !currentFailed);
}
}
playCompletionSound() {
// Placeholder for sound functionality
}
async createHabit(data: any) {
return this.addHabit(data);
}
async deleteHabit(id: string) {
return this.removeHabit(id);
}
updateHabits(habits: HabitWithCompletions[]) {
this.habits = habits.map(h => ({
id: h.id,
userId: h.userId,
name: h.name,
duration: h.duration,
target: h.target,
increment: h.increment,
unit: h.unit,
active: h.active,
startDate: h.startDate,
endDate: h.endDate,
createdAt: h.createdAt,
updatedAt: h.updatedAt
}));
habits.forEach(habit => {
const completionsMap = new SvelteMap<string, HabitCompletion>();
habit.habitCompletions.forEach(completion => {
const dateKey = this.getDateKey(completion.completedAt);
completionsMap.set(dateKey, completion);
});
this.completionsMap.set(habit.id, completionsMap);
});
}
}
const HABIT_SERVICE_KEY = Symbol('HABIT_SERVICE');
export function setHabitService(userId: string) {
return setContext(HABIT_SERVICE_KEY, new HabitService(userId));
}
export function getHabitService() {
return getContext<ReturnType<typeof setHabitService>>(HABIT_SERVICE_KEY);
}