Skip to content

Commit

Permalink
Merge pull request #12 from yamitzky/feat/day-before-reminder
Browse files Browse the repository at this point in the history
feat: add support for day-before reminders
  • Loading branch information
yamitzky authored Feb 13, 2025
2 parents f0a18da + c010779 commit 6a1384f
Show file tree
Hide file tree
Showing 10 changed files with 187 additions and 103 deletions.
75 changes: 52 additions & 23 deletions apps/web/app/components/Settings.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Card, CardBody, CardHeader } from '@nextui-org/react'
import { type ReminderSetting, type User } from '@synk-cal/core'
import { type ReminderSetting, ReminderTiming, type User } from '@synk-cal/core'
import { UserInfo } from '~/components/UserInfo'

import { Button, Input, Select, SelectItem } from '@nextui-org/react'
Expand All @@ -10,21 +10,31 @@ type Props = {
user: User
reminders: readonly ReminderSetting[]
onChange: (reminders: ReminderSetting[]) => void
notifyBeforeOptions?: number[]
minutesBeforeOptions?: number[]
previousDayAtOptions?: { hour: number; minute: number }[]
className?: string
}

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

const getTimingKey = (reminder: ReminderTiming) => {
if ('minutesBefore' in reminder) {
return String(reminder.minutesBefore)
} else {
return `${String(reminder.hour).padStart(2, '0')}:${String(reminder.minute).padStart(2, '0')}`
}
}

