Skip to content

Commit

Permalink
Merge pull request #8 from yamitzky/feature/settings
Browse files Browse the repository at this point in the history
feat: implement reminder
  • Loading branch information
yamitzky authored Feb 11, 2025
2 parents 9768f44 + f854b47 commit 7f6b0f5
Show file tree
Hide file tree
Showing 56 changed files with 5,275 additions and 3,672 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/npm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9
version: 10.3.0

- uses: actions/setup-node@v4
with:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9
version: 10.3.0

- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
Expand Down
1 change: 1 addition & 0 deletions apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
},
"dependencies": {
"@synk-cal/core": "workspace:*",
"@synk-cal/google": "workspace:*",
"@synk-cal/repository": "workspace:*",
"@synk-cal/usecase": "workspace:*",
"date-fns": "^4.1.0"
Expand Down
6 changes: 4 additions & 2 deletions apps/cli/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { NotificationRepository } from '@synk-cal/core'
import { config } from '@synk-cal/core'
import { GoogleCalendarRepository } from '@synk-cal/google'
import {
ConsoleNotificationRepository,
GoogleCalendarRepository,
GlobalReminderSettingsRepository,
WebhookNotificationRepository,
} from '@synk-cal/repository'
import { processReminders } from '@synk-cal/usecase'
Expand All @@ -29,14 +30,15 @@ async function main() {

const baseTime = parseISO(baseTimeArg)

const reminderSettingsRepository = new GlobalReminderSettingsRepository()
const calendarRepositories = config.CALENDAR_IDS.map((id) => new GoogleCalendarRepository(id))
const notificationRepositories: Record<string, NotificationRepository> = {
console: new ConsoleNotificationRepository(),
}
if (config.WEBHOOK_URL) {
notificationRepositories.webhook = new WebhookNotificationRepository(config.WEBHOOK_URL)
}
await processReminders(baseTime, calendarRepositories, notificationRepositories)
await processReminders(baseTime, calendarRepositories, notificationRepositories, reminderSettingsRepository)
}

main().catch((error) => {
Expand Down
27 changes: 7 additions & 20 deletions apps/web/app/components/CalendarHeader.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { CalendarHeader } from './CalendarHeader'

describe('CalendarHeader', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.useFakeTimers({ shouldAdvanceTime: true })
})
afterEach(() => {
vi.useRealTimers()
Expand Down Expand Up @@ -57,30 +58,16 @@ describe('CalendarHeader', () => {
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('May 1, 2023')
})

it('calls onSearch when search input changes', () => {
it('calls onSearch when search input changes', async () => {
const user = userEvent.setup()
const onSearch = vi.fn()
render(<CalendarHeader onSearch={onSearch} />)

const searchButton = screen.getByLabelText('Open Search')
fireEvent.click(searchButton)

const searchInput = screen.getByPlaceholderText('Search...')
fireEvent.change(searchInput, { target: { value: 'meeting' } })
fireEvent.keyDown(searchInput, { key: 'Enter' })
await user.click(searchButton)
// autofocus
await user.keyboard('meeting{Enter}')

expect(onSearch).toHaveBeenCalledWith('meeting')
})

it('shows search input when user icon is clicked', () => {
const user = { email: '[email protected]' }
render(<CalendarHeader user={user} onSearch={vi.fn()} />)

const searchInput = screen.queryByPlaceholderText('Search...')
expect(searchInput).toBeNull()

const userIcon = screen.getByLabelText('User avatar')
fireEvent.click(userIcon)

expect(screen.getByPlaceholderText('Search...')).toBeVisible()
})
})
6 changes: 3 additions & 3 deletions apps/web/app/components/CalendarHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { User } from '@synk-cal/core'
import { subDays } from 'date-fns'
import { useState } from 'react'
import { twMerge } from 'tailwind-merge'
import { UserInfo } from '~/components/UserInfo'
import { UserInfoMenu } from '~/components/UserInfoMenu'
import type { CalendarViewType } from '~/components/viewType'
import useLocale from '~/hooks/useLocale'

Expand Down Expand Up @@ -111,9 +111,9 @@ export const CalendarHeader = ({
</Button>
)}
{user && (
<UserInfo
<UserInfoMenu
user={user}
onClick={() => {
onClickShowMyEvents={() => {
setIsEditing(true)
setQuery(user.email)
onSearch?.(user.email)
Expand Down
7 changes: 6 additions & 1 deletion apps/web/app/components/EventDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@ type Props = {
export const EventDetail = ({ title, start, end, description, location, people, conference, className }: Props) => {
const locale = useLocale()
return (
<div className={twMerge('space-y-2', className)}>
<div
className={twMerge(
'space-y-2 [&_a]:text-teal-500 [&_a]:underline [&_a]:transition [&_a:hover]:opacity-80',
className,
)}
>
<p className="font-bold text-lg">{title}</p>
<p>{formatRange(locale, start, end)}</p>
{conference && (
Expand Down
40 changes: 40 additions & 0 deletions apps/web/app/components/Settings.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { Meta, StoryObj } from '@storybook/react'
import { fn } from '@storybook/test'
import { ReminderSettings } from './Settings'

const meta = {
title: 'Components/ReminderSettings',
component: ReminderSettings,
parameters: {
layout: 'centered',
},
args: {
onChange: fn(),
className: 'w-96',
},
} satisfies Meta<typeof ReminderSettings>

export default meta
type Story = StoryObj<typeof meta>

export const Default: Story = {
args: {
user: {
email: '[email protected]',
name: 'Test User',
},
reminders: [
{ id: '1', minutes: 5, type: 'webhook' },
{ id: '2', minutes: 30, type: 'webhook' },
],
},
}

export const Empty: Story = {
args: {
user: {
email: '[email protected]',
},
reminders: [],
},
}
83 changes: 83 additions & 0 deletions apps/web/app/components/Settings.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { ReminderSetting } from '@synk-cal/core'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { ReminderSettings } from './Settings'

describe('ReminderSettings', () => {
const userInfo = {
email: '[email protected]',
name: 'Test User',
}

it('renders user information', () => {
render(<ReminderSettings user={userInfo} reminders={[]} onChange={vi.fn()} />)
expect(screen.getByText('Test User')).toBeInTheDocument()
expect(screen.getByText('[email protected]')).toBeInTheDocument()
})

it('displays existing reminders', () => {
const reminders = [
{ id: '1', minutesBefore: 5, notificationType: 'webhook' },
{ id: '2', minutesBefore: 30, notificationType: 'webhook' },
] satisfies ReminderSetting[]
render(<ReminderSettings user={userInfo} reminders={reminders} onChange={vi.fn()} />)

expect(screen.getByRole('button', { name: '5 min Notify before' }))
expect(screen.getByRole('button', { name: '30 min Notify before' }))
})

it('calls onChange when adding a reminder', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<ReminderSettings user={userInfo} reminders={[]} onChange={onChange} />)

await user.click(screen.getByText('+'))

expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange).toHaveBeenCalledWith([
expect.objectContaining({
minutesBefore: 5,
notificationType: 'webhook',
} satisfies ReminderSetting),
])
})

it('calls onChange when removing a reminder', async () => {
const user = userEvent.setup()

const reminders = [
{ id: '1', minutesBefore: 5, notificationType: 'webhook' },
{ id: '2', minutesBefore: 30, notificationType: 'webhook' },
] satisfies ReminderSetting[]
const onChange = vi.fn()
render(<ReminderSettings user={userInfo} reminders={reminders} onChange={onChange} />)

const removeButton = screen.getAllByRole('button', { name: '❌' })[0]
if (removeButton) {
await user.click(removeButton)
}

expect(onChange).toHaveBeenCalledWith([reminders[1]])
})

it('calls onChange when updating reminder minutes', async () => {
const user = userEvent.setup()
const reminders = [{ id: '1', minutesBefore: 5, notificationType: 'webhook' }] satisfies ReminderSetting[]
const onChange = vi.fn()
render(<ReminderSettings user={userInfo} reminders={reminders} onChange={onChange} />)

const select = screen.getByRole('button', { name: '5 min Notify before' })
await user.click(select)
const option = screen.getByRole('option', { name: '10 min' })
await user.click(option)

expect(onChange).toHaveBeenCalledWith([
expect.objectContaining({
id: '1',
minutesBefore: 10,
notificationType: 'webhook',
} satisfies ReminderSetting),
])
})
})
115 changes: 115 additions & 0 deletions apps/web/app/components/Settings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { Card, CardBody, CardHeader } from '@nextui-org/react'
import { type ReminderSetting, type User, config } from '@synk-cal/core'
import { UserInfo } from '~/components/UserInfo'

import { Button, Input, Select, SelectItem } from '@nextui-org/react'
import { twMerge } from 'tailwind-merge'
import useLocale from '~/hooks/useLocale'

type Props = {
user: User
reminders: readonly ReminderSetting[]
onChange: (reminders: ReminderSetting[]) => void
className?: string
}

const NOTIFY_BEFORE_OPTIONS = config.REMINDER_MINUTES_BEFORE_OPTIONS.map((value) => {
if (value < 60) {
return { value, unit: 'min' as const, amount: value }
} else if (value < 1440) {
return { value, unit: 'hour' as const, amount: value / 60 }
} else {
return { value, unit: 'day' as const, amount: value / 1440 }
}
})

const unitInJapanese = {
min: '分',
hour: '時間',
day: '日',
} as const

export function ReminderSettings({ user, reminders, onChange, className }: Props) {
const addReminder = () => {
const newReminder: ReminderSetting = {
id: crypto.randomUUID(),
minutesBefore: 5,
notificationType: 'webhook',
}
onChange([...reminders, newReminder])
}

const removeReminder = (id: string | number) => {
onChange(reminders.filter((reminder) => reminder.id !== id))
}

const updateReminder = (id: string | number, minutesBefore: number) => {
onChange(
reminders.map((reminder) =>
reminder.id === id ? ({ ...reminder, minutesBefore } satisfies ReminderSetting) : reminder,
),
)
}

const locale = useLocale()

return (
<div className={twMerge('flex flex-col gap-4', className)}>
<Card>
<CardHeader className="flex gap-4">
<UserInfo user={user} />
<div>
<p className="text-lg">{user.name || user.email}</p>
<p className="text-small text-default-500">{user.email}</p>
</div>
</CardHeader>
</Card>

<Card>
<CardHeader className="flex justify-between items-center">
<h2 className="text-lg font-semibold">{locale === 'ja' ? 'リマインダー' : 'Reminder Settings'}</h2>
<Button color="primary" variant="flat" onClick={addReminder} className="px-4">
</Button>
</CardHeader>
<CardBody>
<div className="flex flex-col gap-4">
{reminders.map((reminder, i) => (
<div key={reminder.id} className="flex items-center gap-4">
<Select
label={locale === 'ja' ? '通知タイミング' : 'Notify before'}
isRequired
selectedKeys={[reminder.minutesBefore.toString()]}
className="max-w-[200px]"
onChange={(e) => updateReminder(reminder.id ?? i, Number(e.target.value))}
items={NOTIFY_BEFORE_OPTIONS}
>
{({ unit, amount, value }) => {
const label = `${amount} ${locale === 'ja' ? unitInJapanese[unit] : unit}`
return (
<SelectItem key={value.toString()} textValue={label}>
{label}
</SelectItem>
)
}}
</Select>
<Input
label={locale === 'ja' ? '通知タイプ' : 'Notification type'}
onChange={() => {
throw new Error('Not implemented')
}}
value={reminder.notificationType}
isReadOnly
className="max-w-[200px]"
/>
<Button color="danger" variant="flat" size="sm" onClick={() => removeReminder(reminder.id ?? i)}>
</Button>
</div>
))}
</div>
</CardBody>
</Card>
</div>
)
}
5 changes: 1 addition & 4 deletions apps/web/app/components/UserInfo.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
import type { Meta, StoryObj } from '@storybook/react'
import { fn } from '@storybook/test'
import { UserInfo } from './UserInfo'

const meta = {
component: UserInfo,
tags: ['autodocs'],
args: {
onClick: fn(),
},
args: {},
} satisfies Meta<typeof UserInfo>

export default meta
Expand Down
Loading

0 comments on commit 7f6b0f5

Please sign in to comment.