├── .gitignore ├── README.md ├── electron ├── TrayBuilder.ts ├── assets │ ├── icon.png │ └── icon@2x.png ├── main.ts └── tsconfig.json ├── icons ├── 512x512.icns └── icon.icns ├── package.json ├── prettier.config.js ├── public ├── favicon.ico ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt ├── src ├── App.module.css ├── App.test.tsx ├── App.tsx ├── EmojiPicker.tsx ├── JobScheduler.ts ├── NotificationScheduler.tsx ├── PageTransition.tsx ├── Routes.tsx ├── RoutineCreator.tsx ├── RoutineItem.tsx ├── RoutineTasks.module.css ├── RoutineTasks.tsx ├── Routines.module.css ├── Routines.tsx ├── TaskItem.tsx ├── Toolbar.module.css ├── Toolbar.tsx ├── database │ ├── RoutinesDB.ts │ ├── RoutinesService.ts │ └── TasksService.ts ├── index.css ├── index.tsx ├── react-app-env.d.ts ├── reportWebVitals.ts ├── setupTests.ts ├── useOverflowY.ts └── variants.ts ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | /out 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | .eslintcache 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Routines Mini 2 | 3 | A Mac Menu Bar application for organizing your routines. 4 | 5 | ![Routines Mini](https://altrim.io/images/routine-creator.png) 6 | 7 | 8 | I wrote a bit about the app on my blog 9 | - [Building a Mac Menu Bar application with React, TypeScript and Electron](https://altrim.io/posts/building-mac-menu-bar-app-with-react-typescript-electron) 10 | - [Schedule recurring reminders with native notifications](https://altrim.io/posts/schedule-recurring-reminders-with-native-notifications) 11 | 12 | The app is built using 13 | 14 | - [React](https://reactjs.org/) with [TypeScript](https://www.typescriptlang.org/) 15 | - [Electron](https://www.electronjs.org/) - used to package it as Menu Bar application 16 | - [Dexie](https://dexie.org/) - used to store the data in IndexedDB 17 | - [Chakra](https://chakra-ui.com/) - used to build the UI 18 | 19 | ## Available Scripts 20 | 21 | In the project directory, you can run: 22 | 23 | ### `yarn electron:dev` 24 | 25 | Runs the app in the development mode.\ 26 | Starts the server on [http://localhost:3000](http://localhost:3000) and launches the app in the Menu Bar 27 | 28 | ### `yarn make` 29 | 30 | Packages the app and generates platform specific distributables to the `out` folder. Uses [Electron Forge](https://www.electronforge.io/) for packaging. 31 | -------------------------------------------------------------------------------- /electron/TrayBuilder.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow, Menu, Tray } from 'electron'; 2 | import * as path from 'path'; 3 | 4 | export class TrayBuilder { 5 | tray: Tray | null; 6 | mainWindow: BrowserWindow | null; 7 | 8 | constructor(mainWindow: BrowserWindow | null) { 9 | this.tray = null; 10 | this.mainWindow = mainWindow; 11 | } 12 | 13 | getWindowPosition = () => { 14 | if (this.mainWindow == null || this.tray == null) { 15 | return; 16 | } 17 | 18 | const windowBounds = this.mainWindow.getBounds(); 19 | const { x, y, width, height } = this.tray.getBounds(); 20 | const posX = Math.round(x + width / 2 - windowBounds.width / 2); 21 | const posY = Math.round(y + height); 22 | 23 | return { x: posX, y: posY }; 24 | }; 25 | 26 | showWindow = () => { 27 | const position = this.getWindowPosition(); 28 | if (this.mainWindow == null || position == null) { 29 | return; 30 | } 31 | 32 | this.mainWindow.setPosition(position.x, position.y, false); 33 | this.mainWindow.show(); 34 | this.mainWindow.setVisibleOnAllWorkspaces(true); 35 | this.mainWindow.focus(); 36 | this.mainWindow.setVisibleOnAllWorkspaces(false); 37 | }; 38 | 39 | toggleWindow = () => { 40 | if (this.mainWindow == null) { 41 | return; 42 | } 43 | return this.mainWindow.isVisible() ? this.mainWindow.hide() : this.showWindow(); 44 | }; 45 | 46 | onRightClick = () => { 47 | if (this.tray == null) { 48 | return; 49 | } 50 | const menu = [ 51 | { 52 | role: 'quit', 53 | accelerator: 'Command+Q', 54 | label: 'Quit Routines Mini', 55 | }, 56 | ]; 57 | this.tray.popUpContextMenu(Menu.buildFromTemplate(menu as any)); 58 | }; 59 | 60 | build = () => { 61 | this.tray = new Tray(path.join(__dirname, './assets/icon.png')); 62 | this.tray.setIgnoreDoubleClickEvents(true); 63 | 64 | this.tray.on('click', this.toggleWindow); 65 | this.tray.on('right-click', this.onRightClick); 66 | }; 67 | } 68 | -------------------------------------------------------------------------------- /electron/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/altrim/routines-mini/131a84a53d6a5527962aff80b269bd6c7d700d66/electron/assets/icon.png -------------------------------------------------------------------------------- /electron/assets/icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/altrim/routines-mini/131a84a53d6a5527962aff80b269bd6c7d700d66/electron/assets/icon@2x.png -------------------------------------------------------------------------------- /electron/main.ts: -------------------------------------------------------------------------------- 1 | import { app, BrowserWindow } from 'electron'; 2 | import * as isDev from 'electron-is-dev'; 3 | import { TrayBuilder } from './TrayBuilder'; 4 | 5 | let mainWindow: BrowserWindow | null = null; 6 | 7 | const createWindow = () => { 8 | mainWindow = new BrowserWindow({ 9 | backgroundColor: '#1a3d53', 10 | width: 440, 11 | height: 730, 12 | show: false, 13 | frame: false, 14 | fullscreenable: false, 15 | resizable: false, 16 | webPreferences: { 17 | devTools: isDev, 18 | nodeIntegration: true, 19 | backgroundThrottling: false, 20 | }, 21 | }); 22 | 23 | mainWindow.loadURL(isDev ? 'http://localhost:3000' : `file://${__dirname}/../index.html`); 24 | if (isDev) { 25 | mainWindow.webContents.openDevTools({ mode: 'detach' }); 26 | } 27 | }; 28 | 29 | let Tray = null; 30 | app.whenReady().then(() => { 31 | createWindow(); 32 | Tray = new TrayBuilder(mainWindow); 33 | Tray.build(); 34 | }); 35 | 36 | app.on('window-all-closed', () => { 37 | if (process.platform !== 'darwin') { 38 | app.quit(); 39 | } 40 | }); 41 | 42 | app.on('activate', () => { 43 | if (BrowserWindow.getAllWindows().length === 0) { 44 | createWindow(); 45 | } 46 | }); 47 | -------------------------------------------------------------------------------- /electron/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "sourceMap": true, 6 | "strict": true, 7 | "outDir": "../build", 8 | "rootDir": "../", 9 | "noEmitOnError": true, 10 | "typeRoots": ["node_modules/@types"] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /icons/512x512.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/altrim/routines-mini/131a84a53d6a5527962aff80b269bd6c7d700d66/icons/512x512.icns -------------------------------------------------------------------------------- /icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/altrim/routines-mini/131a84a53d6a5527962aff80b269bd6c7d700d66/icons/icon.icns -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "routines-mini", 3 | "version": "0.1.0", 4 | "private": true, 5 | "main": "build/electron/main.js", 6 | "homepage": "./", 7 | "dependencies": { 8 | "@chakra-ui/icons": "^1.0.5", 9 | "@chakra-ui/react": "^1.3.2", 10 | "@emotion/react": "^11.1.5", 11 | "@emotion/styled": "^11.1.5", 12 | "@testing-library/jest-dom": "^5.11.4", 13 | "@testing-library/react": "^11.2.5", 14 | "@testing-library/user-event": "^12.6.3", 15 | "concurrently": "^5.3.0", 16 | "copyfiles": "^2.4.1", 17 | "dexie": "^3.1.0-alpha.6", 18 | "dexie-react-hooks": "^1.0.2", 19 | "electron-is-dev": "^1.2.0", 20 | "emoji-picker-react": "^3.4.2", 21 | "framer-motion": "^3.3.0", 22 | "node-schedule": "^2.0.0", 23 | "react": "^17.0.1", 24 | "react-color": "^2.19.3", 25 | "react-datepicker": "^3.4.1", 26 | "react-dom": "^17.0.1", 27 | "react-hook-form": "^6.15.1", 28 | "react-router-dom": "^5.2.0", 29 | "react-scripts": "4.0.2", 30 | "typescript": "^4.0.3", 31 | "wait-on": "^5.2.1", 32 | "web-vitals": "^1.1.0" 33 | }, 34 | "scripts": { 35 | "dev": "concurrently -k \"BROWSER=none yarn start\" \"yarn electron\"", 36 | "assets": "copyfiles electron/assets/* build", 37 | "electron:dev": "concurrently \"BROWSER=none yarn start\" \"yarn assets\" \"wait-on tcp:3000 && tsc -p electron -w\" \"tsc -p electron && yarn electron\"", 38 | "electron": "wait-on tcp:3000 && electron-forge start", 39 | "start": "react-scripts start", 40 | "build": "react-scripts build", 41 | "test": "react-scripts test", 42 | "eject": "react-scripts eject", 43 | "package": "react-scripts build && electron-forge package", 44 | "make": "NODE_ENV=production yarn build && yarn assets && tsc -p electron && electron-forge make" 45 | }, 46 | "eslintConfig": { 47 | "extends": [ 48 | "react-app", 49 | "react-app/jest" 50 | ] 51 | }, 52 | "browserslist": { 53 | "production": [ 54 | ">0.2%", 55 | "not dead", 56 | "not op_mini all" 57 | ], 58 | "development": [ 59 | "last 1 chrome version", 60 | "last 1 firefox version", 61 | "last 1 safari version" 62 | ] 63 | }, 64 | "devDependencies": { 65 | "@electron-forge/cli": "^6.0.0-beta.54", 66 | "@electron-forge/maker-deb": "^6.0.0-beta.54", 67 | "@electron-forge/maker-rpm": "^6.0.0-beta.54", 68 | "@electron-forge/maker-squirrel": "^6.0.0-beta.54", 69 | "@electron-forge/maker-zip": "^6.0.0-beta.54", 70 | "@types/jest": "^26.0.15", 71 | "@types/node": "^14.14.25", 72 | "@types/node-schedule": "^1.3.1", 73 | "@types/react": "^17.0.1", 74 | "@types/react-color": "^3.0.4", 75 | "@types/react-datepicker": "^3.1.3", 76 | "@types/react-dom": "^17.0.0", 77 | "@types/react-router-dom": "^5.1.7", 78 | "electron": "^11.2.3" 79 | }, 80 | "config": { 81 | "forge": { 82 | "packagerConfig": { 83 | "name": "Routines Mini", 84 | "icon": "icons/icon.icns" 85 | }, 86 | "makers": [ 87 | { 88 | "name": "@electron-forge/maker-squirrel", 89 | "config": { 90 | "name": "routines_mini" 91 | } 92 | }, 93 | { 94 | "name": "@electron-forge/maker-zip", 95 | "platforms": [ 96 | "darwin" 97 | ] 98 | }, 99 | { 100 | "name": "@electron-forge/maker-deb", 101 | "config": {} 102 | }, 103 | { 104 | "name": "@electron-forge/maker-rpm", 105 | "config": {} 106 | } 107 | ] 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 100, 3 | tabWidth: 2, 4 | semi: true, 5 | singleQuote: true, 6 | }; 7 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/altrim/routines-mini/131a84a53d6a5527962aff80b269bd6c7d700d66/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 14 | 15 | 24 | Routines Mini 25 | 26 | 27 | 28 |
29 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/altrim/routines-mini/131a84a53d6a5527962aff80b269bd6c7d700d66/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/altrim/routines-mini/131a84a53d6a5527962aff80b269bd6c7d700d66/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/App.module.css: -------------------------------------------------------------------------------- 1 | .App { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | justify-content: center; 6 | } 7 | -------------------------------------------------------------------------------- /src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | render(); 7 | const linkElement = screen.getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { HashRouter as Router } from 'react-router-dom'; 3 | import styles from './App.module.css'; 4 | import { Routes } from './Routes'; 5 | import { Toolbar } from './Toolbar'; 6 | 7 | function App() { 8 | return ( 9 | 10 |
11 | 12 | 13 |
14 |
15 | ); 16 | } 17 | 18 | export default App; 19 | -------------------------------------------------------------------------------- /src/EmojiPicker.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Button, 4 | Flex, 5 | Popover, 6 | PopoverBody, 7 | PopoverContent, 8 | PopoverTrigger, 9 | } from '@chakra-ui/react'; 10 | import Picker, { IEmojiData } from 'emoji-picker-react'; 11 | import React, { useState } from 'react'; 12 | 13 | const Smiley: React.FC = () => { 14 | return ( 15 | 27 | 32 | 33 | ); 34 | }; 35 | 36 | type Props = { 37 | onEmojiSelected: (emoji: IEmojiData) => void; 38 | }; 39 | 40 | export const EmojiPicker: React.FC = ({ onEmojiSelected }) => { 41 | const [isOpen, setIsOpen] = useState(false); 42 | const [emoji, setEmoji] = useState(); 43 | 44 | const open = () => setIsOpen(!isOpen); 45 | const close = () => setIsOpen(false); 46 | 47 | const handleEmojiSelection = (event: React.MouseEvent, emoji: IEmojiData) => { 48 | setEmoji(emoji); 49 | onEmojiSelected(emoji); 50 | close(); 51 | }; 52 | 53 | return ( 54 | <> 55 | 56 | 57 | 67 | 68 | 69 | 70 | {isOpen ? ( 71 | 80 | ) : null} 81 | 82 | 83 | 84 | 85 | ); 86 | }; 87 | -------------------------------------------------------------------------------- /src/JobScheduler.ts: -------------------------------------------------------------------------------- 1 | import schedule from 'node-schedule'; 2 | import { Routine } from './database/RoutinesService'; 3 | 4 | export class JobScheduler { 5 | routine: Routine; 6 | 7 | constructor(routine: Routine) { 8 | this.routine = routine; 9 | } 10 | 11 | schedule(): schedule.Job | null { 12 | if (!this.routine.scheduleAt) { 13 | return null; 14 | } 15 | 16 | return schedule.scheduleJob(this.routine.scheduleAt, () => this.notify()); 17 | } 18 | 19 | private notify() { 20 | return new Notification(`${this.routine.emoji ?? ''} ${this.routine.title}`, { 21 | icon: '/assets/icon@2x.png', 22 | body: `It's time for your ${this.routine.title} routine!.`, 23 | }); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/NotificationScheduler.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import DatePicker from 'react-datepicker'; 3 | import 'react-datepicker/dist/react-datepicker.css'; 4 | import { Controller, useFormContext } from 'react-hook-form'; 5 | import { Checkbox, Flex, Input, Radio, RadioGroup, Stack, Text } from '@chakra-ui/react'; 6 | import { ScheduleFrequency } from './RoutineCreator'; 7 | 8 | const TimePickerInput = React.forwardRef((props: any, ref) => { 9 | return ; 10 | }); 11 | 12 | type Day = { 13 | value: string; // 0-6 14 | label: string; // Mo Tu We ... 15 | }; 16 | const days: Day[] = [ 17 | { value: '1', label: 'Mo' }, 18 | { value: '2', label: 'Tu' }, 19 | { value: '3', label: 'We' }, 20 | { value: '4', label: 'Th' }, 21 | { value: '5', label: 'Fr' }, 22 | { value: '6', label: 'Sa' }, 23 | { value: '0', label: 'Su' }, 24 | ]; 25 | 26 | export const NotificationScheduler: React.FC = () => { 27 | const { register, watch, control } = useFormContext(); 28 | const { scheduleFrequency, timeOfDay } = watch(['scheduleFrequency', 'timeOfDay']); 29 | const isDaily = scheduleFrequency === ScheduleFrequency.Daily; 30 | const isWeekly = scheduleFrequency === ScheduleFrequency.Weekly; 31 | const isCustomTime = timeOfDay === 'custom'; 32 | 33 | return ( 34 | 35 | 36 | How often do you want to be reminded? 37 | 38 | 39 | 40 | 41 | 47 | Daily On 48 | 49 | {/* If the choice is ScheduleFrequency.Daily render the Checkboxes */} 50 | {isDaily ? ( 51 | 52 | {days.map((item) => ( 53 | 0 && parseInt(item.value) < 6} 60 | > 61 | {item.label} 62 | 63 | ))} 64 | 65 | ) : null} 66 | 72 | Once a week on... 73 | 74 | {isWeekly ? ( 75 | 78 | 79 | {days.map(({ value, label }) => ( 80 | 81 | {label} 82 | 83 | ))} 84 | 85 | 86 | } 87 | name="scheduleDays" 88 | control={control} 89 | defaultValue="1" 90 | /> 91 | ) : null} 92 | 93 | 94 | 95 | 96 | At what time of day? 97 | 98 | 99 | 100 | 101 | 102 | Beginning of the day at 9:00 103 | 104 | 105 | End of the day at 17:00 106 | 107 | 108 | Pick a time 109 | 110 | {isCustomTime ? ( 111 | 112 | ( 117 | props.onChange(date)} 122 | showTimeSelect 123 | showTimeSelectOnly 124 | timeIntervals={15} 125 | timeCaption="Time" 126 | dateFormat="HH:mm" 127 | timeFormat="HH:mm" 128 | customInput={} 129 | /> 130 | )} 131 | /> 132 | 133 | ) : null} 134 | 135 | 136 | 137 | 138 | ); 139 | }; 140 | -------------------------------------------------------------------------------- /src/PageTransition.tsx: -------------------------------------------------------------------------------- 1 | import { motion } from 'framer-motion'; 2 | import React from 'react'; 3 | import { variants } from './variants'; 4 | 5 | export const PageTransition: React.FC<{ direction: number }> = ({ children, direction = 0 }) => { 6 | return ( 7 | 18 | {children} 19 | 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /src/Routes.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { AnimatePresence } from 'framer-motion'; 3 | import { Route, Switch, useLocation } from 'react-router-dom'; 4 | import { PageTransition } from './PageTransition'; 5 | import { Routines } from './Routines'; 6 | import { RoutineTasks } from './RoutineTasks'; 7 | 8 | export const Routes = () => { 9 | const [direction, setDirection] = useState(0); 10 | const location = useLocation(); 11 | 12 | useEffect(() => { 13 | setDirection(location.pathname.includes('routine') ? 1 : -1); 14 | }, [location]); 15 | 16 | return ( 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /src/RoutineCreator.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { CirclePicker, ColorResult } from 'react-color'; 3 | import { FormProvider, useForm } from 'react-hook-form'; 4 | import { 5 | Button, 6 | Drawer, 7 | DrawerBody, 8 | DrawerCloseButton, 9 | DrawerContent, 10 | DrawerFooter, 11 | DrawerHeader, 12 | DrawerOverlay, 13 | Flex, 14 | Input, 15 | Text, 16 | useToast, 17 | } from '@chakra-ui/react'; 18 | import { IEmojiData } from 'emoji-picker-react'; 19 | import { Routine, routinesService } from './database/RoutinesService'; 20 | import { EmojiPicker } from './EmojiPicker'; 21 | import { JobScheduler } from './JobScheduler'; 22 | import { NotificationScheduler } from './NotificationScheduler'; 23 | 24 | const getHourAndMinute = (time: string | Date): [string, string] => { 25 | if (typeof time === 'string') { 26 | const [hour, minute] = time.split(':'); 27 | return [hour, minute]; 28 | } 29 | 30 | return [time.getHours().toString(), time.getMinutes().toString()]; 31 | }; 32 | 33 | const getScheduleDays = (days: string | string[]): string => { 34 | return typeof days === 'string' ? days : days.join(','); 35 | }; 36 | 37 | type Props = { 38 | isOpen: boolean; 39 | onClose: () => void; 40 | }; 41 | export enum ScheduleFrequency { 42 | Daily = 'Daily', 43 | Weekly = 'Weekly', 44 | } 45 | type FormValues = { 46 | title: string; 47 | scheduleDays: string | string[]; 48 | timeOfDay: 'custom' | string; 49 | customTime: Date; 50 | scheduleFrequency: ScheduleFrequency; 51 | }; 52 | const defaultFormValues = { 53 | title: '', 54 | scheduleFrequency: ScheduleFrequency.Daily, 55 | scheduleDays: ['1', '2', '3', '4', '5'], 56 | timeOfDay: '9:00', 57 | customTime: new Date(), 58 | }; 59 | export const RoutineCreator: React.FC = ({ isOpen, onClose }) => { 60 | const toast = useToast(); 61 | const methods = useForm({ 62 | defaultValues: defaultFormValues, 63 | }); 64 | const { register, handleSubmit, errors, reset } = methods; 65 | const [emoji, setEmoji] = useState(); 66 | const [color, setColor] = useState('#22506d'); 67 | 68 | const handleColorChange = (color: ColorResult) => { 69 | setColor(color.hex); 70 | }; 71 | 72 | const onSubmit = async (form: FormValues) => { 73 | const { title, timeOfDay, customTime } = form; 74 | 75 | const time = timeOfDay === 'custom' ? customTime : timeOfDay; 76 | const [hour, minute] = getHourAndMinute(time); 77 | 78 | const scheduleDays = getScheduleDays(form.scheduleDays); 79 | const scheduleAt = `${minute} ${hour} * * ${scheduleDays}`; 80 | 81 | const routine: Routine = { 82 | title, 83 | color, 84 | scheduleAt, 85 | emoji: emoji?.emoji, 86 | }; 87 | 88 | // Save to Database 89 | await routinesService.createRoutine(routine); 90 | 91 | // Schedule Notification 92 | const job = new JobScheduler(routine); 93 | job.schedule(); 94 | 95 | // Display the toast 96 | toast({ 97 | position: 'bottom', 98 | title: 'Yay!', 99 | description: 'Reminder scheduled successfully ✅', 100 | status: 'success', 101 | duration: 3210, 102 | isClosable: true, 103 | }); 104 | 105 | // Reset to default values and close the form 106 | reset(defaultFormValues); 107 | onClose(); 108 | }; 109 | 110 | return ( 111 | 112 | 113 | 114 | 115 | 116 | New Routine 117 | 118 | 119 | 120 | 121 |
122 | 123 | 124 | 125 | Name your routine 126 | 127 | 128 | setEmoji(emoji)} /> 129 | 138 | 139 | {errors.title && ( 140 | 141 | Name is required 142 | 143 | )} 144 | 145 | 146 | 147 | 148 | Pick a color 149 | 150 | 155 | 156 | 157 | 158 | 159 | 160 | 161 |
162 |
163 |
164 | 165 | 166 | 169 | 172 | 173 |
174 |
175 |
176 | ); 177 | }; 178 | -------------------------------------------------------------------------------- /src/RoutineItem.tsx: -------------------------------------------------------------------------------- 1 | import { Avatar, Box, Heading, ListItem } from '@chakra-ui/react'; 2 | import { useLiveQuery } from 'dexie-react-hooks'; 3 | import React from 'react'; 4 | import { Link } from 'react-router-dom'; 5 | import { Routine } from './database/RoutinesService'; 6 | import { tasksService } from './database/TasksService'; 7 | import styles from './Routines.module.css'; 8 | 9 | const Emoji: React.FC<{ emoji: string }> = ({ emoji }) => { 10 | return ( 11 | 20 | {emoji} 21 | 22 | ); 23 | }; 24 | 25 | type ItemProps = { 26 | routine: Routine; 27 | }; 28 | 29 | export const RoutineItem: React.FC = ({ routine }) => { 30 | const tasksCount = useLiveQuery(() => tasksService.getTaskCountForRoutine(routine.id!), [ 31 | routine.id, 32 | ]); 33 | const completed = useLiveQuery(() => tasksService.getCompletedTaskCountForRoutine(routine.id!), [ 34 | routine.id, 35 | ]); 36 | 37 | return ( 38 | 39 | 40 | {routine.emoji ? ( 41 | 42 | ) : ( 43 | 44 | )} 45 | 46 | 47 | {routine.title} 48 | 49 | {`${tasksCount} tasks/ ${completed} completed`} 50 | 51 | 52 | 53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /src/RoutineTasks.module.css: -------------------------------------------------------------------------------- 1 | .Tasks { 2 | width: 100vw; 3 | height: 100vh; 4 | overflow-y: auto; 5 | padding-top: 90px; 6 | } 7 | 8 | .Tasks ul li:hover { 9 | background-color: #fafbfc; 10 | } 11 | -------------------------------------------------------------------------------- /src/RoutineTasks.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ArrowBackIcon, RepeatClockIcon } from '@chakra-ui/icons'; 3 | import { Button, Flex, Heading, Input, List, useToast } from '@chakra-ui/react'; 4 | import { useLiveQuery } from 'dexie-react-hooks'; 5 | import { useForm } from 'react-hook-form'; 6 | import { useHistory, useParams } from 'react-router-dom'; 7 | import { routinesService } from './database/RoutinesService'; 8 | import { Task, tasksService } from './database/TasksService'; 9 | import styles from './RoutineTasks.module.css'; 10 | import { TaskItem } from './TaskItem'; 11 | import { Toolbar } from './Toolbar'; 12 | import { useOverflowY } from './useOverflowY'; 13 | 14 | export const RoutineTasks: React.FC = () => { 15 | const history = useHistory(); 16 | const toast = useToast(); 17 | const { register, handleSubmit, reset } = useForm(); 18 | const { id } = useParams<{ id: string }>(); 19 | const routineId = parseInt(id); 20 | const [overflowY] = useOverflowY(); 21 | 22 | const routine = useLiveQuery(() => routinesService.getById(routineId), []); 23 | const allCompleted = useLiveQuery(() => tasksService.allTasksCompletedForRoutine(routineId), [ 24 | routineId, 25 | ]); 26 | const tasks = useLiveQuery(() => tasksService.getTasksForRoutine(routineId), [routineId]); 27 | 28 | React.useEffect(() => { 29 | if (!allCompleted) { 30 | return; 31 | } 32 | toast({ 33 | position: 'bottom', 34 | title: 'Hooray!', 35 | description: 'All tasks completed 🔥', 36 | status: 'success', 37 | duration: 3000, 38 | isClosable: true, 39 | }); 40 | }, [toast, allCompleted]); 41 | 42 | const onSubmit = async (task: Task) => { 43 | await tasksService.createTask({ ...task, routineId }); 44 | reset({ title: '' }); 45 | }; 46 | 47 | return ( 48 |
49 | 50 | 53 | 54 | {routine?.title} 55 | 56 | 72 | 73 | 74 |
75 | 83 |
84 | 85 | {tasks?.map((task) => ( 86 | 87 | ))} 88 | 89 |
90 |
91 | ); 92 | }; 93 | -------------------------------------------------------------------------------- /src/Routines.module.css: -------------------------------------------------------------------------------- 1 | .Routines { 2 | padding-top: 90px; 3 | width: 100vw; 4 | height: 100vh; 5 | overflow-y: auto; 6 | } 7 | 8 | .Routines ul li:hover { 9 | background-color: #fafbfc; 10 | } 11 | 12 | .Routines ul li a { 13 | cursor: pointer; 14 | display: flex; 15 | padding: 0.85rem 0.25rem; 16 | align-items: center; 17 | } 18 | 19 | .Routines ul li a:hover { 20 | color: #22506d; 21 | } 22 | 23 | .flex { 24 | display: flex; 25 | align-items: center; 26 | } 27 | -------------------------------------------------------------------------------- /src/Routines.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { AddIcon } from '@chakra-ui/icons'; 3 | import { Button, Heading, List, useDisclosure } from '@chakra-ui/react'; 4 | import { useLiveQuery } from 'dexie-react-hooks'; 5 | import { routinesService } from './database/RoutinesService'; 6 | import { RoutineCreator } from './RoutineCreator'; 7 | import { RoutineItem } from './RoutineItem'; 8 | import { Toolbar } from './Toolbar'; 9 | import { useOverflowY } from './useOverflowY'; 10 | import styles from './Routines.module.css'; 11 | 12 | export const Routines: React.FC = () => { 13 | const { isOpen, onOpen, onClose } = useDisclosure(); 14 | const routines = useLiveQuery(() => routinesService.getAll(), []); 15 | const [overflowY] = useOverflowY(); 16 | 17 | return ( 18 |
19 | 20 | 21 | Routines 22 | 23 | 26 | 27 | 28 | 29 | {routines?.map((routine) => ( 30 | 31 | ))} 32 | 33 |
34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /src/TaskItem.tsx: -------------------------------------------------------------------------------- 1 | import { CheckIcon } from '@chakra-ui/icons'; 2 | import { ListItem, ListIcon, Text, Fade } from '@chakra-ui/react'; 3 | import React from 'react'; 4 | import { Task, tasksService } from './database/TasksService'; 5 | 6 | type ItemProps = { 7 | task: Task; 8 | color?: string; 9 | }; 10 | 11 | export const TaskItem: React.FC = ({ task, color }) => { 12 | return ( 13 | 14 | tasksService.toggleDone(task)} 22 | > 23 | {task.title} 24 | {task.done ? : null} 25 | 26 | 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /src/Toolbar.module.css: -------------------------------------------------------------------------------- 1 | .Toolbar { 2 | position: fixed; 3 | box-shadow: 0 0 15px 0 rgb(0 0 0 / 10%); 4 | left: 0; 5 | top: 0; 6 | right: 0; 7 | display: flex; 8 | align-items: center; 9 | justify-content: space-between; 10 | padding: 0.5rem; 11 | width: 100%; 12 | height: 90px; 13 | background-color: #22506d; 14 | color: #f9f9f9; 15 | } 16 | -------------------------------------------------------------------------------- /src/Toolbar.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from '@chakra-ui/react'; 2 | import React from 'react'; 3 | import styles from './Toolbar.module.css'; 4 | 5 | export const Toolbar: React.FC<{ 6 | backgroundColor?: string; 7 | zIndex?: number; 8 | }> = ({ children, backgroundColor = '#22506d', zIndex = 1 }) => { 9 | return ( 10 | 11 | {children} 12 | 13 | ); 14 | }; 15 | -------------------------------------------------------------------------------- /src/database/RoutinesDB.ts: -------------------------------------------------------------------------------- 1 | import Dexie, { Table } from 'dexie'; 2 | import { Routine } from './RoutinesService'; 3 | import { Task } from './TasksService'; 4 | 5 | class RoutinesDB extends Dexie { 6 | routines!: Table; 7 | tasks!: Table; 8 | 9 | constructor() { 10 | super('routinesDB'); 11 | this.version(3).stores({ 12 | routines: ` 13 | ++id, 14 | title, 15 | color, 16 | emoji, 17 | scheduleAt`, 18 | tasks: ` 19 | ++id, 20 | title, 21 | routineId, 22 | done`, 23 | }); 24 | } 25 | } 26 | 27 | export const db = new RoutinesDB(); 28 | -------------------------------------------------------------------------------- /src/database/RoutinesService.ts: -------------------------------------------------------------------------------- 1 | import { Table } from 'dexie'; 2 | import { db } from './RoutinesDB'; 3 | 4 | export type Routine = { 5 | id?: number; 6 | title: string; 7 | icon?: string; 8 | color?: string; 9 | emoji?: string; 10 | scheduleAt?: string; 11 | }; 12 | 13 | class RoutinesService { 14 | table: Table; 15 | 16 | constructor() { 17 | this.table = db.routines; 18 | } 19 | 20 | getById(routineId: number) { 21 | return this.table.where('id').equals(routineId).first(); 22 | } 23 | 24 | getAll() { 25 | return this.table.toCollection().reverse().toArray(); 26 | } 27 | 28 | createRoutine(routine: Routine) { 29 | return this.table.add(routine); 30 | } 31 | } 32 | 33 | export const routinesService = new RoutinesService(); 34 | -------------------------------------------------------------------------------- /src/database/TasksService.ts: -------------------------------------------------------------------------------- 1 | import { Table } from 'dexie'; 2 | import { db } from './RoutinesDB'; 3 | 4 | export type Task = { 5 | id?: number; 6 | routineId: number; 7 | title: string; 8 | done?: boolean; 9 | }; 10 | 11 | class TasksService { 12 | table: Table; 13 | 14 | constructor() { 15 | this.table = db.tasks; 16 | } 17 | 18 | getTasksForRoutine(routineId: number) { 19 | return this.table.where('routineId').equals(routineId).reverse().toArray(); 20 | } 21 | 22 | resetTasksForRoutine(routineId: number) { 23 | return this.table.where('routineId').equals(routineId).modify({ done: false }); 24 | } 25 | 26 | createTask(task: Task) { 27 | return this.table.add(task); 28 | } 29 | 30 | async toggleDone(task: Task) { 31 | if (task.id) { 32 | const t = await this.table.where('id').equals(task.id).first(); 33 | return this.table.update(task?.id, { done: !t?.done }); 34 | } 35 | 36 | return Promise.resolve(task); 37 | } 38 | 39 | getTaskCountForRoutine(routineId: number) { 40 | return this.table.where('routineId').equals(routineId).count(); 41 | } 42 | 43 | getCompletedTaskCountForRoutine(routineId: number) { 44 | return this.table 45 | .where('routineId') 46 | .equals(routineId) 47 | .filter((t) => t.done!) 48 | .count(); 49 | } 50 | 51 | async allTasksCompletedForRoutine(routineId: number): Promise { 52 | const tasks = await this.getTaskCountForRoutine(routineId); 53 | const completed = await this.getCompletedTaskCountForRoutine(routineId); 54 | 55 | return tasks > 0 && tasks === completed; 56 | } 57 | } 58 | 59 | export const tasksService = new TasksService(); 60 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | color: #1a3d53; 3 | margin: 0; 4 | width: 100vw; 5 | height: 100vh; 6 | overflow: hidden; 7 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 8 | 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; 9 | -webkit-font-smoothing: antialiased; 10 | -moz-osx-font-smoothing: grayscale; 11 | } 12 | 13 | code { 14 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; 15 | } 16 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | import reportWebVitals from './reportWebVitals'; 6 | import { ChakraProvider } from '@chakra-ui/react'; 7 | 8 | ReactDOM.render( 9 | 10 | 11 | 12 | 13 | , 14 | document.getElementById('root') 15 | ); 16 | 17 | // If you want to start measuring performance in your app, pass a function 18 | // to log results (for example: reportWebVitals(console.log)) 19 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 20 | reportWebVitals(); 21 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /src/useOverflowY.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | type Overflow = 'hidden' | 'auto'; 4 | export const useOverflowY = () => { 5 | const [overflow, setOverflow] = useState('hidden'); 6 | 7 | useEffect(() => { 8 | const timer = setTimeout(() => setOverflow('auto'), 256); 9 | return () => { 10 | setOverflow('hidden'); 11 | clearTimeout(timer); 12 | }; 13 | }, []); 14 | 15 | return [overflow]; 16 | }; 17 | -------------------------------------------------------------------------------- /src/variants.ts: -------------------------------------------------------------------------------- 1 | const transition = { 2 | duration: 0.2, 3 | ease: [0.42, 0.12, 0.22, 0.96], 4 | }; 5 | export const variants = { 6 | enter: (direction: number) => { 7 | return { 8 | x: direction > 0 ? '50%' : '-50%', 9 | opacity: 0, 10 | transition, 11 | }; 12 | }, 13 | center: { 14 | zIndex: 1, 15 | x: 0, 16 | opacity: 1, 17 | }, 18 | exit: (direction: number) => { 19 | return { 20 | zIndex: 0, 21 | x: direction < 0 ? '50%' : '-50%', 22 | opacity: 0, 23 | transition, 24 | }; 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx" 18 | }, 19 | "include": ["src"] 20 | } 21 | --------------------------------------------------------------------------------