export function ReminderSettings({
user,
reminders,
onChange,
notifyBeforeOptions = [5, 10, 15, 30, 60, 120, 180, 360, 720, 1440],
minutesBeforeOptions = [5, 10, 15, 30, 60, 120, 180, 360, 720, 1440],
previousDayAtOptions = Array.from({ length: 24 }).map((_, hour) => ({ hour, minute: 0 })),
className,
}: Props) {
const addReminder = () => {
Expand All @@ -40,15 +50,38 @@ export function ReminderSettings({
onChange(reminders.filter((reminder) => reminder.id !== id))
}

const updateReminder = (id: string | number, minutesBefore: number) => {
const updateReminder = (id: string | number, value: ReminderTiming) => {
onChange(
reminders.map((reminder) =>
reminder.id === id ? ({ ...reminder, minutesBefore } satisfies ReminderSetting) : reminder,
),
reminders.map((reminder) => {
if (reminder.id === id) {
// @ts-expect-error
const { minutesBefore, hour, minute, ...rest } = reminder
return { ...rest, ...value }
}
return reminder
}),
)
}

const locale = useLocale()
const options: { label: string; key: string; value: ReminderTiming }[] = [
...minutesBeforeOptions.map((min) => {
const value = { minutesBefore: min }
let label = ''
if (min < 60) {
label = `${min} ${locale === 'ja' ? unitInJapanese.min : 'min'}`
} else if (min < 1440) {
label = `${min / 60} ${locale === 'ja' ? unitInJapanese.hour : 'hour'}`
} else {
label = `${min / 1440} ${locale === 'ja' ? unitInJapanese.day : 'day'}`
}
return { label, key: getTimingKey(value), value }
}),
...previousDayAtOptions.map((value) => {
const key = getTimingKey(value)
return { label: locale === 'ja' ? `前日の ${key}` : `${key} the day before`, key, value }
}),
]

return (
<div className={twMerge('flex flex-col gap-4', className)}>
Expand Down Expand Up @@ -76,23 +109,19 @@ export function ReminderSettings({
<Select
label={locale === 'ja' ? '通知タイミング' : 'Notify before'}
isRequired
selectedKeys={[reminder.minutesBefore.toString()]}
selectedKeys={[getTimingKey(reminder)]}
className="max-w-[200px]"
onChange={(e) => updateReminder(reminder.id ?? i, Number(e.target.value))}
items={notifyBeforeOptions.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 }
onChange={(e) => {
const value = options.find(({ key }) => key === e.target.value)?.value
if (value) {
updateReminder(reminder.id ?? i, value)
}
})}
}}
items={options}
>
{({ unit, amount, value }) => {
const label = `${amount} ${locale === 'ja' ? unitInJapanese[unit] : unit}`
{({ key, label }) => {
return (
<SelectItem key={value.toString()} textValue={label}>
<SelectItem key={key} textValue={label}>
{label}
</SelectItem>
)
Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/routes/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ export default function SettingsRoute() {
<ReminderSettings
user={user}
reminders={currentReminders}
notifyBeforeOptions={notifyBeforeOptions}
minutesBeforeOptions={notifyBeforeOptions}
onChange={handleRemindersChange}
/>
)}
Expand Down
6 changes: 1 addition & 5 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,7 @@
"bin": {
"synk-cal": "./bin/server.js"
},
"files": [
"bin",
"public",
"build"
],
"files": ["bin", "public", "build"],
"scripts": {
"build": "remix vite:build",
"dev": "remix vite:dev",
Expand Down
29 changes: 18 additions & 11 deletions packages/core/src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,17 @@ import { parseConfig } from './config'

describe('Config', () => {
it('should parse valid configuration', () => {
const mockEnv = {
const mockEnv: NodeJS.ProcessEnv = {
NODE_ENV: 'test',
GOOGLE_AUTH_SUBJECT: '[email protected]',
CALENDAR_IDS: 'calendar1,calendar2',
REMINDER_SETTINGS: JSON.stringify([
{ minutesBefore: 10, notificationType: 'email' },
{ minutesBefore: 30, notificationType: 'sms', target: '+1234567890' },
{ hour: 9, minute: 0, notificationType: 'sms', target: '+1234567890' },
]),
REMINDER_TEMPLATE: 'Your event {eventName} starts in {minutesBefore} minutes',
WEBHOOK_URL: 'https://example.com/webhook',
CALENDAR_PROVIDER: 'google',
REMINDER_MINUTES_BEFORE_OPTIONS: '1,2,3,4,5',
REMINDER_SETTINGS_FIRESTORE_DATABASE_ID: 'test',
REMINDER_SETTINGS_PROVIDER: 'firestore',
AUTH_PROVIDER: 'google-iap',
Expand All @@ -25,61 +25,68 @@ describe('Config', () => {
expect(parsedConfig).toEqual({
GOOGLE_AUTH_SUBJECT: '[email protected]',
CALENDAR_IDS: ['calendar1', 'calendar2'],
TIMEZONE: 'UTC',
REMINDER_SETTINGS: [
{ minutesBefore: 10, notificationType: 'email' },
{ minutesBefore: 30, notificationType: 'sms', target: '+1234567890' },
{ hour: 9, minute: 0, notificationType: 'sms', target: '+1234567890' },
],
REMINDER_TEMPLATE: 'Your event {eventName} starts in {minutesBefore} minutes',
WEBHOOK_URL: 'https://example.com/webhook',
REMINDER_SETTINGS_PROVIDER: 'firestore',
REMINDER_SETTINGS_FIRESTORE_DATABASE_ID: 'test',
REMINDER_MINUTES_BEFORE_OPTIONS: [1, 2, 3, 4, 5],
CALENDAR_PROVIDER: 'google',
AUTH_PROVIDER: 'google-iap',
})
})

it('should handle missing optional values', () => {
const mockEnv = {
const mockEnv: NodeJS.ProcessEnv = {
CALENDAR_IDS: 'calendar1',
NODE_ENV: 'test',
}

const parsedConfig = parseConfig(mockEnv)

expect(parsedConfig).toEqual({
GOOGLE_AUTH_SUBJECT: undefined,
CALENDAR_IDS: ['calendar1'],
TIMEZONE: 'UTC',
REMINDER_SETTINGS: [],
REMINDER_TEMPLATE: undefined,
WEBHOOK_URL: undefined,
REMINDER_SETTINGS_PROVIDER: 'global',
REMINDER_SETTINGS_FIRESTORE_DATABASE_ID: undefined,
REMINDER_MINUTES_BEFORE_OPTIONS: [5, 10, 15, 30, 60, 120, 180, 360, 720, 1440],
AUTH_PROVIDER: undefined,
CALENDAR_PROVIDER: 'google',
})
})

it('should throw an error for invalid WEBHOOK_URL', () => {
const mockEnv = {
const mockEnv: NodeJS.ProcessEnv = {
WEBHOOK_URL: 'invalid-url',
NODE_ENV: 'test',
}

expect(() => parseConfig(mockEnv)).toThrow(v.ValiError)
})

it('should throw an error for invalid REMINDER_SETTINGS', () => {
const mockEnv = {
REMINDER_SETTINGS: JSON.stringify([{ minutesBefore: 'invalid', notificationType: 'email' }]),
}
NODE_ENV: 'test',
REMINDER_SETTINGS: JSON.stringify([
{ minutesBefore: 'invalid', notificationType: 'email' },
{ hour: 25, minute: 0, notificationType: 'email' },
]),
} as NodeJS.ProcessEnv

expect(() => parseConfig(mockEnv)).toThrow(v.ValiError)
})

it('should throw an error for invalid REMINDER_SETTINGS JSON', () => {
const mockEnv = {
REMINDER_SETTINGS: 'invalid-json',
}
NODE_ENV: 'test',
} as NodeJS.ProcessEnv

expect(() => parseConfig(mockEnv)).toThrow('Invalid REMINDER_SETTINGS JSON')
})
Expand Down
32 changes: 14 additions & 18 deletions packages/core/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import * as v from 'valibot'

const reminderSettingSchema = v.object({
minutesBefore: v.number(),
notificationType: v.string(),
target: v.optional(v.string()),
})
const reminderSettingSchema = v.union([
v.object({
notificationType: v.string(),
target: v.optional(v.string()),
minutesBefore: v.number(),
}),
v.object({
notificationType: v.string(),
target: v.optional(v.string()),
hour: v.pipe(v.number(), v.minValue(0), v.maxValue(23)),
minute: v.pipe(v.number(), v.minValue(0), v.maxValue(59)),
}),
])

export const ConfigSchema = v.object({
GOOGLE_AUTH_SUBJECT: v.optional(v.string()),
Expand All @@ -13,6 +21,7 @@ export const ConfigSchema = v.object({
v.optional(v.string()),
v.transform((value) => value?.split(',') ?? []),
),
TIMEZONE: v.optional(v.string(), 'UTC'),
REMINDER_SETTINGS: v.pipe(
v.optional(v.string()),
v.transform((value) => {
Expand All @@ -27,18 +36,6 @@ export const ConfigSchema = v.object({
}),
v.array(reminderSettingSchema),
),
REMINDER_MINUTES_BEFORE_OPTIONS: v.pipe(
v.optional(v.string(), '5,10,15,30,60,120,180,360,720,1440'),
v.transform((value) => {
return value.split(',').map((value) => {
const valueNumber = parseInt(value, 10)
if (isNaN(valueNumber)) {
throw new Error('Invalid REMINDER_MINUTES_BEFORE_OPTIONS')
}
return valueNumber
})
}),
),
REMINDER_TEMPLATE: v.optional(v.string()),
REMINDER_SETTINGS_PROVIDER: v.optional(v.union([v.literal('global'), v.literal('firestore')]), 'global'),
REMINDER_SETTINGS_FIRESTORE_DATABASE_ID: v.optional(v.string()),
Expand All @@ -53,7 +50,6 @@ export function parseConfig(env: NodeJS.ProcessEnv): Config {
GOOGLE_AUTH_SUBJECT: env.GOOGLE_AUTH_SUBJECT,
CALENDAR_PROVIDER: env.CALENDAR_PROVIDER,
CALENDAR_IDS: env.CALENDAR_IDS,
REMINDER_MINUTES_BEFORE_OPTIONS: env.REMINDER_MINUTES_BEFORE_OPTIONS,
REMINDER_SETTINGS: env.REMINDER_SETTINGS,
REMINDER_SETTINGS_PROVIDER: env.REMINDER_SETTINGS_PROVIDER,
REMINDER_SETTINGS_FIRESTORE_DATABASE_ID: env.REMINDER_SETTINGS_FIRESTORE_DATABASE_ID,
Expand Down
14 changes: 11 additions & 3 deletions packages/core/src/reminder.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
export interface ReminderSetting {
export type ReminderTiming =
| {
minutesBefore: number
}
| {
hour: number
minute: number
}

export type ReminderSetting = {
id?: string | number
minutesBefore: number
notificationType: string
target?: string
}
} & ReminderTiming

export interface ReminderSettingsRepository {
getReminderSettings: (userKey: string) => Promise<ReminderSetting[]>
Expand Down
1 change: 1 addition & 0 deletions packages/usecase/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"@synk-cal/core": "workspace:*",
"@synk-cal/repository": "workspace:*",
"date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0",
"eta": "^3.5.0"
},
"devDependencies": {
Expand Down
Loading

0 comments on commit 6a1384f

Please sign in to comment.