From f0891779fd30d594e70ac87f6a6d0b4deb57cda3 Mon Sep 17 00:00:00 2001 From: hiperman Date: Wed, 25 Feb 2026 01:11:31 -0500 Subject: [PATCH] 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 --- src/lib/context/habits.svelte.ts | 379 +++++++++++++++++++++++++++++++ 1 file changed, 379 insertions(+) create mode 100644 src/lib/context/habits.svelte.ts diff --git a/src/lib/context/habits.svelte.ts b/src/lib/context/habits.svelte.ts new file mode 100644 index 0000000..685dcc4 --- /dev/null +++ b/src/lib/context/habits.svelte.ts @@ -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([]); + completionsMap = new SvelteMap>(); + loadedRanges = new SvelteMap(); + + 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()); + } + + 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()); + } + + 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()); + } + 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 { + return this.completionsMap.get(habitId) || new SvelteMap(); + } + + 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 { + 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 | 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 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(); + 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>(HABIT_SERVICE_KEY); +} \ No newline at end of file