diff --git a/src/components/occurrence/OccurrenceChip.tsx b/src/components/occurrence/OccurrenceChip.tsx index c0d89ec..60039dc 100644 --- a/src/components/occurrence/OccurrenceChip.tsx +++ b/src/components/occurrence/OccurrenceChip.tsx @@ -7,7 +7,7 @@ import { useScreenWidth, useKeyboardShortcut } from '@hooks'; import type { Occurrence } from '@models'; import { StorageBuckets } from '@models'; import { getPublicUrl } from '@services'; -import { useOccurrenceDrawerActions } from '@stores'; +import { useNotesByOccurrenceId, useOccurrenceDrawerActions } from '@stores'; export type OccurrenceChipProps = { colorOverride?: string; @@ -28,6 +28,7 @@ const OccurrenceChip = ({ isHabitNameShown = false, occurrences, }: OccurrenceChipProps) => { + const notes = useNotesByOccurrenceId(); const { openOccurrenceDrawer } = useOccurrenceDrawerActions(); const [occurrence] = occurrences; const { habit, habitId } = occurrence; @@ -110,7 +111,7 @@ const OccurrenceChip = ({ if ( occurrences.some((o) => { - return o.note; + return !!notes[o.id]; }) ) { chip = ( diff --git a/src/components/occurrence/OccurrenceForm.tsx b/src/components/occurrence/OccurrenceForm.tsx index 2248320..cc22ce4 100644 --- a/src/components/occurrence/OccurrenceForm.tsx +++ b/src/components/occurrence/OccurrenceForm.tsx @@ -43,6 +43,7 @@ import { useHabits, useNoteActions, useOccurrenceActions, + useNotesByOccurrenceId, useOccurrenceDrawerState, useOccurrenceDrawerActions, } from '@stores'; @@ -68,10 +69,10 @@ const OccurrenceForm = ({ const { dayToLog, isOpen, occurrenceToEdit } = useOccurrenceDrawerState(); const { user } = useUser(); const habits = useHabits(); + const notes = useNotesByOccurrenceId(); const [isSaving, setIsSaving] = React.useState(false); const { addNote, deleteNote, updateNote } = useNoteActions(); - const { addOccurrence, setOccurrenceNote, updateOccurrence } = - useOccurrenceActions(); + const { addOccurrence, updateOccurrence } = useOccurrenceActions(); const [note, handleNoteChange, clearNote] = useTextField(); const [repeat, setRepeat] = React.useState(1); const [selectedHabitId, setSelectedHabitId] = React.useState(''); @@ -85,6 +86,10 @@ const OccurrenceForm = ({ React.useState(null); const { isDesktop, isMobile } = useScreenWidth(); + const occurrenceNote = React.useMemo(() => { + return notes[occurrenceToEdit?.id || '']; + }, [notes, occurrenceToEdit]); + const computeOccurrenceDateTime = React.useCallback( (tz = timeZone) => { const baseNow = now(tz); @@ -160,7 +165,7 @@ const OccurrenceForm = ({ if (isOpen && occurrenceToEdit) { setSelectedHabitId(occurrenceToEdit.habitId.toString()); - handleNoteChange(occurrenceToEdit.note?.content || ''); + handleNoteChange(occurrenceNote?.content || ''); setTime(existingOccurrenceDateTime); setHasSpecificTime(occurrenceToEdit.hasSpecificTime); @@ -176,6 +181,7 @@ const OccurrenceForm = ({ } }, [ dayToLog, + occurrenceNote?.content, occurrenceToEdit, existingOccurrenceDateTime, isOpen, @@ -198,7 +204,7 @@ const OccurrenceForm = ({ const hasTimeChanged = time instanceof ZonedDateTime && !!time.compare(occurrenceToEdit.occurredAt); - const hasNoteChanged = note !== (occurrenceToEdit.note?.content || ''); + const hasNoteChanged = note !== (occurrenceNote?.content || ''); const hasHabitChanged = selectedHabitId !== occurrenceToEdit.habitId.toString(); const hasSpecificTimeChanged = @@ -220,6 +226,7 @@ const OccurrenceForm = ({ }, [ dayToLog, occurrenceToEdit, + occurrenceNote?.content, uploadedFiles.length, note, selectedHabitId, @@ -300,29 +307,20 @@ const OccurrenceForm = ({ }); if (note) { - let newNote; - - if (occurrenceToEdit.note) { - newNote = await updateNote(occurrenceToEdit.note.id, { + if (occurrenceNote) { + await updateNote(occurrenceNote.id, { content: note, occurrenceId: occurrenceToEdit.id, }); } else { - newNote = await addNote({ + await addNote({ content: note, occurrenceId: occurrenceToEdit.id, userId: user.id, }); } - - setOccurrenceNote(occurrenceToEdit, { - content: newNote.content, - id: newNote.id, - }); - } else if (occurrenceToEdit.note) { - await deleteNote(occurrenceToEdit.note.id); - - setOccurrenceNote(occurrenceToEdit, null); + } else if (occurrenceNote) { + await deleteNote(occurrenceNote.id); } }; @@ -355,16 +353,11 @@ const OccurrenceForm = ({ }); if (note) { - const newNote = await addNote({ + await addNote({ content: note, occurrenceId: newOccurrence.id, userId: user.id, }); - - setOccurrenceNote(newOccurrence, { - content: newNote.content, - id: newNote.id, - }); } }); diff --git a/src/components/occurrence/OccurrenceListItem.tsx b/src/components/occurrence/OccurrenceListItem.tsx index d7e9965..3f7f828 100644 --- a/src/components/occurrence/OccurrenceListItem.tsx +++ b/src/components/occurrence/OccurrenceListItem.tsx @@ -5,6 +5,7 @@ import React from 'react'; import { useDateFormatter } from 'react-aria'; import type { Occurrence } from '@models'; +import { useNotesByOccurrenceId } from '@stores'; import OccurrenceChip from './OccurrenceChip'; @@ -23,18 +24,23 @@ const OccurrenceListItem = ({ onEdit, onRemove, }: OccurrenceListItemProps) => { + const notes = useNotesByOccurrenceId(); const timeFormatter = useDateFormatter({ hour: 'numeric', minute: 'numeric', timeZone: getLocalTimeZone(), }); + const occurrenceNote = React.useMemo(() => { + return notes[occurrence.id]; + }, [notes, occurrence]); + return (
  • @@ -53,12 +59,12 @@ const OccurrenceListItem = ({ - {occurrence.note?.content || '(no note)'} + {occurrenceNote?.content || '(no note)'} )} @@ -79,9 +85,9 @@ const OccurrenceListItem = ({ )}
    - {occurrence.note && ( + {occurrenceNote && (
    - {occurrence.note.content} + {occurrenceNote.content}
    )} diff --git a/src/hooks/use-session.ts b/src/hooks/use-session.ts index 2143c88..fd2f0a5 100644 --- a/src/hooks/use-session.ts +++ b/src/hooks/use-session.ts @@ -32,7 +32,12 @@ const useSession = () => { 'USER_UPDATED', ].includes(event) ) { - setUser(camelcaseKeys(session.user, { deep: true })); + setUser( + camelcaseKeys( + { ...session.user, fetchedAt: new Date().toISOString() }, + { deep: true } + ) + ); } if (event === 'SIGNED_OUT') { diff --git a/src/models/note.model.ts b/src/models/note.model.ts index 39147a9..e939aff 100644 --- a/src/models/note.model.ts +++ b/src/models/note.model.ts @@ -8,10 +8,11 @@ import type { Tables, TablesInsert, TablesUpdate } from '@db-types'; import type { Habit } from './habit.model'; -type NoteOfOccurrence> = Tables<'notes'>> = - CamelCasedPropertiesDeep< - Omit, 'period_date' | 'period_kind'> - >; +export type NoteOfOccurrence< + T extends Partial> = Tables<'notes'>, +> = CamelCasedPropertiesDeep< + Omit, 'period_date' | 'period_kind'> +>; export type NoteOfPeriod> = Tables<'notes'>> = CamelCasedPropertiesDeep< diff --git a/src/models/occurrence.model.ts b/src/models/occurrence.model.ts index c14915b..209a698 100644 --- a/src/models/occurrence.model.ts +++ b/src/models/occurrence.model.ts @@ -7,7 +7,6 @@ import type { TablesUpdate, CompositeTypes, } from '@db-types'; -import type { Note } from '@models'; import { type Habit } from './habit.model'; import { type Trait } from './trait.model'; @@ -24,8 +23,6 @@ type HabitWithTrait = OccurrenceHabit & { export type RawOccurrence = BaseOccurrence & { habit: HabitWithTrait; -} & { - note: Pick | null; }; export type Occurrence = Omit< diff --git a/src/services/notes.service.ts b/src/services/notes.service.ts index 62da146..f5d3aa7 100644 --- a/src/services/notes.service.ts +++ b/src/services/notes.service.ts @@ -2,6 +2,7 @@ import type { CalendarDate } from '@internationalized/date'; import camelcaseKeys from 'camelcase-keys'; import decamelizeKeys from 'decamelize-keys'; +import type { Tables } from '@db-types'; import type { Note, NotesUpdate, NotesInsert, NoteWithHabit } from '@models'; import { supabaseClient } from '@utils'; @@ -19,22 +20,46 @@ export const createNote = async (note: NotesInsert): Promise => { return camelcaseKeys(data); }; -export const listPeriodNotes = async ([rangeStart, rangeEnd]: [ +export const listNotes = async ([rangeStart, rangeEnd]: [ CalendarDate, CalendarDate, ]): Promise => { - const { data, error } = await supabaseClient + const startDateString = rangeStart.toString(); + const endDateString = rangeEnd.toString(); + + const periodNotesPromise = supabaseClient .from('notes') .select() .in('period_kind', ['day', 'week', 'month']) - .gte('period_date', rangeStart.toString()) - .lte('period_date', rangeEnd.toString()); + .gte('period_date', startDateString) + .lte('period_date', endDateString); - if (error) { - throw new Error(error.message); + const occurrenceNotesPromise = supabaseClient + .from('notes') + .select('*, occurrence:occurrences!inner(occurred_at)') + .gte('occurrences.occurred_at', startDateString) + .lte('occurrences.occurred_at', endDateString); + + const [periodResult, occurrenceResult] = await Promise.all([ + periodNotesPromise, + occurrenceNotesPromise, + ]); + + if (periodResult.error) { + throw new Error(periodResult.error.message); } - return camelcaseKeys(data); + if (occurrenceResult.error) { + throw new Error(occurrenceResult.error.message); + } + + const notesMap = new Map>(); + + for (const note of [...periodResult.data, ...occurrenceResult.data]) { + notesMap.set(note.id, note); + } + + return camelcaseKeys([...notesMap.values()], { deep: true }); }; export const updateNote = async ( diff --git a/src/services/occurrences.service.ts b/src/services/occurrences.service.ts index 7d89aee..048fed4 100644 --- a/src/services/occurrences.service.ts +++ b/src/services/occurrences.service.ts @@ -18,9 +18,7 @@ export const createOccurrence = async (occurrence: OccurrencesInsert) => { const { data, error } = await supabaseClient .from('occurrences') .insert(decamelizeKeys(occurrence)) - .select( - '*, habit:habits(name, icon_path, trait:traits(id, name, color)), note:notes(id, content)' - ) + .select('*, habit:habits(name, icon_path, trait:traits(id, name, color))') .single(); if (error) { @@ -36,9 +34,7 @@ export const listOccurrences = async ([rangeStart, rangeEnd]: [ ]): Promise => { const { data, error } = await supabaseClient .from('occurrences') - .select( - '*, habit:habits(name, icon_path, trait:traits(id, name, color)), note:notes(id, content)' - ) + .select('*, habit:habits(name, icon_path, trait:traits(id, name, color))') .order('occurred_at') .gt('occurred_at', rangeStart.toAbsoluteString()) .lt('occurred_at', rangeEnd.toAbsoluteString()); @@ -58,9 +54,7 @@ export const patchOccurrence = async ( .from('occurrences') .update(decamelizeKeys(occurrence)) .eq('id', id) - .select( - '*, habit:habits(name, icon_path, trait:traits(id, name, color)), note:notes(id, content)' - ) + .select('*, habit:habits(name, icon_path, trait:traits(id, name, color))') .single(); if (error) { diff --git a/src/services/user.service.ts b/src/services/user.service.ts index 9d7c298..b4d933c 100644 --- a/src/services/user.service.ts +++ b/src/services/user.service.ts @@ -74,5 +74,8 @@ export const getSession = async () => { return null; } - return camelcaseKeys(session.user, { deep: true }); + return camelcaseKeys( + { ...session.user, fetchedAt: new Date().toISOString() }, + { deep: true } + ); }; diff --git a/src/stores/notes.store.ts b/src/stores/notes.store.ts index 96c79ee..4adbd62 100644 --- a/src/stores/notes.store.ts +++ b/src/stores/notes.store.ts @@ -2,19 +2,15 @@ import { toCalendarDate } from '@internationalized/date'; import keyBy from 'lodash.keyby'; import { useShallow } from 'zustand/react/shallow'; -import type { Note, NotesInsert, NotesUpdate } from '@models'; -import { - createNote, - updateNote, - destroyNote, - listPeriodNotes, -} from '@services'; -import { isNoteOfPeriod } from '@utils'; +import type { Note, Occurrence, NotesInsert, NotesUpdate } from '@models'; +import { listNotes, createNote, updateNote, destroyNote } from '@services'; +import { isNoteOfPeriod, isNoteOfOccurrence } from '@utils'; import { useBoundStore, type SliceCreator } from './bound.store'; export type NotesSlice = { notes: Record; + notesByOccurrenceId: Record; noteActions: { addNote: (note: NotesInsert) => Promise; clearNotes: () => void; @@ -30,6 +26,7 @@ export const createNotesSlice: SliceCreator = ( ) => { return { notes: {}, + notesByOccurrenceId: {}, noteActions: { addNote: async (note: NotesInsert) => { @@ -37,6 +34,10 @@ export const createNotesSlice: SliceCreator = ( set((state) => { state.notes[newNote.id] = newNote; + + if ('occurrenceId' in newNote && newNote.occurrenceId) { + state.notesByOccurrenceId[newNote.occurrenceId] = newNote; + } }); return newNote; @@ -52,7 +53,12 @@ export const createNotesSlice: SliceCreator = ( await destroyNote(id); set((state) => { + const noteToDelete = state.notes[id]; delete state.notes[id]; + + if ('occurrenceId' in noteToDelete && noteToDelete?.occurrenceId) { + delete state.notesByOccurrenceId[noteToDelete.occurrenceId]; + } }); }, @@ -66,13 +72,17 @@ export const createNotesSlice: SliceCreator = ( return; } - const notes = await listPeriodNotes([ + const notes = await listNotes([ toCalendarDate(rangeStart), toCalendarDate(rangeEnd), ]); set((state) => { state.notes = keyBy(notes, 'id'); + state.notesByOccurrenceId = keyBy( + notes.filter(isNoteOfOccurrence), + 'occurrenceId' + ); }); }, @@ -81,6 +91,10 @@ export const createNotesSlice: SliceCreator = ( set((state) => { state.notes[id] = updatedNote; + + if ('occurrenceId' in updatedNote && updatedNote.occurrenceId) { + state.notesByOccurrenceId[updatedNote.occurrenceId] = updatedNote; + } }); return updatedNote; @@ -97,6 +111,14 @@ export const useNotes = () => { ); }; +export const useNotesByOccurrenceId = () => { + return useBoundStore( + useShallow((state) => { + return state.notesByOccurrenceId; + }) + ); +}; + export const usePeriodNotes = () => { return useNotes().filter(isNoteOfPeriod); }; diff --git a/src/stores/occurrences.store.ts b/src/stores/occurrences.store.ts index 4028400..718c79d 100644 --- a/src/stores/occurrences.store.ts +++ b/src/stores/occurrences.store.ts @@ -7,7 +7,6 @@ import { import { useShallow } from 'zustand/react/shallow'; import type { - Note, Occurrence, RawOccurrence, OccurrencesInsert, @@ -23,16 +22,13 @@ import { import { useBoundStore, type SliceCreator } from './bound.store'; export type OccurrencesSlice = { - occurrences: Record>; + occurrences: Occurrence[]; + occurrencesByDate: Record>; occurrencesActions: { addOccurrence: (occurrence: OccurrencesInsert) => Promise; clearOccurrences: () => void; fetchOccurrences: () => Promise; removeOccurrence: (occurrence: Occurrence) => Promise; - setOccurrenceNote: ( - occurrence: Occurrence, - note: Pick | null - ) => void; updateOccurrence: ( occurrence: Occurrence, body: OccurrencesUpdate @@ -56,7 +52,8 @@ export const createOccurrencesSlice: SliceCreator = ( getState ) => { return { - occurrences: {}, + occurrences: [], + occurrencesByDate: {}, occurrencesActions: { addOccurrence: async (occurrence) => { @@ -65,12 +62,16 @@ export const createOccurrencesSlice: SliceCreator = ( const clientOccurrence = toClientOccurrence(nextOccurrence); set((state) => { - const dateKey = occurrence.occurredAt.split('T')[0]; - - if (state.occurrences[dateKey]) { - state.occurrences[dateKey][nextOccurrence.id] = clientOccurrence; + state.occurrences.push(clientOccurrence); + const dateKey = toCalendarDate( + clientOccurrence.occurredAt + ).toString(); + + if (state.occurrencesByDate[dateKey]) { + state.occurrencesByDate[dateKey][nextOccurrence.id] = + clientOccurrence; } else { - state.occurrences[dateKey] = { + state.occurrencesByDate[dateKey] = { [nextOccurrence.id]: clientOccurrence, }; } @@ -81,7 +82,8 @@ export const createOccurrencesSlice: SliceCreator = ( clearOccurrences: () => { set((state) => { - state.occurrences = {}; + state.occurrences = []; + state.occurrencesByDate = {}; }); }, @@ -100,6 +102,8 @@ export const createOccurrencesSlice: SliceCreator = ( toZoned(rangeEnd, getLocalTimeZone()), ]); + const clientOccurrences = occurrences.map(toClientOccurrence); + const occurrencesByDate: Record< string, Record @@ -113,21 +117,17 @@ export const createOccurrencesSlice: SliceCreator = ( currentDate = currentDate.add({ days: 1 }); } - occurrences.forEach((occurrence) => { - const occurredAt = parseAbsolute( - occurrence.occurredAt, - occurrence.timeZone - ); - const dateKey = toCalendarDate(occurredAt).toString(); + clientOccurrences.forEach((occurrence) => { + const dateKey = toCalendarDate(occurrence.occurredAt).toString(); if (occurrencesByDate[dateKey]) { - occurrencesByDate[dateKey][occurrence.id] = - toClientOccurrence(occurrence); + occurrencesByDate[dateKey][occurrence.id] = occurrence; } }); set((state) => { - state.occurrences = occurrencesByDate; + state.occurrences = clientOccurrences; + state.occurrencesByDate = occurrencesByDate; }); }, @@ -135,29 +135,37 @@ export const createOccurrencesSlice: SliceCreator = ( await destroyOccurrence({ id, photoPaths }); set((state) => { - delete state.occurrences[toCalendarDate(occurredAt).toString()]?.[id]; - }); - }, - - setOccurrenceNote: ({ id, occurredAt }, note) => { - set((state) => { - const occurrence = - state.occurrences[toCalendarDate(occurredAt).toString()]?.[id]; - - if (!occurrence) { - return; - } - - occurrence.note = note; + delete state.occurrencesByDate[ + toCalendarDate(occurredAt).toString() + ]?.[id]; + state.occurrences = state.occurrences.filter((occ) => { + return occ.id !== id; + }); }); }, updateOccurrence: async ({ id, occurredAt }, body) => { const updatedOccurrence = await patchOccurrence(id, body); + const updatedClientOccurrence = toClientOccurrence(updatedOccurrence); set((state) => { - state.occurrences[toCalendarDate(occurredAt).toString()][id] = - toClientOccurrence(updatedOccurrence); + state.occurrences = state.occurrences.map((occurrence) => { + return occurrence.id === id ? updatedClientOccurrence : occurrence; + }); + const prevDateKey = toCalendarDate(occurredAt).toString(); + const nextDateKey = toCalendarDate( + updatedClientOccurrence.occurredAt + ).toString(); + + if (state.occurrencesByDate[prevDateKey]) { + delete state.occurrencesByDate[prevDateKey][id]; + } + + if (!state.occurrencesByDate[nextDateKey]) { + state.occurrencesByDate[nextDateKey] = {}; + } + + state.occurrencesByDate[nextDateKey][id] = updatedClientOccurrence; }); }, }, @@ -167,7 +175,7 @@ export const createOccurrencesSlice: SliceCreator = ( export const useOccurrences = () => { return useBoundStore( useShallow((state) => { - return state.occurrences; + return state.occurrencesByDate; }) ); }; diff --git a/src/stores/user.store.ts b/src/stores/user.store.ts index 1148deb..525c227 100644 --- a/src/stores/user.store.ts +++ b/src/stores/user.store.ts @@ -1,4 +1,7 @@ -import type { User, UserAttributes } from '@supabase/supabase-js'; +import type { + UserAttributes, + User as SupabaseUser, +} from '@supabase/supabase-js'; import type { CamelCasedPropertiesDeep } from 'type-fest'; import { useShallow } from 'zustand/react/shallow'; @@ -6,6 +9,10 @@ import { updateUser } from '@services'; import { useBoundStore, type SliceCreator } from './bound.store'; +type User = SupabaseUser & { + fetchedAt: string; +}; + export type UserSlice = { user: null | CamelCasedPropertiesDeep; actions: { @@ -65,13 +72,18 @@ export const createUserSlice: SliceCreator = ( userAttributes.data = userMetadata; } - const updatedUser = await updateUser(userAttributes); + const updatedSupabaseUser = await updateUser(userAttributes); + + const newUser = { + ...updatedSupabaseUser, + fetchedAt: new Date().toISOString(), + }; set((state) => { - state.user = { ...state.user, ...updatedUser }; + state.user = newUser; }); - return updatedUser; + return newUser; }, }, }; diff --git a/src/utils/type-guards.ts b/src/utils/type-guards.ts index ccc92cd..e083ec4 100644 --- a/src/utils/type-guards.ts +++ b/src/utils/type-guards.ts @@ -4,6 +4,7 @@ import type { NoteOfPeriod, UploadResult, SuccessfulUpload, + NoteOfOccurrence, } from '@models'; export const isFulfilled = ( @@ -26,6 +27,10 @@ export const isNoteOfPeriod = (input: Note): input is NoteOfPeriod => { return 'periodKind' in input && 'periodDate' in input; }; +export const isNoteOfOccurrence = (input: Note): input is NoteOfOccurrence => { + return 'occurrenceId' in input && input.occurrenceId !== null; +}; + export const isFailedUpload = ( input: PromiseFulfilledResult ): input is PromiseFulfilledResult => { diff --git a/tests/makeTestOccurrence.ts b/tests/makeTestOccurrence.ts index d7a8101..7d22c7b 100644 --- a/tests/makeTestOccurrence.ts +++ b/tests/makeTestOccurrence.ts @@ -8,7 +8,6 @@ const makeTestOccurrence = (override: Partial = {}): Occurrence => { habitId: crypto.randomUUID(), hasSpecificTime: true, id: crypto.randomUUID(), - note: null, occurredAt: now('Europe/Madrid'), photoPaths: [], timeZone: 'Europe/Madrid',