Skip to content

Commit 822c53b

Browse files
authored
feat(calendar): allow adding notes to habit entries (#133)
1 parent 5f038a8 commit 822c53b

13 files changed

Lines changed: 262 additions & 85 deletions

src/components/calendar/AddOccurrenceDialog.test.tsx

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useHabitsStore, useOccurrencesStore } from '@stores';
1+
import { useHabitsStore, useNotesStore, useOccurrencesStore } from '@stores';
22
import { useUser } from '@supabase/auth-helpers-react';
33
import { fireEvent, render, waitFor } from '@testing-library/react';
44
import { makeTestHabit } from '@tests';
@@ -11,6 +11,7 @@ import AddOccurrenceDialog from './AddOccurrenceDialog';
1111
jest.mock('@stores', () => ({
1212
useHabitsStore: jest.fn(),
1313
useOccurrencesStore: jest.fn(),
14+
useNotesStore: jest.fn(),
1415
}));
1516

1617
jest.mock('@supabase/auth-helpers-react', () => ({
@@ -37,6 +38,10 @@ describe(AddOccurrenceDialog.name, () => {
3738

3839
it('should render', () => {
3940
(useHabitsStore as unknown as jest.Mock).mockReturnValue({ habits: [] });
41+
(useNotesStore as unknown as jest.Mock).mockReturnValue({
42+
addNote: jest.fn(),
43+
addingNote: false,
44+
});
4045
(useUser as jest.Mock).mockReturnValue({ id: '1' });
4146
(format as jest.Mock).mockReturnValue('2021-01-01');
4247
(useOccurrencesStore as unknown as jest.Mock).mockReturnValue({
@@ -53,6 +58,10 @@ describe(AddOccurrenceDialog.name, () => {
5358

5459
it('should not render if date is null', () => {
5560
(useHabitsStore as unknown as jest.Mock).mockReturnValue({ habits: [] });
61+
(useNotesStore as unknown as jest.Mock).mockReturnValue({
62+
addNote: jest.fn(),
63+
addingNote: false,
64+
});
5665
(useUser as jest.Mock).mockReturnValue({ id: '1' });
5766
(format as jest.Mock).mockReturnValue('2021-01-01');
5867
(useOccurrencesStore as unknown as jest.Mock).mockReturnValue({
@@ -67,6 +76,10 @@ describe(AddOccurrenceDialog.name, () => {
6776

6877
it('should not render if open is false', () => {
6978
(useHabitsStore as unknown as jest.Mock).mockReturnValue({ habits: [] });
79+
(useNotesStore as unknown as jest.Mock).mockReturnValue({
80+
addNote: jest.fn(),
81+
addingNote: false,
82+
});
7083
(useUser as jest.Mock).mockReturnValue({ id: '1' });
7184
(format as jest.Mock).mockReturnValue('2021-01-01');
7285
(useOccurrencesStore as unknown as jest.Mock).mockReturnValue({
@@ -81,6 +94,10 @@ describe(AddOccurrenceDialog.name, () => {
8194

8295
it('should not render if date is null', () => {
8396
(useHabitsStore as unknown as jest.Mock).mockReturnValue({ habits: [] });
97+
(useNotesStore as unknown as jest.Mock).mockReturnValue({
98+
addNote: jest.fn(),
99+
addingNote: false,
100+
});
84101
(useUser as jest.Mock).mockReturnValue({ id: '1' });
85102
(format as jest.Mock).mockReturnValue('2021-01-01');
86103
(useOccurrencesStore as unknown as jest.Mock).mockReturnValue({
@@ -95,26 +112,34 @@ describe(AddOccurrenceDialog.name, () => {
95112

96113
it('if no habits are available, should show a message', () => {
97114
(useHabitsStore as unknown as jest.Mock).mockReturnValue({ habits: [] });
115+
(useNotesStore as unknown as jest.Mock).mockReturnValue({
116+
addNote: jest.fn(),
117+
addingNote: false,
118+
});
98119
(useUser as jest.Mock).mockReturnValue({ id: '1' });
99120
(format as jest.Mock).mockReturnValue('2021-01-01');
100121
(useOccurrencesStore as unknown as jest.Mock).mockReturnValue({
101122
addOccurrence: jest.fn(),
102123
addingOccurrence: false,
103124
});
104-
const { getByText } = render(
125+
const { getAllByText } = render(
105126
<BrowserRouter>
106127
<AddOccurrenceDialog {...props} />
107128
</BrowserRouter>
108129
);
109130
expect(
110-
getByText('No habits yet. Create a habit to get started.')
131+
getAllByText('No habits yet. Create a habit to get started.')[0]
111132
).toBeInTheDocument();
112133
});
113134

114135
it('should render habit options', () => {
115136
(useHabitsStore as unknown as jest.Mock).mockReturnValue({
116137
habits: [makeTestHabit()],
117138
});
139+
(useNotesStore as unknown as jest.Mock).mockReturnValue({
140+
addNote: jest.fn(),
141+
addingNote: false,
142+
});
118143
(useUser as jest.Mock).mockReturnValue({ id: '1' });
119144
(format as jest.Mock).mockReturnValue('2021-01-01');
120145
(useOccurrencesStore as unknown as jest.Mock).mockReturnValue({
@@ -150,6 +175,10 @@ describe(AddOccurrenceDialog.name, () => {
150175
(useHabitsStore as unknown as jest.Mock).mockReturnValue({
151176
habits: [makeTestHabit()],
152177
});
178+
(useNotesStore as unknown as jest.Mock).mockReturnValue({
179+
addNote: jest.fn(),
180+
addingNote: false,
181+
});
153182
(useUser as jest.Mock).mockReturnValue({ id: '1' });
154183
(format as jest.Mock).mockReturnValue('2021-01-01');
155184
(useOccurrencesStore as unknown as jest.Mock).mockReturnValue({

src/components/calendar/AddOccurrenceDialog.tsx

Lines changed: 64 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,16 @@ import {
55
ModalHeader,
66
ModalBody,
77
ModalFooter,
8-
Listbox,
9-
ListboxSection,
108
ListboxItem,
9+
Textarea,
10+
Select,
11+
SelectItem,
12+
SelectSection,
1113
} from '@nextui-org/react';
12-
import { useHabitsStore, useOccurrencesStore } from '@stores';
14+
import { ArrowsClockwise } from '@phosphor-icons/react';
15+
import { useHabitsStore, useNotesStore, useOccurrencesStore } from '@stores';
1316
import { useUser } from '@supabase/auth-helpers-react';
17+
import { getHabitIconUrl } from '@utils';
1418
import { format } from 'date-fns';
1519
import React, { type MouseEventHandler } from 'react';
1620
import { Link } from 'react-router-dom';
@@ -29,7 +33,9 @@ const AddOccurrenceDialog = ({
2933
const { habits } = useHabitsStore();
3034
const user = useUser();
3135
const { addOccurrence, addingOccurrence } = useOccurrencesStore();
32-
const [selectedHabitIds, setSelectedHabitIds] = React.useState<string[]>([]);
36+
const [selectedHabitId, setSelectedHabitId] = React.useState('');
37+
const [note, setNote] = React.useState('');
38+
const { addNote, addingNote } = useNotesStore();
3339

3440
const habitsByTraitName = React.useMemo(() => {
3541
return Object.groupBy(habits, (habit) => habit.trait?.name || 'Unknown');
@@ -44,36 +50,37 @@ const AddOccurrenceDialog = ({
4450
const handleSubmit: MouseEventHandler<HTMLButtonElement> = async (event) => {
4551
event.preventDefault();
4652

47-
if (!user || !hasHabits) {
53+
if (!user || !hasHabits || !selectedHabitId) {
4854
return null;
4955
}
5056

51-
const addOccurrences = selectedHabitIds.map((id) => {
52-
return addOccurrence({
53-
day: date.toISOString().split('T')[0],
54-
timestamp: +date,
55-
habitId: +id,
56-
userId: user?.id as string,
57-
time: null, // TODO: Add time picker
58-
});
57+
const newOccurrence = await addOccurrence({
58+
day: date.toISOString().split('T')[0],
59+
timestamp: +date,
60+
habitId: +selectedHabitId,
61+
userId: user?.id as string,
62+
time: null, // TODO: Add time picker
5963
});
6064

61-
await Promise.all(addOccurrences);
65+
if (note) {
66+
await addNote({
67+
content: note,
68+
occurrenceId: newOccurrence.id,
69+
userId: user.id,
70+
});
71+
}
6272

6373
handleClose();
6474
};
6575

6676
const handleClose = () => {
67-
setSelectedHabitIds([]);
77+
setSelectedHabitId('');
78+
setNote('');
6879
onClose();
6980
};
7081

7182
const handleHabitSelect = (habitId: string) => {
72-
if (selectedHabitIds.includes(habitId)) {
73-
setSelectedHabitIds(selectedHabitIds.filter((id) => id !== habitId));
74-
} else {
75-
setSelectedHabitIds([...selectedHabitIds, habitId]);
76-
}
83+
setSelectedHabitId(habitId);
7784
};
7885

7986
return (
@@ -88,39 +95,57 @@ const AddOccurrenceDialog = ({
8895
Add habit entries for {format(date, 'iii, LLL d, y')}
8996
</ModalHeader>
9097
<ModalBody>
91-
<Listbox
98+
<Select
99+
disableSelectorIconRotation
92100
variant="flat"
93-
color="primary"
94-
selectionMode="multiple"
95-
selectedKeys={selectedHabitIds}
96-
disabledKeys={['none']}
97-
emptyContent="No habits yet. Create a habit to get started."
98-
className="max-h-80 overflow-auto rounded border border-neutral-200 p-2 dark:border-neutral-800"
101+
selectedKeys={selectedHabitId}
102+
label={
103+
hasHabits
104+
? 'Habits'
105+
: 'No habits yet. Create a habit to get started.'
106+
}
107+
description="Select from your habits"
108+
data-testid="habit-select"
109+
selectorIcon={<ArrowsClockwise />}
99110
>
100111
{Object.keys(habitsByTraitName).map((traitName) => (
101-
<ListboxSection key={traitName} showDivider title={traitName}>
112+
<SelectSection key={traitName} title={traitName} showDivider>
102113
{habitsByTraitName[traitName] ? (
103-
habitsByTraitName[traitName].map((habit) => (
104-
<ListboxItem
105-
key={habit.id.toString()}
106-
onClick={() => handleHabitSelect(habit.id.toString())}
107-
>
108-
{habit.name}
109-
</ListboxItem>
110-
))
114+
habitsByTraitName[traitName].map((habit) => {
115+
const iconUrl = getHabitIconUrl(habit.iconPath);
116+
117+
return (
118+
<SelectItem
119+
key={habit.id.toString()}
120+
textValue={habit.name}
121+
onClick={() => handleHabitSelect(habit.id.toString())}
122+
>
123+
<div className="flex items-center gap-2">
124+
<img
125+
src={iconUrl}
126+
alt={habit.name}
127+
role="habit-icon"
128+
className="h-4 w-4"
129+
/>
130+
<span>{habit.name}</span>
131+
</div>
132+
</SelectItem>
133+
);
134+
})
111135
) : (
112136
<ListboxItem key="none">No habits</ListboxItem>
113137
)}
114-
</ListboxSection>
138+
</SelectSection>
115139
))}
116-
</Listbox>
140+
</Select>
141+
<Textarea onValueChange={setNote} value={note} placeholder="Note" />
117142
</ModalBody>
118143
<ModalFooter>
119144
<Button
120145
as={hasHabits ? Button : Link}
121146
type="submit"
122147
color="primary"
123-
isLoading={addingOccurrence}
148+
isLoading={addingOccurrence || addingNote}
124149
onClick={hasHabits ? handleSubmit : undefined}
125150
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
126151
// @ts-ignore

src/components/calendar/Calendar.tsx

Lines changed: 44 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import React from 'react';
77
import { type AriaButtonProps, useCalendar, useLocale } from 'react-aria';
88
import { useCalendarState } from 'react-stately';
99

10+
import AddOccurrenceDialog from './AddOccurrenceDialog';
1011
import CalendarGrid from './CalendarGrid';
1112
import CalendarHeader from './CalendarHeader';
1213

@@ -28,6 +29,8 @@ const Calendar = () => {
2829
});
2930
const { calendarProps, prevButtonProps, nextButtonProps, title } =
3031
useCalendar({}, state);
32+
const [dayModalDialogOpen, setDayModalDialogOpen] = React.useState(false);
33+
const [activeDate, setActiveDate] = React.useState<Date | null>(null);
3134

3235
React.useEffect(() => {
3336
onRangeChange(
@@ -78,24 +81,49 @@ const Calendar = () => {
7881
setFocusedDate(year, month + 1, day);
7982
};
8083

84+
const handleDayModalDialogOpen = (
85+
dateNumber: number,
86+
monthIndex: number,
87+
fullYear: number
88+
) => {
89+
setDayModalDialogOpen(true);
90+
setActiveDate(new Date(fullYear, monthIndex - 1, dateNumber, 12));
91+
};
92+
93+
const handleDayModalDialogClose = () => {
94+
setActiveDate(null);
95+
setDayModalDialogOpen(false);
96+
};
97+
8198
return (
82-
<div
83-
{...calendarProps}
84-
className="flex h-full w-full max-w-full flex-1 flex-col gap-2 p-0 pb-8 lg:gap-4 lg:px-16 lg:py-4"
85-
>
86-
<CalendarHeader
87-
activeMonthLabel={capitalizeFirstLetter(activeMonthLabel)}
88-
activeYear={activeYear}
89-
prevButtonProps={transformButtonProps(prevButtonProps)}
90-
nextButtonProps={transformButtonProps(nextButtonProps)}
91-
onNavigateBack={state.focusPreviousPage}
92-
onNavigateForward={state.focusNextPage}
93-
onNavigateToMonth={navigateToMonth}
94-
onNavigateToYear={navigateToYear}
95-
onResetFocusedDate={resetFocusedDate}
99+
<>
100+
<div
101+
{...calendarProps}
102+
className="flex h-full w-full max-w-full flex-1 flex-col gap-2 p-0 pb-8 lg:gap-4 lg:px-16 lg:py-4"
103+
>
104+
<CalendarHeader
105+
activeMonthLabel={capitalizeFirstLetter(activeMonthLabel)}
106+
activeYear={activeYear}
107+
prevButtonProps={transformButtonProps(prevButtonProps)}
108+
nextButtonProps={transformButtonProps(nextButtonProps)}
109+
onNavigateBack={state.focusPreviousPage}
110+
onNavigateForward={state.focusNextPage}
111+
onNavigateToMonth={navigateToMonth}
112+
onNavigateToYear={navigateToYear}
113+
onResetFocusedDate={resetFocusedDate}
114+
/>
115+
<CalendarGrid
116+
state={state}
117+
onDayModalDialogOpen={handleDayModalDialogOpen}
118+
/>
119+
</div>
120+
121+
<AddOccurrenceDialog
122+
open={dayModalDialogOpen}
123+
onClose={handleDayModalDialogClose}
124+
date={activeDate}
96125
/>
97-
<CalendarGrid state={state} />
98-
</div>
126+
</>
99127
);
100128
};
101129

0 commit comments

Comments
 (0)