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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user