├── .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 | 
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------