diff --git a/package-lock.json b/package-lock.json
index af42f51f..0ddbe487 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 1b1ecf36..9c5ff834 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/public/pomodoro/arrive.gif b/public/pomodoro/arrive.gif
new file mode 100644
index 00000000..58cea9ed
Binary files /dev/null and b/public/pomodoro/arrive.gif differ
diff --git a/public/pomodoro/attack.gif b/public/pomodoro/attack.gif
new file mode 100644
index 00000000..b3372d76
Binary files /dev/null and b/public/pomodoro/attack.gif differ
diff --git a/public/pomodoro/code.gif b/public/pomodoro/code.gif
new file mode 100644
index 00000000..4057a9f8
Binary files /dev/null and b/public/pomodoro/code.gif differ
diff --git a/public/pomodoro/drink.gif b/public/pomodoro/drink.gif
new file mode 100644
index 00000000..7f3b14af
Binary files /dev/null and b/public/pomodoro/drink.gif differ
diff --git a/public/pomodoro/hop.gif b/public/pomodoro/hop.gif
new file mode 100644
index 00000000..03388a2d
Binary files /dev/null and b/public/pomodoro/hop.gif differ
diff --git a/public/pomodoro/leave.gif b/public/pomodoro/leave.gif
new file mode 100644
index 00000000..30edd169
Binary files /dev/null and b/public/pomodoro/leave.gif differ
diff --git a/public/pomodoro/love.gif b/public/pomodoro/love.gif
new file mode 100644
index 00000000..1d646e8e
Binary files /dev/null and b/public/pomodoro/love.gif differ
diff --git a/public/pomodoro/mail.gif b/public/pomodoro/mail.gif
new file mode 100644
index 00000000..39cc508d
Binary files /dev/null and b/public/pomodoro/mail.gif differ
diff --git a/public/pomodoro/no.gif b/public/pomodoro/no.gif
new file mode 100644
index 00000000..ad112914
Binary files /dev/null and b/public/pomodoro/no.gif differ
diff --git a/public/pomodoro/okay.gif b/public/pomodoro/okay.gif
new file mode 100644
index 00000000..ff1f7406
Binary files /dev/null and b/public/pomodoro/okay.gif differ
diff --git a/public/pomodoro/question.gif b/public/pomodoro/question.gif
new file mode 100644
index 00000000..974d808a
Binary files /dev/null and b/public/pomodoro/question.gif differ
diff --git a/public/pomodoro/rest.gif b/public/pomodoro/rest.gif
new file mode 100644
index 00000000..6a41344f
Binary files /dev/null and b/public/pomodoro/rest.gif differ
diff --git a/public/pomodoro/ride.gif b/public/pomodoro/ride.gif
new file mode 100644
index 00000000..46d6201b
Binary files /dev/null and b/public/pomodoro/ride.gif differ
diff --git a/public/pomodoro/rideon.ogg b/public/pomodoro/rideon.ogg
new file mode 100644
index 00000000..9071fe10
Binary files /dev/null and b/public/pomodoro/rideon.ogg differ
diff --git a/public/pomodoro/run.gif b/public/pomodoro/run.gif
new file mode 100644
index 00000000..a04d4a66
Binary files /dev/null and b/public/pomodoro/run.gif differ
diff --git a/public/pomodoro/sad.gif b/public/pomodoro/sad.gif
new file mode 100644
index 00000000..18917912
Binary files /dev/null and b/public/pomodoro/sad.gif differ
diff --git a/public/pomodoro/sleep.gif b/public/pomodoro/sleep.gif
new file mode 100644
index 00000000..7d14e59c
Binary files /dev/null and b/public/pomodoro/sleep.gif differ
diff --git a/public/pomodoro/ticking.wav b/public/pomodoro/ticking.wav
new file mode 100644
index 00000000..83a17c30
Binary files /dev/null and b/public/pomodoro/ticking.wav differ
diff --git a/public/pomodoro/warning.ogg b/public/pomodoro/warning.ogg
new file mode 100644
index 00000000..c04c74b6
Binary files /dev/null and b/public/pomodoro/warning.ogg differ
diff --git a/src/pages/honkai3rd/media/index.tsx b/src/pages/honkai3rd/media/index.tsx
index c6bbff35..d6c9f66b 100644
--- a/src/pages/honkai3rd/media/index.tsx
+++ b/src/pages/honkai3rd/media/index.tsx
@@ -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,42 +28,51 @@ const MediaPage = () => {
/>
{t('common.media')}
- {t('common.novels')}
+
+ {t('common.novels')}
-
-
-
- πΊπΈ Anti-Entropy Beta
-
-
-
-
- πΊπΈ Durandal Beta
-
-
-
-
- πΊπΈ Seven Swords (WIP)
-
-
-
-
- π°π· λ€κ²νΈλ‘νΌ Beta
-
-
-
-
- π°π· λλλ¬ Beta(μμ
μ€)
-
-
-
-
- π°π· μ μ£Όμ κ²λ‘ (μμ
μ€)
-
+
+
+
+ πΊπΈ Anti-Entropy Beta
+
+
+
+
+ πΊπΈ Durandal Beta
+
+
+
+
+ πΊπΈ Seven Swords (WIP)
+
+
+
+
+ π°π· λ€κ²νΈλ‘νΌ Beta
+
+
+
+
+ π°π· λλλ¬ Beta(μμ
μ€)
+
+
+
+
+ π°π· μ μ£Όμ κ²λ‘ (μμ
μ€)
+
+
- Other novels will be available one day.
+ Abyss Lab Custom
+
+
+
+ Pomodoro Timer
+
+
+
)
diff --git a/src/pages/honkai3rd/media/pomodoro.tsx b/src/pages/honkai3rd/media/pomodoro.tsx
new file mode 100644
index 00000000..7e065a27
--- /dev/null
+++ b/src/pages/honkai3rd/media/pomodoro.tsx
@@ -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([])
+ useEffect(() => {
+ setInitialTimerEvents(loadTimerEvents())
+ setInitialized(true)
+ }, [])
+
+ if (!initialized) {
+ return Loading
+ }
+ return (
+
+
+
+
+
+ )
+}
+
+export default PomodoroAppPage
diff --git a/src/pomodoro/PomodoroAppMain.tsx b/src/pomodoro/PomodoroAppMain.tsx
new file mode 100644
index 00000000..7102d451
--- /dev/null
+++ b/src/pomodoro/PomodoroAppMain.tsx
@@ -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(initialTimerEvents)
+ const initialRenderingRef = useRef(false)
+ const [timerType, setTimerType] = useState('pomodoro')
+ const [settings, setSettings] = useState(() => {
+ console.log(loadPomodoroSettings())
+ return loadPomodoroSettings()
+ })
+ const settingsRef = useRef(settings)
+ useEffect(() => {
+ settingsRef.current = settings
+ savePomodoroSettings(settings)
+ }, [settings])
+
+ const eventsRef = useRef(events)
+ const longBreakIntervalRef = useValueRef(settings.longBreakInterval)
+ useMemo(() => {
+ eventsRef.current = events
+ }, [events])
+ const alarmAudioRef = useRef(null)
+ const tickingAudioRef = useRef(null)
+ const rideonAudioRef = useRef(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(null)
+ const refreshTimerIntervalRef = useRef(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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Settings
+
+
+ Pomodoro Count: {finishedCount}
+
+
+
+
+ {/* */}
+ {/*
+ */}
+ {/* */}
+
+
+ {
+ setSettings((prevSettings) => {
+ return {
+ ...prevSettings,
+ volume: parseInt(event.target.value, 10) / 100,
+ }
+ })
+ }}
+ />
+
+
+ {/*
+
+
+
+ */}
+
+
+
+ {
+ let newValue = parseInt(event.target.value, 10)
+ setSettings((prevSettings) => {
+ return {
+ ...prevSettings,
+ pomodoroDuration: isNaN(newValue) ? 1 : newValue,
+ }
+ })
+ }}
+ />
+
+ {
+ let newValue = parseInt(event.target.value, 10)
+ setSettings((prevSettings) => {
+ return {
+ ...prevSettings,
+ shortBreakDuration: isNaN(newValue) ? 1 : newValue,
+ }
+ })
+ }}
+ />
+
+ {
+ let newValue = parseInt(event.target.value, 10)
+ setSettings((prevSettings) => {
+ return {
+ ...prevSettings,
+ longBreakDuration: isNaN(newValue) ? 1 : newValue,
+ }
+ })
+ }}
+ />
+
+ {
+ let newValue = parseInt(event.target.value, 10)
+ setSettings((prevSettings) => {
+ return {
+ ...prevSettings,
+ longBreakInterval: isNaN(newValue) ? 0 : newValue,
+ }
+ })
+ }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* */}
+
+ )
+}
+
+export default PomodoroAppMain
+
+function createStartTimerEvent(
+ eventData: Omit
+): StartTimerEvent {
+ return {
+ type: 'start',
+ ...eventData,
+ date: new Date(),
+ }
+}
+
+function createTimerEvent(type: Exclude): 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
+}
diff --git a/src/pomodoro/PomodoroImage.tsx b/src/pomodoro/PomodoroImage.tsx
new file mode 100644
index 00000000..4c58a176
--- /dev/null
+++ b/src/pomodoro/PomodoroImage.tsx
@@ -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 (
+
+ )
+}
+
+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'
+}
diff --git a/src/pomodoro/PomodoroStatusBox.tsx b/src/pomodoro/PomodoroStatusBox.tsx
new file mode 100644
index 00000000..270ce2ca
--- /dev/null
+++ b/src/pomodoro/PomodoroStatusBox.tsx
@@ -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 (
+
+ {finishedCount > 0 ? `π
Done: ${finishedCount}` : `Let's start!`}
+
+
+ {nextTimerType === 'pomodoro' ? (
+ <>Next: Pomodoro>
+ ) : nextTimerType === 'short-break' ? (
+ <>
+ Next: Short Break({finishedCountSinceLongBreak}/
+ {longBreakInterval - 1})
+ >
+ ) : (
+ <>Next: Long Break>
+ )}
+
+ )
+}
+
+export default memo(PomodoroStatusBox)
diff --git a/src/pomodoro/PomodoroTimerTypeButtonGroup.tsx b/src/pomodoro/PomodoroTimerTypeButtonGroup.tsx
new file mode 100644
index 00000000..14309413
--- /dev/null
+++ b/src/pomodoro/PomodoroTimerTypeButtonGroup.tsx
@@ -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 (
+
+
+
+
+
+ )
+}
+
+export default memo(PomodoroTimerTypeButtonGroup)
diff --git a/src/pomodoro/ProgressCircle.tsx b/src/pomodoro/ProgressCircle.tsx
new file mode 100644
index 00000000..8902d8e1
--- /dev/null
+++ b/src/pomodoro/ProgressCircle.tsx
@@ -0,0 +1,45 @@
+interface ProgressCircleProps {
+ size: number
+ progress: number
+}
+
+const ProgressCircle = ({ size, progress }: ProgressCircleProps) => {
+ return (
+
+ )
+}
+
+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
diff --git a/src/pomodoro/RemainingTimeBox.tsx b/src/pomodoro/RemainingTimeBox.tsx
new file mode 100644
index 00000000..2c2776ad
--- /dev/null
+++ b/src/pomodoro/RemainingTimeBox.tsx
@@ -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 (
+
+ {prependZero(minutes, 2)}:{prependZero(secs, 2)}
+
+ )
+}
+
+export default RemainingTimeBox
+
+function prependZero(value: number, digits: number) {
+ const fillCount = digits - value.toString().length
+
+ return `${'0'.repeat(fillCount)}${value}`
+}
diff --git a/src/pomodoro/colors.ts b/src/pomodoro/colors.ts
new file mode 100644
index 00000000..7c668ce3
--- /dev/null
+++ b/src/pomodoro/colors.ts
@@ -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
+ }, {})
diff --git a/src/pomodoro/data.ts b/src/pomodoro/data.ts
new file mode 100644
index 00000000..3b1f91e5
--- /dev/null
+++ b/src/pomodoro/data.ts
@@ -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))
+}
diff --git a/src/pomodoro/hooks.ts b/src/pomodoro/hooks.ts
new file mode 100644
index 00000000..602ed8f3
--- /dev/null
+++ b/src/pomodoro/hooks.ts
@@ -0,0 +1,30 @@
+import {
+ Dispatch,
+ MutableRefObject,
+ SetStateAction,
+ useEffect,
+ useRef,
+ useState,
+} from 'react'
+
+export function useRefState(
+ initialState: S | (() => S)
+): [S, Dispatch>, MutableRefObject] {
+ const [state, setState] = useState(initialState)
+ const stateRef = useRef(state)
+ useEffect(() => {
+ stateRef.current = state
+ }, [state])
+ return [state, setState, stateRef]
+}
+
+export function useTimer() {}
+
+export function useValueRef(value: V) {
+ const ref = useRef(value)
+ useEffect(() => {
+ ref.current = value
+ }, [value])
+
+ return ref
+}
diff --git a/src/pomodoro/interfaces.ts b/src/pomodoro/interfaces.ts
new file mode 100644
index 00000000..f459de4a
--- /dev/null
+++ b/src/pomodoro/interfaces.ts
@@ -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
+}
diff --git a/src/pomodoro/theme.ts b/src/pomodoro/theme.ts
new file mode 100644
index 00000000..ce902433
--- /dev/null
+++ b/src/pomodoro/theme.ts
@@ -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',
+ },
+ },
+}
diff --git a/src/pomodoro/utils.ts b/src/pomodoro/utils.ts
new file mode 100644
index 00000000..f7a8a5d5
--- /dev/null
+++ b/src/pomodoro/utils.ts
@@ -0,0 +1,13 @@
+export function createMemoFunction(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
+ }
+}