├── public ├── pwa-144x144.png ├── pwa-192x192.png ├── pwa-512x512.png ├── vite.svg └── favicon.svg ├── src ├── vite-env.d.ts ├── theme.ts ├── main.tsx ├── App.css ├── claims-sw.ts ├── prompt-sw.ts ├── ReloadPrompt.css ├── logic │ ├── asyncLocalStorage.ts │ ├── localStorage.ts │ ├── utils.ts │ └── store.ts ├── index.css ├── ReloadPrompt.tsx ├── App.tsx ├── assets │ └── react.svg └── views │ ├── components.tsx │ └── boardComponent.tsx ├── tsconfig.node.json ├── githubDeploy.bat ├── .gitignore ├── index.html ├── package.json ├── tsconfig.json ├── readme.md └── vite.config.ts /public/pwa-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidacm/cronos/main/public/pwa-144x144.png -------------------------------------------------------------------------------- /public/pwa-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidacm/cronos/main/public/pwa-192x192.png -------------------------------------------------------------------------------- /public/pwa-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidacm/cronos/main/public/pwa-512x512.png -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /githubDeploy.bat: -------------------------------------------------------------------------------- 1 | call git checkout -b gh-pages 2 | call npm run build 3 | ren dist docs 4 | call git add docs 5 | call git commit -m "deploy for gh pages" 6 | call git push --force --set-upstream origin gh-pages 7 | call git checkout main 8 | call git branch -d gh-pages 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | *.code-workspace 26 | certs 27 | -------------------------------------------------------------------------------- /src/theme.ts: -------------------------------------------------------------------------------- 1 | import { createTheme } from '@mui/material/styles'; 2 | import { red } from '@mui/material/colors'; 3 | 4 | // Create a theme instance. 5 | const theme = createTheme({ 6 | palette: { 7 | primary: { 8 | main: '#556cd6', 9 | }, 10 | secondary: { 11 | main: '#19857b', 12 | }, 13 | error: { 14 | main: red.A400, 15 | }, 16 | }, 17 | }); 18 | 19 | export default theme; -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import CssBaseline from '@mui/material/CssBaseline'; 4 | import { ThemeProvider } from '@mui/material/styles'; 5 | 6 | import App from './App' 7 | import './index.css' 8 | import theme from './theme'; 9 | 10 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( 11 | 12 | 13 | 14 | 15 | 16 | 17 | ) 18 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | 2 | .logo { 3 | height: 6em; 4 | padding: 1.5em; 5 | will-change: filter; 6 | } 7 | .logo:hover { 8 | filter: drop-shadow(0 0 2em #646cffaa); 9 | } 10 | .logo.react:hover { 11 | filter: drop-shadow(0 0 2em #61dafbaa); 12 | } 13 | 14 | @keyframes logo-spin { 15 | from { 16 | transform: rotate(0deg); 17 | } 18 | to { 19 | transform: rotate(360deg); 20 | } 21 | } 22 | 23 | @media (prefers-reduced-motion: no-preference) { 24 | a:nth-of-type(2) .logo { 25 | animation: logo-spin infinite 20s linear; 26 | } 27 | } 28 | 29 | 30 | .vw-100 { 31 | width: 100vw !important; 32 | } 33 | 34 | .vh-100 { 35 | height: 100vh !important; 36 | } 37 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | Cronos 13 | 14 | 15 |
16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/claims-sw.ts: -------------------------------------------------------------------------------- 1 | import { cleanupOutdatedCaches, createHandlerBoundToURL, precacheAndRoute } from 'workbox-precaching' 2 | import { clientsClaim } from 'workbox-core' 3 | import { NavigationRoute, registerRoute } from 'workbox-routing' 4 | 5 | declare let self: ServiceWorkerGlobalScope 6 | 7 | // self.__WB_MANIFEST is default injection point 8 | precacheAndRoute(self.__WB_MANIFEST) 9 | 10 | // clean old assets 11 | cleanupOutdatedCaches() 12 | 13 | let allowlist: undefined | RegExp[] 14 | if (import.meta.env.DEV) 15 | allowlist = [/^\/$/] 16 | 17 | // to allow work offline 18 | registerRoute(new NavigationRoute( 19 | createHandlerBoundToURL('index.html'), 20 | { allowlist }, 21 | )) 22 | 23 | self.skipWaiting() 24 | clientsClaim() 25 | -------------------------------------------------------------------------------- /src/prompt-sw.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { cleanupOutdatedCaches, createHandlerBoundToURL, precacheAndRoute } from 'workbox-precaching' 4 | import { NavigationRoute, registerRoute } from 'workbox-routing' 5 | /// 6 | export default null 7 | declare let self: ServiceWorkerGlobalScope 8 | 9 | self.addEventListener('message', (event) => { 10 | if (event.data && event.data.type === 'SKIP_WAITING') 11 | self.skipWaiting() 12 | }) 13 | 14 | // self.__WB_MANIFEST is default injection point 15 | precacheAndRoute(self.__WB_MANIFEST) 16 | 17 | // clean old assets 18 | cleanupOutdatedCaches() 19 | 20 | // to allow work offline 21 | registerRoute(new NavigationRoute(createHandlerBoundToURL('index.html'))) 22 | -------------------------------------------------------------------------------- /src/ReloadPrompt.css: -------------------------------------------------------------------------------- 1 | .ReloadPrompt-container { 2 | padding: 0; 3 | margin: 0; 4 | width: 0; 5 | height: 0; 6 | } 7 | .ReloadPrompt-date { 8 | visibility: hidden; 9 | } 10 | .ReloadPrompt-toast { 11 | position: fixed; 12 | right: 0; 13 | bottom: 0; 14 | margin: 16px; 15 | padding: 12px; 16 | border: 1px solid #8885; 17 | border-radius: 4px; 18 | z-index: 1; 19 | text-align: left; 20 | box-shadow: 3px 4px 5px 0 #8885; 21 | background-color: white; 22 | } 23 | .ReloadPrompt-toast-message { 24 | margin-bottom: 8px; 25 | } 26 | .ReloadPrompt-toast-button { 27 | border: 1px solid #8885; 28 | outline: none; 29 | margin-right: 5px; 30 | border-radius: 2px; 31 | padding: 3px 10px; 32 | } 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cronos", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "@mui/icons-material": "^5.10.3", 13 | "@mui/material": "^5.10.3", 14 | "framer-motion": "^6.5.1", 15 | "mobx": "^6.6.1", 16 | "mobx-react-lite": "^3.4.0", 17 | "mobx-state-tree": "^5.1.6", 18 | "react": "^18.2.0", 19 | "react-dom": "^18.2.0" 20 | }, 21 | "devDependencies": { 22 | "@types/react": "^18.0.15", 23 | "@types/react-dom": "^18.0.6", 24 | "@vitejs/plugin-react": "^2.0.0", 25 | "typescript": "^4.6.4", 26 | "vite": "^3.0.0", 27 | "vite-plugin-pwa": "^0.12.4" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": [ 6 | "DOM", 7 | "DOM.Iterable", 8 | "ESNext" 9 | ], 10 | "allowJs": false, 11 | "skipLibCheck": true, 12 | "esModuleInterop": false, 13 | "allowSyntheticDefaultImports": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "module": "ESNext", 16 | "moduleResolution": "Node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "react-jsx", 21 | "strict": true, 22 | "strictNullChecks": true, 23 | "strictFunctionTypes": true, 24 | "noImplicitAny": true, 25 | "noImplicitReturns": true, 26 | "noImplicitThis": true, 27 | "types": [ 28 | "vite-plugin-pwa/client" 29 | ] 30 | }, 31 | "include": [ 32 | "src" 33 | ], 34 | "references": [ 35 | { 36 | "path": "./tsconfig.node.json" 37 | } 38 | ] 39 | } -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # cronos application. 2 | 3 | This app has been created as a react learning exercise, but should be functional on local mode. 4 | This was created using vite, react, MobX, MobXStateTree, and chakra UI 5 | The goal of this app is to help you to measure your time. You need to start the timer of your current activity, stop it when you finish the activity. You can use the stop button, or tab in the current timer. 6 | 7 | ## features 8 | * you can create, delete and rename timers. 9 | * the timers are sorted by the most recent timer used. 10 | * the current timer is updated each second. 11 | * you can see the log of each used timer. 12 | * the log is for just one day, the current day. 13 | * you can install it as a PWA. 14 | * the data is saved in the local storage. 15 | 16 | ## todo: 17 | * Implement a sumary of the timers by date range. 18 | * add the option to adjust the range for the log, for now you can see just the current day. 19 | * the possibility to add, change, remove and rename boards. 20 | * add pretty styles and icons. 21 | * change the UI to be more useable. 22 | * add the option to sign in, and sync boards, timers and the history between devices. 23 | -------------------------------------------------------------------------------- /src/logic/asyncLocalStorage.ts: -------------------------------------------------------------------------------- 1 | interface IAsyncLocalStorage { 2 | clear(): Promise 3 | getItem(key: string): Promise 4 | removeItem(key: string): Promise 5 | setItem(key: string, value: string): Promise 6 | } 7 | 8 | export const AsyncLocalStorage: IAsyncLocalStorage = { 9 | // must use wrapper functions when passing localStorage functions 10 | clear /* ignore next */ () 11 | { 12 | return callWithPromise(() => window.localStorage.clear()) 13 | }, 14 | getItem (key) { 15 | return callWithPromise(() => window.localStorage.getItem(key)) 16 | }, 17 | removeItem /* istanbul ignore next */ (key) { 18 | return callWithPromise(() => window.localStorage.removeItem(key)) 19 | }, 20 | setItem (key, value) { 21 | return callWithPromise(() => window.localStorage.setItem(key, value)) 22 | } 23 | } 24 | 25 | function callWithPromise (func: Function, ...args: any[]): Promise { 26 | try { 27 | return Promise.resolve(func(...args)) 28 | } catch (err) { 29 | /* istanbul ignore next */ 30 | return Promise.reject(err) 31 | } 32 | } 33 | 34 | export default AsyncLocalStorage -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: Inter, Avenir, Helvetica, Arial, sans-serif; 3 | font-size: 16px; 4 | line-height: 24px; 5 | font-weight: 400; 6 | 7 | color-scheme: light dark; 8 | color: rgba(255, 255, 255, 0.87); 9 | background-color: #242424; 10 | 11 | font-synthesis: none; 12 | text-rendering: optimizeLegibility; 13 | -webkit-font-smoothing: antialiased; 14 | -moz-osx-font-smoothing: grayscale; 15 | -webkit-text-size-adjust: 100%; 16 | } 17 | 18 | a { 19 | font-weight: 500; 20 | color: #646cff; 21 | text-decoration: inherit; 22 | } 23 | a:hover { 24 | color: #535bf2; 25 | } 26 | 27 | body { 28 | margin: 0; 29 | display: flex; 30 | place-items: center; 31 | min-width: 320px; 32 | min-height: 100vh; 33 | } 34 | 35 | h1 { 36 | font-size: 3.2em; 37 | line-height: 1.1; 38 | } 39 | 40 | button { 41 | border-radius: 8px; 42 | border: 1px solid transparent; 43 | padding: 0.6em 1.2em; 44 | font-size: 1em; 45 | font-weight: 500; 46 | font-family: inherit; 47 | background-color: #1a1a1a; 48 | cursor: pointer; 49 | transition: border-color 0.25s; 50 | } 51 | button:hover { 52 | border-color: #646cff; 53 | } 54 | button:focus, 55 | button:focus-visible { 56 | outline: 4px auto -webkit-focus-ring-color; 57 | } 58 | 59 | @media (prefers-color-scheme: light) { 60 | :root { 61 | color: #213547; 62 | background-color: #ffffff; 63 | } 64 | a:hover { 65 | color: #747bff; 66 | } 67 | button { 68 | background-color: #f9f9f9; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/ReloadPrompt.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './ReloadPrompt.css' 3 | 4 | import { useRegisterSW } from 'virtual:pwa-register/react' 5 | 6 | function ReloadPrompt() { 7 | // replaced dynamically 8 | const buildDate = '__DATE__' 9 | // replaced dyanmicaly 10 | const reloadSW = '__RELOAD_SW__' 11 | 12 | const { 13 | offlineReady: [offlineReady, setOfflineReady], 14 | needRefresh: [needRefresh, setNeedRefresh], 15 | updateServiceWorker, 16 | } = useRegisterSW({ 17 | onRegistered(r) { 18 | // @ts-expect-error just ignore 19 | if (reloadSW === 'true') { 20 | r && setInterval(() => { 21 | // eslint-disable-next-line no-console 22 | console.log('Checking for sw update') 23 | r.update() 24 | }, 20000 /* 20s for testing purposes */) 25 | } 26 | else { 27 | // eslint-disable-next-line prefer-template,no-console 28 | console.log('SW Registered: ' + r) 29 | } 30 | }, 31 | onRegisterError(error) { 32 | // eslint-disable-next-line no-console 33 | console.log('SW registration error', error) 34 | }, 35 | }) 36 | 37 | const close = () => { 38 | setOfflineReady(false) 39 | setNeedRefresh(false) 40 | } 41 | 42 | return ( 43 |
44 | { (offlineReady || needRefresh) 45 | &&
46 |
47 | { offlineReady 48 | ? App ready to work offline 49 | : New content available, click on reload button to update. 50 | } 51 |
52 | { needRefresh && } 53 | 54 |
55 | } 56 |
{buildDate}
57 |
58 | ) 59 | } 60 | 61 | export default ReloadPrompt 62 | -------------------------------------------------------------------------------- /src/logic/localStorage.ts: -------------------------------------------------------------------------------- 1 | import { onSnapshot, applySnapshot, IStateTreeNode } from "mobx-state-tree" 2 | 3 | import AsyncLocalStorage from "./asyncLocalStorage" 4 | 5 | export interface IArgs { 6 | (name: string, store: IStateTreeNode, options?: IOptions): Promise 7 | } 8 | export interface IOptions { 9 | storage?: any, 10 | jsonify?: boolean, 11 | readonly whitelist?: Array, 12 | readonly blacklist?: Array 13 | } 14 | type StrToAnyMap = {[key: string]: any} 15 | 16 | export const persist: IArgs = (name, store, options = {}) => { 17 | let {storage, jsonify = true, whitelist, blacklist} = options 18 | 19 | // use AsyncLocalStorage by default (or if localStorage was passed in) 20 | if ( 21 | typeof window !== 'undefined' && 22 | typeof window.localStorage !== 'undefined' && 23 | (!storage || storage === window.localStorage) 24 | ) { 25 | storage = AsyncLocalStorage 26 | } 27 | if (!storage) { 28 | return Promise.reject('localStorage (the default storage engine) is not ' + 29 | 'supported in this environment. Please configure a different storage ' + 30 | 'engine via the `storage:` option.') 31 | } 32 | 33 | const whitelistDict = arrToDict(whitelist) 34 | const blacklistDict = arrToDict(blacklist) 35 | 36 | onSnapshot(store, (_snapshot: StrToAnyMap) => { 37 | // need to shallow clone as otherwise properties are non-configurable (https://github.com/agilgur5/mst-persist/pull/21#discussion_r348105595) 38 | const snapshot = { ..._snapshot } 39 | Object.keys(snapshot).forEach((key) => { 40 | if (whitelist && !whitelistDict[key]) { 41 | delete snapshot[key] 42 | } 43 | if (blacklist && blacklistDict[key]) { 44 | delete snapshot[key] 45 | } 46 | }) 47 | 48 | const data = !jsonify ? snapshot : JSON.stringify(snapshot) 49 | storage.setItem(name, data) 50 | }) 51 | 52 | return storage.getItem(name) 53 | .then((data: object | string) => { 54 | const snapshot = !isString(data) ? data : JSON.parse(data) 55 | // don't apply falsey (which will error), leave store in initial state 56 | if (!snapshot) { return } 57 | applySnapshot(store, snapshot) 58 | }) 59 | } 60 | 61 | type StrToBoolMap = {[key: string]: boolean} 62 | 63 | function arrToDict (arr?: Array): StrToBoolMap { 64 | if (!arr) { return {} } 65 | return arr.reduce((dict: StrToBoolMap, elem) => { 66 | dict[elem] = true 67 | return dict 68 | }, {}) 69 | } 70 | 71 | function isString (value: any): value is string { 72 | return typeof value === 'string' 73 | } 74 | 75 | export default persist -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState, SyntheticEvent } from 'react' 2 | import Tabs from '@mui/material/Tabs'; 3 | import Tab from '@mui/material/Tab'; 4 | import Box from '@mui/material/Box'; 5 | import { Typography } from '@mui/material'; 6 | import Container from '@mui/material/Container'; 7 | 8 | import './App.css' 9 | import { beepSlower } from "./logic/utils" 10 | import { EditTimers, LogView, Stopwatch, SummaryView } from "./views/boardComponent" 11 | import { ITimer, factory, store } from "./logic/store" 12 | 13 | // window.store = store 14 | 15 | 16 | interface TabPanelProps { 17 | children?: React.ReactNode; 18 | index: number; 19 | value: number; 20 | } 21 | 22 | function TabPanel(props: TabPanelProps) { 23 | const { children, value, index, ...other } = props; 24 | 25 | return ( 26 | 39 | ); 40 | } 41 | 42 | 43 | function a11yProps(index: number) { 44 | return { 45 | id: `simple-tab-${index}`, 46 | 'aria-controls': `simple-tabpanel-${index}`, 47 | }; 48 | } 49 | 50 | function App() { 51 | const [value, setValue] = useState(0); 52 | 53 | const handleChange = (event: SyntheticEvent, newValue: number) => { 54 | setValue(newValue); 55 | }; 56 | 57 | return ( 58 |
59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 |
84 | ) 85 | } 86 | 87 | export default App 88 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | import type { ManifestOptions, VitePWAOptions } from 'vite-plugin-pwa' 4 | import { VitePWA } from 'vite-plugin-pwa' 5 | import replace from '@rollup/plugin-replace' 6 | 7 | const pwaOptions: Partial = { 8 | mode: 'production', 9 | base: '/cronos/', 10 | includeAssets: ['favicon.svg'], 11 | manifest: { 12 | name: 'Cronos', 13 | short_name: 'Cronos', 14 | theme_color: '#ffffff', 15 | icons: [ 16 | { 17 | src: 'pwa-192x192.png', 18 | sizes: '192x192', 19 | type: 'image/png', 20 | }, 21 | { 22 | src: '/pwa-512x512.png', 23 | sizes: '512x512', 24 | type: 'image/png', 25 | }, 26 | { 27 | src: 'pwa-512x512.png', 28 | sizes: '512x512', 29 | type: 'image/png', 30 | purpose: 'any maskable', 31 | }, 32 | { 33 | src: 'pwa-144x144.png', 34 | sizes: '144x144', 35 | type: 'image/png', 36 | purpose: 'any', 37 | }, 38 | ], 39 | }, 40 | devOptions: { 41 | enabled: process.env.SW_DEV === 'true', 42 | /* when using generateSW the PWA plugin will switch to classic */ 43 | type: 'module', 44 | navigateFallback: 'index.html', 45 | }, 46 | } 47 | 48 | const replaceOptions = { __DATE__: new Date().toISOString() } 49 | const claims = process.env.CLAIMS === 'true' 50 | const reload = process.env.RELOAD_SW === 'true' 51 | const selfDestroying = process.env.SW_DESTROY === 'true' 52 | 53 | if (process.env.SW === 'true') { 54 | pwaOptions.srcDir = 'src' 55 | pwaOptions.filename = claims ? 'claims-sw.ts' : 'prompt-sw.ts' 56 | pwaOptions.strategies = 'injectManifest' 57 | ;(pwaOptions.manifest as Partial).name = 'PWA Inject Manifest' 58 | ;(pwaOptions.manifest as Partial).short_name = 'PWA Inject' 59 | } 60 | 61 | if (claims) 62 | pwaOptions.registerType = 'autoUpdate' 63 | 64 | if (reload) { 65 | // @ts-expect-error just ignore 66 | replaceOptions.__RELOAD_SW__ = 'true' 67 | } 68 | 69 | if (selfDestroying) 70 | pwaOptions.selfDestroying = selfDestroying 71 | 72 | 73 | // https://vitejs.dev/config/ 74 | export default defineConfig({ 75 | base: '/cronos/', 76 | build: { 77 | sourcemap: process.env.SOURCE_MAP === 'true', 78 | rollupOptions: { 79 | output: { 80 | manualChunks: (id) => { 81 | if (id.includes("node_modules")) { 82 | if (id.includes("@material-ui")) { 83 | return "vendor_mui" 84 | } 85 | return "vendor" 86 | } 87 | }, 88 | } 89 | } 90 | }, 91 | 92 | plugins: [ 93 | react(), 94 | VitePWA(pwaOptions), 95 | replace(replaceOptions), 96 | ] 97 | }) 98 | -------------------------------------------------------------------------------- /src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/views/components.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, ButtonGroup, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, InputLabel, styled, TextField } from '@mui/material' 2 | import { Ref, useEffect, useId, useRef, useState } from 'react' 3 | import AddIcon from '@mui/icons-material/Add' 4 | 5 | 6 | export function useToggleFocus(): [boolean, () => void, Ref, () => void] { 7 | const focusRef = useRef(null) 8 | const [_toggle, _setToggle] = useState(false) 9 | const [shouldFocus, setShouldFocus] = useState(false) 10 | 11 | useEffect(() => { 12 | if (shouldFocus) { 13 | focusRef.current?.focus() 14 | setShouldFocus(false) 15 | } 16 | }) 17 | const setFocus = () => setShouldFocus(true) 18 | const toggle = () => { 19 | if (_toggle) { 20 | _setToggle(false) 21 | setFocus() 22 | } 23 | else _setToggle(true) 24 | } 25 | return [_toggle, toggle, focusRef, setFocus] 26 | } 27 | 28 | 29 | export const Item = styled(Box)(({ theme }) => ({ 30 | backgroundColor: theme.palette.mode === 'dark' ? '#1A2027' : '#fff', 31 | ...theme.typography.body2, 32 | padding: theme.spacing(1), 33 | textAlign: 'center', 34 | color: theme.palette.text.secondary 35 | })) 36 | 37 | 38 | type DialogCallback = (value: string) => void 39 | type SimpleDialogProps = { open: boolean, title: string, labelConfirm: string, onOk: DialogCallback, onCancel: DialogCallback } 40 | type DialogInputProps = SimpleDialogProps & { labelInput: string, initialValue?: string } 41 | 42 | export function GetInputDialog(p: DialogInputProps) { 43 | const idInput = useId() 44 | const refInput = useRef(null) 45 | const handleOK = () => { 46 | p.onOk(refInput.current!.value) 47 | }; 48 | 49 | const handleCancel = () => { 50 | p.onCancel("") 51 | }; 52 | return ( 53 | 54 | {p.title} 55 | 56 | {p.labelInput} 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | ); 67 | } 68 | 69 | type ConfirmDialogProps = SimpleDialogProps & { description: string } 70 | export function ConfirmDialog(p: ConfirmDialogProps) { 71 | const handleOK = () => { 72 | p.onOk("ok") 73 | }; 74 | const handleCancel = () => { 75 | p.onCancel("cancel") 76 | }; 77 | 78 | return ( 79 | {p.title} 80 | 81 | 82 | {p.description} 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | ) 92 | } 93 | 94 | 95 | export function ButtonNewDialog(p: Omit & { labelOpen: string }) { 96 | const [open, toggleOpen, focusRef] = useToggleFocus() 97 | 98 | const handleOk = (value: string) => { 99 | p.onOk(value) 100 | toggleOpen() 101 | } 102 | 103 | if (!open) return () 106 | 107 | return () 108 | } 109 | 110 | 111 | 112 | -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 18 | 20 | 21 | 23 | image/svg+xml 24 | 26 | 27 | 28 | 29 | 30 | 52 | 56 | 58 | 65 | 68 | 72 | 73 | 80 | 83 | 87 | 91 | 92 | 93 | 97 | 101 | 104 | 112 | 120 | 128 | 129 | 130 | 131 | -------------------------------------------------------------------------------- /src/logic/utils.ts: -------------------------------------------------------------------------------- 1 | export function range(start: number, end: number, step: number): number[] { 2 | let r: number[] = [] 3 | for (let i = start; i < end; i += step) 4 | r.push(i) 5 | return r; 6 | } 7 | 8 | // the function type to get the value in a list to be used in functions like getNearest. 9 | export type GetVal = (it: T) => number | string | boolean 10 | 11 | 12 | /** 13 | * get the position for the equal or greater element just after of the given item for the specified array. 14 | * this function assumes the array is sorted. 15 | * if the key is greater than all elements in the array, -1 is returned. 16 | * if less is set to true, this reverse the behavior, to gett the less or equal element instead. 17 | * -1 is used when the conditions were not met. 18 | * @param val: the item to compare with, this item should be less or equals that the element to find. 19 | * @param arr: array of sorted elements. 20 | * @param fnVal: the function to get the value to be used in comparations. 21 | * @less: reverses the behavior. default is false. 22 | * @returns: the equals or greater element than the specified element, or the reverse case if less is true. -1 if the conditions were not met. 23 | */ 24 | export function getNearest(val: number | string | boolean, arr: t[], fnVal: GetVal, less: boolean = false): number { 25 | let max = arr.length; 26 | if ((max == 0) || (!less && val > fnVal(arr[max - 1])) || (less && val < fnVal(arr[0]))) 27 | return -1 28 | if (max == 1) 29 | return 0 30 | if (!less && val < fnVal(arr[0])) 31 | return 0 32 | if (less && val > fnVal(arr[max - 1])) 33 | return max - 1 34 | --max; 35 | let min = 0; 36 | for (let i = 0; i <= arr.length; ++i) { 37 | let curPos = Math.ceil((min + max) / 2); 38 | let prev = fnVal(arr[curPos - 1]) 39 | let next = fnVal(arr[curPos]) 40 | if (prev <= val && val <= next) { 41 | if (prev == val) 42 | return curPos - 1 43 | if (next == val) 44 | return curPos 45 | return less ? curPos - 1 : curPos 46 | } 47 | if (prev < val) 48 | min = curPos; 49 | else 50 | max = curPos 51 | } 52 | return -1 53 | } 54 | 55 | 56 | /** 57 | * a helper to manage the local storage. 58 | */ 59 | export const tLocal = { 60 | /** 61 | * get the value from the local storage. The value will be parsed before return. 62 | * the default value, if given, won't be parsed. 63 | * @param key: the key to get from the local storage. 64 | * @param defaultValue: an optional value to be returned if the key is not found. 65 | * @returns: the value of the key if exist. Default value or null otherwise. 66 | */ 67 | get(key: string, defaultValue?: any): any | null { 68 | let v = localStorage.getItem(key) 69 | return v !== null ? JSON.parse(v) : defaultValue ? defaultValue : v 70 | }, 71 | 72 | /** 73 | * 74 | * @param key: the key to asign the value. 75 | * @param value: an object to store. JSON.stringify will be aplied to this object. 76 | * @returns: true if all is OK. 77 | */ 78 | set(key: string, value: any) { 79 | localStorage.setItem(key, JSON.stringify(value)) 80 | return true; 81 | }, 82 | 83 | remove(key: string) { 84 | localStorage.removeItem(key) 85 | } 86 | } 87 | 88 | 89 | // generates an unique id (consecutive number) to use as a ID. Just a temporal solution to do some tests. 90 | export const uuid = { 91 | counter: 0, 92 | // tLocal.get('count', 0) as number, 93 | getId(): string { 94 | // tLocal.set("count", ++this.counter) 95 | ++this.counter 96 | return String(this.counter) 97 | } 98 | } 99 | 100 | export function msToHumanTime(t: number): string { 101 | let d = new Date(t) 102 | let [day, hours] = d.toISOString().split("T") as [string, string] 103 | hours = hours.substring(0, 8) 104 | let stringDays = "" 105 | for (let [i, v] of day.split("-").entries()) { 106 | let n = Number(v) 107 | // year 108 | if (i === 0 && n - 1970 > 0) 109 | stringDays = `${n - 1970} years, ` 110 | // months or days. 111 | else if (i > 0 && n - 1 > 0) 112 | stringDays += `${n - 1} ${i == 1 ? "months" : "days"}, ` 113 | } 114 | 115 | let stringHours = "" 116 | for (let [i, v] of hours.split(":").entries()) { 117 | let n = Number(v) 118 | switch (i) { 119 | case 0: 120 | stringHours = `${n}h, ` 121 | break 122 | case 1: 123 | stringHours += `${n}m, ` 124 | break 125 | case 2: 126 | stringHours += `${n}s` 127 | } 128 | } 129 | return stringDays + stringHours 130 | } 131 | 132 | export const dateUtils = { 133 | getCurrentStartDay() { 134 | let d = new Date() 135 | d.setHours(0, 0, 0) 136 | return d 137 | }, 138 | getCurrentStartWeek(): Date { 139 | let d = dateUtils.getCurrentStartDay() 140 | d = dateUtils.sumDay(d, -d.getDay()) 141 | return d 142 | }, 143 | getCurrentStartMonth(): Date { 144 | let d = dateUtils.getCurrentStartDay() 145 | d.setDate(1) 146 | return d 147 | }, 148 | 149 | sumDay(date: Date, n: number): Date { 150 | let d = new Date(date) 151 | d.setDate(date.getDate() + n) 152 | return d 153 | }, 154 | sumWeek(date: Date, n: number): Date { 155 | let d = new Date(date) 156 | d.setDate(date.getDate() + n * 7) 157 | return d 158 | }, 159 | sumMonth(date: Date, n: number): Date { 160 | let d = new Date(date) 161 | d.setMonth(date.getMonth() + n) 162 | return d 163 | }, 164 | sumYear(date: Date, n: number): Date { 165 | let d = new Date(date) 166 | d.setFullYear(date.getFullYear() + n) 167 | return d 168 | } 169 | } 170 | 171 | 172 | const beepState = { 173 | audioContext: undefined as AudioContext | undefined, 174 | osc: undefined as OscillatorNode | undefined, 175 | waitToBreak: 0, 176 | isRunning: false 177 | } 178 | 179 | function startAudioContext() { 180 | if (!beepState.audioContext) 181 | beepState.audioContext = new AudioContext() 182 | } 183 | 184 | 185 | /** 186 | * produces a beep tone with a sine wave in the browser. 187 | * @param frequency: the freq of the wave. default 440. 188 | * @param duration: duration in ms. default is 100. 189 | * @param volume: the volume, default is 1. Don't use very high values or you can get distorted sounds. 190 | */ 191 | export function beep(frequency: number = 440, duration: number = 100, volume: number = 1) { 192 | startAudioContext() 193 | if (!beepState.audioContext) 194 | return 195 | if (beepState.osc) 196 | beepState.osc.stop(beepState.audioContext.currentTime) 197 | beepState.osc = beepState.audioContext?.createOscillator() as OscillatorNode 198 | let [osc, audio] = [beepState.osc, beepState.audioContext] 199 | let g = audio.createGain() as GainNode 200 | osc.connect(g) 201 | osc.frequency.value = frequency 202 | osc.type = "sine" 203 | g.connect(audio.destination) 204 | g.gain.value = volume 205 | osc.start(audio.currentTime) 206 | beepState.isRunning = true 207 | osc.stop(audio.currentTime + duration * 0.001) 208 | osc.onended = e => beepState.isRunning = false 209 | } 210 | 211 | const WAIT_TIME = 30 212 | 213 | /** 214 | * for debug purposes. If a beep is running, this will wait WAIT_TIME ms more from the last beep to interrupt the current beep. This will allow to hear the differences between events. 215 | * WAIT_TIME is set to 30 ms, change it to your needs. 216 | * */ 217 | export function beepSlower(frequency: number = 440, duration: number = 100, volume: number = 1) { 218 | if (beepState.waitToBreak < 0) 219 | beepState.waitToBreak = 0 220 | if (beepState.isRunning) { 221 | beepState.waitToBreak += WAIT_TIME 222 | setTimeout(() => { 223 | beep(frequency, duration, volume) 224 | beepState.waitToBreak -= WAIT_TIME 225 | }, 226 | beepState.waitToBreak) 227 | } 228 | else 229 | beep(frequency, duration, volume) 230 | } 231 | -------------------------------------------------------------------------------- /src/logic/store.ts: -------------------------------------------------------------------------------- 1 | import { Instance, types } from "mobx-state-tree" 2 | import persist from "./localStorage" 3 | type ReferenceIdentifier = string | number 4 | 5 | import { dateUtils, getNearest, uuid } from "./utils" 6 | 7 | 8 | const Timer = types.model("timer", { 9 | id: types.identifier, 10 | name: types.string, 11 | lastDuration: 0 12 | }) 13 | .actions(self => ({ 14 | setName(name: string) { 15 | self.name = name 16 | }, 17 | setLastDuration(duration: number) { 18 | self.lastDuration = duration 19 | } 20 | })) 21 | export interface ITimer extends Instance { } 22 | 23 | 24 | export const MarkTime = types.model("markTime", { 25 | time: types.number, 26 | timer: types.safeReference(Timer) 27 | }) 28 | .actions(self => ({ 29 | setTimer(timer: ITimer) { 30 | self.timer = timer 31 | } 32 | })) 33 | export interface IMarkTime extends Instance { } 34 | 35 | 36 | function getMarkTime(mark: IMarkTime): number { 37 | return mark.time 38 | } 39 | 40 | 41 | const Board = types.model("board", { 42 | id: types.identifier, 43 | name: types.string, 44 | timers: types.map(Timer), 45 | history: types.array(MarkTime), 46 | mostRecent: types.array(types.safeReference(Timer, { acceptsUndefined: false })) 47 | }) 48 | .views(self => ({ 49 | get lastMark(): IMarkTime | undefined { 50 | return self.history.at(-1) 51 | }, 52 | 53 | getHistoryBetween(start: number, end: number): IMarkTime[] { 54 | const posStart = getNearest(start, self.history, getMarkTime) 55 | const posEnd = getNearest(end, self.history, getMarkTime, true) 56 | if (posStart !== -1 && posEnd !== -1) { 57 | const r = self.history.slice(posStart, posEnd + 1) 58 | if (r.length > 0) return r 59 | } 60 | return [] 61 | }, 62 | 63 | getPrevMark(time: number): IMarkTime | undefined { 64 | const pos = getNearest(time, self.history, getMarkTime, true) 65 | if (pos !== -1) return self.history[pos] 66 | return undefined 67 | }, 68 | getNextMark(time: number): IMarkTime | undefined { 69 | const pos = getNearest(time, self.history, getMarkTime) 70 | if (pos !== -1) return self.history[pos] 71 | return undefined 72 | } 73 | })) 74 | .actions(self => ({ 75 | setName(name: string) { 76 | self.name = name 77 | }, 78 | 79 | updateMostRecent(timer: ITimer) { 80 | self.mostRecent.remove(timer) 81 | self.mostRecent.push(timer) 82 | }, 83 | 84 | addTimer(timer: ITimer) { 85 | self.timers.put(timer) 86 | this.updateMostRecent(timer) 87 | }, 88 | 89 | deleteTimer(id: string) { 90 | self.timers.delete(id) 91 | // remove marks that are not stoper marks. That is, a mark with undefined timer, after another undefined timer mark. 92 | for (let i = 1; i < self.history.length; ++i) { 93 | if (self.history[i - 1].timer === undefined) { 94 | self.history.splice(i, 1) 95 | --i 96 | } 97 | } 98 | if (self.history.length > 0 && self.history[0].timer === undefined) 99 | self.history.splice(0, 1) 100 | }, 101 | 102 | startTimer(timer: ITimer | undefined, time: number) { 103 | if (self.lastMark && self.lastMark.time > time) { 104 | throw Error("The mark time can't be less than the last mark time") 105 | } 106 | if (self.lastMark) { 107 | if (timer === self.lastMark.timer) return 108 | self.lastMark.timer?.setLastDuration(time - self.lastMark.time) 109 | } 110 | const m = MarkTime.create({ time: time }) 111 | self.history.push(m) 112 | if (timer) { 113 | m.setTimer(timer) 114 | this.updateMostRecent(timer) 115 | } 116 | }, 117 | 118 | stopTimer(time: number) { 119 | this.startTimer(undefined, time) 120 | }, 121 | updateTimers(timers: ITimer[]) { 122 | for (const k of timers) { 123 | self.timers.put(k) 124 | } 125 | }, 126 | 127 | updateHistory(marks: IMarkTime[]): boolean | never { 128 | if (marks.length == 0) 129 | return false 130 | if (self.history.length == 0) 131 | self.history.replace(marks) 132 | else if ((marks.at(-1) as IMarkTime).time < self.history[0].time) 133 | self.history.replace([...marks, ...self.history]) 134 | else if ((self.lastMark as IMarkTime).time < marks[0].time) 135 | self.history.replace([...self.history, ...marks]) 136 | else 137 | throw Error(`error updating the istory of marks. The specified items are inside the current range of the history. in board ${self.id} name: ${self.name}`) 138 | return true 139 | } 140 | })) 141 | export interface IBoard extends Instance { } 142 | 143 | 144 | export interface IGenerator { 145 | getCurrentTime(): number 146 | generateUId(): string 147 | } 148 | 149 | export interface IFactory { 150 | createBoard(name: string): IBoard 151 | createTimer(name: string, containerId: string): ITimer 152 | createMarkTime(timer: ITimer, board: IBoard): IMarkTime 153 | } 154 | 155 | 156 | class Generator implements IGenerator { 157 | getCurrentTime(): number { 158 | return Date.now() 159 | } 160 | 161 | generateUId(): string { 162 | return uuid.getId() 163 | } 164 | } 165 | 166 | 167 | class Factory implements IFactory { 168 | public generator; 169 | constructor(generator: IGenerator) { 170 | this.generator = generator 171 | } 172 | 173 | public createBoard(name: string): IBoard { 174 | return Board.create({ id: this.generator.generateUId(), name: name }) 175 | } 176 | 177 | public createTimer(name: string, containerId: string): ITimer { 178 | return Timer.create({ id: this.generator.generateUId(), name: name }) 179 | } 180 | 181 | public createMarkTime(timer: ITimer, board: IBoard): IMarkTime { 182 | return MarkTime.create({ timer: timer.id, time: this.generator.getCurrentTime() }) 183 | } 184 | } 185 | 186 | const Boards = types.model({ 187 | boards: types.map(Board), 188 | currentBoard: types.safeReference(Board) 189 | }).actions(self => ({ 190 | addBoard(board: IBoard) { 191 | self.boards.put(board) 192 | self.currentBoard = board 193 | }, 194 | deleteBoard(id: string) { 195 | self.boards.delete(id) 196 | } 197 | })) 198 | export interface IBoards extends Instance { } 199 | 200 | function initialize() { 201 | const f = new Factory(new Generator()) 202 | const s = Boards.create() 203 | const b = f.createBoard("Personal") 204 | s.addBoard(b) 205 | b.addTimer(f.createTimer("Pereceando", "")) 206 | b.addTimer(f.createTimer("Comiendo", "")) 207 | b.addTimer(f.createTimer("ejercicio", "")) 208 | b.addTimer(f.createTimer("durmiendo", "")) 209 | b.addTimer(f.createTimer("redes sociales", "")) 210 | b.addTimer(f.createTimer("tertulia", "")) 211 | b.addTimer(f.createTimer("hablar con amigos", "")) 212 | return { factory: f, store: s } 213 | } 214 | 215 | export const { factory, store } = initialize() 216 | persist("Cronos", store) 217 | 218 | 219 | 220 | /** 221 | * an easier way to show the mark time data. 222 | * if the duration is undefined, the duration should be updated dynamically. 223 | */ 224 | export class ReportMark implements IMarkTime { 225 | time: number 226 | duration: number | undefined 227 | timer: ITimer 228 | constructor(timer: ITimer, time: number, duration?: number) { 229 | this.time = time 230 | this.timer = timer 231 | this.duration = duration 232 | } 233 | setTimer(timer: ITimer) { 234 | this.timer = timer 235 | } 236 | } 237 | 238 | 239 | /** 240 | * an easier way to show the summary of a timer. 241 | * the total duration and the number of times the timer has been activated, are saved here. 242 | */ 243 | export class TimerSummary implements ITimer { 244 | id: string 245 | name: string 246 | lastDuration: number = 0 247 | count: number = 0 248 | constructor(id: string, name: string) { 249 | this.id = id 250 | this.name = name 251 | } 252 | setName(name: string) { 253 | this.name = name 254 | } 255 | 256 | setLastDuration(duration: number) { 257 | this.lastDuration += duration 258 | } 259 | 260 | addDuration(duration: number) { 261 | this.lastDuration += duration 262 | } 263 | addCount() { 264 | ++this.count 265 | } 266 | } 267 | 268 | export function getHistoryRange(startDate: Date, endDate: Date, board: IBoard): ReportMark[] { 269 | const logRange = board.getHistoryBetween(startDate.getTime(), endDate.getTime()) 270 | let greatestTime = factory.generator.getCurrentTime() 271 | if (greatestTime > endDate.getTime()) greatestTime = endDate.getTime() 272 | const prev = board.getPrevMark(startDate.getTime()) 273 | // if a mark time is before the current start range, count the time from the start of the current range to the first mark of the current range. 274 | if (prev && prev.timer) { 275 | const time = startDate.getTime() 276 | const duration = (logRange.length > 0? logRange[0].time: greatestTime) -time 277 | const m = new ReportMark(prev.timer, time, duration) 278 | logRange.unshift(m) 279 | } 280 | 281 | const items: ReportMark[] = [] 282 | for (let i = 0; i < logRange!.length - 1; ++i) { 283 | const m = logRange[i] 284 | if (!m.timer) continue 285 | items.push(new ReportMark(m.timer, m.time, logRange[i + 1].time - m.time)) 286 | } 287 | 288 | // set the duration for the last mark in the range. 289 | const last = logRange.at(-1) 290 | if (last && last!.timer) 291 | items.push(new ReportMark(last.timer, last.time, greatestTime -last.time)) 292 | return items 293 | } 294 | 295 | export function getSummaryRange(startDate: Date, endDate: Date, board: IBoard): TimerSummary[] { 296 | const marks = getHistoryRange(startDate, endDate, board) 297 | const summary: Record = {} 298 | for (const m of marks) { 299 | if (!(m.timer!.id in summary)) 300 | summary[m.timer!.id] = new TimerSummary(m.timer!.id, m.timer!.name) 301 | const duration = m.duration ? m.duration : factory.generator.getCurrentTime() - m.time 302 | summary[m.timer!.id].addDuration(duration) 303 | summary[m.timer!.id].addCount() 304 | } 305 | return Object.values(summary) 306 | } 307 | -------------------------------------------------------------------------------- /src/views/boardComponent.tsx: -------------------------------------------------------------------------------- 1 | import { SyntheticEvent, useEffect, useRef, useState } from 'react' 2 | import { Box, Button, Divider, Drawer, FormControlLabel, List, ListItem, ListItemButton, ListItemIcon, ListItemText, Radio, RadioGroup, Stack } from '@mui/material' 3 | import { Add as AddIcon, ArrowLeftRounded, ArrowRight, ArrowRightRounded, Stop as StopIcon, TimelapseRounded } from '@mui/icons-material' 4 | 5 | import { observer } from "mobx-react-lite" 6 | 7 | 8 | import { ButtonNewDialog, ConfirmDialog, GetInputDialog, Item, useToggleFocus } from "./components" 9 | 10 | import { factory, getHistoryRange, getSummaryRange, IBoard, IMarkTime, ITimer, ReportMark, TimerSummary } from "../logic/store" 11 | import { dateUtils, msToHumanTime, uuid } from '../logic/utils' 12 | 13 | 14 | function createTimer(board: IBoard, name: string) { 15 | board.addTimer(factory.createTimer(name, board.id)) 16 | } 17 | 18 | function deleteTimer(board: IBoard, timer: ITimer) { 19 | board.deleteTimer(timer.id) 20 | } 21 | 22 | function renameTimer(timer: ITimer, name: string) { 23 | timer.setName(name) 24 | } 25 | 26 | function toggleTimer(board: IBoard, t: ITimer, lastMark: number | undefined) { 27 | const time = factory.generator.getCurrentTime() 28 | if (lastMark) board.stopTimer(time) 29 | else board.startTimer(t, time) 30 | } 31 | 32 | 33 | 34 | 35 | export const TimerComponent = observer((p: { timer: ITimer, lastMark: number | undefined }) => { 36 | const [duration, setDuration] = useState(msToHumanTime(p.timer.lastDuration)) 37 | useEffect(() => { 38 | if (p.lastMark) { 39 | setDuration(msToHumanTime(0)) 40 | let iv = setInterval(() => p.lastMark && setDuration(msToHumanTime(Date.now() - p.lastMark)), 1000) 41 | return () => clearInterval(iv) 42 | } 43 | setDuration(msToHumanTime(p.timer.lastDuration)) 44 | return undefined 45 | }, [p.lastMark]) 46 | 47 | return (<>{p.timer.name}. {duration}) 48 | }) 49 | 50 | type WrapperTimerNode = (p: { timer: ITimer, lastMark: number | undefined, children: any }) => any 51 | export const TimersView = observer((p: { board: IBoard, WrapperTimer: WrapperTimerNode }) => { 52 | const { board, WrapperTimer } = p 53 | 54 | const listRef = useRef(null) 55 | useEffect(() => { 56 | if (listRef.current) { 57 | listRef.current.scrollTop = 100000 58 | console.log("actualizando scroll", uuid.getId()) 59 | } 60 | }) 61 | console.log("construyendo board", uuid.getId(), board.history.length, board.lastMark?.time, board.lastMark?.timer?.id) 62 | 63 | return ( 64 | 65 | {board.mostRecent.map(v => { 66 | let l: number | undefined = undefined 67 | if (board.lastMark && v.id === board.lastMark.timer?.id) 68 | l = board.lastMark.time 69 | return ( 70 | 71 | 72 | 73 | ) 74 | })} 75 | 76 | 77 | ) 78 | }) 79 | 80 | 81 | export const TimersFooter = observer((p: { board: IBoard }) => { 82 | return (<> 83 | 84 | 85 | {p.board.lastMark?.timer && ( 86 | 89 | )} 90 | createTimer(p.board, n)} /> 91 | 92 | ) 93 | }) 94 | 95 | 96 | export const Stopwatch = observer((p: { board: IBoard }) => { 97 | const Wrapper = (wp: { timer: ITimer, lastMark: number | undefined, children: any }) => ( 98 | toggleTimer(p.board, wp.timer, wp.lastMark)}> 100 | {wp.lastMark !== undefined && ()} 101 | 102 | {wp.children} 103 | 104 | 105 | ) 106 | 107 | return ( 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | ) 117 | }) 118 | 119 | export const EditTimers = observer((p: { board: IBoard }) => { 120 | 121 | const Wrapper = (wp: { timer: ITimer, lastMark: number | undefined, children: any }) => { 122 | const [openDrawer, toggleDrawer, focusRef, setFocus] = useToggleFocus() 123 | const [openDialog, setOpenDialog] = useState(0) 124 | 125 | const handleCloseDialog = () => { 126 | setOpenDialog(0) 127 | setFocus() 128 | } 129 | 130 | const rename = (v: string) => { 131 | renameTimer(wp.timer, v) 132 | handleCloseDialog() 133 | } 134 | 135 | const _delete = (v: string) => { 136 | deleteTimer(p.board, wp.timer) 137 | handleCloseDialog() 138 | } 139 | 140 | const handleKeyMouse = (event: React.KeyboardEvent | React.MouseEvent) => { 141 | if (event.type === 'keydown') { 142 | const key = (event as React.KeyboardEvent).key 143 | if (key !== 'Escape') return 144 | } 145 | toggleDrawer() 146 | }; 147 | 148 | switch (openDialog) { 149 | case 1: 150 | return () 151 | case 2: 152 | return () 160 | default: 161 | return (<> 162 | 163 | 164 | 165 | 166 | setOpenDialog(1)}> 167 | 168 | 169 | 170 | 171 | setOpenDialog(2)}> 172 | 173 | 174 | 175 | 176 | 177 | ) 178 | } 179 | } 180 | 181 | return ( 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | {p.board.lastMark?.timer && ( 192 | 193 | 194 | 195 | )} 196 | 197 | createTimer(p.board, n)} /> 198 | 199 | 200 | 201 | 202 | 203 | ) 204 | }) 205 | 206 | export function MarkTimeEntry(p: { mark: ReportMark }) { 207 | const mark = p.mark 208 | const [duration, setDuration] = useState(mark.duration ? mark.duration : factory.generator.getCurrentTime() - mark.time) 209 | 210 | useEffect(() => { 211 | if (!mark.duration) { 212 | let iv = setInterval(() => setDuration(Date.now() - mark.time), 1000) 213 | return () => clearInterval(iv) 214 | } 215 | return undefined 216 | }, [p.mark]) 217 | 218 | return ( 219 | 220 | {mark.timer!.name} 221 | 222 | 223 | {new Date(mark.time).toLocaleTimeString()} 224 | 225 | 226 | {msToHumanTime(duration)} 227 | 228 | ) 229 | } 230 | 231 | 232 | interface IDatePicker { 233 | startDate: Date 234 | endDate: Date 235 | setStartDate(d: Date): void 236 | setEndDate(d: Date): void 237 | } 238 | 239 | 240 | function useDatePicker(): IDatePicker { 241 | const [startDate, setStartDate] = useState(dateUtils.getCurrentStartDay) 242 | const [endDate, setEndDate] = useState(dateUtils.sumDay(startDate, 1)) 243 | return { startDate, endDate, setStartDate, setEndDate } 244 | } 245 | 246 | 247 | function RangeDatePicker(props: { dateRange: IDatePicker }) { 248 | const d = props.dateRange 249 | const [rangeType, setRangeType] = useState("day") 250 | 251 | type FNStart = () => Date 252 | type FNSum = (d: Date, n: number) => Date 253 | const setRange = (fnStart: FNStart, fnSum: FNSum) => { 254 | const start = fnStart() 255 | d.setStartDate(start) 256 | d.setEndDate(fnSum(start, 1)) 257 | } 258 | const addToRange = (fnSum: FNSum, n: number) => { 259 | d.setStartDate(fnSum(d.startDate, n)) 260 | d.setEndDate(fnSum(d.endDate, n)) 261 | } 262 | const fnSumbs: Record = { 263 | day: [dateUtils.getCurrentStartDay, dateUtils.sumDay], 264 | week: [dateUtils.getCurrentStartWeek, dateUtils.sumWeek], 265 | month: [dateUtils.getCurrentStartMonth, dateUtils.sumMonth] 266 | } 267 | 268 | const onSelectRadio = (e: SyntheticEvent) => { 269 | const v = e.currentTarget.value 270 | setRangeType(v) 271 | setRange(fnSumbs[v]![0], fnSumbs[v][1]) 272 | } 273 | 274 | return (
275 | 276 | 277 | } /> 278 | } /> 279 | } /> 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 |
) 288 | } 289 | 290 | 291 | function ListHistory(p: { marks: ReportMark[] }) { 292 | return ( 293 | 294 | 297 | 300 | 303 | 304 | {p.marks.map((v, i) => ())} 305 |
295 | timer 296 | 298 | date 299 | 301 | Duration 302 |
) 306 | } 307 | 308 | 309 | function SumaryHistory(p: { summary: TimerSummary[] }) { 310 | const [data, setData] = useState([...p.summary]) 311 | const [sortBy, setSortBy] = useState("duration") 312 | const fnSorts = { 313 | duration: (a: TimerSummary, b: TimerSummary) => a.lastDuration == b.lastDuration ? 0 : a.lastDuration > b.lastDuration ? -1 : 1, 314 | count: (a: TimerSummary, b: TimerSummary) => a.count == b.count ? 0 : a.count > b.count ? -1 : 1 315 | } 316 | const handleSort = () => { 317 | switch (sortBy) { 318 | case "duration": 319 | setData([...p.summary].sort(fnSorts.duration)) 320 | break 321 | case "count": 322 | setData([...p.summary].sort(fnSorts.count)) 323 | break 324 | } 325 | } 326 | 327 | useEffect(() => { 328 | handleSort() 329 | }, [p.summary]) 330 | return ( 331 | 332 | 335 | 340 | 345 | 346 | {data.map((v) => ( 347 | 350 | 353 | 356 | ))} 357 |
333 | timer 334 | 336 | 339 | 341 | 344 |
348 | {v.name} 349 | 351 | {msToHumanTime(v.lastDuration)} 352 | 354 | {v.count} 355 |
) 358 | } 359 | 360 | export const LogView = observer((p: { board: IBoard }) => { 361 | const date = useDatePicker() 362 | 363 | return ( 364 | 365 | 366 | 367 | 368 | ) 369 | }) 370 | 371 | 372 | export const SummaryView = observer((p: { board: IBoard }) => { 373 | const date = useDatePicker() 374 | 375 | return ( 376 | 377 | 378 | 379 | 380 | ) 381 | }) 382 | --------------------------------------------------------------------------------