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 + } +}