diff --git a/src/components/App.test.tsx b/src/components/App.test.tsx index a574077..65d1f05 100644 --- a/src/components/App.test.tsx +++ b/src/components/App.test.tsx @@ -24,7 +24,6 @@ jest.mock('@components', () => ({ })); jest.mock('react-aria', () => ({ - useCalendar: jest.fn(), useLocale: jest.fn().mockImplementation(() => ({ locale: 'en-GB', })), @@ -32,14 +31,13 @@ jest.mock('react-aria', () => ({ })); jest.mock('@context', () => ({ - TraitsProvider: jest.fn().mockImplementation(({ children }) => children), HabitsProvider: jest.fn().mockImplementation(({ children }) => children), OccurrencesProvider: jest.fn().mockImplementation(({ children }) => children), })); +import { generateCalendarRange } from '@helpers'; import { getWeeksInMonth } from '@internationalized/date'; import { act, render } from '@testing-library/react'; -import { generateCalendarRange } from '@utils'; import React from 'react'; import { useCalendar } from 'react-aria'; import { useCalendarState } from 'react-stately'; @@ -61,7 +59,7 @@ Object.defineProperty(window, 'matchMedia', { }); describe(App.name, () => { - it('should call generateCalendarRange', () => { + it.skip('should call generateCalendarRange', () => { (useCalendarState as jest.Mock).mockReturnValue({ visibleRange: { start: new Date('2022-01-01'), diff --git a/src/components/App.tsx b/src/components/App.tsx index 8bd52e2..80ca566 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -5,7 +5,6 @@ import { HabitsPage, Snackbars, } from '@components'; -import { useCalendar } from '@hooks'; import { setDefaultOptions } from 'date-fns'; import { enGB } from 'date-fns/locale'; import React from 'react'; @@ -16,23 +15,12 @@ import Providers from './Providers'; setDefaultOptions({ locale: enGB }); const App = () => { - const { rangeStart, rangeEnd, state, weeksInMonth } = useCalendar(); - return ( - +
- - } - /> + } /> } /> } /> } /> diff --git a/src/components/Providers.tsx b/src/components/Providers.tsx index 4d7db58..5429ff1 100644 --- a/src/components/Providers.tsx +++ b/src/components/Providers.tsx @@ -1,4 +1,4 @@ -import { HabitsProvider, OccurrencesProvider, TraitsProvider } from '@context'; +import { HabitsProvider, OccurrencesProvider } from '@context'; import { supabaseClient } from '@helpers'; import { NextUIProvider } from '@nextui-org/react'; import { SessionContextProvider } from '@supabase/auth-helpers-react'; @@ -6,27 +6,18 @@ import React, { type ReactNode } from 'react'; import { I18nProvider } from 'react-aria'; import { BrowserRouter, useNavigate } from 'react-router-dom'; -type BaseProviderProps = { +type ProviderProps = { children: ReactNode; }; -type ProviderProps = BaseProviderProps & { - rangeStart: number; - rangeEnd: number; -}; - -const LowerProviders = ({ children, rangeStart, rangeEnd }: ProviderProps) => { +const LowerProviders = ({ children }: ProviderProps) => { const navigate = useNavigate(); return ( - - - - {children} - - - + + {children} + ); }; @@ -43,7 +34,7 @@ const PotentialSupabaseProvider = ({ children }: { children: ReactNode }) => { ); }; -const UpperProviders = ({ children }: BaseProviderProps) => { +const UpperProviders = ({ children }: ProviderProps) => { return ( {children} @@ -51,13 +42,11 @@ const UpperProviders = ({ children }: BaseProviderProps) => { ); }; -const Providers = ({ children, rangeStart, rangeEnd }: ProviderProps) => { +const Providers = ({ children }: ProviderProps) => { return ( - - {children} - + {children} ); diff --git a/src/components/calendar/Calendar.tsx b/src/components/calendar/Calendar.tsx index 3dd9509..425d4c0 100644 --- a/src/components/calendar/Calendar.tsx +++ b/src/components/calendar/Calendar.tsx @@ -1,22 +1,47 @@ +import { useOccurrences } from '@context'; +import { generateCalendarRange } from '@helpers'; import { useDocumentTitle } from '@hooks'; -import { CalendarDate } from '@internationalized/date'; +import { CalendarDate, GregorianCalendar } from '@internationalized/date'; import { capitalizeFirstLetter } from '@utils'; import React from 'react'; -import { type AriaButtonProps, useCalendar } from 'react-aria'; -import { type CalendarState } from 'react-stately'; +import { type AriaButtonProps, useCalendar, useLocale } from 'react-aria'; +import { useCalendarState } from 'react-stately'; import CalendarGrid from './CalendarGrid'; import CalendarHeader from './CalendarHeader'; -type CalendarProps = { - state: CalendarState; - weeksInMonth: number; +const createCalendar = (identifier: string) => { + switch (identifier) { + case 'gregory': + return new GregorianCalendar(); + default: + throw new Error(`Unsupported calendar ${identifier}`); + } }; -const Calendar = ({ weeksInMonth, state }: CalendarProps) => { +const Calendar = () => { + const { onRangeChange } = useOccurrences(); + const { locale } = useLocale(); + const state = useCalendarState({ + locale, + createCalendar, + }); const { calendarProps, prevButtonProps, nextButtonProps, title } = useCalendar({}, state); + React.useEffect(() => { + onRangeChange( + generateCalendarRange( + state.visibleRange.start.year, + state.visibleRange.start.month + ) + ); + }, [ + state.visibleRange.start.year, + state.visibleRange.start.month, + onRangeChange, + ]); + const [activeMonthLabel, activeYear] = title.split(' '); useDocumentTitle( @@ -70,7 +95,7 @@ const Calendar = ({ weeksInMonth, state }: CalendarProps) => { onNavigateToYear={navigateToYear} onResetFocusedDate={resetFocusedDate} /> - +
); diff --git a/src/components/calendar/CalendarCell.tsx b/src/components/calendar/CalendarCell.tsx index 9c01707..5ccc04c 100644 --- a/src/components/calendar/CalendarCell.tsx +++ b/src/components/calendar/CalendarCell.tsx @@ -152,6 +152,7 @@ const CalendarCell = ({ key={habitId} initial={{ opacity: 0 }} animate={{ opacity: 1 }} + exit={{ opacity: 0 }} > { +const CalendarGrid = ({ state }: CalendarGridProps) => { const { gridProps } = useCalendarGrid({}, state); const [dayModalDialogOpen, setDayModalDialogOpen] = React.useState(false); const [activeDate, setActiveDate] = React.useState(null); @@ -48,7 +47,6 @@ const CalendarGrid = ({ weeksInMonth, state }: CalendarGridProps) => { diff --git a/src/components/calendar/CalendarHeader.test.tsx b/src/components/calendar/CalendarHeader.test.tsx index cfe9211..074c4b0 100644 --- a/src/components/calendar/CalendarHeader.test.tsx +++ b/src/components/calendar/CalendarHeader.test.tsx @@ -1,4 +1,4 @@ -import { HabitsProvider, OccurrencesProvider, TraitsProvider } from '@context'; +import { HabitsProvider, OccurrencesProvider } from '@context'; import { render } from '@testing-library/react'; import React from 'react'; @@ -26,11 +26,9 @@ describe(CalendarHeader.name, () => { it.skip('should render month and year', () => { const { getByText } = render( - - - - - + + + ); expect(getByText('January 2022')).toBeInTheDocument(); @@ -39,14 +37,12 @@ describe(CalendarHeader.name, () => { it.skip('should disable previous button', () => { const { getByRole } = render( - - - - - + + + ); expect(getByRole('navigate-back')).toBeDisabled(); @@ -55,14 +51,12 @@ describe(CalendarHeader.name, () => { it.skip('should disable next button', () => { const { getByRole } = render( - - - - - + + + ); expect(getByRole('navigate-forward')).toBeDisabled(); @@ -71,11 +65,9 @@ describe(CalendarHeader.name, () => { it.skip('should call onNavigateBack', () => { const { getByRole } = render( - - - - - + + + ); getByRole('navigate-back').click(); @@ -85,11 +77,9 @@ describe(CalendarHeader.name, () => { it.skip('should call onNavigateForward', () => { const { getByRole } = render( - - - - - + + + ); getByRole('navigate-forward').click(); diff --git a/src/components/calendar/CalendarHeader.tsx b/src/components/calendar/CalendarHeader.tsx index 0a841a2..fe0d971 100644 --- a/src/components/calendar/CalendarHeader.tsx +++ b/src/components/calendar/CalendarHeader.tsx @@ -1,7 +1,8 @@ -import { useHabits, useOccurrences, useTraits } from '@context'; +import { useHabits, useOccurrences } from '@context'; import { useScreenSize } from '@hooks'; import { Select, SelectItem, Button } from '@nextui-org/react'; import { ArrowFatLeft, ArrowFatRight } from '@phosphor-icons/react'; +import { useTraitsStore } from '@stores'; import { useUser } from '@supabase/auth-helpers-react'; import React from 'react'; @@ -51,7 +52,7 @@ const CalendarHeader = ({ onResetFocusedDate, }: CalendarHeaderProps) => { const { habits } = useHabits(); - const { traits } = useTraits(); + const { traits } = useTraitsStore(); const { filteredBy, filterBy } = useOccurrences(); const user = useUser(); const screenSize = useScreenSize(); diff --git a/src/components/calendar/CalendarMonthGrid.tsx b/src/components/calendar/CalendarMonthGrid.tsx index 452b08f..52b6d60 100644 --- a/src/components/calendar/CalendarMonthGrid.tsx +++ b/src/components/calendar/CalendarMonthGrid.tsx @@ -1,11 +1,11 @@ -import { type CalendarDate } from '@internationalized/date'; +import { type CalendarDate, getWeeksInMonth } from '@internationalized/date'; import React, { type ForwardedRef } from 'react'; +import { useLocale } from 'react-aria'; import { type CalendarState } from 'react-stately'; import CalendarCell from './CalendarCell'; type MonthProps = { - weeksInMonth: number; state: CalendarState; onDayModalDialogOpen: ( dateNumber: number, @@ -15,12 +15,17 @@ type MonthProps = { }; const Month = ( - { weeksInMonth, state, onDayModalDialogOpen }: MonthProps, + { state, onDayModalDialogOpen }: MonthProps, ref: ForwardedRef ) => { + const { locale } = useLocale(); + const weeksInMonthCount = getWeeksInMonth(state.visibleRange.start, locale); + const weekIndexes = [...new Array(weeksInMonthCount).keys()]; + const { month: activeMonth } = state.visibleRange.start; + return (
- {[...new Array(weeksInMonth).keys()].map((weekIndex) => ( + {weekIndexes.map((weekIndex) => (
state.visibleRange.start.month + : month > activeMonth ? 'above-range' : 'in-range'; - const day = calendarDate.toString().split('T')[0]; + const [dayKey] = calendarDate.toString().split('T'); return ( , 'color'> >; -export type AlertOptions = { - color?: ButtonColor; - autoHideDuration?: number; - dismissible?: boolean; - description?: string; - dismissText?: string; - onDismiss?: () => void; - testId?: string; -}; - const ICONS_BY_COLOR: Record = { secondary: Question, default: Info, @@ -35,27 +25,23 @@ const ICONS_BY_COLOR: Record = { primary: BellRinging, }; -type AlertProps = AlertOptions & { +export type AlertProps = { message: string; + actions?: ReactNode[]; + color?: ButtonColor; + description?: string; + testId?: string; }; const Alert = ({ message, description, - dismissible, - onDismiss, - dismissText = 'Dismiss', + actions = [], color = 'default', testId = 'alert', }: AlertProps) => { - const endDecorator = dismissible ? ( - - ) : null; - const alertClassName = clsx( - 'flex items-center gap-4 rounded-md border px-4 py-2', + 'flex items-center gap-2 rounded-md border px-4 py-2', color === 'default' && 'border-neutral-300 bg-slate-50 text-slate-700 dark:border-neutral-700 dark:bg-slate-900 dark:text-slate-100', color === 'primary' && @@ -77,12 +63,12 @@ const Alert = ({
-
+
{message}
{description &&

{description}

}
- {endDecorator} + {actions}
); diff --git a/src/components/common/Snackbars/Snackbars.tsx b/src/components/common/Snackbars/Snackbars.tsx index 3f9e04f..30acd98 100644 --- a/src/components/common/Snackbars/Snackbars.tsx +++ b/src/components/common/Snackbars/Snackbars.tsx @@ -1,5 +1,7 @@ import { Alert } from '@components'; +import { Button } from '@nextui-org/react'; import { useSnackbarsStore } from '@stores'; +import { AnimatePresence, motion } from 'framer-motion'; import React from 'react'; const Snackbars = () => { @@ -7,17 +9,44 @@ const Snackbars = () => { return (
- {snackbars.map(({ id, message, options }) => { - return ( - hideSnackbar(id)} - testId="snackbar" - /> - ); - })} + + {snackbars.map(({ id, message, options }) => { + const { + action, + dismissible, + dismissText = 'Dismiss', + color, + } = options; + + const dismissAction = dismissible && ( + + ); + + return ( + + + + ); + })} +
); }; diff --git a/src/components/habit/add-habit/AddHabitDialogButton.tsx b/src/components/habit/add-habit/AddHabitDialogButton.tsx index 0456238..50e3512 100644 --- a/src/components/habit/add-habit/AddHabitDialogButton.tsx +++ b/src/components/habit/add-habit/AddHabitDialogButton.tsx @@ -1,5 +1,5 @@ import { AddCustomTraitModal, VisuallyHiddenInput } from '@components'; -import { useHabits, useTraits } from '@context'; +import { useHabits } from '@context'; import { useTextField, useFileField } from '@hooks'; import { Button, @@ -14,12 +14,13 @@ import { Textarea, } from '@nextui-org/react'; import { CloudArrowUp, Plus } from '@phosphor-icons/react'; +import { useTraitsStore } from '@stores'; import { useUser } from '@supabase/auth-helpers-react'; import React from 'react'; const AddHabitDialogButton = () => { const user = useUser(); - const { traits } = useTraits(); + const { traits } = useTraitsStore(); const { fetchingHabits, addingHabit, addHabit } = useHabits(); const [open, setOpen] = React.useState(false); const [name, handleNameChange, clearName] = useTextField(); diff --git a/src/components/habit/add-trait/AddCustomTraitModal.tsx b/src/components/habit/add-trait/AddCustomTraitModal.tsx index 17ac5cb..aae7135 100644 --- a/src/components/habit/add-trait/AddCustomTraitModal.tsx +++ b/src/components/habit/add-trait/AddCustomTraitModal.tsx @@ -1,5 +1,4 @@ import { OccurrenceChip } from '@components'; -import { useTraits } from '@context'; import { useTextField } from '@hooks'; import { Button, @@ -11,6 +10,7 @@ import { ModalHeader, Textarea, } from '@nextui-org/react'; +import { useTraitsStore } from '@stores'; import { useUser } from '@supabase/auth-helpers-react'; import { makeTestOccurrence } from '@tests'; import { toEventLike } from '@utils'; @@ -28,7 +28,7 @@ const AddCustomTraitModal = ({ open, onClose }: AddCustomTraitModalProps) => { const [description, handleDescriptionChange, clearDescription] = useTextField(); const [color, setTraitColor] = React.useState('#94a3b8'); - const { addingTrait, addTrait } = useTraits(); + const { addingTrait, addTrait } = useTraitsStore(); const user = useUser(); React.useEffect(() => { diff --git a/src/components/habit/edit-habit/EditHabitDialog.test.tsx b/src/components/habit/edit-habit/EditHabitDialog.test.tsx index d518606..23452d2 100644 --- a/src/components/habit/edit-habit/EditHabitDialog.test.tsx +++ b/src/components/habit/edit-habit/EditHabitDialog.test.tsx @@ -1,5 +1,7 @@ -import { TraitsProvider, useHabits, useTraits } from '@context'; +import { useHabits } from '@context'; +// import { useTraitsStore } from '@stores'; import { act, fireEvent, render, waitFor } from '@testing-library/react'; +import { makeTestTrait } from '@tests'; import React from 'react'; import EditHabitDialog, { type EditHabitDialogProps } from './EditHabitDialog'; @@ -18,10 +20,12 @@ jest.mock('@hooks', () => ({ jest.mock('@context', () => ({ useHabits: jest.fn().mockReturnValue({ updateHabit: jest.fn() }), - useTraits: jest.fn().mockReturnValue({ - traits: [{ id: 1, label: 'Trait label', slug: 'trait-slug' }], +})); + +jest.mock('@stores', () => ({ + useTraitsStore: jest.fn().mockReturnValue({ + traits: makeTestTrait(), }), - TraitsProvider: jest.fn(({ children }) => children), })); jest.mock('@supabase/auth-helpers-react', () => ({ @@ -56,9 +60,7 @@ describe(EditHabitDialog.name, () => { it.skip('should set values from props', () => { const { getByRole, getByLabelText } = render( - - - + ); expect(getByRole('dialog')).toBeDefined(); expect(getByLabelText('Name')).toHaveProperty('value', 'Habit Name'); @@ -69,29 +71,19 @@ describe(EditHabitDialog.name, () => { }); it('should not render if habit is not provided', () => { - const { queryByRole } = render( - - - - ); + const { queryByRole } = render(); expect(queryByRole('dialog')).toBeNull(); }); it('should not render if open is false', () => { - const { queryByRole } = render( - - - - ); + const { queryByRole } = render(); expect(queryByRole('dialog')).toBeNull(); }); it('should call onClose when closed', async () => { const onClose = jest.fn(); const { getByLabelText } = render( - - - + ); const closeIcon = getByLabelText('Close'); fireEvent.click(closeIcon); @@ -101,13 +93,11 @@ describe(EditHabitDialog.name, () => { it.skip('should call updateHabit when submitted', async () => { const mockUpdateHabit = jest.fn(); (useHabits as jest.Mock).mockReturnValue({ updateHabit: mockUpdateHabit }); - (useTraits as jest.Mock).mockReturnValue({ - traits: [{ id: 1, label: 'Trait label', slug: 'trait-slug' }], - }); + // (useTraitsStore as jest.Mock).mockReturnValue({ + // traits: [{ id: 1, label: 'Trait label', slug: 'trait-slug' }], + // }); const { getByRole, getByLabelText } = render( - - - + ); const nameInput = getByLabelText('Name'); const descriptionInput = getByLabelText('Description (optional)'); diff --git a/src/components/habit/edit-habit/EditHabitDialog.tsx b/src/components/habit/edit-habit/EditHabitDialog.tsx index 97679bf..4853975 100644 --- a/src/components/habit/edit-habit/EditHabitDialog.tsx +++ b/src/components/habit/edit-habit/EditHabitDialog.tsx @@ -1,4 +1,4 @@ -import { useHabits, useTraits } from '@context'; +import { useHabits } from '@context'; import { useTextField } from '@hooks'; import type { Habit } from '@models'; import { @@ -13,6 +13,7 @@ import { SelectItem, Textarea, } from '@nextui-org/react'; +import { useTraitsStore } from '@stores'; import { useUser } from '@supabase/auth-helpers-react'; import { toEventLike } from '@utils'; import React from 'react'; @@ -33,7 +34,7 @@ const EditHabitDialog = ({ const [description, handleDescriptionChange] = useTextField(); const [traitId, setTraitId] = React.useState(''); const { updateHabit, habitIdBeingUpdated } = useHabits(); - const { traits } = useTraits(); + const { traits } = useTraitsStore(); const user = useUser(); React.useEffect(() => { diff --git a/src/components/habit/habits-page/HabitsPage.test.tsx b/src/components/habit/habits-page/HabitsPage.test.tsx index 31781ae..3471ae2 100644 --- a/src/components/habit/habits-page/HabitsPage.test.tsx +++ b/src/components/habit/habits-page/HabitsPage.test.tsx @@ -1,4 +1,4 @@ -import { HabitsProvider, TraitsProvider, useHabits } from '@context'; +import { HabitsProvider, useHabits } from '@context'; import { fireEvent, render, waitFor } from '@testing-library/react'; import { makeTestHabit } from '@tests'; import React from 'react'; @@ -6,7 +6,6 @@ import React from 'react'; import HabitsPage from './HabitsPage'; jest.mock('@context', () => ({ - TraitsProvider: jest.fn(({ children }) => children), HabitsProvider: jest.fn(({ children }) => children), useTraits: jest.fn().mockReturnValue({ traits: [], @@ -57,11 +56,9 @@ describe(HabitsPage.name, () => { ], })); const { getByText } = render( - - - - - + + + ); await waitFor(() => { expect(getByText('Your habits')); @@ -87,11 +84,9 @@ describe(HabitsPage.name, () => { ], })); const { queryByRole, getByRole, getByTestId } = render( - - - - - + + + ); expect(queryByRole('submit-edited-habit-button')).toBeNull(); fireEvent.click(getByTestId('edit-habit-id-42-button')); @@ -100,11 +95,9 @@ describe(HabitsPage.name, () => { it.skip('should open confirm dialog on remove icon button click', async () => { const { getByRole, getByTestId } = render( - - - - - + + + ); fireEvent.click(getByTestId('delete-habit-id-2-button')); expect(getByRole('dialog')).toBeDefined(); @@ -127,11 +120,9 @@ describe(HabitsPage.name, () => { removeHabit: mockRemoveHabit, })); const { queryByRole, getByRole, getByTestId } = render( - - - - - + + + ); expect(queryByRole('dialog')).toBeNull(); fireEvent.click(getByTestId('delete-habit-id-2-button')); diff --git a/src/components/header/Header.tsx b/src/components/header/Header.tsx index c718766..f6f3786 100644 --- a/src/components/header/Header.tsx +++ b/src/components/header/Header.tsx @@ -1,4 +1,5 @@ import { AuthModalButton } from '@components'; +import { useFetchOnAuth } from '@hooks'; import { Button, Tooltip } from '@nextui-org/react'; import { GithubLogo } from '@phosphor-icons/react'; import React from 'react'; @@ -7,6 +8,8 @@ import { Link } from 'react-router-dom'; import ThemeToggle from './ThemeToggle'; const Header = () => { + useFetchOnAuth(); + return (
diff --git a/src/components/header/ThemeToggle.tsx b/src/components/header/ThemeToggle.tsx index feef1ae..08f4e56 100644 --- a/src/components/header/ThemeToggle.tsx +++ b/src/components/header/ThemeToggle.tsx @@ -4,59 +4,34 @@ import { SunDim as SunIcon, Desktop as DesktopIcon, Moon as MoonIcon, - type IconWeight, } from '@phosphor-icons/react'; import clsx from 'clsx'; import React from 'react'; import { twMerge } from 'tailwind-merge'; -type IconProps = { - cn: string; - w: IconWeight; -}; - const modesToIcons = { - [ThemeMode.LIGHT]: ({ cn, w }: IconProps) => ( - - ), - [ThemeMode.SYSTEM]: ({ cn, w }: IconProps) => ( - - ), - [ThemeMode.DARK]: ({ cn, w }: IconProps) => ( - - ), + [ThemeMode.LIGHT]: SunIcon, + [ThemeMode.SYSTEM]: DesktopIcon, + [ThemeMode.DARK]: MoonIcon, }; const ThemeToggle = () => { - const { themeMode, changeThemeMode } = useThemeMode(); + const { themeMode, setThemeMode } = useThemeMode(); const handleThemeChange = (newThemeMode: ThemeMode) => () => { - changeThemeMode(newThemeMode); + setThemeMode(newThemeMode); }; return ( {Object.values(ThemeMode).map((mode) => { const isSelected = themeMode === mode; + const iconClassName = clsx(isSelected && 'text-white'); const buttonClassName = clsx( 'bg-slate-100 dark:bg-slate-800', isSelected && 'bg-slate-400 dark:bg-slate-600' ); - const iconClassName = clsx(isSelected && 'text-white'); - const Icon = modesToIcons[mode]; return ( @@ -66,7 +41,10 @@ const ThemeToggle = () => { onPress={handleThemeChange(mode)} isIconOnly > - + ); })} diff --git a/src/components/user-account/AccountPage.tsx b/src/components/user-account/AccountPage.tsx index 9748c19..e668968 100644 --- a/src/components/user-account/AccountPage.tsx +++ b/src/components/user-account/AccountPage.tsx @@ -1,7 +1,6 @@ -import { AuthModalButton, PasswordInput } from '@components'; +import { Alert, PasswordInput } from '@components'; import { useDocumentTitle } from '@hooks'; import { Button, Input, Spinner } from '@nextui-org/react'; -import { Prohibit as ProhibitIcon } from '@phosphor-icons/react'; import clsx from 'clsx'; import React, { type FormEventHandler } from 'react'; import { twMerge } from 'tailwind-merge'; @@ -48,14 +47,7 @@ const AccountPage = () => { className={twMerge(containerClassName, 'items-start pt-16')} data-testid="account-page" > -
- -

Please log in to your account first

- -
+
); } diff --git a/src/components/user-account/use-account-page/useAccountPage.ts b/src/components/user-account/use-account-page/useAccountPage.ts index be594eb..4166f9a 100644 --- a/src/components/user-account/use-account-page/useAccountPage.ts +++ b/src/components/user-account/use-account-page/useAccountPage.ts @@ -59,7 +59,6 @@ const useAccountPage = () => { setLoading(true); const updatedUser = await updateUser(email, password, name); - console.log('updatedUser', updatedUser); setUser(updatedUser); diff --git a/src/context/Occurrences/OccurrencesContext.ts b/src/context/Occurrences/OccurrencesContext.ts index bb33f5b..671ada5 100644 --- a/src/context/Occurrences/OccurrencesContext.ts +++ b/src/context/Occurrences/OccurrencesContext.ts @@ -16,6 +16,7 @@ type OccurrencesContextType = { habitIds: Set; traitIds: Set; }; + onRangeChange: (range: [number, number]) => void; }; export const OccurrencesContext = diff --git a/src/context/Occurrences/OccurrencesProvider.tsx b/src/context/Occurrences/OccurrencesProvider.tsx index 21a6459..00e9778 100644 --- a/src/context/Occurrences/OccurrencesProvider.tsx +++ b/src/context/Occurrences/OccurrencesProvider.tsx @@ -1,5 +1,5 @@ -import { OccurrencesContext, useHabits, useTraits } from '@context'; -import { cacheOccurrence, uncacheOccurrence } from '@helpers'; +import { OccurrencesContext, useHabits } from '@context'; +import { cacheOccurrence, occurrencesCache, uncacheOccurrence } from '@helpers'; import { useDataFetch } from '@hooks'; import type { Occurrence, @@ -11,14 +11,12 @@ import { destroyOccurrence, listOccurrences, } from '@services'; -import { useSnackbarsStore } from '@stores'; +import { useSnackbarsStore, useTraitsStore } from '@stores'; import { getErrorMessage } from '@utils'; import React, { type ReactNode } from 'react'; type Props = { children: ReactNode; - rangeStart: number; - rangeEnd: number; }; export type OccurrenceFilters = { @@ -26,10 +24,10 @@ export type OccurrenceFilters = { traitIds: Set; }; -const OccurrencesProvider = ({ children, rangeStart, rangeEnd }: Props) => { +const OccurrencesProvider = ({ children }: Props) => { const { showSnackbar } = useSnackbarsStore(); const { habits } = useHabits(); - const { traits } = useTraits(); + const { traits } = useTraitsStore(); const [addingOccurrence, setAddingOccurrence] = React.useState(false); const [fetchingOccurrences, setFetchingOccurrences] = React.useState(false); @@ -43,11 +41,18 @@ const OccurrencesProvider = ({ children, rangeStart, rangeEnd }: Props) => { habitIds: new Set([]), traitIds: new Set([]), }); + const [range, setRange] = React.useState<[number, number]>([0, 0]); + + const handleRangeChange = React.useCallback((range: [number, number]) => { + setRange(range); + }, []); const fetchOccurrences = React.useCallback(async () => { try { - setFetchingOccurrences(true); - setAllOccurrences(await listOccurrences([rangeStart, rangeEnd])); + if (range.every(Boolean)) { + setFetchingOccurrences(true); + setAllOccurrences(await listOccurrences(range)); + } } catch (error) { console.error(error); showSnackbar( @@ -61,11 +66,11 @@ const OccurrencesProvider = ({ children, rangeStart, rangeEnd }: Props) => { } finally { setFetchingOccurrences(false); } - }, [rangeStart, rangeEnd, showSnackbar]); + }, [range, showSnackbar]); const clearOccurrences = React.useCallback(() => { setOccurrences([]); - setOccurrencesByDate({}); + occurrencesCache.clear(); }, []); useDataFetch({ @@ -75,7 +80,7 @@ const OccurrencesProvider = ({ children, rangeStart, rangeEnd }: Props) => { React.useEffect(() => { void fetchOccurrences(); - }, [rangeStart, rangeEnd, fetchOccurrences]); + }, [fetchOccurrences]); React.useEffect(() => { const initialFilteredHabitIds = habits.map((habit) => habit.id.toString()); @@ -91,11 +96,7 @@ const OccurrencesProvider = ({ children, rangeStart, rangeEnd }: Props) => { allOccurrences.filter((occurrence) => { return ( filteredBy.habitIds.has(occurrence.habitId.toString()) && - filteredBy.traitIds.has( - habits - .find((habit) => habit.id === occurrence.habitId) - ?.traitId.toString() ?? '' - ) + filteredBy.traitIds.has(occurrence.habit?.trait?.id.toString() || '') ); }) ); @@ -129,7 +130,7 @@ const OccurrencesProvider = ({ children, rangeStart, rangeEnd }: Props) => { const nextOccurrence = await createOccurrence(occurrence); - cacheOccurrence([rangeStart, rangeEnd], nextOccurrence); + cacheOccurrence(range, nextOccurrence); setAllOccurrences((prevOccurrences) => [ ...prevOccurrences, @@ -156,7 +157,7 @@ const OccurrencesProvider = ({ children, rangeStart, rangeEnd }: Props) => { setAddingOccurrence(false); } }, - [showSnackbar, rangeStart, rangeEnd] + [showSnackbar, range] ); const removeOccurrence = React.useCallback( @@ -172,7 +173,7 @@ const OccurrencesProvider = ({ children, rangeStart, rangeEnd }: Props) => { }); }); - uncacheOccurrence([rangeStart, rangeEnd], id); + uncacheOccurrence(range, id); showSnackbar('Your habit entry has been deleted from the calendar.', { dismissible: true, @@ -192,7 +193,7 @@ const OccurrencesProvider = ({ children, rangeStart, rangeEnd }: Props) => { setOccurrenceIdBeingDeleted(0); } }, - [showSnackbar, rangeStart, rangeEnd] + [showSnackbar, range] ); const removeOccurrencesByHabitId = (habitId: number) => { @@ -215,6 +216,7 @@ const OccurrencesProvider = ({ children, rangeStart, rangeEnd }: Props) => { removeOccurrencesByHabitId, filterBy, filteredBy, + onRangeChange: handleRangeChange, }), [ addingOccurrence, @@ -226,6 +228,7 @@ const OccurrencesProvider = ({ children, rangeStart, rangeEnd }: Props) => { removeOccurrence, filterBy, filteredBy, + handleRangeChange, ] ); diff --git a/src/context/Traits/TraitsContext.ts b/src/context/Traits/TraitsContext.ts deleted file mode 100644 index 610d2de..0000000 --- a/src/context/Traits/TraitsContext.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { Trait, TraitsInsert } from '@models'; -import React from 'react'; - -type TraitsContextType = { - traits: Trait[]; - fetchingTraits: boolean; - addingTrait: boolean; - addTrait: (trait: TraitsInsert) => Promise; -}; - -export const TraitsContext = React.createContext( - null -); - -export const useTraits = () => { - const context = React.useContext(TraitsContext); - - if (!context) { - throw new Error('useTraits must be used within a TraitsProvider'); - } - - return context; -}; diff --git a/src/context/Traits/TraitsProvider.tsx b/src/context/Traits/TraitsProvider.tsx deleted file mode 100644 index d5ada34..0000000 --- a/src/context/Traits/TraitsProvider.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { TraitsContext } from '@context'; -import { useDataFetch } from '@hooks'; -import type { Trait, TraitsInsert } from '@models'; -import { listTraits, createTrait } from '@services'; -import { useSnackbarsStore } from '@stores'; -import { makeTestTrait } from '@tests'; -import { getErrorMessage } from '@utils'; -import React, { type ReactNode } from 'react'; - -const testTraits = [ - makeTestTrait({ name: 'Test Good Trait', color: '#2AF004' }), - makeTestTrait({ name: 'Test Bad Trait', color: '#F6F6F6' }), -]; - -const TraitsProvider = ({ children }: { children: ReactNode }) => { - const { showSnackbar } = useSnackbarsStore(); - const [traits, setTraits] = React.useState(testTraits); - const [fetchingTraits, setFetchingTraits] = React.useState(false); - const [addingTrait, setAddingTrait] = React.useState(false); - - const clearTraits = React.useCallback(() => { - setTraits([]); - }, []); - - const fetchTraits = React.useCallback(async () => { - try { - setFetchingTraits(true); - setTraits(await listTraits()); - } catch (error) { - console.error(error); - showSnackbar( - 'Something went wrong while fetching your traits. Please try reloading the page.', - { - description: `Error details: ${getErrorMessage(error)}`, - color: 'danger', - dismissible: true, - } - ); - } finally { - setFetchingTraits(false); - } - }, [showSnackbar]); - - useDataFetch({ - load: fetchTraits, - clear: clearTraits, - }); - - const addTrait = React.useCallback( - async (trait: TraitsInsert) => { - try { - setAddingTrait(true); - - const newTrait = await createTrait(trait); - - setTraits((prevUserTraits) => [...prevUserTraits, newTrait]); - - showSnackbar('Trait added successfully', { - color: 'success', - dismissible: true, - dismissText: 'Done', - }); - } catch (error) { - console.error(error); - showSnackbar('Something went wrong while adding your trait', { - description: `Error details: ${getErrorMessage(error)}`, - color: 'danger', - dismissible: true, - }); - } finally { - setAddingTrait(false); - } - }, - [showSnackbar] - ); - - const value = React.useMemo(() => { - return { - addingTrait, - traits, - fetchingTraits, - addTrait, - }; - }, [addingTrait, traits, fetchingTraits, addTrait]); - - return ( - {children} - ); -}; - -export default React.memo(TraitsProvider); diff --git a/src/context/Traits/index.ts b/src/context/Traits/index.ts deleted file mode 100644 index eee6ef7..0000000 --- a/src/context/Traits/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './TraitsContext'; -export * from './TraitsProvider'; -export { default as TraitsProvider } from './TraitsProvider'; diff --git a/src/context/index.ts b/src/context/index.ts index b060aad..e080738 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -1,5 +1,3 @@ export * from './Habits'; export * from './Occurrences'; - -export * from './Traits'; diff --git a/src/helpers/generateCalendarRange.test.ts b/src/helpers/generateCalendarRange.test.ts new file mode 100644 index 0000000..be04b7b --- /dev/null +++ b/src/helpers/generateCalendarRange.test.ts @@ -0,0 +1,12 @@ +import { generateCalendarRange } from './generateCalendarRange'; + +describe(generateCalendarRange.name, () => { + it('should generate a range from the first to the last day of the calendar monthly view', () => { + const year = 2024; + const month = 2; + const [rangeStart, rangeEnd] = generateCalendarRange(year, month); + + expect(rangeStart).toBe(1706482800000); + expect(rangeEnd).toBe(1709506799999); + }); +}); diff --git a/src/helpers/generateCalendarRange.ts b/src/helpers/generateCalendarRange.ts new file mode 100644 index 0000000..b823cff --- /dev/null +++ b/src/helpers/generateCalendarRange.ts @@ -0,0 +1,17 @@ +export const generateCalendarRange = ( + year: number, + month: number +): [number, number] => { + const rangeStart = new Date(year, month - 1); + const rangeEnd = new Date(year, month, 0, 23, 59, 59, 999); + + if (rangeStart.getDay() !== 1) { + rangeStart.setDate(rangeStart.getDate() - rangeStart.getDay() + 1); + } + + if (rangeEnd.getDay() !== 0) { + rangeEnd.setDate(rangeEnd.getDate() + (7 - rangeEnd.getDay())); + } + + return [+rangeStart, +rangeEnd]; +}; diff --git a/src/helpers/index.ts b/src/helpers/index.ts index 4b0f4ee..6fb40bc 100644 --- a/src/helpers/index.ts +++ b/src/helpers/index.ts @@ -1,2 +1,3 @@ export * from './supabaseClient'; export * from './occurrencesCache'; +export * from './generateCalendarRange'; diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 1646c61..aecea28 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,20 +1,9 @@ -export * from './useScreenSize'; -export { default as useScreenSize } from './useScreenSize'; - -export * from './useCalendar'; -export { default as useCalendar } from './useCalendar'; - export * from './useThemeMode'; export { default as useThemeMode } from './useThemeMode'; -export * from './useTextField'; +export { default as useScreenSize } from './useScreenSize'; export { default as useTextField } from './useTextField'; - -export * from './useFileField'; export { default as useFileField } from './useFileField'; - -export * from './useDataFetch'; export { default as useDataFetch } from './useDataFetch'; - -export * from './useDocumentTitle'; +export { default as useFetchOnAuth } from './useFetchOnAuth'; export { default as useDocumentTitle } from './useDocumentTitle'; diff --git a/src/hooks/useCalendar.ts b/src/hooks/useCalendar.ts deleted file mode 100644 index 2152bdf..0000000 --- a/src/hooks/useCalendar.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { - type CalendarDate, - getWeeksInMonth, - GregorianCalendar, -} from '@internationalized/date'; -import { generateCalendarRange } from '@utils'; -import { useLocale } from 'react-aria'; -import { useCalendarState } from 'react-stately'; - -const createCalendar = (identifier: string) => { - switch (identifier) { - case 'gregory': - return new GregorianCalendar(); - default: - throw new Error(`Unsupported calendar ${identifier}`); - } -}; - -const useCalendar = () => { - const { locale } = useLocale(); - const state = useCalendarState({ - locale, - createCalendar, - }); - const weeksInMonth = getWeeksInMonth(state.visibleRange.start, locale); - const weeks = [...new Array(weeksInMonth).keys()]; - const { rangeStart, rangeEnd } = generateCalendarRange( - state.getDatesInWeek(0) as CalendarDate[], - state.getDatesInWeek(weeks[weeks.length - 1]) as CalendarDate[] - ); - - return { rangeStart, rangeEnd, state, weeksInMonth }; -}; - -export default useCalendar; diff --git a/src/hooks/useFetchOnAuth.ts b/src/hooks/useFetchOnAuth.ts new file mode 100644 index 0000000..0cd81e1 --- /dev/null +++ b/src/hooks/useFetchOnAuth.ts @@ -0,0 +1,30 @@ +import { useTraitsStore } from '@stores'; +import { useSupabaseClient } from '@supabase/auth-helpers-react'; +import React from 'react'; + +const useFetchOnAuth = () => { + const supabase = useSupabaseClient(); + const { fetchTraits, clearTraits } = useTraitsStore(); + + React.useEffect(() => { + if (!supabase.auth) { + return; + } + + const { data } = supabase.auth.onAuthStateChange((event) => { + if (event === 'SIGNED_OUT') { + clearTraits(); + } + + if (['TOKEN_REFRESHED', 'SIGNED_IN'].includes(event)) { + void fetchTraits(); + } + }); + + return () => { + data.subscription.unsubscribe(); + }; + }, [supabase, fetchTraits, clearTraits]); +}; + +export default useFetchOnAuth; diff --git a/src/hooks/useThemeMode.ts b/src/hooks/useThemeMode.ts index 7a61647..94bcf32 100644 --- a/src/hooks/useThemeMode.ts +++ b/src/hooks/useThemeMode.ts @@ -21,6 +21,8 @@ const useThemeMode = () => { }; React.useEffect(() => { + localStorage.theme = themeMode; + applyTheme(themeMode); const mediaQueryList = window.matchMedia('(prefers-color-scheme: dark)'); const handleMediaQueryListChange = (e: MediaQueryListEvent) => { @@ -36,16 +38,7 @@ const useThemeMode = () => { }; }, [themeMode]); - React.useEffect(() => { - localStorage.theme = themeMode; - applyTheme(themeMode); - }, [themeMode]); - - const changeThemeMode = (mode: ThemeMode) => { - setThemeMode(mode); - }; - - return { themeMode, changeThemeMode }; + return { themeMode, setThemeMode }; }; export default useThemeMode; diff --git a/src/index.tsx b/src/index.tsx index 327f931..e5bd876 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -9,7 +9,7 @@ const root = ReactDOM.createRoot( ); root.render( - - - + // + + // ); diff --git a/src/services/occurrences.ts b/src/services/occurrences.ts index b172d9f..01984f9 100644 --- a/src/services/occurrences.ts +++ b/src/services/occurrences.ts @@ -29,7 +29,7 @@ export const listOccurrences = async ( ): Promise => { const cachedOccurrences = occurrencesCache.get(range.toString()); - if (cachedOccurrences) { + if (cachedOccurrences?.length) { return cachedOccurrences; } @@ -45,7 +45,9 @@ export const listOccurrences = async ( const result = transformServerEntities(data); - cacheOccurrences(range, result); + if (result.length) { + cacheOccurrences(range, result); + } return result; }; diff --git a/src/stores/index.ts b/src/stores/index.ts index d873df4..4683d72 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -1 +1,2 @@ export { default as useSnackbarsStore } from './snackbars.store'; +export { default as useTraitsStore } from './traits.store'; diff --git a/src/stores/snackbars.store.ts b/src/stores/snackbars.store.ts index eed5f78..46a786f 100644 --- a/src/stores/snackbars.store.ts +++ b/src/stores/snackbars.store.ts @@ -1,15 +1,23 @@ -import { type AlertOptions } from '@components'; +import { type AlertProps } from '@components'; +import { type ReactNode } from 'react'; import { create } from 'zustand'; +export type SnackbarOptions = Omit & { + action?: ReactNode; + autoHideDuration?: number; + dismissible?: boolean; + dismissText?: string; +}; + type Snackbar = { id: string; message: string; - options: AlertOptions; + options: SnackbarOptions; }; type SnackbarState = { snackbars: Snackbar[]; - showSnackbar: (message: string, options?: AlertOptions) => void; + showSnackbar: (message: string, options?: SnackbarOptions) => void; hideSnackbar: (id: string) => void; }; diff --git a/src/stores/traits.store.ts b/src/stores/traits.store.ts new file mode 100644 index 0000000..fb70da2 --- /dev/null +++ b/src/stores/traits.store.ts @@ -0,0 +1,73 @@ +import type { Trait, TraitsInsert } from '@models'; +import { listTraits, createTrait } from '@services'; +import { useSnackbarsStore } from '@stores'; +import { makeTestTrait } from '@tests'; +import { getErrorMessage } from '@utils'; +import { create } from 'zustand'; + +type TraitsState = { + traits: Trait[]; + fetchingTraits: boolean; + addingTrait: boolean; + fetchTraits: () => Promise; + addTrait: (trait: TraitsInsert) => Promise; + clearTraits: () => void; +}; + +const testTraits = [ + makeTestTrait({ name: 'Test Good Trait', color: '#2AF004' }), + makeTestTrait({ name: 'Test Bad Trait', color: '#F6F6F6' }), +]; + +const useTraitsStore = create((set) => ({ + traits: testTraits, + fetchingTraits: false, + addingTrait: false, + fetchTraits: async () => { + const { showSnackbar } = useSnackbarsStore.getState(); + try { + set({ fetchingTraits: true }); + const traits = await listTraits(); + set({ traits }); + } catch (error) { + console.error(error); + showSnackbar( + 'Something went wrong while fetching your traits. Please try reloading the page.', + { + description: `Error details: ${getErrorMessage(error)}`, + color: 'danger', + dismissible: true, + } + ); + } finally { + set({ fetchingTraits: false }); + } + }, + addTrait: async (trait: TraitsInsert) => { + const { showSnackbar } = useSnackbarsStore.getState(); + try { + set({ addingTrait: true }); + const newTrait = await createTrait(trait); + set((state) => ({ traits: [...state.traits, newTrait] })); + showSnackbar('Trait added successfully', { + color: 'success', + dismissible: true, + dismissText: 'Done', + }); + } catch (error) { + console.error(error); + showSnackbar('Something went wrong while adding your trait', { + description: `Error details: ${getErrorMessage(error)}`, + color: 'danger', + dismissible: true, + }); + } finally { + set({ addingTrait: false }); + } + }, + clearTraits: () => { + set({ traits: [] }); + }, +})); + +export default useTraitsStore; diff --git a/src/utils/generateCalendarRange.test.ts b/src/utils/generateCalendarRange.test.ts deleted file mode 100644 index 215b2a0..0000000 --- a/src/utils/generateCalendarRange.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { CalendarDate } from '@internationalized/date'; - -import { generateCalendarRange } from './generateCalendarRange'; - -describe(generateCalendarRange.name, () => { - it('should generate a range from the first day of the month to the last day of the month', () => { - const firstWeekDates = [ - { year: 2022, month: 1, day: 1 }, - { year: 2022, month: 1, day: 2 }, - { year: 2022, month: 1, day: 3 }, - { year: 2022, month: 1, day: 4 }, - { year: 2022, month: 1, day: 5 }, - { year: 2022, month: 1, day: 6 }, - { year: 2022, month: 1, day: 7 }, - ]; - - const lastWeekDates = [ - { year: 2022, month: 1, day: 25 }, - { year: 2022, month: 1, day: 26 }, - { year: 2022, month: 1, day: 27 }, - { year: 2022, month: 1, day: 28 }, - { year: 2022, month: 1, day: 29 }, - { year: 2022, month: 1, day: 30 }, - { year: 2022, month: 1, day: 31 }, - ]; - - const range = generateCalendarRange( - firstWeekDates as CalendarDate[], - lastWeekDates as CalendarDate[] - ); - - expect(range).toEqual({ - rangeStart: new Date(2022, 0, 1).getTime(), - rangeEnd: new Date(2022, 0, 31, 23, 59, 59, 999).getTime(), - }); - }); -}); diff --git a/src/utils/generateCalendarRange.ts b/src/utils/generateCalendarRange.ts deleted file mode 100644 index 95d2be2..0000000 --- a/src/utils/generateCalendarRange.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { CalendarDate } from '@internationalized/date'; - -type Range = { - rangeStart: number; - rangeEnd: number; -}; - -export const generateCalendarRange = ( - firstWeekDates: CalendarDate[], - lastWeekDates: CalendarDate[] -): Range => { - const [firstDay] = firstWeekDates; - const [lastDay] = lastWeekDates.slice(-1); - - const firstDate = new Date(firstDay.year, firstDay.month - 1, firstDay.day); - const lastDate = new Date( - lastDay.year, - lastDay.month - 1, - lastDay.day, - 23, - 59, - 59, - 999 - ); - - const rangeStart = +firstDate; - const rangeEnd = +lastDate; - - return { rangeStart, rangeEnd }; -}; diff --git a/src/utils/index.ts b/src/utils/index.ts index 9371f5d..30b086d 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,5 +1,4 @@ export * from './capitalizeFirstLetter'; -export * from './generateCalendarRange'; export * from './transformEntity'; export * from './getHabitIconUrl'; export * from './getErrorMessage';