Add pomodoro timer
@@ -15,6 +15,7 @@
|
||||
"@mdi/js": "^6.5.95",
|
||||
"@mdi/react": "^1.5.0",
|
||||
"@types/file-saver": "^2.0.5",
|
||||
"classcat": "^5.0.4",
|
||||
"date-fns": "^2.27.0",
|
||||
"html-to-image": "^1.10.6",
|
||||
"next": "^12.1.6",
|
||||
@@ -2022,6 +2023,11 @@
|
||||
"integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/classcat": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.4.tgz",
|
||||
"integrity": "sha512-sbpkOw6z413p+HDGcBENe498WM9woqWHiJxCq7nvmxe9WmrUmqfAcxpIwAiMtM5Q3AhYkzXcNQHqsWq0mND51g=="
|
||||
},
|
||||
"node_modules/classnames": {
|
||||
"version": "2.2.6",
|
||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.6.tgz",
|
||||
@@ -7235,6 +7241,11 @@
|
||||
"domutils": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"classcat": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.4.tgz",
|
||||
"integrity": "sha512-sbpkOw6z413p+HDGcBENe498WM9woqWHiJxCq7nvmxe9WmrUmqfAcxpIwAiMtM5Q3AhYkzXcNQHqsWq0mND51g=="
|
||||
},
|
||||
"classnames": {
|
||||
"version": "2.2.6",
|
||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.6.tgz",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"@mdi/js": "^6.5.95",
|
||||
"@mdi/react": "^1.5.0",
|
||||
"@types/file-saver": "^2.0.5",
|
||||
"classcat": "^5.0.4",
|
||||
"date-fns": "^2.27.0",
|
||||
"html-to-image": "^1.10.6",
|
||||
"next": "^12.1.6",
|
||||
|
||||
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 217 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 222 KiB |
@@ -6,6 +6,7 @@ import Head from '../../../components/atoms/Head'
|
||||
import Honkai3rdLayout from '../../../components/layouts/Honkai3rdLayout'
|
||||
import { getI18NProps } from 'server/i18n'
|
||||
import { NextPageContext } from 'next'
|
||||
import NextLink from 'next/link'
|
||||
|
||||
const MediaPage = () => {
|
||||
const { t } = useTranslation()
|
||||
@@ -27,6 +28,7 @@ const MediaPage = () => {
|
||||
/>
|
||||
|
||||
<Heading as='h1'>{t('common.media')}</Heading>
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Heading as='h2'>{t('common.novels')}</Heading>
|
||||
|
||||
<Box sx={{ mb: 3, pl: 2 }} as='ul'>
|
||||
@@ -61,8 +63,16 @@ const MediaPage = () => {
|
||||
</Link>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Paragraph>Other novels will be available one day.</Paragraph>
|
||||
<Heading as='h2'>Abyss Lab Custom</Heading>
|
||||
<Box sx={{ mb: 3, pl: 2 }} as='ul'>
|
||||
<Box as='li'>
|
||||
<NextLink href='/honkai3rd/media/pomodoro' passHref>
|
||||
<Link>Pomodoro Timer</Link>
|
||||
</NextLink>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Honkai3rdLayout>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Box, ThemeProvider } from 'theme-ui'
|
||||
import PomodoroAppMain from '../../../pomodoro/PomodoroAppMain'
|
||||
import { loadTimerEvents } from '../../../pomodoro/data'
|
||||
import { TimerEvent } from '../../../pomodoro/interfaces'
|
||||
import { theme } from '../../../pomodoro/theme'
|
||||
|
||||
const PomodoroAppPage = () => {
|
||||
const [initialized, setInitialized] = useState(false)
|
||||
const [initialTimerEvents, setInitialTimerEvents] = useState<TimerEvent[]>([])
|
||||
useEffect(() => {
|
||||
setInitialTimerEvents(loadTimerEvents())
|
||||
setInitialized(true)
|
||||
}, [])
|
||||
|
||||
if (!initialized) {
|
||||
return <Box>Loading</Box>
|
||||
}
|
||||
return (
|
||||
<Box>
|
||||
<ThemeProvider theme={theme}>
|
||||
<PomodoroAppMain initialTimerEvents={initialTimerEvents} />
|
||||
</ThemeProvider>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default PomodoroAppPage
|
||||
@@ -0,0 +1,838 @@
|
||||
import classcat from 'classcat'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
// Checkbox,
|
||||
Flex,
|
||||
Heading,
|
||||
Input,
|
||||
Label,
|
||||
Link,
|
||||
Slider,
|
||||
Text,
|
||||
} from 'theme-ui'
|
||||
import {
|
||||
defaultPomodoroSettings,
|
||||
loadPomodoroSettings,
|
||||
savePomodoroSettings,
|
||||
saveTimerEvents,
|
||||
} from './data'
|
||||
import {
|
||||
PauseTimerEvent,
|
||||
PomodoroSettings,
|
||||
ResumeTimerEvent,
|
||||
StartTimerEvent,
|
||||
TimerEvent,
|
||||
TimerEventType,
|
||||
TimerType,
|
||||
} from './interfaces'
|
||||
// import JSONDebugBox from './JSONDebugBox'
|
||||
import PomodoroTimerTypeButtonGroup from './PomodoroTimerTypeButtonGroup'
|
||||
import { createMemoFunction } from './utils'
|
||||
import { useValueRef } from './hooks'
|
||||
import ProgressCircle from './ProgressCircle'
|
||||
import RemainingTimeBox from './RemainingTimeBox'
|
||||
import PomodoroStatusBox from './PomodoroStatusBox'
|
||||
import PomodoroImage from './PomodoroImage'
|
||||
|
||||
interface PomodoroAppMainProps {
|
||||
initialTimerEvents: TimerEvent[]
|
||||
}
|
||||
|
||||
const PomodoroAppMain = ({ initialTimerEvents }: PomodoroAppMainProps) => {
|
||||
const [events, setEvents] = useState<TimerEvent[]>(initialTimerEvents)
|
||||
const initialRenderingRef = useRef(false)
|
||||
const [timerType, setTimerType] = useState<TimerType>('pomodoro')
|
||||
const [settings, setSettings] = useState<PomodoroSettings>(() => {
|
||||
console.log(loadPomodoroSettings())
|
||||
return loadPomodoroSettings()
|
||||
})
|
||||
const settingsRef = useRef<PomodoroSettings>(settings)
|
||||
useEffect(() => {
|
||||
settingsRef.current = settings
|
||||
savePomodoroSettings(settings)
|
||||
}, [settings])
|
||||
|
||||
const eventsRef = useRef<TimerEvent[]>(events)
|
||||
const longBreakIntervalRef = useValueRef(settings.longBreakInterval)
|
||||
useMemo(() => {
|
||||
eventsRef.current = events
|
||||
}, [events])
|
||||
const alarmAudioRef = useRef<HTMLAudioElement>(null)
|
||||
const tickingAudioRef = useRef<HTMLAudioElement>(null)
|
||||
const rideonAudioRef = useRef<HTMLAudioElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (alarmAudioRef.current != null) {
|
||||
alarmAudioRef.current.volume = settings.volume
|
||||
}
|
||||
if (tickingAudioRef.current != null) {
|
||||
tickingAudioRef.current.volume = settings.volume
|
||||
}
|
||||
if (rideonAudioRef.current != null) {
|
||||
rideonAudioRef.current.volume = settings.volume
|
||||
}
|
||||
}, [settings.volume])
|
||||
|
||||
const playTickingSound = useCallback(() => {
|
||||
if (tickingAudioRef.current == null) {
|
||||
return
|
||||
}
|
||||
tickingAudioRef.current.currentTime = 0
|
||||
tickingAudioRef.current.loop = true
|
||||
tickingAudioRef.current.play()
|
||||
}, [])
|
||||
const stopTickingSound = useCallback(() => {
|
||||
if (tickingAudioRef.current == null) {
|
||||
return
|
||||
}
|
||||
tickingAudioRef.current.pause()
|
||||
}, [])
|
||||
|
||||
const unshiftTimerEvent = useCallback((event: TimerEvent) => {
|
||||
setEvents((previousEvents) => {
|
||||
return [event, ...previousEvents]
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if ((initialRenderingRef.current = false)) {
|
||||
initialRenderingRef.current = true
|
||||
} else {
|
||||
console.log('save timer events')
|
||||
saveTimerEvents(events)
|
||||
}
|
||||
}, [events])
|
||||
|
||||
const lastEvent = events[0]
|
||||
|
||||
const [
|
||||
finishedPomodoroCountSinceLongBreak,
|
||||
setFinishedPomodoroCountSinceLongBreak,
|
||||
] = useState(() => {
|
||||
return getFinishedPomodoroCountSinceLongBreak(events)
|
||||
})
|
||||
|
||||
const [displayStatus, setDisplayStatus] = useState(() => {
|
||||
const { status } = getTimerStatus(events)
|
||||
return status
|
||||
})
|
||||
const [displayRemainingTime, setDisplayRemainingTime] = useState(() => {
|
||||
const { remainingTime } = getTimerStatus(events)
|
||||
return remainingTime
|
||||
})
|
||||
const [displayDuration, setDisplayDuration] = useState(() => {
|
||||
const { lastTimerDuration } = getTimerStatus(events)
|
||||
return lastTimerDuration
|
||||
})
|
||||
const [finishedCount, setFinishedCount] = useState(() => {
|
||||
return getFinishedPomodoroCount(events)
|
||||
})
|
||||
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const refreshTimerIntervalRef = useRef<NodeJS.Timer | null>(null)
|
||||
const startRefresh = useCallback(() => {
|
||||
if (refreshTimerIntervalRef.current != null) {
|
||||
clearInterval(refreshTimerIntervalRef.current)
|
||||
}
|
||||
|
||||
refreshTimerIntervalRef.current = setInterval(() => {
|
||||
const { status, remainingTime, lastTimerDuration } = getTimerStatus(
|
||||
eventsRef.current
|
||||
)
|
||||
if (status !== 'idle') {
|
||||
setDisplayRemainingTime(remainingTime)
|
||||
setDisplayDuration(lastTimerDuration)
|
||||
}
|
||||
}, 1000 / 60)
|
||||
}, [])
|
||||
const endRefresh = useCallback(() => {
|
||||
if (refreshTimerIntervalRef.current != null) {
|
||||
clearInterval(refreshTimerIntervalRef.current)
|
||||
}
|
||||
}, [])
|
||||
const startTimer = useCallback((callback: () => void, ms: number) => {
|
||||
if (timeoutRef.current != null) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
}
|
||||
startRefresh()
|
||||
timeoutRef.current = setTimeout(callback, ms)
|
||||
}, [])
|
||||
const endTimer = useCallback(() => {
|
||||
if (timeoutRef.current != null) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
}
|
||||
endRefresh()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
endRefresh()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const finish = useCallback((silentAlarm: boolean = false) => {
|
||||
endTimer()
|
||||
const { lastTimerType } = getTimerStatus(eventsRef.current)
|
||||
const finishedPomodoroCountSinceLongBreak =
|
||||
getFinishedPomodoroCountSinceLongBreak(eventsRef.current)
|
||||
const finishedCount = getFinishedPomodoroCount(eventsRef.current)
|
||||
if (!silentAlarm) {
|
||||
if (lastTimerType === 'pomodoro' && alarmAudioRef.current != null) {
|
||||
alarmAudioRef.current.currentTime = 0
|
||||
alarmAudioRef.current.play()
|
||||
} else if (rideonAudioRef.current != null) {
|
||||
rideonAudioRef.current.currentTime = 0
|
||||
rideonAudioRef.current.play()
|
||||
}
|
||||
}
|
||||
stopTickingSound()
|
||||
const nextBreakShouldBeLongBreak =
|
||||
finishedPomodoroCountSinceLongBreak >= longBreakIntervalRef.current - 1
|
||||
const nextTimerType =
|
||||
lastTimerType === 'pomodoro'
|
||||
? nextBreakShouldBeLongBreak
|
||||
? 'long-break'
|
||||
: 'short-break'
|
||||
: 'pomodoro'
|
||||
unshiftTimerEvent(createTimerEvent('finish'))
|
||||
if (lastTimerType === 'pomodoro') {
|
||||
setFinishedPomodoroCountSinceLongBreak(
|
||||
nextBreakShouldBeLongBreak ? 0 : finishedPomodoroCountSinceLongBreak + 1
|
||||
)
|
||||
setFinishedCount(finishedCount + 1)
|
||||
}
|
||||
|
||||
const nextTimerDuration = getTimerDuration(
|
||||
nextTimerType,
|
||||
settingsRef.current
|
||||
)
|
||||
setDisplayDuration(nextTimerDuration)
|
||||
setTimerType(nextTimerType)
|
||||
setDisplayRemainingTime(nextTimerDuration * 1000)
|
||||
setDisplayStatus('idle')
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const { status, remainingTime } = getTimerStatus(events)
|
||||
if (status === 'running') {
|
||||
setTimeout(finish, remainingTime < 0 ? 0 : remainingTime)
|
||||
}
|
||||
return () => {
|
||||
endTimer()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onStartResumeButtonClick = useCallback(() => {
|
||||
if (alarmAudioRef.current != null) {
|
||||
alarmAudioRef.current.pause()
|
||||
}
|
||||
const { status, remainingTime } = getTimerStatus(events)
|
||||
switch (status) {
|
||||
case 'running': // pause
|
||||
stopTickingSound()
|
||||
if (remainingTime > 0) {
|
||||
setDisplayStatus('paused')
|
||||
unshiftTimerEvent(createTimerEvent('pause'))
|
||||
endTimer()
|
||||
}
|
||||
break
|
||||
case 'paused': // pause -> running
|
||||
if (timerType === 'pomodoro') {
|
||||
playTickingSound()
|
||||
}
|
||||
setDisplayStatus('running')
|
||||
unshiftTimerEvent(createTimerEvent('resume'))
|
||||
startTimer(finish, remainingTime)
|
||||
break
|
||||
case 'idle': // idle -> running
|
||||
default:
|
||||
if (timerType === 'pomodoro') {
|
||||
playTickingSound()
|
||||
}
|
||||
|
||||
setDisplayStatus('running')
|
||||
const newEvent = createStartTimerEvent({
|
||||
duration: getTimerDuration(timerType, settings),
|
||||
timerType,
|
||||
})
|
||||
unshiftTimerEvent(newEvent)
|
||||
startTimer(finish, getTimerDuration(timerType, settings) * 1000)
|
||||
}
|
||||
}, [timerType, events, settings])
|
||||
|
||||
const onTimerButtonSelect = useCallback(
|
||||
(selectedTimerType: TimerType) => {
|
||||
const { status } = getTimerStatus(events)
|
||||
|
||||
if (status === 'idle') {
|
||||
setTimerType(selectedTimerType)
|
||||
setDisplayRemainingTime(
|
||||
getTimerDuration(selectedTimerType, settings) * 1000
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'running' || status === 'paused') {
|
||||
// should ask to finish or to cancel
|
||||
}
|
||||
},
|
||||
[events, settings]
|
||||
)
|
||||
|
||||
const onFinishButtonClick = useCallback(() => {
|
||||
const { status } = getTimerStatus(events)
|
||||
if (status === 'idle') {
|
||||
return
|
||||
}
|
||||
|
||||
finish(true)
|
||||
}, [events])
|
||||
|
||||
const onCancelButtonClick = useCallback(() => {
|
||||
const { status } = getTimerStatus(events)
|
||||
if (status === 'idle') {
|
||||
return
|
||||
}
|
||||
|
||||
stopTickingSound()
|
||||
endTimer()
|
||||
unshiftTimerEvent(createTimerEvent('cancel'))
|
||||
setDisplayRemainingTime(getTimerDuration(timerType, settings) * 1000)
|
||||
setDisplayStatus('idle')
|
||||
}, [events])
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
height: '100vh',
|
||||
overflow: 'auto',
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
color: 'white',
|
||||
bg:
|
||||
timerType === 'pomodoro'
|
||||
? 'red.6'
|
||||
: timerType === 'short-break'
|
||||
? 'green.9'
|
||||
: 'blue.9',
|
||||
fontFamily: 'monospace',
|
||||
p: 0,
|
||||
m: 0,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
mt: 5,
|
||||
p: 2,
|
||||
}}
|
||||
>
|
||||
<Flex sx={{ justifyContent: 'center' }}>
|
||||
<PomodoroTimerTypeButtonGroup
|
||||
timerType={timerType}
|
||||
onTimerButtonSelect={onTimerButtonSelect}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex
|
||||
sx={{
|
||||
flexDirection: ['column', 'row'],
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<Flex sx={{ justifyContent: 'center', mt: 4, mb: 2 }}>
|
||||
<Box sx={{ width: 150, height: 150, position: 'relative' }}>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: 150,
|
||||
height: 150,
|
||||
}}
|
||||
>
|
||||
<ProgressCircle
|
||||
size={150}
|
||||
progress={
|
||||
(displayDuration - displayRemainingTime / 1000) /
|
||||
displayDuration
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<PomodoroImage
|
||||
displayStatus={displayStatus}
|
||||
timerType={timerType}
|
||||
finishedCountSinceLongBreak={
|
||||
finishedPomodoroCountSinceLongBreak
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</Flex>
|
||||
<Flex
|
||||
sx={{
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
mx: 3,
|
||||
mt: 2,
|
||||
}}
|
||||
>
|
||||
<RemainingTimeBox displayRemainingTime={displayRemainingTime} />
|
||||
<PomodoroStatusBox
|
||||
finishedCount={finishedCount}
|
||||
timerType={timerType}
|
||||
finishedCountSinceLongBreak={finishedPomodoroCountSinceLongBreak}
|
||||
longBreakInterval={settings.longBreakInterval}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
<Flex sx={{ justifyContent: 'center' }}>
|
||||
<Button
|
||||
variant='pomodoro'
|
||||
sx={{ mr: 1, width: [100, 150, 200] }}
|
||||
className={classcat([
|
||||
lastEvent != null && lastEvent.type === 'start' && 'active',
|
||||
])}
|
||||
onClick={onStartResumeButtonClick}
|
||||
>
|
||||
{lastEvent != null && lastEvent.type === 'start'
|
||||
? 'Pause'
|
||||
: lastEvent != null && lastEvent.type === 'pause'
|
||||
? 'Resume'
|
||||
: 'Start'}
|
||||
</Button>
|
||||
<Button
|
||||
variant='pomodoro'
|
||||
sx={{ mr: 1 }}
|
||||
onClick={onFinishButtonClick}
|
||||
>
|
||||
Finish
|
||||
</Button>
|
||||
<Button
|
||||
variant='pomodoro'
|
||||
onClick={onCancelButtonClick}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
<Box sx={{ maxWidth: '460px', margin: '0 auto' }}>
|
||||
<Box sx={{ mt: '60px', mb: 3 }}>
|
||||
<Box sx={{ width: '100%', background: 'white', height: 1 }}></Box>
|
||||
</Box>
|
||||
|
||||
<Heading>Settings</Heading>
|
||||
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Box>Pomodoro Count: {finishedCount}</Box>
|
||||
<Button
|
||||
variant='pomodoro'
|
||||
onClick={() => {
|
||||
setEvents((prevEvents) => {
|
||||
setFinishedPomodoroCountSinceLongBreak(0)
|
||||
setFinishedCount(0)
|
||||
return shiftFinishedEvents(prevEvents)
|
||||
})
|
||||
}}
|
||||
>
|
||||
Reset Count
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 4 }}>
|
||||
{/* <Label>Sound</Label> */}
|
||||
{/* <Label>
|
||||
<Checkbox
|
||||
checked={settings.tickSound}
|
||||
onChange={(event) => {
|
||||
setSettings((prevSettings) => {
|
||||
return {
|
||||
...prevSettings,
|
||||
tickSound: event.target.checked,
|
||||
}
|
||||
})
|
||||
}}
|
||||
/>{' '}
|
||||
Tick Sound
|
||||
</Label>
|
||||
<Label>
|
||||
<Checkbox
|
||||
checked={settings.pomodoroFinishSound}
|
||||
onChange={(event) => {
|
||||
setSettings((prevSettings) => {
|
||||
return {
|
||||
...prevSettings,
|
||||
pomodoroFinishSound: event.target.checked,
|
||||
}
|
||||
})
|
||||
}}
|
||||
/>{' '}
|
||||
Alarm Sound when Pomodoro End
|
||||
</Label> */}
|
||||
{/* <Label>
|
||||
<Checkbox
|
||||
checked={settings.breakFinishSound}
|
||||
onChange={(event) => {
|
||||
setSettings((prevSettings) => {
|
||||
return {
|
||||
...prevSettings,
|
||||
breakFinishSound: event.target.checked,
|
||||
}
|
||||
})
|
||||
}}
|
||||
/>{' '}
|
||||
Rideon Sound when Break End
|
||||
</Label> */}
|
||||
<Box>
|
||||
<Label>Sound Volumn</Label>
|
||||
<Slider
|
||||
value={settings.volume * 100}
|
||||
max={100}
|
||||
min={0}
|
||||
onChange={(event) => {
|
||||
setSettings((prevSettings) => {
|
||||
return {
|
||||
...prevSettings,
|
||||
volume: parseInt(event.target.value, 10) / 100,
|
||||
}
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
{/* <Box sx={{ mb: 3 }}>
|
||||
<Label>Auto Start</Label>
|
||||
<Label>
|
||||
<Checkbox
|
||||
checked={settings.autoStartPomodoros}
|
||||
onChange={(event) => {
|
||||
setSettings((prevSettings) => {
|
||||
return {
|
||||
...prevSettings,
|
||||
autoStartPomodoros: event.target.checked,
|
||||
}
|
||||
})
|
||||
}}
|
||||
/>{' '}
|
||||
Auto Start Pomodoros
|
||||
</Label>
|
||||
<Label>
|
||||
<Checkbox
|
||||
checked={settings.autoStartBreaks}
|
||||
onChange={(event) => {
|
||||
setSettings((prevSettings) => {
|
||||
return {
|
||||
...prevSettings,
|
||||
autoStartBreaks: event.target.checked,
|
||||
}
|
||||
})
|
||||
}}
|
||||
/>{' '}
|
||||
Auto-start Breaks
|
||||
</Label>
|
||||
</Box> */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Label>Duration</Label>
|
||||
<Label>Pomodoro Duration(seconds)</Label>
|
||||
<Input
|
||||
type='number'
|
||||
min={1}
|
||||
value={settings.pomodoroDuration}
|
||||
onChange={(event) => {
|
||||
let newValue = parseInt(event.target.value, 10)
|
||||
setSettings((prevSettings) => {
|
||||
return {
|
||||
...prevSettings,
|
||||
pomodoroDuration: isNaN(newValue) ? 1 : newValue,
|
||||
}
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<Label>Short Break Duration(seconds)</Label>
|
||||
<Input
|
||||
type='number'
|
||||
min={1}
|
||||
value={settings.shortBreakDuration}
|
||||
onChange={(event) => {
|
||||
let newValue = parseInt(event.target.value, 10)
|
||||
setSettings((prevSettings) => {
|
||||
return {
|
||||
...prevSettings,
|
||||
shortBreakDuration: isNaN(newValue) ? 1 : newValue,
|
||||
}
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<Label>Long Break Duration(seconds)</Label>
|
||||
<Input
|
||||
type='number'
|
||||
min={1}
|
||||
value={settings.longBreakDuration}
|
||||
onChange={(event) => {
|
||||
let newValue = parseInt(event.target.value, 10)
|
||||
setSettings((prevSettings) => {
|
||||
return {
|
||||
...prevSettings,
|
||||
longBreakDuration: isNaN(newValue) ? 1 : newValue,
|
||||
}
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<Label>Long Break Interval</Label>
|
||||
<Input
|
||||
type='number'
|
||||
min={0}
|
||||
value={settings.longBreakInterval}
|
||||
onChange={(event) => {
|
||||
let newValue = parseInt(event.target.value, 10)
|
||||
setSettings((prevSettings) => {
|
||||
return {
|
||||
...prevSettings,
|
||||
longBreakInterval: isNaN(newValue) ? 0 : newValue,
|
||||
}
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Label>Reset Settings</Label>
|
||||
<Button
|
||||
variant='pomodoro'
|
||||
onClick={() => {
|
||||
setSettings(defaultPomodoroSettings)
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</Box>
|
||||
<Box mb={4}>
|
||||
<Label>Made by Sakura Knoll</Label>
|
||||
<Label>
|
||||
<Text sx={{ mr: 2 }}>Feedback:</Text>
|
||||
<Link
|
||||
href='https://github.com/sakura-knoll/abyss-lab/issues'
|
||||
target='_blank'
|
||||
>
|
||||
Github
|
||||
</Link>
|
||||
,
|
||||
<Link
|
||||
href='https://arca.live/b/cherryhill'
|
||||
target='_blank'
|
||||
sx={{ ml: 2 }}
|
||||
>
|
||||
arca.live
|
||||
</Link>
|
||||
</Label>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<audio src='/pomodoro/warning.ogg' ref={alarmAudioRef} />
|
||||
<audio src='/pomodoro/ticking.wav' ref={tickingAudioRef} />
|
||||
<audio src='/pomodoro/rideon.ogg' ref={rideonAudioRef} />
|
||||
</Box>
|
||||
{/* <JSONDebugBox data={events} /> */}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default PomodoroAppMain
|
||||
|
||||
function createStartTimerEvent(
|
||||
eventData: Omit<StartTimerEvent, 'type' | 'date'>
|
||||
): StartTimerEvent {
|
||||
return {
|
||||
type: 'start',
|
||||
...eventData,
|
||||
date: new Date(),
|
||||
}
|
||||
}
|
||||
|
||||
function createTimerEvent(type: Exclude<TimerEventType, 'start'>): TimerEvent {
|
||||
return {
|
||||
type,
|
||||
date: new Date(),
|
||||
}
|
||||
}
|
||||
|
||||
export type TimerStatus = 'idle' | 'running' | 'paused'
|
||||
interface TimerStats {
|
||||
status: TimerStatus
|
||||
lastTimerType: TimerType
|
||||
remainingTime: number
|
||||
lastTimerDuration: number
|
||||
}
|
||||
|
||||
function getTimerStatus(events: TimerEvent[]): TimerStats {
|
||||
const unresolvedTimerEvents = getUnresolvedTimerEvents(events)
|
||||
|
||||
if (unresolvedTimerEvents.length === 0) {
|
||||
return {
|
||||
status: 'idle',
|
||||
lastTimerType: 'pomodoro',
|
||||
remainingTime: 0,
|
||||
lastTimerDuration: 1,
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
lastEventType,
|
||||
lastTimerType,
|
||||
calculatedRemainingTimeInMs,
|
||||
previousResumeDate,
|
||||
lastTimerDuration,
|
||||
} = getUnresolvedTimerStatus(unresolvedTimerEvents)
|
||||
|
||||
if (lastEventType === 'pause') {
|
||||
return {
|
||||
status: 'paused',
|
||||
lastTimerType,
|
||||
remainingTime: calculatedRemainingTimeInMs,
|
||||
lastTimerDuration,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
status: 'running',
|
||||
lastTimerType,
|
||||
remainingTime:
|
||||
calculatedRemainingTimeInMs -
|
||||
(new Date().getTime() - previousResumeDate.getTime()),
|
||||
lastTimerDuration,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type UnresolvedTimerEvent = StartTimerEvent | PauseTimerEvent | ResumeTimerEvent
|
||||
const getUnresolvedTimerEvents = createMemoFunction(function (
|
||||
events: TimerEvent[]
|
||||
): UnresolvedTimerEvent[] {
|
||||
let unresolvedTimerEvents = []
|
||||
|
||||
if (events.length > 0) {
|
||||
for (let targetIndex = 0; targetIndex < events.length; targetIndex++) {
|
||||
const targetEvent = events[targetIndex]
|
||||
if (targetEvent.type === 'cancel' || targetEvent.type === 'finish') {
|
||||
break
|
||||
}
|
||||
unresolvedTimerEvents.unshift(targetEvent)
|
||||
if (targetEvent.type === 'start') {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return unresolvedTimerEvents
|
||||
})
|
||||
|
||||
interface UnresolvedTimerStatus {
|
||||
lastEventType: 'pause' | 'resume' | 'start'
|
||||
lastTimerType: TimerType
|
||||
calculatedRemainingTimeInMs: number
|
||||
previousResumeDate: Date
|
||||
lastTimerDuration: number
|
||||
}
|
||||
|
||||
const getUnresolvedTimerStatus = createMemoFunction(function (
|
||||
unresolvedTimerEvents: UnresolvedTimerEvent[]
|
||||
): UnresolvedTimerStatus {
|
||||
unresolvedTimerEvents = unresolvedTimerEvents.slice()
|
||||
let startEvent = unresolvedTimerEvents.shift() as StartTimerEvent
|
||||
let calculatedRemainingTimeInMs = startEvent.duration * 1000
|
||||
|
||||
let previousResumeDate = startEvent.date
|
||||
let lastEventType: 'start' | 'pause' | 'resume' = startEvent.type
|
||||
|
||||
while (unresolvedTimerEvents.length > 0) {
|
||||
const targetEvent = unresolvedTimerEvents.shift()
|
||||
if (targetEvent == null) {
|
||||
break
|
||||
}
|
||||
lastEventType = targetEvent.type
|
||||
if (targetEvent.type === 'pause') {
|
||||
calculatedRemainingTimeInMs -=
|
||||
targetEvent.date.getTime() - previousResumeDate.getTime()
|
||||
}
|
||||
if (targetEvent.type === 'resume') {
|
||||
previousResumeDate = targetEvent.date
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
lastEventType,
|
||||
lastTimerType: startEvent.timerType,
|
||||
calculatedRemainingTimeInMs,
|
||||
previousResumeDate,
|
||||
lastTimerDuration: startEvent.duration,
|
||||
}
|
||||
})
|
||||
|
||||
function getFinishedPomodoroCountSinceLongBreak(events: TimerEvent[]): number {
|
||||
let finishedPomodoroCount = 0
|
||||
let starting = false
|
||||
for (const event of events.slice()) {
|
||||
if (!starting) {
|
||||
if (event.type === 'finish') {
|
||||
starting = true
|
||||
}
|
||||
} else {
|
||||
if (event.type === 'start') {
|
||||
starting = false
|
||||
if (event.timerType === 'pomodoro') {
|
||||
finishedPomodoroCount++
|
||||
}
|
||||
if (event.timerType === 'long-break') {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return finishedPomodoroCount
|
||||
}
|
||||
|
||||
function getFinishedPomodoroCount(events: TimerEvent[]): number {
|
||||
let finishedPomodoroCount = 0
|
||||
let starting = false
|
||||
for (const event of events.slice()) {
|
||||
if (!starting) {
|
||||
if (event.type === 'finish') {
|
||||
starting = true
|
||||
}
|
||||
} else {
|
||||
if (event.type === 'start') {
|
||||
starting = false
|
||||
if (event.timerType === 'pomodoro') {
|
||||
finishedPomodoroCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return finishedPomodoroCount
|
||||
}
|
||||
|
||||
function getTimerDuration(timerType: TimerType, settings: PomodoroSettings) {
|
||||
switch (timerType) {
|
||||
case 'long-break':
|
||||
return settings.longBreakDuration
|
||||
case 'short-break':
|
||||
return settings.shortBreakDuration
|
||||
case 'pomodoro':
|
||||
default:
|
||||
return settings.pomodoroDuration
|
||||
}
|
||||
}
|
||||
|
||||
function shiftFinishedEvents(events: TimerEvent[]): TimerEvent[] {
|
||||
let newEvents = []
|
||||
for (const event of events) {
|
||||
if (event.type === 'finish') {
|
||||
break
|
||||
} else {
|
||||
newEvents.unshift(event)
|
||||
}
|
||||
}
|
||||
return newEvents
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Image } from 'theme-ui'
|
||||
import { TimerType } from './interfaces'
|
||||
import { TimerStatus } from './PomodoroAppMain'
|
||||
|
||||
const images: {
|
||||
[key: string]: { size: number; src: string; offsetY?: number }
|
||||
} = {
|
||||
arrive: {
|
||||
size: 100,
|
||||
src: '/pomodoro/arrive.gif',
|
||||
offsetY: -10,
|
||||
},
|
||||
ride: {
|
||||
size: 100,
|
||||
src: '/pomodoro/ride.gif',
|
||||
},
|
||||
code: {
|
||||
size: 76,
|
||||
src: '/pomodoro/code.gif',
|
||||
},
|
||||
hop: {
|
||||
size: 60,
|
||||
src: '/pomodoro/hop.gif',
|
||||
},
|
||||
attack: {
|
||||
size: 100,
|
||||
src: '/pomodoro/attack.gif',
|
||||
},
|
||||
drink: {
|
||||
size: 68,
|
||||
src: '/pomodoro/drink.gif',
|
||||
},
|
||||
rest: {
|
||||
size: 100,
|
||||
src: '/pomodoro/rest.gif',
|
||||
},
|
||||
love: {
|
||||
size: 60,
|
||||
src: '/pomodoro/love.gif',
|
||||
offsetY: 4,
|
||||
},
|
||||
sleep: {
|
||||
size: 85,
|
||||
src: '/pomodoro/sleep.gif',
|
||||
offsetY: -5,
|
||||
},
|
||||
no: {
|
||||
size: 80,
|
||||
src: '/pomodoro/no.gif',
|
||||
offsetY: -5,
|
||||
},
|
||||
}
|
||||
|
||||
interface PomodoroImageProps {
|
||||
displayStatus: TimerStatus
|
||||
timerType: TimerType
|
||||
finishedCountSinceLongBreak: number
|
||||
}
|
||||
|
||||
const PomodoroImage = ({
|
||||
displayStatus,
|
||||
timerType,
|
||||
finishedCountSinceLongBreak,
|
||||
}: PomodoroImageProps) => {
|
||||
const imageKey = getImageKey(
|
||||
displayStatus,
|
||||
timerType,
|
||||
finishedCountSinceLongBreak
|
||||
)
|
||||
const { size, src, offsetY = 0 } = images[imageKey]
|
||||
const offset = (150 - size) / 2
|
||||
return (
|
||||
<Image
|
||||
sx={{
|
||||
borderRadius: 10,
|
||||
position: 'absolute',
|
||||
left: offset,
|
||||
top: offset + offsetY,
|
||||
}}
|
||||
width={size}
|
||||
height={size}
|
||||
src={src}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default PomodoroImage
|
||||
|
||||
function getImageKey(
|
||||
displayStatus: TimerStatus,
|
||||
timerType: TimerType,
|
||||
finishedCountSinceLongBreak: number
|
||||
) {
|
||||
return displayStatus === 'paused'
|
||||
? 'no'
|
||||
: timerType === 'pomodoro'
|
||||
? displayStatus === 'idle'
|
||||
? 'arrive'
|
||||
: finishedCountSinceLongBreak === 0
|
||||
? 'ride'
|
||||
: finishedCountSinceLongBreak === 1
|
||||
? 'code'
|
||||
: finishedCountSinceLongBreak === 2
|
||||
? 'hop'
|
||||
: 'attack'
|
||||
: timerType === 'short-break'
|
||||
? finishedCountSinceLongBreak === 1
|
||||
? 'rest'
|
||||
: finishedCountSinceLongBreak === 2
|
||||
? 'drink'
|
||||
: 'love'
|
||||
: 'sleep'
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { memo } from 'react'
|
||||
import { Text } from 'theme-ui'
|
||||
import { TimerType } from './interfaces'
|
||||
|
||||
interface PomodoroStatusBoxProps {
|
||||
finishedCount: number
|
||||
finishedCountSinceLongBreak: number
|
||||
longBreakInterval: number
|
||||
timerType: TimerType
|
||||
}
|
||||
|
||||
const PomodoroStatusBox = ({
|
||||
finishedCount,
|
||||
finishedCountSinceLongBreak,
|
||||
longBreakInterval,
|
||||
timerType,
|
||||
}: PomodoroStatusBoxProps) => {
|
||||
const nextBreakShouldBeLongBreak =
|
||||
finishedCountSinceLongBreak >= longBreakInterval - 1
|
||||
const nextTimerType =
|
||||
timerType === 'pomodoro'
|
||||
? nextBreakShouldBeLongBreak
|
||||
? 'long-break'
|
||||
: 'short-break'
|
||||
: 'pomodoro'
|
||||
|
||||
return (
|
||||
<Text sx={{ textAlign: 'center', display: 'block' }}>
|
||||
{finishedCount > 0 ? `🍅 Done: ${finishedCount}` : `Let's start!`}
|
||||
<br />
|
||||
|
||||
{nextTimerType === 'pomodoro' ? (
|
||||
<>Next: Pomodoro</>
|
||||
) : nextTimerType === 'short-break' ? (
|
||||
<>
|
||||
Next: Short Break({finishedCountSinceLongBreak}/
|
||||
{longBreakInterval - 1})
|
||||
</>
|
||||
) : (
|
||||
<>Next: Long Break</>
|
||||
)}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(PomodoroStatusBox)
|
||||
@@ -0,0 +1,45 @@
|
||||
import classcat from 'classcat'
|
||||
import { memo } from 'react'
|
||||
import { Button, Flex } from 'theme-ui'
|
||||
import { TimerType } from './interfaces'
|
||||
|
||||
interface PomodoroTimerTypeButtonGroupProps {
|
||||
timerType: TimerType
|
||||
onTimerButtonSelect: (timerType: TimerType) => void
|
||||
}
|
||||
|
||||
const PomodoroTimerTypeButtonGroup = ({
|
||||
timerType,
|
||||
onTimerButtonSelect,
|
||||
}: PomodoroTimerTypeButtonGroupProps) => {
|
||||
return (
|
||||
<Flex sx={{ justifyContent: 'center', maxWidth: 400, width: '100%' }}>
|
||||
<Button
|
||||
variant='pomodoro'
|
||||
sx={{ mr: 1, flex: 1 }}
|
||||
className={classcat([timerType === 'pomodoro' && 'active'])}
|
||||
onClick={() => onTimerButtonSelect('pomodoro')}
|
||||
>
|
||||
Pomodoro
|
||||
</Button>
|
||||
<Button
|
||||
variant='pomodoro'
|
||||
sx={{ mr: 1, flex: 1 }}
|
||||
className={classcat([timerType === 'short-break' && 'active'])}
|
||||
onClick={() => onTimerButtonSelect('short-break')}
|
||||
>
|
||||
Short Break
|
||||
</Button>
|
||||
<Button
|
||||
sx={{ flex: 1 }}
|
||||
variant='pomodoro'
|
||||
className={classcat([timerType === 'long-break' && 'active'])}
|
||||
onClick={() => onTimerButtonSelect('long-break')}
|
||||
>
|
||||
Long Break
|
||||
</Button>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(PomodoroTimerTypeButtonGroup)
|
||||
@@ -0,0 +1,45 @@
|
||||
interface ProgressCircleProps {
|
||||
size: number
|
||||
progress: number
|
||||
}
|
||||
|
||||
const ProgressCircle = ({ size, progress }: ProgressCircleProps) => {
|
||||
return (
|
||||
<svg viewBox={`0 0 ${size} ${size}`} xmlns='http://www.w3.org/2000/svg'>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
fill='rgba(0,0,0,0.3)'
|
||||
d={progressPath(1, size, 10)}
|
||||
/>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
fill='white'
|
||||
d={progressPath(progress, size, 10)}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function progressPath(progress: number, width: number, strokeWidth: number) {
|
||||
const cx = width / 2
|
||||
const cy = width / 2
|
||||
const radius = width / 2
|
||||
const innerRadius = width / 2 - strokeWidth
|
||||
if (progress >= 1) {
|
||||
return `M ${cx} 0 A ${radius} ${radius} 0 0 1 ${cx} ${width} A ${radius} ${radius} 0 0 1 ${cx} 0 z M ${cx} ${strokeWidth} A ${innerRadius} ${innerRadius} 0 0 1 ${cx} ${
|
||||
cy + innerRadius
|
||||
} A ${innerRadius} ${innerRadius} 0 0 1 ${cx} ${strokeWidth} Z`
|
||||
}
|
||||
|
||||
const offsetRadians = -Math.PI / 2
|
||||
const endRadians = offsetRadians + progress * Math.PI * 2
|
||||
const outerEndX = cx + (width / 2) * Math.cos(endRadians)
|
||||
const outerEndY = cy + (width / 2) * Math.sin(endRadians)
|
||||
const innerEndX = cx + (width / 2 - strokeWidth) * Math.cos(endRadians)
|
||||
const innerEndY = cy + (width / 2 - strokeWidth) * Math.sin(endRadians)
|
||||
const flip = progress > 0.5 ? 1 : 0
|
||||
|
||||
return `M ${cx} 0 A ${cx} ${cy} 0 ${flip} 1 ${outerEndX} ${outerEndY} L ${innerEndX} ${innerEndY} A ${innerRadius} ${innerRadius} 0 ${flip} 0 ${cx} ${strokeWidth}`
|
||||
}
|
||||
|
||||
export default ProgressCircle
|
||||
@@ -0,0 +1,29 @@
|
||||
import { secondsToMinutes } from 'date-fns'
|
||||
import { Text } from 'theme-ui'
|
||||
|
||||
interface RemainingTimeBoxProps {
|
||||
displayRemainingTime: number
|
||||
}
|
||||
|
||||
const RemainingTimeBox = ({ displayRemainingTime }: RemainingTimeBoxProps) => {
|
||||
if (displayRemainingTime < 0) {
|
||||
displayRemainingTime = 0
|
||||
}
|
||||
const durationInSecs = Math.ceil(displayRemainingTime / 1000)
|
||||
const minutes = secondsToMinutes(durationInSecs)
|
||||
const secs = durationInSecs % 60
|
||||
|
||||
return (
|
||||
<Text sx={{ fontSize: 72, fontFamily: 'monospace', lineHeight: '1em' }}>
|
||||
{prependZero(minutes, 2)}:{prependZero(secs, 2)}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
export default RemainingTimeBox
|
||||
|
||||
function prependZero(value: number, digits: number) {
|
||||
const fillCount = digits - value.toString().length
|
||||
|
||||
return `${'0'.repeat(fillCount)}${value}`
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import openColors from 'open-color'
|
||||
|
||||
export const colors = Object.entries(openColors)
|
||||
.map(([colorName, colorObjectMap]) => {
|
||||
return [colorName, Object.values(colorObjectMap) as string[]] as [
|
||||
string,
|
||||
string[]
|
||||
]
|
||||
})
|
||||
.reduce<{ [key: string]: string[] }>((map, [colorName, colorArray]) => {
|
||||
map[colorName] = colorArray
|
||||
return map
|
||||
}, {})
|
||||
@@ -0,0 +1,65 @@
|
||||
import { PomodoroSettings, TimerEvent } from './interfaces'
|
||||
|
||||
const pomodoroTasksKey = 'pomodoro:tasks'
|
||||
export function loadTimerEvents(): TimerEvent[] {
|
||||
try {
|
||||
const parsedData = JSON.parse(
|
||||
localStorage.getItem(pomodoroTasksKey) || '[]'
|
||||
)
|
||||
|
||||
return (parsedData as any[]).map(({ date, ...otherProps }) => {
|
||||
const task: TimerEvent = {
|
||||
...otherProps,
|
||||
date: new Date(date),
|
||||
}
|
||||
return task
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn(error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function saveTimerEvents(timerEvents: TimerEvent[]) {
|
||||
const serializedTasks = timerEvents.map(({ ...otherTaskProps }) => {
|
||||
const serializedTask: any = {
|
||||
...otherTaskProps,
|
||||
date: otherTaskProps.date.toString(),
|
||||
}
|
||||
|
||||
return serializedTask
|
||||
})
|
||||
localStorage.setItem(pomodoroTasksKey, JSON.stringify(serializedTasks))
|
||||
}
|
||||
|
||||
export const defaultPomodoroSettings: PomodoroSettings = {
|
||||
tickSound: true,
|
||||
pomodoroFinishSound: true,
|
||||
breakFinishSound: true,
|
||||
volume: 1,
|
||||
autoStartBreaks: false,
|
||||
autoStartPomodoros: false,
|
||||
pomodoroDuration: 25 * 60,
|
||||
shortBreakDuration: 5 * 60,
|
||||
longBreakDuration: 15 * 60,
|
||||
longBreakInterval: 4,
|
||||
}
|
||||
|
||||
const pomodoroSettingsKey = 'pomodoro:settings'
|
||||
export function loadPomodoroSettings(): PomodoroSettings {
|
||||
try {
|
||||
const storedValue = localStorage.getItem(pomodoroSettingsKey)
|
||||
if (storedValue == null) {
|
||||
return defaultPomodoroSettings
|
||||
}
|
||||
const settings = JSON.parse(storedValue)
|
||||
return settings
|
||||
} catch (error) {
|
||||
console.warn(error)
|
||||
return defaultPomodoroSettings
|
||||
}
|
||||
}
|
||||
|
||||
export function savePomodoroSettings(settings: PomodoroSettings) {
|
||||
localStorage.setItem(pomodoroSettingsKey, JSON.stringify(settings))
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
Dispatch,
|
||||
MutableRefObject,
|
||||
SetStateAction,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
|
||||
export function useRefState<S>(
|
||||
initialState: S | (() => S)
|
||||
): [S, Dispatch<SetStateAction<S>>, MutableRefObject<S>] {
|
||||
const [state, setState] = useState<S>(initialState)
|
||||
const stateRef = useRef(state)
|
||||
useEffect(() => {
|
||||
stateRef.current = state
|
||||
}, [state])
|
||||
return [state, setState, stateRef]
|
||||
}
|
||||
|
||||
export function useTimer() {}
|
||||
|
||||
export function useValueRef<V>(value: V) {
|
||||
const ref = useRef(value)
|
||||
useEffect(() => {
|
||||
ref.current = value
|
||||
}, [value])
|
||||
|
||||
return ref
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
export type TimerType = 'pomodoro' | 'short-break' | 'long-break'
|
||||
|
||||
export type TimerEventType = 'start' | 'pause' | 'resume' | 'cancel' | 'finish'
|
||||
|
||||
export interface StartTimerEvent {
|
||||
type: 'start'
|
||||
date: Date
|
||||
duration: number
|
||||
timerType: TimerType
|
||||
}
|
||||
|
||||
export interface PauseTimerEvent {
|
||||
type: 'pause'
|
||||
date: Date
|
||||
}
|
||||
|
||||
export interface ResumeTimerEvent {
|
||||
type: 'resume'
|
||||
date: Date
|
||||
}
|
||||
export interface CancelTimerEvent {
|
||||
type: 'cancel'
|
||||
date: Date
|
||||
}
|
||||
|
||||
export interface FinishTimerEvent {
|
||||
type: 'finish'
|
||||
date: Date
|
||||
}
|
||||
|
||||
export type TimerEvent =
|
||||
| StartTimerEvent
|
||||
| PauseTimerEvent
|
||||
| ResumeTimerEvent
|
||||
| CancelTimerEvent
|
||||
| FinishTimerEvent
|
||||
|
||||
export interface PomodoroSettings {
|
||||
tickSound: boolean
|
||||
pomodoroFinishSound: boolean
|
||||
breakFinishSound: boolean
|
||||
volume: number
|
||||
autoStartPomodoros: boolean
|
||||
autoStartBreaks: boolean
|
||||
pomodoroDuration: number
|
||||
shortBreakDuration: number
|
||||
longBreakDuration: number
|
||||
longBreakInterval: number
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import { Theme } from 'theme-ui'
|
||||
import { colors } from './colors'
|
||||
|
||||
export const theme: Theme = {
|
||||
breakpoints: ['576px', '768px', '992px', '1200px'],
|
||||
colors: {
|
||||
...colors,
|
||||
white: '#fff',
|
||||
black: '#000',
|
||||
text: '#212529',
|
||||
background: '#fff',
|
||||
primary: '#007bff',
|
||||
secondary: '#6c757d',
|
||||
muted: '#dee2e6',
|
||||
success: '#28a745',
|
||||
info: '#17a2b8',
|
||||
warning: '#ffc107',
|
||||
danger: '#dc3545',
|
||||
light: '#f8f9fa',
|
||||
dark: '#343a40',
|
||||
textMuted: '#6c757d',
|
||||
transparent: 'transparent',
|
||||
},
|
||||
space: ['0rem', '0.25rem', '0.5rem', '1rem', '1.5rem', '3rem'],
|
||||
fonts: {
|
||||
body: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"',
|
||||
heading:
|
||||
'SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
||||
monospace:
|
||||
'SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
||||
sans: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"',
|
||||
},
|
||||
fontSizes: [
|
||||
'0.75rem',
|
||||
'0.875rem',
|
||||
'1rem',
|
||||
'1.25rem',
|
||||
'1.5rem',
|
||||
'1.75rem',
|
||||
'2rem',
|
||||
'2.5rem',
|
||||
'3.5rem',
|
||||
'4.5rem',
|
||||
'5.5rem',
|
||||
'6rem',
|
||||
],
|
||||
fontWeights: {
|
||||
body: 400,
|
||||
heading: 600,
|
||||
bold: 700,
|
||||
light: 300,
|
||||
normal: 400,
|
||||
display: 300,
|
||||
},
|
||||
borders: {
|
||||
default: `1px solid ${colors.gray[5]}`,
|
||||
primary: `1px solid #007bff`,
|
||||
},
|
||||
lineHeights: {
|
||||
body: 1.6,
|
||||
heading: 1.2,
|
||||
},
|
||||
sizes: {
|
||||
sm: 540,
|
||||
md: 720,
|
||||
lg: 960,
|
||||
xl: 1140,
|
||||
},
|
||||
shadows: {
|
||||
default: '0 .5rem 1rem rgba(0, 0, 0, .15)',
|
||||
sm: '0 .125rem .25rem rgba(0, 0, 0, .075)',
|
||||
lg: '0 1rem 3rem rgba(0, 0, 0, .175)',
|
||||
},
|
||||
radii: {
|
||||
default: '0.25rem',
|
||||
sm: '0.2rem',
|
||||
lg: '0.3rem',
|
||||
pill: '50rem',
|
||||
},
|
||||
text: {
|
||||
heading: {
|
||||
fontFamily: 'heading',
|
||||
fontWeight: 'heading',
|
||||
lineHeight: 'heading',
|
||||
mt: 0,
|
||||
mb: 2,
|
||||
},
|
||||
display: {
|
||||
fontWeight: 'display',
|
||||
lineHeight: 'heading',
|
||||
},
|
||||
},
|
||||
buttons: {
|
||||
pomodoro: {
|
||||
bg: 'rgba(0,0,0,0.2)',
|
||||
color: 'white',
|
||||
px: 2,
|
||||
borderWidth: 1,
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'none',
|
||||
borderRadius: 'default',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
fontFamily: 'monospace',
|
||||
'&.active, &:active, &:hover:active, &.active:hover': {
|
||||
color: 'background',
|
||||
bg: 'rgba(0,0,0,0.55)',
|
||||
},
|
||||
transition:
|
||||
'box-shadow 200ms ease-in-out, color 200ms ease-in-out, background-color 200ms ease-in-out',
|
||||
'&:hover': {
|
||||
bg: 'rgba(0, 0, 0, 0.35)',
|
||||
boxShadow: 'default',
|
||||
},
|
||||
},
|
||||
primary: {
|
||||
color: 'primary',
|
||||
borderColor: 'primary',
|
||||
borderStyle: 'solid',
|
||||
borderWidth: 1,
|
||||
bg: 'background',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'none',
|
||||
borderRadius: 'default',
|
||||
'&.active, &:active, &:hover:active, &.active:hover': {
|
||||
color: 'background',
|
||||
bg: 'primary',
|
||||
},
|
||||
transition:
|
||||
'box-shadow 200ms ease-in-out, color 200ms ease-in-out, background-color 200ms ease-in-out',
|
||||
'&:hover': {
|
||||
bg: 'rgba(0, 123, 255, 0.2)',
|
||||
boxShadow: 'default',
|
||||
},
|
||||
'&.hidden': {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
secondary: {
|
||||
color: 'background',
|
||||
bg: 'secondary',
|
||||
},
|
||||
gray: {
|
||||
color: 'background',
|
||||
bg: 'gray',
|
||||
},
|
||||
},
|
||||
styles: {
|
||||
root: {
|
||||
fontFamily: 'body',
|
||||
lineHeight: 'body',
|
||||
fontWeight: 'body',
|
||||
},
|
||||
a: {
|
||||
color: 'primary',
|
||||
textDecoration: 'none',
|
||||
},
|
||||
p: {
|
||||
mb: 3,
|
||||
lineHeight: 'body',
|
||||
},
|
||||
h1: {
|
||||
fontFamily: 'heading',
|
||||
fontWeight: 'heading',
|
||||
lineHeight: 'heading',
|
||||
mt: 0,
|
||||
mb: 2,
|
||||
fontSize: 7,
|
||||
},
|
||||
h2: {
|
||||
fontFamily: 'heading',
|
||||
fontWeight: 'heading',
|
||||
lineHeight: 'heading',
|
||||
mt: 0,
|
||||
mb: 2,
|
||||
fontSize: 6,
|
||||
},
|
||||
h3: {
|
||||
fontFamily: 'heading',
|
||||
fontWeight: 'heading',
|
||||
lineHeight: 'heading',
|
||||
mt: 0,
|
||||
mb: 2,
|
||||
fontSize: 5,
|
||||
},
|
||||
h4: {
|
||||
fontFamily: 'heading',
|
||||
fontWeight: 'heading',
|
||||
lineHeight: 'heading',
|
||||
mt: 0,
|
||||
mb: 2,
|
||||
fontSize: 4,
|
||||
},
|
||||
h5: {
|
||||
fontFamily: 'heading',
|
||||
fontWeight: 'heading',
|
||||
lineHeight: 'heading',
|
||||
mt: 0,
|
||||
mb: 2,
|
||||
fontSize: 3,
|
||||
},
|
||||
h6: {
|
||||
fontFamily: 'heading',
|
||||
fontWeight: 'heading',
|
||||
lineHeight: 'heading',
|
||||
mt: 0,
|
||||
mb: 2,
|
||||
fontSize: 2,
|
||||
},
|
||||
blockquote: {
|
||||
fontSize: 3,
|
||||
mb: 3,
|
||||
},
|
||||
table: {
|
||||
width: '100%',
|
||||
marginBottom: 3,
|
||||
color: 'gray.9',
|
||||
borderCollapse: 'collapse',
|
||||
},
|
||||
th: {
|
||||
verticalAlign: 'bottom',
|
||||
borderTopWidth: 2,
|
||||
borderTopStyle: 'solid',
|
||||
borderTopColor: 'gray.3',
|
||||
borderBottomWidth: 2,
|
||||
borderBottomStyle: 'solid',
|
||||
borderBottomColor: 'gray.3',
|
||||
padding: '.75rem',
|
||||
textAlign: 'inherit',
|
||||
},
|
||||
td: {
|
||||
borderBottomWidth: 2,
|
||||
borderBottomStyle: 'solid',
|
||||
borderBottomColor: 'gray.3',
|
||||
verticalAlign: 'top',
|
||||
padding: '.75rem',
|
||||
},
|
||||
inlineCode: {
|
||||
color: 'pink',
|
||||
},
|
||||
img: {
|
||||
maxWidth: '100%',
|
||||
height: 'auto',
|
||||
},
|
||||
},
|
||||
cards: {
|
||||
primary: {
|
||||
border: 'default',
|
||||
borderRadius: 'default',
|
||||
|
||||
transition:
|
||||
'box-shadow 200ms ease-in-out, color 200ms ease-in-out, background-color 200ms ease-in-out',
|
||||
'&:hover': {
|
||||
boxShadow: 'default',
|
||||
},
|
||||
},
|
||||
stigmata: {
|
||||
padding: 2,
|
||||
borderRadius: 4,
|
||||
borderColor: 'gray.3',
|
||||
borderWidth: 1,
|
||||
borderStyle: 'solid',
|
||||
},
|
||||
},
|
||||
badges: {
|
||||
primary: {
|
||||
backgroundColor: 'primary',
|
||||
},
|
||||
secondary: {
|
||||
backgroundColor: 'secondary',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export function createMemoFunction<A, R>(method: (arg: A) => R): (arg: A) => R {
|
||||
let previousArg: any
|
||||
let previousResult: any
|
||||
return function (arg) {
|
||||
if (previousArg === arg) {
|
||||
return previousResult
|
||||
}
|
||||
|
||||
previousResult = method(arg)
|
||||
previousArg = arg
|
||||
return previousResult
|
||||
}
|
||||
}
|
||||