Add pomodoro timer

This commit is contained in:
Sakura Knoll
2022-09-12 07:49:42 +09:00 Unverified
parent 3be94aee50
commit 502d4d8976
35 changed files with 1642 additions and 32 deletions
+11
View File
@@ -15,6 +15,7 @@
"@mdi/js": "^6.5.95", "@mdi/js": "^6.5.95",
"@mdi/react": "^1.5.0", "@mdi/react": "^1.5.0",
"@types/file-saver": "^2.0.5", "@types/file-saver": "^2.0.5",
"classcat": "^5.0.4",
"date-fns": "^2.27.0", "date-fns": "^2.27.0",
"html-to-image": "^1.10.6", "html-to-image": "^1.10.6",
"next": "^12.1.6", "next": "^12.1.6",
@@ -2022,6 +2023,11 @@
"integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==",
"dev": true "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": { "node_modules/classnames": {
"version": "2.2.6", "version": "2.2.6",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.6.tgz", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.6.tgz",
@@ -7235,6 +7241,11 @@
"domutils": "^2.8.0" "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": { "classnames": {
"version": "2.2.6", "version": "2.2.6",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.6.tgz", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.6.tgz",
+1
View File
@@ -10,6 +10,7 @@
"@mdi/js": "^6.5.95", "@mdi/js": "^6.5.95",
"@mdi/react": "^1.5.0", "@mdi/react": "^1.5.0",
"@types/file-saver": "^2.0.5", "@types/file-saver": "^2.0.5",
"classcat": "^5.0.4",
"date-fns": "^2.27.0", "date-fns": "^2.27.0",
"html-to-image": "^1.10.6", "html-to-image": "^1.10.6",
"next": "^12.1.6", "next": "^12.1.6",
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.
Binary file not shown.
+11 -1
View File
@@ -6,6 +6,7 @@ import Head from '../../../components/atoms/Head'
import Honkai3rdLayout from '../../../components/layouts/Honkai3rdLayout' import Honkai3rdLayout from '../../../components/layouts/Honkai3rdLayout'
import { getI18NProps } from 'server/i18n' import { getI18NProps } from 'server/i18n'
import { NextPageContext } from 'next' import { NextPageContext } from 'next'
import NextLink from 'next/link'
const MediaPage = () => { const MediaPage = () => {
const { t } = useTranslation() const { t } = useTranslation()
@@ -27,6 +28,7 @@ const MediaPage = () => {
/> />
<Heading as='h1'>{t('common.media')}</Heading> <Heading as='h1'>{t('common.media')}</Heading>
<Box sx={{ mb: 3 }}>
<Heading as='h2'>{t('common.novels')}</Heading> <Heading as='h2'>{t('common.novels')}</Heading>
<Box sx={{ mb: 3, pl: 2 }} as='ul'> <Box sx={{ mb: 3, pl: 2 }} as='ul'>
@@ -61,8 +63,16 @@ const MediaPage = () => {
</Link> </Link>
</Box> </Box>
</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> </Box>
</Honkai3rdLayout> </Honkai3rdLayout>
) )
+28
View File
@@ -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
+838
View File
@@ -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
}
+113
View File
@@ -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'
}
+46
View File
@@ -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)
+45
View File
@@ -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
+29
View File
@@ -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}`
}
+13
View File
@@ -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
}, {})
+65
View File
@@ -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))
}
+30
View File
@@ -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
}
+49
View File
@@ -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
}
+274
View File
@@ -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',
},
},
}
+13
View File
@@ -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
}
}