├── .babelrc ├── .editorconfig ├── .env ├── .env.production ├── .env.staging ├── .eslintrc.js ├── .flowconfig ├── .gitignore ├── .nvmrc ├── .stylelintrc.js ├── .travis.yml ├── LICENSE ├── README.md ├── extension ├── README.md ├── app │ ├── components │ │ ├── App.css │ │ ├── App.js │ │ └── Entry.js │ └── index.js ├── chrome │ ├── assets │ │ ├── icon-128x128.png │ │ ├── icon-16x16.png │ │ ├── icon-48x48.png │ │ ├── icon-on-128x128.png │ │ ├── icon-on-16x16.png │ │ ├── icon-on-48x48.png │ │ └── screenshot-1.png │ ├── manifest.json │ ├── popup.html │ └── src │ │ ├── background.js │ │ └── main.js ├── firefox │ ├── assets │ │ ├── icon-128x128.png │ │ ├── icon-16x16.png │ │ ├── icon-48x48.png │ │ ├── icon-on-128x128.png │ │ ├── icon-on-16x16.png │ │ ├── icon-on-48x48.png │ │ └── screenshot-1.png │ ├── manifest.json │ ├── popup.html │ └── src │ │ ├── background.js │ │ ├── content.js │ │ └── main.js └── webpack.config.js ├── flow-typed ├── npm │ ├── @babel │ │ ├── core_vx.x.x.js │ │ ├── plugin-proposal-class-properties_vx.x.x.js │ │ ├── preset-env_vx.x.x.js │ │ ├── preset-flow_vx.x.x.js │ │ └── preset-react_vx.x.x.js │ ├── @sentry │ │ └── browser_vx.x.x.js │ ├── canvg_vx.x.x.js │ ├── classnames_v2.x.x.js │ ├── css-loader_vx.x.x.js │ ├── env-cmd_vx.x.x.js │ ├── enzyme-adapter-react-16_vx.x.x.js │ ├── enzyme_v3.x.x.js │ ├── eslint-config-airbnb_vx.x.x.js │ ├── eslint-plugin-flowtype_vx.x.x.js │ ├── eslint-plugin-import_vx.x.x.js │ ├── eslint-plugin-jsx-a11y_vx.x.x.js │ ├── eslint-plugin-react-hooks_vx.x.x.js │ ├── eslint-plugin-react_vx.x.x.js │ ├── file-saver_v2.x.x.js │ ├── flow-bin_v0.x.x.js │ ├── formik_vx.x.x.js │ ├── fsevents_vx.x.x.js │ ├── jspdf-autotable_vx.x.x.js │ ├── jspdf_vx.x.x.js │ ├── left-pad_v1.x.x.js │ ├── lodash_v4.x.x.js │ ├── mitt_v1.x.x.js │ ├── pre-push_vx.x.x.js │ ├── qs_v6.5.x.js │ ├── react-chartjs_vx.x.x.js │ ├── react-dom-confetti_vx.x.x.js │ ├── react-redux_v7.x.x.js │ ├── react-redux_vx.x.x.js │ ├── react-router-dom_v5.x.x.js │ ├── react-router_v5.x.x.js │ ├── react-scripts_vx.x.x.js │ ├── react-stripe-elements_vx.x.x.js │ ├── react-svg-piechart_vx.x.x.js │ ├── react-test-renderer_v16.x.x.js │ ├── redux-observable_vx.x.x.js │ ├── redux_v4.x.x.js │ ├── reselect_v4.x.x.js │ ├── rxjs_v6.x.x.js │ ├── semantic-ui-css_vx.x.x.js │ ├── semantic-ui-react_vx.x.x.js │ ├── semver_vx.x.x.js │ ├── shortid_v2.2.x.js │ ├── socket.io-client_v2.x.x.js │ ├── source-map-explorer_vx.x.x.js │ ├── stylelint-config-standard_vx.x.x.js │ ├── stylelint_vx.x.x.js │ ├── thyme-connect_vx.x.x.js │ └── webpack-cli_vx.x.x.js └── thymeLibDef.js ├── jsconfig.json ├── netlify.toml ├── package-lock.json ├── package.json ├── public ├── _redirects ├── favicon.ico ├── favicon.png ├── icons │ ├── android-icon-144x144.png │ ├── android-icon-192x192.png │ ├── android-icon-36x36.png │ ├── android-icon-48x48.png │ ├── android-icon-72x72.png │ ├── android-icon-96x96.png │ ├── apple-icon-114x114.png │ ├── apple-icon-120x120.png │ ├── apple-icon-144x144.png │ ├── apple-icon-152x152.png │ ├── apple-icon-180x180.png │ ├── apple-icon-57x57.png │ ├── apple-icon-60x60.png │ ├── apple-icon-72x72.png │ ├── apple-icon-76x76.png │ ├── apple-icon-precomposed.png │ ├── apple-icon.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon-96x96.png │ ├── icon-128x128.png │ ├── icon-144x144.png │ ├── icon-152x152.png │ ├── icon-192x192.png │ ├── icon-384x384.png │ ├── icon-512x512.png │ ├── icon-72x72.png │ ├── icon-96x96.png │ ├── ms-icon-144x144.png │ ├── ms-icon-150x150.png │ ├── ms-icon-310x310.png │ └── ms-icon-70x70.png ├── index.html ├── manifest.json └── screenshots │ ├── screenshot_projects.png │ ├── screenshot_reports.png │ └── screenshot_timesheets.png ├── scripts ├── check-versions.js └── create-mock.js └── src ├── Routes.js ├── __tests__ ├── integration │ ├── projects.spec.js │ └── time.spec.js └── unit │ ├── breakpoints.spec.js │ ├── compareVersions.spec.js │ ├── dom.spec.js │ ├── importExport.spec.js │ ├── insights.spec.js │ ├── migrations.spec.js │ ├── projects.spec.js │ ├── thyme.spec.js │ └── validate.spec.js ├── actions └── app.js ├── api └── app.js ├── components ├── App │ ├── App.css │ ├── Thyme.svg │ ├── index.js │ └── print.css ├── BuySubscription │ ├── Button.js │ ├── Complete.js │ ├── Message.css │ └── Message.js ├── ChangeOnBlurInput │ └── index.js ├── ErrorBoundary │ └── index.js ├── FormField │ ├── FormField.js │ └── style.css ├── Loading │ └── index.js ├── Notifier │ ├── index.js │ └── style.css └── Responsive │ └── index.js ├── core ├── analytics.js ├── compareVersions.js ├── debug.js ├── dom.js ├── errorReporting.js ├── extensions │ ├── chrome.js │ ├── events.js │ ├── firefox.js │ ├── load.js │ └── socket.js ├── fetch.js ├── importExport.js ├── intl.js ├── jwt.js ├── localStorage.js ├── projects.js ├── reportQueryString.js ├── sync.js ├── thyme.js ├── useActions.js └── validate.js ├── createStore.js ├── epics ├── index.js ├── plugins.js ├── tracking.js └── update.js ├── index.js ├── migrations └── index.js ├── plugins ├── Insights │ ├── async.js │ ├── components │ │ ├── BarItem │ │ │ ├── BarItem.css │ │ │ └── index.js │ │ └── Insights │ │ │ ├── Insights.css │ │ │ └── index.js │ ├── helpers.js │ ├── index.js │ └── injectables.js ├── ProjectRates │ ├── actions.js │ ├── async.js │ ├── components │ │ ├── Projects │ │ │ ├── ProjectHourlyRate.js │ │ │ └── ProjectTableHeader.js │ │ ├── Reports │ │ │ ├── ReportTableRow.js │ │ │ └── ReportTableTotal.js │ │ └── Settings │ │ │ └── ProjectRatesSettings.js │ ├── currencies.js │ ├── helpers.js │ ├── index.js │ ├── injectables.js │ ├── reducers.js │ ├── selectors.js │ └── types.js └── load.js ├── reducers ├── app.js └── index.js ├── register ├── Actions.js ├── Consumer.js ├── Context.js ├── Provider.js ├── Store.js ├── component.js ├── epic.js ├── plugin.js ├── reducer.js ├── settings.js └── table.js ├── sections ├── Account │ ├── Account.js │ ├── actions.js │ ├── api.js │ ├── components │ │ ├── ListSubscription.js │ │ ├── Login.js │ │ ├── MenuItem │ │ │ ├── MenuItem.css │ │ │ └── index.js │ │ └── Status │ │ │ ├── Status.css │ │ │ └── index.js │ ├── epics.js │ ├── index.js │ ├── reducers.js │ ├── screens │ │ ├── Premium │ │ │ ├── Completed.js │ │ │ ├── Premium.css │ │ │ ├── SignUp.js │ │ │ ├── Subscribe.js │ │ │ ├── countries.js │ │ │ └── index.js │ │ └── Settings │ │ │ ├── Account.js │ │ │ ├── Subscription.js │ │ │ └── index.js │ └── selectors.js ├── Projects │ ├── Projects.js │ ├── actions.js │ ├── colours.js │ ├── components │ │ ├── NewProject │ │ │ └── index.js │ │ ├── ProjectColourPicker │ │ │ ├── ProjectColourPicker.css │ │ │ └── index.js │ │ ├── ProjectInput │ │ │ └── index.js │ │ └── ProjectsList │ │ │ ├── ProjectItem.js │ │ │ ├── ProjectsList.css │ │ │ ├── ProjectsList.js │ │ │ └── index.js │ ├── index.js │ ├── reducers │ │ ├── index.js │ │ └── project.js │ └── selectors.js ├── Reports │ ├── Reports.js │ ├── actions.js │ ├── components │ │ ├── ActionMenu │ │ │ ├── ActionMenu.css │ │ │ └── index.js │ │ ├── Charts │ │ │ ├── ReportCharts.css │ │ │ └── index.js │ │ ├── Credits │ │ │ ├── Credits.css │ │ │ └── index.js │ │ ├── DateRange │ │ │ ├── ReportRange.css │ │ │ └── index.js │ │ ├── Detailed │ │ │ ├── ReportDetailed.css │ │ │ └── index.js │ │ ├── Filters │ │ │ ├── Filters.css │ │ │ └── index.js │ │ └── SavedReports │ │ │ ├── Load.css │ │ │ ├── Load.js │ │ │ └── Save.js │ ├── helpers │ │ └── downloadPdf.js │ ├── index.js │ ├── reducers │ │ ├── index.js │ │ └── report.js │ └── selectors.js ├── Settings │ ├── Settings.css │ ├── Settings.js │ ├── actions.js │ ├── components │ │ ├── Advanced │ │ │ ├── Advanced.css │ │ │ └── index.js │ │ ├── DeleteData.js │ │ ├── ImportExport.js │ │ ├── Rounding │ │ │ ├── Rounding.css │ │ │ ├── RoundingExample.js │ │ │ ├── RoundingField.js │ │ │ ├── RoundingOn.js │ │ │ └── index.js │ │ └── TimeSheet.js │ ├── index.js │ ├── reducers │ │ ├── advanced.js │ │ ├── index.js │ │ ├── rounding.js │ │ └── timesheet.js │ └── selectors.js └── TimeSheet │ ├── TimeSheet.css │ ├── TimeSheet.js │ ├── actions.js │ ├── api.js │ ├── components │ ├── DateInput │ │ └── index.js │ ├── DateRange │ │ └── index.js │ ├── Entry │ │ ├── EditableEntry.css │ │ ├── EditableEntry.js │ │ └── New.js │ ├── NotesInput │ │ └── index.js │ ├── Table │ │ ├── DayHeader.css │ │ ├── DayHeader.js │ │ ├── ListEntry.css │ │ ├── ListEntry.js │ │ └── index.js │ └── TimeInput │ │ └── index.js │ ├── index.js │ ├── reducers │ ├── index.js │ └── time.js │ └── selectors.js ├── selectors ├── app.js └── importExport.js ├── serviceWorker.js └── setupTests.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/env", "@babel/react", "@babel/flow"] 3 | } 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.json] 12 | indent_size = 4 13 | 14 | [*.md] 15 | trim_trailing_whitespace = false 16 | 17 | [*.html] 18 | indent_size = 4 19 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | REACT_APP_VERSION=$npm_package_version 2 | -------------------------------------------------------------------------------- /.env.production: -------------------------------------------------------------------------------- 1 | PUBLIC_URL=/thyme/ 2 | REACT_APP_API_ROOT=https://api.usethyme.com 3 | REACT_APP_THYME_PLUGINS=ProjectRates,Insights 4 | REACT_APP_STRIPE_KEY=pk_live_GRvAe1x0WVZIx1yxKb8HbFKr 5 | REACT_APP_SENTRY_DSN=https://8768783bffa1462bac4b3308c4fd4200@sentry.io/1353486 6 | REACT_APP_TAB_URL=https://usethyme.com/thyme/ 7 | REACT_APP_CHROME_EXTENSION_ID=folhcadkkopbibfggjpoekbiblicffic 8 | -------------------------------------------------------------------------------- /.env.staging: -------------------------------------------------------------------------------- 1 | PUBLIC_URL=/ 2 | REACT_APP_API_ROOT=https://api.usethyme.com 3 | REACT_APP_THYME_PLUGINS=ProjectRates,Insights 4 | REACT_APP_STRIPE_KEY=pk_live_GRvAe1x0WVZIx1yxKb8HbFKr 5 | REACT_APP_TAB_URL=http://localhost:3000 6 | REACT_APP_CHROME_EXTENSION_ID=chrome-extension-id 7 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: 'babel-eslint', 3 | env: { 4 | browser: true, 5 | jest: true, 6 | webextensions: true, 7 | }, 8 | plugins: ['flowtype', 'react-hooks'], 9 | extends: ['airbnb', 'plugin:flowtype/recommended'], 10 | rules: { 11 | 'react/jsx-filename-extension': [1, { extensions: ['.js'] }], 12 | 'react/require-default-props': 0, 13 | 'react/jsx-props-no-spreading': 0, 14 | 'import/prefer-default-export': 0, 15 | 'jsx-a11y/anchor-is-valid': ['error', { 16 | components: ['Link'], 17 | specialLink: ['to'], 18 | aspects: ['noHref', 'invalidHref', 'preferButton'], 19 | }], 20 | 'jsx-a11y/label-has-for': 0, 21 | 'jsx-a11y/label-has-associated-control': 0, 22 | 'react-hooks/rules-of-hooks': 'error', 23 | 'react-hooks/exhaustive-deps': 'warn', 24 | }, 25 | settings: { 26 | 'import/resolver': { 27 | node: { 28 | paths: ['src'], 29 | }, 30 | }, 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | 3 | [include] 4 | 5 | [libs] 6 | 7 | [lints] 8 | 9 | [options] 10 | module.system.node.resolve_dirname=node_modules 11 | 12 | module.name_mapper='^actions\(.*\)$' -> '/src/actions/\1' 13 | module.name_mapper='^api\(.*\)$' -> '/src/api/\1' 14 | module.name_mapper='^components\(.*\)$' -> '/src/components/\1' 15 | module.name_mapper='^core\(.*\)$' -> '/src/core/\1' 16 | module.name_mapper='^epics\(.*\)$' -> '/src/epics/\1' 17 | module.name_mapper='^migrations\(.*\)$' -> '/src/migrations/\1' 18 | module.name_mapper='^plugins\(.*\)$' -> '/src/plugins/\1' 19 | module.name_mapper='^reducers\(.*\)$' -> '/src/reducers/\1' 20 | module.name_mapper='^register\(.*\)$' -> '/src/register/\1' 21 | module.name_mapper='^sections\(.*\)$' -> '/src/sections/\1' 22 | module.name_mapper='^selectors\(.*\)$' -> '/src/selectors/\1' 23 | 24 | [strict] 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | /dist 12 | 13 | # extensions 14 | /extension/*/dist 15 | /extension/*/output 16 | 17 | # misc 18 | .DS_Store 19 | .env.local 20 | .env.development.local 21 | .env.test.local 22 | .env.production.local 23 | 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | stats.json 28 | sample.json 29 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v12.8.1 2 | -------------------------------------------------------------------------------- /.stylelintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: 'stylelint-config-standard', 3 | rules: { 4 | 'no-descending-specificity': null, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '12.8.1' 4 | cache: 5 | directories: 6 | - '$HOME/.npm' 7 | script: 8 | - npm run check-versions 9 | - npm run lint 10 | - npm run flow 11 | - npm run test 12 | notifications: 13 | email: false 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Gaya Kessler 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /extension/README.md: -------------------------------------------------------------------------------- 1 | # Thyme Source Code 2 | 3 | All source files are located in `firefox` and `app`. 4 | 5 | Written in ES6 + Flow. Transformed with babel. 6 | 7 | ## How to build 8 | 9 | More information on how to build the project can be found on the [project's GitHub repository](https://github.com/ThymeApp/thyme#building-browser-extensions). 10 | 11 | TLDR; 12 | 13 | 1. `git clone git@github.com:ThymeApp/thyme.git` 14 | 2. `cd thyme` 15 | 3. `npm install` 16 | 4. `npm run build:extensions` 17 | 5. Find generated files in `./extensions/(firefox|chrome)/dist` 18 | 19 | -------------------------------------------------------------------------------- /extension/app/components/App.css: -------------------------------------------------------------------------------- 1 | body, 2 | html { 3 | margin: 0; 4 | height: auto; 5 | } 6 | 7 | .EditableEntry { 8 | width: 620px; 9 | padding-top: 1em !important; 10 | box-shadow: none !important; 11 | margin: 0; 12 | } 13 | -------------------------------------------------------------------------------- /extension/app/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React from 'react'; 4 | import ReactDOM from 'react-dom'; 5 | import { createStore } from 'redux'; 6 | import { Provider } from 'react-redux'; 7 | 8 | import 'semantic-ui-css/semantic.min.css'; 9 | 10 | import ExtensionApp from './components/App'; 11 | 12 | export default function CreateApp( 13 | connectToExtension: () => any, 14 | openTab: () => void, 15 | ) { 16 | const port = connectToExtension(); 17 | 18 | const store = createStore(( 19 | state: StateShape = {}, 20 | action: any, 21 | ) => ( 22 | action.type === 'UPDATE' ? action.state : state 23 | )); 24 | 25 | ReactDOM.render( 26 | 27 | port.onMessage.addListener(cb)} 30 | postMessage={msg => port.postMessage(msg)} 31 | /> 32 | , 33 | document.getElementById('content') || document.createElement('div'), 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /extension/chrome/assets/icon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThymeApp/thyme/91c85e1a55df854b7bb7557daa3917f87e2b4d5d/extension/chrome/assets/icon-128x128.png -------------------------------------------------------------------------------- /extension/chrome/assets/icon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThymeApp/thyme/91c85e1a55df854b7bb7557daa3917f87e2b4d5d/extension/chrome/assets/icon-16x16.png -------------------------------------------------------------------------------- /extension/chrome/assets/icon-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThymeApp/thyme/91c85e1a55df854b7bb7557daa3917f87e2b4d5d/extension/chrome/assets/icon-48x48.png -------------------------------------------------------------------------------- /extension/chrome/assets/icon-on-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThymeApp/thyme/91c85e1a55df854b7bb7557daa3917f87e2b4d5d/extension/chrome/assets/icon-on-128x128.png -------------------------------------------------------------------------------- /extension/chrome/assets/icon-on-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThymeApp/thyme/91c85e1a55df854b7bb7557daa3917f87e2b4d5d/extension/chrome/assets/icon-on-16x16.png -------------------------------------------------------------------------------- /extension/chrome/assets/icon-on-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThymeApp/thyme/91c85e1a55df854b7bb7557daa3917f87e2b4d5d/extension/chrome/assets/icon-on-48x48.png -------------------------------------------------------------------------------- /extension/chrome/assets/screenshot-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThymeApp/thyme/91c85e1a55df854b7bb7557daa3917f87e2b4d5d/extension/chrome/assets/screenshot-1.png -------------------------------------------------------------------------------- /extension/chrome/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Thyme", 3 | "version": "1.1.0", 4 | "description": "Chrome Extension for controlling Thyme", 5 | "manifest_version": 2, 6 | "permissions": [ 7 | "https://usethyme.com/thyme", 8 | "tabs" 9 | ], 10 | "background": { 11 | "scripts": ["dist/background.js"], 12 | "persistent": false 13 | }, 14 | "browser_action": { 15 | "default_popup": "popup.html" 16 | }, 17 | "externally_connectable": { 18 | "matches": ["https://usethyme.com/thyme/*", "http://localhost/*"] 19 | }, 20 | "icons": { 21 | "16": "assets/icon-16x16.png", 22 | "48": "assets/icon-48x48.png", 23 | "128": "assets/icon-128x128.png" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /extension/chrome/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /extension/chrome/src/main.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import CreateApp from '../../app'; 4 | 5 | CreateApp( 6 | chrome.extension.connect, 7 | () => window.chrome.tabs.create({ url: process.env.REACT_APP_TAB_URL }), 8 | ); 9 | -------------------------------------------------------------------------------- /extension/firefox/assets/icon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThymeApp/thyme/91c85e1a55df854b7bb7557daa3917f87e2b4d5d/extension/firefox/assets/icon-128x128.png -------------------------------------------------------------------------------- /extension/firefox/assets/icon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThymeApp/thyme/91c85e1a55df854b7bb7557daa3917f87e2b4d5d/extension/firefox/assets/icon-16x16.png -------------------------------------------------------------------------------- /extension/firefox/assets/icon-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThymeApp/thyme/91c85e1a55df854b7bb7557daa3917f87e2b4d5d/extension/firefox/assets/icon-48x48.png -------------------------------------------------------------------------------- /extension/firefox/assets/icon-on-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThymeApp/thyme/91c85e1a55df854b7bb7557daa3917f87e2b4d5d/extension/firefox/assets/icon-on-128x128.png -------------------------------------------------------------------------------- /extension/firefox/assets/icon-on-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThymeApp/thyme/91c85e1a55df854b7bb7557daa3917f87e2b4d5d/extension/firefox/assets/icon-on-16x16.png -------------------------------------------------------------------------------- /extension/firefox/assets/icon-on-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThymeApp/thyme/91c85e1a55df854b7bb7557daa3917f87e2b4d5d/extension/firefox/assets/icon-on-48x48.png -------------------------------------------------------------------------------- /extension/firefox/assets/screenshot-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThymeApp/thyme/91c85e1a55df854b7bb7557daa3917f87e2b4d5d/extension/firefox/assets/screenshot-1.png -------------------------------------------------------------------------------- /extension/firefox/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Thyme", 3 | "version": "1.1.0", 4 | "description": "Firefox Extension for controlling Thyme", 5 | "manifest_version": 2, 6 | "background": { 7 | "scripts": ["dist/background.js"] 8 | }, 9 | "content_scripts": [{ 10 | "matches": ["https://usethyme.com/thyme/*"], 11 | "js": ["dist/content.js"] 12 | }], 13 | "browser_action": { 14 | "default_popup": "popup.html" 15 | }, 16 | "icons": { 17 | "16": "assets/icon-16x16.png", 18 | "48": "assets/icon-48x48.png", 19 | "128": "assets/icon-128x128.png" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /extension/firefox/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /extension/firefox/src/content.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | const port = browser.runtime.connect({ name: 'content' }); 4 | 5 | function postToPage(msg: { type: string } & any) { 6 | return window.postMessage({ 7 | from: 'content', 8 | ...msg, 9 | }, '*'); 10 | } 11 | 12 | port.onMessage.addListener(postToPage); 13 | 14 | window.addEventListener('message', (event) => { 15 | if ( 16 | event.source === window 17 | && event.data 18 | && event.data.type 19 | && event.data.from === 'page' 20 | ) { 21 | port.postMessage(event.data); 22 | } 23 | }); 24 | -------------------------------------------------------------------------------- /extension/firefox/src/main.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import CreateApp from '../../app'; 4 | 5 | CreateApp( 6 | () => browser.runtime.connect({ name: 'popup' }), 7 | () => browser.tabs.create({ url: process.env.REACT_APP_TAB_URL }), 8 | ); 9 | -------------------------------------------------------------------------------- /extension/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); // eslint-disable-line 3 | const getEnv = require('react-scripts/config/env'); 4 | 5 | const env = getEnv(); 6 | 7 | const base = { 8 | mode: 'production', 9 | resolve: { 10 | modules: ['node_modules', '../src'], 11 | }, 12 | output: { 13 | publicPath: './dist/', 14 | }, 15 | module: { 16 | rules: [ 17 | { 18 | test: /\.js$/, 19 | exclude: /node_modules/, 20 | use: { 21 | loader: 'babel-loader', 22 | }, 23 | }, 24 | { 25 | test: /\.css$/, 26 | use: ['style-loader', 'css-loader'], 27 | }, 28 | { 29 | test: [/\.eot$/, /\.ttf$/, /\.svg$/, /\.woff$/, /\.woff2$/], 30 | loader: require.resolve('file-loader'), 31 | options: { 32 | name: 'static/media/[name].[hash:8].[ext]', 33 | }, 34 | }, 35 | { 36 | test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/], 37 | loader: require.resolve('url-loader'), 38 | options: { 39 | limit: 10000, 40 | name: 'static/media/[name].[hash:8].[ext]', 41 | }, 42 | }, 43 | ], 44 | }, 45 | plugins: [ 46 | new webpack.DefinePlugin(env.stringified), 47 | ], 48 | }; 49 | 50 | function browserConfig(folder, entry) { 51 | return { 52 | ...base, 53 | context: path.resolve(__dirname, `./${folder}`), 54 | entry, 55 | output: { 56 | ...base.output, 57 | path: path.resolve(__dirname, `./${folder}/dist`), 58 | }, 59 | }; 60 | } 61 | 62 | module.exports = [ 63 | browserConfig( 64 | './chrome', 65 | { 66 | main: './src/main.js', 67 | background: './src/background.js', 68 | }, 69 | ), 70 | browserConfig( 71 | './firefox', 72 | { 73 | main: './src/main.js', 74 | content: './src/content.js', 75 | background: './src/background.js', 76 | }, 77 | ), 78 | ]; 79 | -------------------------------------------------------------------------------- /flow-typed/npm/@babel/plugin-proposal-class-properties_vx.x.x.js: -------------------------------------------------------------------------------- 1 | // flow-typed signature: 669c3d44584f25360e9a0a9040a4b6a2 2 | // flow-typed version: <>/@babel/plugin-proposal-class-properties_v^7.3.4/flow_v0.93.0 3 | 4 | /** 5 | * This is an autogenerated libdef stub for: 6 | * 7 | * '@babel/plugin-proposal-class-properties' 8 | * 9 | * Fill this stub out by replacing all the `any` types. 10 | * 11 | * Once filled out, we encourage you to share your work with the 12 | * community by sending a pull request to: 13 | * https://github.com/flowtype/flow-typed 14 | */ 15 | 16 | declare module '@babel/plugin-proposal-class-properties' { 17 | declare module.exports: any; 18 | } 19 | 20 | /** 21 | * We include stubs for each file inside this npm package in case you need to 22 | * require those files directly. Feel free to delete any files that aren't 23 | * needed. 24 | */ 25 | declare module '@babel/plugin-proposal-class-properties/lib/index' { 26 | declare module.exports: any; 27 | } 28 | 29 | // Filename aliases 30 | declare module '@babel/plugin-proposal-class-properties/lib/index.js' { 31 | declare module.exports: $Exports<'@babel/plugin-proposal-class-properties/lib/index'>; 32 | } 33 | -------------------------------------------------------------------------------- /flow-typed/npm/@babel/preset-flow_vx.x.x.js: -------------------------------------------------------------------------------- 1 | // flow-typed signature: 9074f024ea264bb8831d850dc0b692bb 2 | // flow-typed version: <>/@babel/preset-flow_v^7.0.0/flow_v0.106.0 3 | 4 | /** 5 | * This is an autogenerated libdef stub for: 6 | * 7 | * '@babel/preset-flow' 8 | * 9 | * Fill this stub out by replacing all the `any` types. 10 | * 11 | * Once filled out, we encourage you to share your work with the 12 | * community by sending a pull request to: 13 | * https://github.com/flowtype/flow-typed 14 | */ 15 | 16 | declare module '@babel/preset-flow' { 17 | declare module.exports: any; 18 | } 19 | 20 | /** 21 | * We include stubs for each file inside this npm package in case you need to 22 | * require those files directly. Feel free to delete any files that aren't 23 | * needed. 24 | */ 25 | declare module '@babel/preset-flow/lib' { 26 | declare module.exports: any; 27 | } 28 | 29 | // Filename aliases 30 | declare module '@babel/preset-flow/lib/index' { 31 | declare module.exports: $Exports<'@babel/preset-flow/lib'>; 32 | } 33 | declare module '@babel/preset-flow/lib/index.js' { 34 | declare module.exports: $Exports<'@babel/preset-flow/lib'>; 35 | } 36 | -------------------------------------------------------------------------------- /flow-typed/npm/@babel/preset-react_vx.x.x.js: -------------------------------------------------------------------------------- 1 | // flow-typed signature: f3c96c4439abdbdcca630c50dbf0d8fa 2 | // flow-typed version: <>/@babel/preset-react_v^7.0.0/flow_v0.106.0 3 | 4 | /** 5 | * This is an autogenerated libdef stub for: 6 | * 7 | * '@babel/preset-react' 8 | * 9 | * Fill this stub out by replacing all the `any` types. 10 | * 11 | * Once filled out, we encourage you to share your work with the 12 | * community by sending a pull request to: 13 | * https://github.com/flowtype/flow-typed 14 | */ 15 | 16 | declare module '@babel/preset-react' { 17 | declare module.exports: any; 18 | } 19 | 20 | /** 21 | * We include stubs for each file inside this npm package in case you need to 22 | * require those files directly. Feel free to delete any files that aren't 23 | * needed. 24 | */ 25 | declare module '@babel/preset-react/lib' { 26 | declare module.exports: any; 27 | } 28 | 29 | // Filename aliases 30 | declare module '@babel/preset-react/lib/index' { 31 | declare module.exports: $Exports<'@babel/preset-react/lib'>; 32 | } 33 | declare module '@babel/preset-react/lib/index.js' { 34 | declare module.exports: $Exports<'@babel/preset-react/lib'>; 35 | } 36 | -------------------------------------------------------------------------------- /flow-typed/npm/canvg_vx.x.x.js: -------------------------------------------------------------------------------- 1 | // flow-typed signature: 639abcc574e0b50537f8174d88402ed8 2 | // flow-typed version: <>/canvg_v^1.5.3/flow_v0.106.0 3 | 4 | /** 5 | * This is an autogenerated libdef stub for: 6 | * 7 | * 'canvg' 8 | * 9 | * Fill this stub out by replacing all the `any` types. 10 | * 11 | * Once filled out, we encourage you to share your work with the 12 | * community by sending a pull request to: 13 | * https://github.com/flowtype/flow-typed 14 | */ 15 | 16 | declare module 'canvg' { 17 | declare module.exports: any; 18 | } 19 | 20 | /** 21 | * We include stubs for each file inside this npm package in case you need to 22 | * require those files directly. Feel free to delete any files that aren't 23 | * needed. 24 | */ 25 | declare module 'canvg/dist/browser/canvg' { 26 | declare module.exports: any; 27 | } 28 | 29 | declare module 'canvg/dist/browser/canvg.min' { 30 | declare module.exports: any; 31 | } 32 | 33 | declare module 'canvg/dist/node/canvg' { 34 | declare module.exports: any; 35 | } 36 | 37 | // Filename aliases 38 | declare module 'canvg/dist/browser/canvg.js' { 39 | declare module.exports: $Exports<'canvg/dist/browser/canvg'>; 40 | } 41 | declare module 'canvg/dist/browser/canvg.min.js' { 42 | declare module.exports: $Exports<'canvg/dist/browser/canvg.min'>; 43 | } 44 | declare module 'canvg/dist/node/canvg.js' { 45 | declare module.exports: $Exports<'canvg/dist/node/canvg'>; 46 | } 47 | -------------------------------------------------------------------------------- /flow-typed/npm/classnames_v2.x.x.js: -------------------------------------------------------------------------------- 1 | // flow-typed signature: a00cf41b09af4862583460529d5cfcb9 2 | // flow-typed version: c6154227d1/classnames_v2.x.x/flow_>=v0.104.x 3 | 4 | type $npm$classnames$Classes = 5 | | string 6 | | { [className: string]: *, ... } 7 | | false 8 | | void 9 | | null; 10 | 11 | declare module "classnames" { 12 | declare module.exports: ( 13 | ...classes: Array<$npm$classnames$Classes | $npm$classnames$Classes[]> 14 | ) => string; 15 | } 16 | 17 | declare module "classnames/bind" { 18 | declare module.exports: $Exports<"classnames">; 19 | } 20 | 21 | declare module "classnames/dedupe" { 22 | declare module.exports: $Exports<"classnames">; 23 | } 24 | -------------------------------------------------------------------------------- /flow-typed/npm/env-cmd_vx.x.x.js: -------------------------------------------------------------------------------- 1 | // flow-typed signature: f440d6ff430e852ca99ceca929406527 2 | // flow-typed version: <>/env-cmd_v^8.0.2/flow_v0.106.0 3 | 4 | /** 5 | * This is an autogenerated libdef stub for: 6 | * 7 | * 'env-cmd' 8 | * 9 | * Fill this stub out by replacing all the `any` types. 10 | * 11 | * Once filled out, we encourage you to share your work with the 12 | * community by sending a pull request to: 13 | * https://github.com/flowtype/flow-typed 14 | */ 15 | 16 | declare module 'env-cmd' { 17 | declare module.exports: any; 18 | } 19 | 20 | /** 21 | * We include stubs for each file inside this npm package in case you need to 22 | * require those files directly. Feel free to delete any files that aren't 23 | * needed. 24 | */ 25 | declare module 'env-cmd/bin/env-cmd' { 26 | declare module.exports: any; 27 | } 28 | 29 | declare module 'env-cmd/lib' { 30 | declare module.exports: any; 31 | } 32 | 33 | // Filename aliases 34 | declare module 'env-cmd/bin/env-cmd.js' { 35 | declare module.exports: $Exports<'env-cmd/bin/env-cmd'>; 36 | } 37 | declare module 'env-cmd/lib/index' { 38 | declare module.exports: $Exports<'env-cmd/lib'>; 39 | } 40 | declare module 'env-cmd/lib/index.js' { 41 | declare module.exports: $Exports<'env-cmd/lib'>; 42 | } 43 | -------------------------------------------------------------------------------- /flow-typed/npm/eslint-plugin-react-hooks_vx.x.x.js: -------------------------------------------------------------------------------- 1 | // flow-typed signature: 445a57b81a1c827fdc81a814b8de9f3f 2 | // flow-typed version: <>/eslint-plugin-react-hooks_v^2.0.1/flow_v0.106.0 3 | 4 | /** 5 | * This is an autogenerated libdef stub for: 6 | * 7 | * 'eslint-plugin-react-hooks' 8 | * 9 | * Fill this stub out by replacing all the `any` types. 10 | * 11 | * Once filled out, we encourage you to share your work with the 12 | * community by sending a pull request to: 13 | * https://github.com/flowtype/flow-typed 14 | */ 15 | 16 | declare module 'eslint-plugin-react-hooks' { 17 | declare module.exports: any; 18 | } 19 | 20 | /** 21 | * We include stubs for each file inside this npm package in case you need to 22 | * require those files directly. Feel free to delete any files that aren't 23 | * needed. 24 | */ 25 | declare module 'eslint-plugin-react-hooks/cjs/eslint-plugin-react-hooks.development' { 26 | declare module.exports: any; 27 | } 28 | 29 | declare module 'eslint-plugin-react-hooks/cjs/eslint-plugin-react-hooks.production.min' { 30 | declare module.exports: any; 31 | } 32 | 33 | // Filename aliases 34 | declare module 'eslint-plugin-react-hooks/cjs/eslint-plugin-react-hooks.development.js' { 35 | declare module.exports: $Exports<'eslint-plugin-react-hooks/cjs/eslint-plugin-react-hooks.development'>; 36 | } 37 | declare module 'eslint-plugin-react-hooks/cjs/eslint-plugin-react-hooks.production.min.js' { 38 | declare module.exports: $Exports<'eslint-plugin-react-hooks/cjs/eslint-plugin-react-hooks.production.min'>; 39 | } 40 | declare module 'eslint-plugin-react-hooks/index' { 41 | declare module.exports: $Exports<'eslint-plugin-react-hooks'>; 42 | } 43 | declare module 'eslint-plugin-react-hooks/index.js' { 44 | declare module.exports: $Exports<'eslint-plugin-react-hooks'>; 45 | } 46 | -------------------------------------------------------------------------------- /flow-typed/npm/file-saver_v2.x.x.js: -------------------------------------------------------------------------------- 1 | // flow-typed signature: 04f45fad272bbac7e89d130f26598ee4 2 | // flow-typed version: c6154227d1/file-saver_v2.x.x/flow_>=v0.104.x 3 | 4 | declare function saveAs( 5 | data: Blob | File | string, 6 | filename?: string, 7 | options?: {| autoBom: boolean |} 8 | ): void; 9 | 10 | declare module "file-saver" { 11 | declare module.exports: { 12 | [[call]]: typeof saveAs, 13 | saveAs: typeof saveAs, 14 | ... 15 | }; 16 | } 17 | -------------------------------------------------------------------------------- /flow-typed/npm/flow-bin_v0.x.x.js: -------------------------------------------------------------------------------- 1 | // flow-typed signature: 28fdff7f110e1c75efab63ff205dda30 2 | // flow-typed version: c6154227d1/flow-bin_v0.x.x/flow_>=v0.104.x 3 | 4 | declare module "flow-bin" { 5 | declare module.exports: string; 6 | } 7 | -------------------------------------------------------------------------------- /flow-typed/npm/formik_vx.x.x.js: -------------------------------------------------------------------------------- 1 | // flow-typed signature: 15b4912193a9aac8e6febceafe92be41 2 | // flow-typed version: <>/formik_v^1.5.8/flow_v0.106.0 3 | 4 | /** 5 | * This is an autogenerated libdef stub for: 6 | * 7 | * 'formik' 8 | * 9 | * Fill this stub out by replacing all the `any` types. 10 | * 11 | * Once filled out, we encourage you to share your work with the 12 | * community by sending a pull request to: 13 | * https://github.com/flowtype/flow-typed 14 | */ 15 | 16 | declare module 'formik' { 17 | declare module.exports: any; 18 | } 19 | 20 | /** 21 | * We include stubs for each file inside this npm package in case you need to 22 | * require those files directly. Feel free to delete any files that aren't 23 | * needed. 24 | */ 25 | declare module 'formik/dist/formik.cjs.development' { 26 | declare module.exports: any; 27 | } 28 | 29 | declare module 'formik/dist/formik.cjs.production' { 30 | declare module.exports: any; 31 | } 32 | 33 | declare module 'formik/dist/formik.esm' { 34 | declare module.exports: any; 35 | } 36 | 37 | declare module 'formik/dist/formik.umd.development' { 38 | declare module.exports: any; 39 | } 40 | 41 | declare module 'formik/dist/formik.umd.production' { 42 | declare module.exports: any; 43 | } 44 | 45 | declare module 'formik/dist' { 46 | declare module.exports: any; 47 | } 48 | 49 | // Filename aliases 50 | declare module 'formik/dist/formik.cjs.development.js' { 51 | declare module.exports: $Exports<'formik/dist/formik.cjs.development'>; 52 | } 53 | declare module 'formik/dist/formik.cjs.production.js' { 54 | declare module.exports: $Exports<'formik/dist/formik.cjs.production'>; 55 | } 56 | declare module 'formik/dist/formik.esm.js' { 57 | declare module.exports: $Exports<'formik/dist/formik.esm'>; 58 | } 59 | declare module 'formik/dist/formik.umd.development.js' { 60 | declare module.exports: $Exports<'formik/dist/formik.umd.development'>; 61 | } 62 | declare module 'formik/dist/formik.umd.production.js' { 63 | declare module.exports: $Exports<'formik/dist/formik.umd.production'>; 64 | } 65 | declare module 'formik/dist/index' { 66 | declare module.exports: $Exports<'formik/dist'>; 67 | } 68 | declare module 'formik/dist/index.js' { 69 | declare module.exports: $Exports<'formik/dist'>; 70 | } 71 | -------------------------------------------------------------------------------- /flow-typed/npm/fsevents_vx.x.x.js: -------------------------------------------------------------------------------- 1 | // flow-typed signature: 631f55443992114bf40e25b87297be3e 2 | // flow-typed version: <>/fsevents_v^2.0.7/flow_v0.106.0 3 | 4 | /** 5 | * This is an autogenerated libdef stub for: 6 | * 7 | * 'fsevents' 8 | * 9 | * Fill this stub out by replacing all the `any` types. 10 | * 11 | * Once filled out, we encourage you to share your work with the 12 | * community by sending a pull request to: 13 | * https://github.com/flowtype/flow-typed 14 | */ 15 | 16 | declare module 'fsevents' { 17 | declare module.exports: any; 18 | } 19 | 20 | /** 21 | * We include stubs for each file inside this npm package in case you need to 22 | * require those files directly. Feel free to delete any files that aren't 23 | * needed. 24 | */ 25 | declare module 'fsevents/fsevents' { 26 | declare module.exports: any; 27 | } 28 | 29 | // Filename aliases 30 | declare module 'fsevents/fsevents.js' { 31 | declare module.exports: $Exports<'fsevents/fsevents'>; 32 | } 33 | -------------------------------------------------------------------------------- /flow-typed/npm/jspdf-autotable_vx.x.x.js: -------------------------------------------------------------------------------- 1 | // flow-typed signature: 4e5be59879a342b54765d9e3087b20fc 2 | // flow-typed version: <>/jspdf-autotable_v^3.2.4/flow_v0.106.0 3 | 4 | /** 5 | * This is an autogenerated libdef stub for: 6 | * 7 | * 'jspdf-autotable' 8 | * 9 | * Fill this stub out by replacing all the `any` types. 10 | * 11 | * Once filled out, we encourage you to share your work with the 12 | * community by sending a pull request to: 13 | * https://github.com/flowtype/flow-typed 14 | */ 15 | 16 | declare module 'jspdf-autotable' { 17 | declare module.exports: any; 18 | } 19 | 20 | /** 21 | * We include stubs for each file inside this npm package in case you need to 22 | * require those files directly. Feel free to delete any files that aren't 23 | * needed. 24 | */ 25 | declare module 'jspdf-autotable/dist/jspdf.plugin.autotable' { 26 | declare module.exports: any; 27 | } 28 | 29 | declare module 'jspdf-autotable/dist/jspdf.plugin.autotable.min' { 30 | declare module.exports: any; 31 | } 32 | 33 | // Filename aliases 34 | declare module 'jspdf-autotable/dist/jspdf.plugin.autotable.js' { 35 | declare module.exports: $Exports<'jspdf-autotable/dist/jspdf.plugin.autotable'>; 36 | } 37 | declare module 'jspdf-autotable/dist/jspdf.plugin.autotable.min.js' { 38 | declare module.exports: $Exports<'jspdf-autotable/dist/jspdf.plugin.autotable.min'>; 39 | } 40 | -------------------------------------------------------------------------------- /flow-typed/npm/jspdf_vx.x.x.js: -------------------------------------------------------------------------------- 1 | // flow-typed signature: 8c5abe091b53df9a86b42d00dac16d55 2 | // flow-typed version: <>/jspdf_v^1.5.3/flow_v0.106.0 3 | 4 | /** 5 | * This is an autogenerated libdef stub for: 6 | * 7 | * 'jspdf' 8 | * 9 | * Fill this stub out by replacing all the `any` types. 10 | * 11 | * Once filled out, we encourage you to share your work with the 12 | * community by sending a pull request to: 13 | * https://github.com/flowtype/flow-typed 14 | */ 15 | 16 | declare module 'jspdf' { 17 | declare module.exports: any; 18 | } 19 | 20 | /** 21 | * We include stubs for each file inside this npm package in case you need to 22 | * require those files directly. Feel free to delete any files that aren't 23 | * needed. 24 | */ 25 | declare module 'jspdf/dist/jspdf.debug' { 26 | declare module.exports: any; 27 | } 28 | 29 | declare module 'jspdf/dist/jspdf.min' { 30 | declare module.exports: any; 31 | } 32 | 33 | declare module 'jspdf/dist/jspdf.node.debug' { 34 | declare module.exports: any; 35 | } 36 | 37 | declare module 'jspdf/dist/jspdf.node.min' { 38 | declare module.exports: any; 39 | } 40 | 41 | // Filename aliases 42 | declare module 'jspdf/dist/jspdf.debug.js' { 43 | declare module.exports: $Exports<'jspdf/dist/jspdf.debug'>; 44 | } 45 | declare module 'jspdf/dist/jspdf.min.js' { 46 | declare module.exports: $Exports<'jspdf/dist/jspdf.min'>; 47 | } 48 | declare module 'jspdf/dist/jspdf.node.debug.js' { 49 | declare module.exports: $Exports<'jspdf/dist/jspdf.node.debug'>; 50 | } 51 | declare module 'jspdf/dist/jspdf.node.min.js' { 52 | declare module.exports: $Exports<'jspdf/dist/jspdf.node.min'>; 53 | } 54 | -------------------------------------------------------------------------------- /flow-typed/npm/left-pad_v1.x.x.js: -------------------------------------------------------------------------------- 1 | // flow-typed signature: b675d9200a0cd0c6435d7a1e9e4c3481 2 | // flow-typed version: c6154227d1/left-pad_v1.x.x/flow_>=v0.104.x 3 | 4 | declare module 'left-pad' { 5 | declare module.exports: (str: string, len: number, ch?: string | number) => string; 6 | } 7 | -------------------------------------------------------------------------------- /flow-typed/npm/mitt_v1.x.x.js: -------------------------------------------------------------------------------- 1 | // flow-typed signature: 9c210c52cd1939f6a887b060a4d3c835 2 | // flow-typed version: c6154227d1/mitt_v1.x.x/flow_>=v0.104.x 3 | 4 | declare module 'mitt' { 5 | declare type EventHandler = (event?: any) => void; 6 | 7 | declare interface EventEmitter { 8 | on: (type: string, handler: EventHandler) => void, 9 | off: (type: string, handler: EventHandler) => void, 10 | emit: (type: string, event: any) => void, 11 | } 12 | 13 | declare module.exports: () => EventEmitter; 14 | } 15 | -------------------------------------------------------------------------------- /flow-typed/npm/pre-push_vx.x.x.js: -------------------------------------------------------------------------------- 1 | // flow-typed signature: b41dccd99da7d0b964c56d303960f67c 2 | // flow-typed version: <>/pre-push_v^0.1.1/flow_v0.106.0 3 | 4 | /** 5 | * This is an autogenerated libdef stub for: 6 | * 7 | * 'pre-push' 8 | * 9 | * Fill this stub out by replacing all the `any` types. 10 | * 11 | * Once filled out, we encourage you to share your work with the 12 | * community by sending a pull request to: 13 | * https://github.com/flowtype/flow-typed 14 | */ 15 | 16 | declare module 'pre-push' { 17 | declare module.exports: any; 18 | } 19 | 20 | /** 21 | * We include stubs for each file inside this npm package in case you need to 22 | * require those files directly. Feel free to delete any files that aren't 23 | * needed. 24 | */ 25 | declare module 'pre-push/install' { 26 | declare module.exports: any; 27 | } 28 | 29 | declare module 'pre-push/test' { 30 | declare module.exports: any; 31 | } 32 | 33 | declare module 'pre-push/uninstall' { 34 | declare module.exports: any; 35 | } 36 | 37 | // Filename aliases 38 | declare module 'pre-push/index' { 39 | declare module.exports: $Exports<'pre-push'>; 40 | } 41 | declare module 'pre-push/index.js' { 42 | declare module.exports: $Exports<'pre-push'>; 43 | } 44 | declare module 'pre-push/install.js' { 45 | declare module.exports: $Exports<'pre-push/install'>; 46 | } 47 | declare module 'pre-push/test.js' { 48 | declare module.exports: $Exports<'pre-push/test'>; 49 | } 50 | declare module 'pre-push/uninstall.js' { 51 | declare module.exports: $Exports<'pre-push/uninstall'>; 52 | } 53 | -------------------------------------------------------------------------------- /flow-typed/npm/qs_v6.5.x.js: -------------------------------------------------------------------------------- 1 | // flow-typed signature: 3b10feecd428564fda5f9b58b595328d 2 | // flow-typed version: c6154227d1/qs_v6.5.x/flow_>=v0.104.x 3 | 4 | declare module "qs" { 5 | declare type ParseOptions = { 6 | allowPrototypes?: boolean, 7 | arrayLimit?: number, 8 | decoder?: Function, 9 | delimiter?: string, 10 | depth?: number, 11 | parameterLimit?: number, 12 | plainObjects?: boolean, 13 | strictNullHandling?: boolean, 14 | ignoreQueryPrefix?: boolean, 15 | parseArrays?: boolean, 16 | allowDots?: boolean, 17 | ... 18 | }; 19 | 20 | declare type ArrayFormat = "brackets" | "indices" | "repeat"; 21 | 22 | declare type FilterFunction = (prefix: string, value: any) => any; 23 | declare type FilterArray = Array; 24 | declare type Filter = FilterArray | FilterFunction; 25 | 26 | declare type StringifyOptions = { 27 | encoder?: Function, 28 | delimiter?: string, 29 | strictNullHandling?: boolean, 30 | skipNulls?: boolean, 31 | encode?: boolean, 32 | sort?: Function, 33 | allowDots?: boolean, 34 | serializeDate?: Function, 35 | encodeValuesOnly?: boolean, 36 | format?: string, 37 | addQueryPrefix?: boolean, 38 | arrayFormat?: ArrayFormat, 39 | filter?: Filter, 40 | ... 41 | }; 42 | 43 | declare type Formatter = (any) => string; 44 | 45 | declare type Formats = { 46 | RFC1738: string, 47 | RFC3986: string, 48 | "default": string, 49 | formatters: { 50 | RFC1738: Formatter, 51 | RFC3986: Formatter, 52 | ... 53 | }, 54 | ... 55 | }; 56 | 57 | declare module.exports: { 58 | parse(str: string, opts?: ParseOptions): Object, 59 | stringify(obj: Object | Array, opts?: StringifyOptions): string, 60 | formats: Formats, 61 | ... 62 | }; 63 | } 64 | -------------------------------------------------------------------------------- /flow-typed/npm/react-dom-confetti_vx.x.x.js: -------------------------------------------------------------------------------- 1 | // flow-typed signature: f5faa95da00d645e9df2f7a68d733ea7 2 | // flow-typed version: <>/react-dom-confetti_v^0.1.1/flow_v0.106.0 3 | 4 | /** 5 | * This is an autogenerated libdef stub for: 6 | * 7 | * 'react-dom-confetti' 8 | * 9 | * Fill this stub out by replacing all the `any` types. 10 | * 11 | * Once filled out, we encourage you to share your work with the 12 | * community by sending a pull request to: 13 | * https://github.com/flowtype/flow-typed 14 | */ 15 | 16 | declare module 'react-dom-confetti' { 17 | declare module.exports: any; 18 | } 19 | 20 | /** 21 | * We include stubs for each file inside this npm package in case you need to 22 | * require those files directly. Feel free to delete any files that aren't 23 | * needed. 24 | */ 25 | declare module 'react-dom-confetti/lib/confetti' { 26 | declare module.exports: any; 27 | } 28 | 29 | declare module 'react-dom-confetti/src/confetti' { 30 | declare module.exports: any; 31 | } 32 | 33 | // Filename aliases 34 | declare module 'react-dom-confetti/lib/confetti.js' { 35 | declare module.exports: $Exports<'react-dom-confetti/lib/confetti'>; 36 | } 37 | declare module 'react-dom-confetti/src/confetti.js' { 38 | declare module.exports: $Exports<'react-dom-confetti/src/confetti'>; 39 | } 40 | -------------------------------------------------------------------------------- /flow-typed/npm/semver_vx.x.x.js: -------------------------------------------------------------------------------- 1 | // flow-typed signature: 9f7a10cfab4779f6a01385d2a4980c10 2 | // flow-typed version: <>/semver_v^6.3.0/flow_v0.106.0 3 | 4 | /** 5 | * This is an autogenerated libdef stub for: 6 | * 7 | * 'semver' 8 | * 9 | * Fill this stub out by replacing all the `any` types. 10 | * 11 | * Once filled out, we encourage you to share your work with the 12 | * community by sending a pull request to: 13 | * https://github.com/flowtype/flow-typed 14 | */ 15 | 16 | declare module 'semver' { 17 | declare module.exports: any; 18 | } 19 | 20 | /** 21 | * We include stubs for each file inside this npm package in case you need to 22 | * require those files directly. Feel free to delete any files that aren't 23 | * needed. 24 | */ 25 | declare module 'semver/bin/semver' { 26 | declare module.exports: any; 27 | } 28 | 29 | declare module 'semver/semver' { 30 | declare module.exports: any; 31 | } 32 | 33 | // Filename aliases 34 | declare module 'semver/bin/semver.js' { 35 | declare module.exports: $Exports<'semver/bin/semver'>; 36 | } 37 | declare module 'semver/semver.js' { 38 | declare module.exports: $Exports<'semver/semver'>; 39 | } 40 | -------------------------------------------------------------------------------- /flow-typed/npm/shortid_v2.2.x.js: -------------------------------------------------------------------------------- 1 | // flow-typed signature: 7cb18569665ce92a9a8156df681f8f2e 2 | // flow-typed version: c6154227d1/shortid_v2.2.x/flow_>=v0.104.x 3 | 4 | declare module 'shortid' { 5 | declare type ShortIdModule = {| 6 | (): string, 7 | generate(): string, 8 | seed(seed: number): ShortIdModule, 9 | worker(workerId: number): ShortIdModule, 10 | characters(characters: string): string, 11 | decode(id: string): { 12 | version: number, 13 | worker: number, 14 | ... 15 | }, 16 | isValid(id: mixed): boolean, 17 | |}; 18 | declare module.exports: ShortIdModule; 19 | }; 20 | -------------------------------------------------------------------------------- /flow-typed/npm/source-map-explorer_vx.x.x.js: -------------------------------------------------------------------------------- 1 | // flow-typed signature: 37fdd6b8b18b997b2cdc429e04986f24 2 | // flow-typed version: <>/source-map-explorer_v^1.8.0/flow_v0.106.0 3 | 4 | /** 5 | * This is an autogenerated libdef stub for: 6 | * 7 | * 'source-map-explorer' 8 | * 9 | * Fill this stub out by replacing all the `any` types. 10 | * 11 | * Once filled out, we encourage you to share your work with the 12 | * community by sending a pull request to: 13 | * https://github.com/flowtype/flow-typed 14 | */ 15 | 16 | declare module 'source-map-explorer' { 17 | declare module.exports: any; 18 | } 19 | 20 | /** 21 | * We include stubs for each file inside this npm package in case you need to 22 | * require those files directly. Feel free to delete any files that aren't 23 | * needed. 24 | */ 25 | declare module 'source-map-explorer/vendor/webtreemap' { 26 | declare module.exports: any; 27 | } 28 | 29 | // Filename aliases 30 | declare module 'source-map-explorer/index' { 31 | declare module.exports: $Exports<'source-map-explorer'>; 32 | } 33 | declare module 'source-map-explorer/index.js' { 34 | declare module.exports: $Exports<'source-map-explorer'>; 35 | } 36 | declare module 'source-map-explorer/vendor/webtreemap.js' { 37 | declare module.exports: $Exports<'source-map-explorer/vendor/webtreemap'>; 38 | } 39 | -------------------------------------------------------------------------------- /flow-typed/npm/stylelint-config-standard_vx.x.x.js: -------------------------------------------------------------------------------- 1 | // flow-typed signature: 2992b01d4a42f80ef16101bdfac56671 2 | // flow-typed version: <>/stylelint-config-standard_v^18.3.0/flow_v0.106.0 3 | 4 | /** 5 | * This is an autogenerated libdef stub for: 6 | * 7 | * 'stylelint-config-standard' 8 | * 9 | * Fill this stub out by replacing all the `any` types. 10 | * 11 | * Once filled out, we encourage you to share your work with the 12 | * community by sending a pull request to: 13 | * https://github.com/flowtype/flow-typed 14 | */ 15 | 16 | declare module 'stylelint-config-standard' { 17 | declare module.exports: any; 18 | } 19 | 20 | /** 21 | * We include stubs for each file inside this npm package in case you need to 22 | * require those files directly. Feel free to delete any files that aren't 23 | * needed. 24 | */ 25 | 26 | 27 | // Filename aliases 28 | declare module 'stylelint-config-standard/index' { 29 | declare module.exports: $Exports<'stylelint-config-standard'>; 30 | } 31 | declare module 'stylelint-config-standard/index.js' { 32 | declare module.exports: $Exports<'stylelint-config-standard'>; 33 | } 34 | -------------------------------------------------------------------------------- /flow-typed/npm/thyme-connect_vx.x.x.js: -------------------------------------------------------------------------------- 1 | // flow-typed signature: 4d971bdfcfbda73b62eeaa1a57209db4 2 | // flow-typed version: <>/thyme-connect_v^1.0.2/flow_v0.106.0 3 | 4 | /** 5 | * This is an autogenerated libdef stub for: 6 | * 7 | * 'thyme-connect' 8 | * 9 | * Fill this stub out by replacing all the `any` types. 10 | * 11 | * Once filled out, we encourage you to share your work with the 12 | * community by sending a pull request to: 13 | * https://github.com/flowtype/flow-typed 14 | */ 15 | 16 | declare module 'thyme-connect' { 17 | declare module.exports: any; 18 | } 19 | 20 | /** 21 | * We include stubs for each file inside this npm package in case you need to 22 | * require those files directly. Feel free to delete any files that aren't 23 | * needed. 24 | */ 25 | declare module 'thyme-connect/main' { 26 | declare module.exports: any; 27 | } 28 | 29 | // Filename aliases 30 | declare module 'thyme-connect/main.js' { 31 | declare module.exports: $Exports<'thyme-connect/main'>; 32 | } 33 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "src" 4 | }, 5 | "include": ["src"] 6 | } 7 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [context.production] 2 | command = "npm run build" 3 | 4 | [context.deploy-preview] 5 | command = "npm run build:staging" 6 | -------------------------------------------------------------------------------- /public/_redirects: -------------------------------------------------------------------------------- 1 | /* /index.html 200 2 | /thyme/* https://thyme.netlify.com/:splat 200 3 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThymeApp/thyme/91c85e1a55df854b7bb7557daa3917f87e2b4d5d/public/favicon.ico -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThymeApp/thyme/91c85e1a55df854b7bb7557daa3917f87e2b4d5d/public/favicon.png -------------------------------------------------------------------------------- /public/icons/android-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThymeApp/thyme/91c85e1a55df854b7bb7557daa3917f87e2b4d5d/public/icons/android-icon-144x144.png -------------------------------------------------------------------------------- /public/icons/android-icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThymeApp/thyme/91c85e1a55df854b7bb7557daa3917f87e2b4d5d/public/icons/android-icon-192x192.png -------------------------------------------------------------------------------- /public/icons/android-icon-36x36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThymeApp/thyme/91c85e1a55df854b7bb7557daa3917f87e2b4d5d/public/icons/android-icon-36x36.png -------------------------------------------------------------------------------- /public/icons/android-icon-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThymeApp/thyme/91c85e1a55df854b7bb7557daa3917f87e2b4d5d/public/icons/android-icon-48x48.png -------------------------------------------------------------------------------- /public/icons/android-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThymeApp/thyme/91c85e1a55df854b7bb7557daa3917f87e2b4d5d/public/icons/android-icon-72x72.png -------------------------------------------------------------------------------- /public/icons/android-icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThymeApp/thyme/91c85e1a55df854b7bb7557daa3917f87e2b4d5d/public/icons/android-icon-96x96.png -------------------------------------------------------------------------------- /public/icons/apple-icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThymeApp/thyme/91c85e1a55df854b7bb7557daa3917f87e2b4d5d/public/icons/apple-icon-114x114.png -------------------------------------------------------------------------------- /public/icons/apple-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThymeApp/thyme/91c85e1a55df854b7bb7557daa3917f87e2b4d5d/public/icons/apple-icon-120x120.png -------------------------------------------------------------------------------- /public/icons/apple-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThymeApp/thyme/91c85e1a55df854b7bb7557daa3917f87e2b4d5d/public/icons/apple-icon-144x144.png -------------------------------------------------------------------------------- /public/icons/apple-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThymeApp/thyme/91c85e1a55df854b7bb7557daa3917f87e2b4d5d/public/icons/apple-icon-152x152.png -------------------------------------------------------------------------------- /public/icons/apple-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThymeApp/thyme/91c85e1a55df854b7bb7557daa3917f87e2b4d5d/public/icons/apple-icon-180x180.png -------------------------------------------------------------------------------- /public/icons/apple-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThymeApp/thyme/91c85e1a55df854b7bb7557daa3917f87e2b4d5d/public/icons/apple-icon-57x57.png -------------------------------------------------------------------------------- /public/icons/apple-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThymeApp/thyme/91c85e1a55df854b7bb7557daa3917f87e2b4d5d/public/icons/apple-icon-60x60.png -------------------------------------------------------------------------------- /public/icons/apple-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThymeApp/thyme/91c85e1a55df854b7bb7557daa3917f87e2b4d5d/public/icons/apple-icon-72x72.png -------------------------------------------------------------------------------- /public/icons/apple-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThymeApp/thyme/91c85e1a55df854b7bb7557daa3917f87e2b4d5d/public/icons/apple-icon-76x76.png -------------------------------------------------------------------------------- /public/icons/apple-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThymeApp/thyme/91c85e1a55df854b7bb7557daa3917f87e2b4d5d/public/icons/apple-icon-precomposed.png -------------------------------------------------------------------------------- /public/icons/apple-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThymeApp/thyme/91c85e1a55df854b7bb7557daa3917f87e2b4d5d/public/icons/apple-icon.png -------------------------------------------------------------------------------- /public/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThymeApp/thyme/91c85e1a55df854b7bb7557daa3917f87e2b4d5d/public/icons/favicon-16x16.png -------------------------------------------------------------------------------- /public/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThymeApp/thyme/91c85e1a55df854b7bb7557daa3917f87e2b4d5d/public/icons/favicon-32x32.png -------------------------------------------------------------------------------- /public/icons/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThymeApp/thyme/91c85e1a55df854b7bb7557daa3917f87e2b4d5d/public/icons/favicon-96x96.png -------------------------------------------------------------------------------- /public/icons/icon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThymeApp/thyme/91c85e1a55df854b7bb7557daa3917f87e2b4d5d/public/icons/icon-128x128.png -------------------------------------------------------------------------------- /public/icons/icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThymeApp/thyme/91c85e1a55df854b7bb7557daa3917f87e2b4d5d/public/icons/icon-144x144.png -------------------------------------------------------------------------------- /public/icons/icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThymeApp/thyme/91c85e1a55df854b7bb7557daa3917f87e2b4d5d/public/icons/icon-152x152.png -------------------------------------------------------------------------------- /public/icons/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThymeApp/thyme/91c85e1a55df854b7bb7557daa3917f87e2b4d5d/public/icons/icon-192x192.png -------------------------------------------------------------------------------- /public/icons/icon-384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThymeApp/thyme/91c85e1a55df854b7bb7557daa3917f87e2b4d5d/public/icons/icon-384x384.png -------------------------------------------------------------------------------- /public/icons/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThymeApp/thyme/91c85e1a55df854b7bb7557daa3917f87e2b4d5d/public/icons/icon-512x512.png -------------------------------------------------------------------------------- /public/icons/icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThymeApp/thyme/91c85e1a55df854b7bb7557daa3917f87e2b4d5d/public/icons/icon-72x72.png -------------------------------------------------------------------------------- /public/icons/icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThymeApp/thyme/91c85e1a55df854b7bb7557daa3917f87e2b4d5d/public/icons/icon-96x96.png -------------------------------------------------------------------------------- /public/icons/ms-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThymeApp/thyme/91c85e1a55df854b7bb7557daa3917f87e2b4d5d/public/icons/ms-icon-144x144.png -------------------------------------------------------------------------------- /public/icons/ms-icon-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThymeApp/thyme/91c85e1a55df854b7bb7557daa3917f87e2b4d5d/public/icons/ms-icon-150x150.png -------------------------------------------------------------------------------- /public/icons/ms-icon-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThymeApp/thyme/91c85e1a55df854b7bb7557daa3917f87e2b4d5d/public/icons/ms-icon-310x310.png -------------------------------------------------------------------------------- /public/icons/ms-icon-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThymeApp/thyme/91c85e1a55df854b7bb7557daa3917f87e2b4d5d/public/icons/ms-icon-70x70.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Thyme", 3 | "name": "Thyme", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "96x96 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "icons/icon-72x72.png", 12 | "sizes": "72x72", 13 | "type": "image/png" 14 | }, 15 | { 16 | "src": "icons/icon-96x96.png", 17 | "sizes": "96x96", 18 | "type": "image/png" 19 | }, 20 | { 21 | "src": "icons/icon-128x128.png", 22 | "sizes": "128x128", 23 | "type": "image/png" 24 | }, 25 | { 26 | "src": "icons/icon-144x144.png", 27 | "sizes": "144x144", 28 | "type": "image/png" 29 | }, 30 | { 31 | "src": "icons/icon-152x152.png", 32 | "sizes": "152x152", 33 | "type": "image/png" 34 | }, 35 | { 36 | "src": "icons/icon-192x192.png", 37 | "sizes": "192x192", 38 | "type": "image/png" 39 | }, 40 | { 41 | "src": "icons/icon-384x384.png", 42 | "sizes": "384x384", 43 | "type": "image/png" 44 | }, 45 | { 46 | "src": "icons/icon-512x512.png", 47 | "sizes": "512x512", 48 | "type": "image/png" 49 | } 50 | ], 51 | "start_url": "./index.html", 52 | "display": "standalone", 53 | "theme_color": "#1b1c1d", 54 | "background_color": "#ffffff" 55 | } 56 | -------------------------------------------------------------------------------- /public/screenshots/screenshot_projects.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThymeApp/thyme/91c85e1a55df854b7bb7557daa3917f87e2b4d5d/public/screenshots/screenshot_projects.png -------------------------------------------------------------------------------- /public/screenshots/screenshot_reports.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThymeApp/thyme/91c85e1a55df854b7bb7557daa3917f87e2b4d5d/public/screenshots/screenshot_reports.png -------------------------------------------------------------------------------- /public/screenshots/screenshot_timesheets.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThymeApp/thyme/91c85e1a55df854b7bb7557daa3917f87e2b4d5d/public/screenshots/screenshot_timesheets.png -------------------------------------------------------------------------------- /scripts/check-versions.js: -------------------------------------------------------------------------------- 1 | const https = require('https'); 2 | const semver = require('semver'); 3 | 4 | const packageJson = require('../package.json'); 5 | const packageLockJson = require('../package-lock.json'); 6 | 7 | // skip checking remote version if travis is checking master 8 | const checkRemoteVersion = process.env.TRAVIS_PULL_REQUEST !== 'false' 9 | || typeof process.env.TRAVIS_PULL_REQUEST === 'undefined'; 10 | 11 | const githubPackageJsonLocation = 'https://raw.githubusercontent.com/ThymeApp/thyme/master/package.json'; 12 | 13 | const errorMessage = `Package versions do not match. 14 | 15 | package.json (${packageJson.version}) - package-lock.json (${packageLockJson.version}). 16 | 17 | Run \`npm install\` to solve. 18 | `; 19 | 20 | if (packageJson.version !== packageLockJson.version) { 21 | // eslint-disable-next-line no-console 22 | console.error(errorMessage); 23 | process.exit(1); 24 | } 25 | 26 | // eslint-disable-next-line no-console 27 | console.error('package.json and package-lock.json versions match'); 28 | 29 | if (checkRemoteVersion) { 30 | https.get(githubPackageJsonLocation, (res) => { 31 | let rawData = ''; 32 | res.on('data', (chunk) => { rawData += chunk; }); 33 | res.on('end', () => { 34 | const data = JSON.parse(rawData); 35 | 36 | if (!semver.lt(data.version, packageJson.version)) { 37 | // eslint-disable-next-line no-console 38 | console.error(`Published version ${data.version} is greater or equal to ${packageJson.version}. 39 | 40 | Increase version in package.json 41 | 42 | Checked against: ${githubPackageJsonLocation} 43 | `); 44 | process.exit(1); 45 | } 46 | 47 | // eslint-disable-next-line no-console 48 | console.error('package.json version is newer than current origin/master'); 49 | }); 50 | }).on('error', (e) => { 51 | // eslint-disable-next-line no-console 52 | console.error(e); 53 | }); 54 | } 55 | -------------------------------------------------------------------------------- /src/Routes.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React from 'react'; 4 | import { Switch, Route } from 'react-router-dom'; 5 | 6 | import TimeSheet from 'sections/TimeSheet'; 7 | import Projects from 'sections/Projects'; 8 | import Settings from 'sections/Settings'; 9 | import Reports from 'sections/Reports'; 10 | import Account from 'sections/Account'; 11 | 12 | function Routes() { 13 | return ( 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | ); 22 | } 23 | 24 | export default Routes; 25 | -------------------------------------------------------------------------------- /src/__tests__/unit/compareVersions.spec.js: -------------------------------------------------------------------------------- 1 | import isNewer from '../../core/compareVersions'; 2 | 3 | describe('isNewer', () => { 4 | it('Checks for same version', () => { 5 | expect(isNewer('1.1.0', '1.1.0')).toBe(false); 6 | }); 7 | 8 | it('Checks major versions correctly', () => { 9 | expect(isNewer('1.0.0', '0.0.0')).toBe(true); 10 | expect(isNewer('1.0.0', '0.0.1')).toBe(true); 11 | expect(isNewer('1.0.0', '0.1.0')).toBe(true); 12 | 13 | expect(isNewer('1.0.1', '2.0.0')).toBe(false); 14 | }); 15 | 16 | it('Checks minor versions correctly', () => { 17 | expect(isNewer('1.1.0', '1.0.0')).toBe(true); 18 | expect(isNewer('1.1.0', '1.0.1')).toBe(true); 19 | 20 | expect(isNewer('1.1.1', '1.2.0')).toBe(false); 21 | }); 22 | 23 | it('Checks bugfix versions correctly', () => { 24 | expect(isNewer('1.0.1', '1.0.0')).toBe(true); 25 | 26 | expect(isNewer('1.1.1', '1.1.2')).toBe(false); 27 | }); 28 | 29 | it('Other formats', () => { 30 | expect(isNewer('2', '1')).toBe(true); 31 | expect(isNewer('1', '2')).toBe(false); 32 | 33 | expect(isNewer('3.2.1', '2')).toBe(true); 34 | expect(isNewer('1.2.3', '2')).toBe(false); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /src/__tests__/unit/dom.spec.js: -------------------------------------------------------------------------------- 1 | import { valueFromEventTarget } from '../../core/dom'; 2 | 3 | describe('value from event target', () => { 4 | it('Gets the value from the event target or returns empty string', () => { 5 | const input = document.createElement('input'); 6 | input.setAttribute('value', 'test'); 7 | 8 | expect(valueFromEventTarget(input)).toBe('test'); 9 | 10 | const fakeInput = document.createElement('div'); 11 | 12 | expect(valueFromEventTarget(fakeInput)).toBe(''); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/__tests__/unit/insights.spec.js: -------------------------------------------------------------------------------- 1 | import { hoursAndDivisions } from '../../plugins/Insights/helpers'; 2 | 3 | describe('Calculate hours and divisions for insights', () => { 4 | it('Should calculate correct hours', () => { 5 | expect(hoursAndDivisions(1)).toEqual([1, 1]); 6 | expect(hoursAndDivisions(2)).toEqual([2, 2]); 7 | expect(hoursAndDivisions(3)).toEqual([3, 3]); 8 | expect(hoursAndDivisions(4)).toEqual([4, 4]); 9 | expect(hoursAndDivisions(5)).toEqual([6, 3]); 10 | expect(hoursAndDivisions(6)).toEqual([6, 3]); 11 | expect(hoursAndDivisions(7)).toEqual([8, 4]); 12 | expect(hoursAndDivisions(8)).toEqual([8, 4]); 13 | expect(hoursAndDivisions(9)).toEqual([9, 3]); 14 | expect(hoursAndDivisions(10)).toEqual([10, 2]); 15 | expect(hoursAndDivisions(11)).toEqual([12, 4]); 16 | expect(hoursAndDivisions(12)).toEqual([12, 4]); 17 | }); 18 | 19 | it('Should handle fractions', () => { 20 | expect(hoursAndDivisions(0.5)).toEqual([1, 1]); 21 | expect(hoursAndDivisions(7.1)).toEqual([8, 4]); 22 | }); 23 | 24 | it('Should throw on too small input', () => { 25 | expect(() => hoursAndDivisions(0)).toThrow(); 26 | expect(() => hoursAndDivisions(-1)).toThrow(); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /src/__tests__/unit/projects.spec.js: -------------------------------------------------------------------------------- 1 | import { sortProjects } from '../../core/projects'; 2 | 3 | describe('Sort projects', () => { 4 | it('Sorts given projects by name and hierarchy', () => { 5 | const createdAt = new Date(); 6 | const updatedAt = new Date(); 7 | 8 | const projects = [ 9 | { 10 | id: '1', 11 | parent: null, 12 | name: 'Main Project', 13 | createdAt, 14 | updatedAt, 15 | }, 16 | { 17 | id: '2', 18 | parent: null, 19 | name: 'Another Project', 20 | createdAt, 21 | updatedAt, 22 | }, 23 | { 24 | id: '3', 25 | parent: '1', 26 | name: 'Sub Project', 27 | createdAt, 28 | updatedAt, 29 | }, 30 | { 31 | id: '4', 32 | parent: '1', 33 | name: 'Before Sub Project', 34 | createdAt, 35 | updatedAt, 36 | }, 37 | { 38 | id: '5', 39 | parent: '4', 40 | name: 'Sub Project', 41 | createdAt, 42 | updatedAt, 43 | }, 44 | { 45 | id: '6', 46 | parent: '4', 47 | name: 'Before Sub Project', 48 | createdAt, 49 | updatedAt, 50 | }, 51 | { 52 | id: '7', 53 | parent: null, 54 | name: 'Main Project (2)', 55 | createdAt, 56 | updatedAt, 57 | }, 58 | ]; 59 | const sorted = sortProjects(projects).map((item) => item.id); 60 | 61 | expect(sorted).toEqual(['2', '1', '4', '6', '5', '3', '7']); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /src/__tests__/unit/validate.spec.js: -------------------------------------------------------------------------------- 1 | import createValidator from '../../core/validate'; 2 | 3 | describe('Validate', () => { 4 | it('Checks required fields', () => { 5 | const validator = createValidator({ 6 | name: { 7 | required: 'Required field.', 8 | }, 9 | name2: { 10 | required: 'Required field.', 11 | }, 12 | checkbox: { 13 | required: 'Agree', 14 | }, 15 | checkbox2: { 16 | required: 'Agree too!', 17 | }, 18 | }); 19 | 20 | const values = { 21 | name: '', 22 | name2: 'Something', 23 | name3: '', 24 | checkbox2: true, 25 | }; 26 | const expectedErrors = { name: 'Required field.', checkbox: 'Agree' }; 27 | 28 | expect(validator(values)).toEqual(expectedErrors); 29 | }); 30 | 31 | it('Checks email fields', () => { 32 | const validator = createValidator({ 33 | email: { 34 | email: 'Invalid email address', 35 | }, 36 | email2: { 37 | email: 'Invalid email address', 38 | }, 39 | email3: { 40 | email: 'Invalid email address', 41 | }, 42 | }); 43 | 44 | const values = { 45 | email: 'bla', 46 | email2: '', 47 | email3: 'test@usethyme.com', 48 | checkbox2: true, 49 | }; 50 | const expectedErrors = { email: 'Invalid email address' }; 51 | 52 | expect(validator(values)).toEqual(expectedErrors); 53 | }); 54 | 55 | it('Checks matching fields', () => { 56 | const validator = createValidator({ 57 | password2: { 58 | matches: { 59 | field: 'password', 60 | error: 'Passwords do not match', 61 | }, 62 | }, 63 | password3: { 64 | matches: { 65 | field: 'password', 66 | error: 'Passwords do not match', 67 | }, 68 | }, 69 | }); 70 | 71 | const values = { 72 | password: 'bla', 73 | password2: 'alb', 74 | password3: 'bla', 75 | }; 76 | const expectedErrors = { password2: 'Passwords do not match' }; 77 | 78 | expect(validator(values)).toEqual(expectedErrors); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /src/actions/app.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | export type importDataType = { 4 | time: Array, 5 | projects: Array, 6 | reports: Array, 7 | }; 8 | 9 | export function importJSONData(data: importDataType) { 10 | return { 11 | type: 'IMPORT_JSON_DATA', 12 | ...data, 13 | }; 14 | } 15 | 16 | export function migrateStoreData() { 17 | return { type: 'MIGRATE_STORE_DATA' }; 18 | } 19 | 20 | export function alert(message: string) { 21 | return { 22 | type: 'SET_ALERT', 23 | message, 24 | }; 25 | } 26 | 27 | export function clearAlert() { 28 | return { type: 'CLEAR_ALERT' }; 29 | } 30 | 31 | export function updateAvailable() { 32 | return { type: 'UPDATE_AVAILABLE' }; 33 | } 34 | 35 | export function sync() { 36 | return { type: 'SYNC' }; 37 | } 38 | 39 | export function syncFailed(error: Error) { 40 | return { type: 'SYNC_FAILED', error }; 41 | } 42 | 43 | export function syncSuccess() { 44 | return { type: 'SYNC_SUCCESS' }; 45 | } 46 | 47 | export function appInit() { 48 | return { type: 'APP_INIT' }; 49 | } 50 | 51 | export function checkForUpdate() { 52 | return { type: 'APP_CHECK_UPDATE' }; 53 | } 54 | 55 | export function pluginInit(name: string) { 56 | return { 57 | type: 'PLUGIN_INIT', 58 | name, 59 | }; 60 | } 61 | -------------------------------------------------------------------------------- /src/api/app.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { get } from 'core/fetch'; 4 | 5 | export default function getAppVersion(): Promise { 6 | return get('/version'); 7 | } 8 | -------------------------------------------------------------------------------- /src/components/App/App.css: -------------------------------------------------------------------------------- 1 | .page.modals.visible.transition { 2 | display: flex !important; 3 | } 4 | 5 | body { 6 | height: auto; 7 | } 8 | 9 | .App .pushable, 10 | .App .pusher { 11 | min-height: 100vh; 12 | } 13 | 14 | .ui.menu .item.App-Title { 15 | width: calc(100% - 53px); 16 | justify-content: center; 17 | padding: 0.92857143em 53px 0.92857143em 0; 18 | } 19 | 20 | .ui.menu .item.App-Title::before { 21 | display: none; 22 | } 23 | 24 | .App__Container { 25 | margin-top: 5em; 26 | margin-bottom: 2em; 27 | } 28 | 29 | .Msg-Error, 30 | .Msg-Warn { 31 | color: #9f3a38; 32 | display: block; 33 | margin: 4px 0; 34 | } 35 | -------------------------------------------------------------------------------- /src/components/App/print.css: -------------------------------------------------------------------------------- 1 | @media print { 2 | .top.menu, 3 | .SavedReports, 4 | .ReportRange__Presets, 5 | .accordion .title, 6 | .Reports h1, 7 | .Reports > .grid:first-child, 8 | .Reports .Insights, 9 | .App .Report__filters { 10 | display: none !important; 11 | } 12 | 13 | .App__Container { 14 | margin: 0; 15 | } 16 | 17 | .ReportRange__Input input { 18 | width: auto; 19 | border: 0; 20 | } 21 | 22 | .ReportDetailed__toggle, 23 | .ui.popup, 24 | .ui.dropdown { 25 | display: none !important; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/components/BuySubscription/Button.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React from 'react'; 4 | import { Link } from 'react-router-dom'; 5 | 6 | import Button from 'semantic-ui-react/dist/commonjs/elements/Button'; 7 | import Icon from 'semantic-ui-react/dist/commonjs/elements/Icon'; 8 | 9 | type BuyButtonProps = { 10 | children?: string; 11 | basic?: boolean; 12 | primary?: boolean; 13 | showIcon?: boolean; 14 | icon?: string; 15 | }; 16 | 17 | function BuyButton({ 18 | children, 19 | basic, 20 | primary, 21 | showIcon, 22 | icon, 23 | }: BuyButtonProps) { 24 | return ( 25 | 34 | ); 35 | } 36 | 37 | export default BuyButton; 38 | -------------------------------------------------------------------------------- /src/components/BuySubscription/Complete.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React from 'react'; 4 | import { Route, Switch } from 'react-router'; 5 | import { useSelector } from 'react-redux'; 6 | 7 | import Container from 'semantic-ui-react/dist/commonjs/elements/Container'; 8 | import Message from 'semantic-ui-react/dist/commonjs/collections/Message'; 9 | 10 | import { hasPremium, isLoaded, isLoggedIn } from 'sections/Account/selectors'; 11 | 12 | import BuyButton from './Button'; 13 | 14 | const showMessageSelector = (state) => !hasPremium(state) && isLoaded(state) && isLoggedIn(state); 15 | 16 | function Complete() { 17 | const showMessage = useSelector(showMessageSelector); 18 | 19 | if (!showMessage) return null; 20 | 21 | return ( 22 | 23 | 30 |
31 | Please complete your purchase of Thyme Premium. 32 |
33 | 34 | 35 | Finish Purchase 36 | 37 |
38 |
39 | ); 40 | } 41 | 42 | export default function CompletedOnRoute() { 43 | return ( 44 | 45 | 46 | 47 | 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /src/components/BuySubscription/Message.css: -------------------------------------------------------------------------------- 1 | .BuyMessageWrapper { 2 | margin: 1em 0; 3 | display: flex; 4 | justify-content: center; 5 | } 6 | 7 | .BuyMessage .ui.button { 8 | margin: 0 0 0 1em; 9 | } 10 | 11 | @media print { 12 | .BuyMessage { 13 | display: none !important; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/components/BuySubscription/Message.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React from 'react'; 4 | 5 | import Message from 'semantic-ui-react/dist/commonjs/collections/Message'; 6 | 7 | import BuyButton from './Button'; 8 | 9 | import './Message.css'; 10 | 11 | type BuyMessageProps = { 12 | children: string; 13 | }; 14 | 15 | function BuyMessage({ children }: BuyMessageProps) { 16 | return ( 17 |
18 | 19 | {children} 20 | 21 | Get Premium 22 | 23 | 24 |
25 | ); 26 | } 27 | 28 | export default BuyMessage; 29 | -------------------------------------------------------------------------------- /src/components/ChangeOnBlurInput/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React, { useState, useCallback, useEffect } from 'react'; 4 | 5 | import { valueFromEventTarget } from 'core/dom'; 6 | 7 | import Input from 'semantic-ui-react/dist/commonjs/elements/Input'; 8 | 9 | function DebouncedInput(props: any) { 10 | const { 11 | value, 12 | setRef, 13 | onChange, 14 | ...otherProps 15 | } = props; 16 | const [cachedValue, setValue] = useState(value); 17 | const [prevValue, setPrevValue] = useState(value); 18 | const noop = useCallback(() => undefined, []); 19 | 20 | const updateValue = useCallback( 21 | (e: Event) => { 22 | setValue(valueFromEventTarget(e.target)); 23 | }, 24 | [setValue], 25 | ); 26 | 27 | const onBlur = useCallback( 28 | () => { 29 | if (onChange && value !== cachedValue) onChange(cachedValue); 30 | }, 31 | [cachedValue, onChange, value], 32 | ); 33 | 34 | useEffect(() => { 35 | if (value !== prevValue) { 36 | setPrevValue(value); 37 | } 38 | 39 | if (value !== prevValue && value !== cachedValue) { 40 | setValue(value); 41 | } 42 | }, [value, prevValue, cachedValue, setPrevValue, setValue]); 43 | 44 | return ( 45 | 52 | ); 53 | } 54 | 55 | export default DebouncedInput; 56 | -------------------------------------------------------------------------------- /src/components/ErrorBoundary/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React, { Component } from 'react'; 4 | 5 | import logError from 'core/errorReporting'; 6 | 7 | type ErrorBoundaryState = { 8 | showError: boolean; 9 | }; 10 | 11 | class ErrorBoundary extends Component<*, ErrorBoundaryState> { 12 | constructor() { 13 | super(); 14 | 15 | this.state = { 16 | showError: false, 17 | }; 18 | } 19 | 20 | componentDidCatch(error: Error, errorInfo: any) { 21 | if (error.message.match(/Loading( CSS)? chunk [0-9]+ failed/)) { 22 | this.setState({ showError: true }); 23 | return; 24 | } 25 | 26 | logError(error, errorInfo); 27 | } 28 | 29 | render() { 30 | const { showError } = this.state; 31 | const { children } = this.props; 32 | 33 | if (showError) { 34 | return ( 35 |
36 |

Whoops! Could not load necessary files for this page.

37 |

38 | Please reload the page to try again. 39 |

40 |
41 | ); 42 | } 43 | 44 | return children; 45 | } 46 | } 47 | 48 | export default ErrorBoundary; 49 | -------------------------------------------------------------------------------- /src/components/FormField/FormField.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React from 'react'; 4 | 5 | import Form from 'semantic-ui-react/dist/commonjs/collections/Form'; 6 | import Input from 'semantic-ui-react/dist/commonjs/elements/Input'; 7 | 8 | import './style.css'; 9 | 10 | type FieldRenderProps = { 11 | label: string; 12 | value: string; 13 | name: string; 14 | type: 'text' | 'password' | 'email'; 15 | placeholder: string; 16 | autoComplete?: string; 17 | required?: boolean; 18 | error: string; 19 | onChange: () => void; 20 | onBlur: () => void; 21 | } 22 | 23 | const FormField = ({ 24 | label, 25 | name, 26 | type, 27 | value, 28 | placeholder, 29 | required = false, 30 | autoComplete = '', 31 | error, 32 | onChange, 33 | onBlur, 34 | }: FieldRenderProps) => ( 35 | 36 | 39 |
40 | 51 | {error && ( 52 | 53 | {error} 54 | 55 | )} 56 |
57 |
58 | ); 59 | 60 | export default FormField; 61 | -------------------------------------------------------------------------------- /src/components/FormField/style.css: -------------------------------------------------------------------------------- 1 | .ui.input.error > input { 2 | background-color: #fff6f6; 3 | border-color: #e0b4b4; 4 | color: #9f3a38; 5 | } 6 | -------------------------------------------------------------------------------- /src/components/Loading/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React from 'react'; 4 | 5 | import Loader from 'semantic-ui-react/dist/commonjs/elements/Loader'; 6 | 7 | type LoadingType = { 8 | size?: 'mini' | 'tiny' | 'small' | 'medium' | 'large' | 'big' | 'huge' | 'massive', 9 | noPadding?: boolean; 10 | }; 11 | 12 | function Loading({ size, noPadding }: LoadingType) { 13 | return ( 14 |
15 | 16 |
17 | ); 18 | } 19 | 20 | export default Loading; 21 | -------------------------------------------------------------------------------- /src/components/Notifier/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React from 'react'; 4 | import { useSelector } from 'react-redux'; 5 | 6 | import Message from 'semantic-ui-react/dist/commonjs/collections/Message'; 7 | import Icon from 'semantic-ui-react/dist/commonjs/elements/Icon'; 8 | 9 | import { updateAvailable } from 'selectors/app'; 10 | 11 | import './style.css'; 12 | 13 | const reloadWindow = () => window.location.reload(); 14 | 15 | function Notifier() { 16 | const isUpdateAvailable = useSelector(updateAvailable); 17 | 18 | if (!isUpdateAvailable) return null; 19 | 20 | return ( 21 |
22 | 23 | 24 | New version available. Refresh the page to load. 25 | 26 |
27 | ); 28 | } 29 | 30 | export default Notifier; 31 | -------------------------------------------------------------------------------- /src/components/Notifier/style.css: -------------------------------------------------------------------------------- 1 | .Notifier { 2 | position: fixed; 3 | display: flex; 4 | align-items: center; 5 | justify-content: center; 6 | top: 4.5em; 7 | width: 100%; 8 | padding: 0 2em; 9 | z-index: 100; 10 | pointer-events: none; 11 | } 12 | 13 | .Notifier .message { 14 | cursor: pointer; 15 | pointer-events: auto; 16 | } 17 | -------------------------------------------------------------------------------- /src/core/analytics.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { useEffect } from 'react'; 4 | 5 | const _paq = window._paq || []; // eslint-disable-line 6 | const skipEnvs = ['development', 'test']; 7 | 8 | const enabled = skipEnvs.indexOf(process.env.NODE_ENV) === -1; 9 | 10 | export function trackPageview(pageName: string = '') { 11 | if (enabled) _paq.push(['trackPageView', pageName]); 12 | } 13 | 14 | export function trackEvent( 15 | category: string = '', 16 | action: string = '', 17 | varName?: string, 18 | varValue?: string, 19 | ) { 20 | if (enabled) _paq.push(['trackEvent', category, action, varName, varValue]); 21 | } 22 | 23 | export function useTrackPageview(pageName: string = '') { 24 | useEffect(() => trackPageview(pageName), [pageName]); 25 | } 26 | -------------------------------------------------------------------------------- /src/core/compareVersions.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | export default function isNewer(compare: string, version: string) { 4 | if (compare === version) { 5 | return false; 6 | } 7 | 8 | const numbersCompare = compare.split('.').map((num) => parseInt(num, 10)); 9 | const numbersVersion = version.split('.').map((num) => parseInt(num, 10)); 10 | 11 | for (let i = 0; i < numbersCompare.length; i += 1) { 12 | if (!numbersVersion[i] || numbersCompare[i] > numbersVersion[i]) { 13 | return true; 14 | } 15 | 16 | if (numbersCompare[i] < numbersVersion[i]) { 17 | return false; 18 | } 19 | } 20 | 21 | return false; 22 | } 23 | -------------------------------------------------------------------------------- /src/core/debug.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | const isDebug = process.env.NODE_ENV !== 'production'; 4 | 5 | export function debugInfo(...args: any) { 6 | if (!isDebug) return; 7 | console.info(...args); 8 | } 9 | 10 | export function debugLog(...args: any) { 11 | if (!isDebug) return; 12 | console.log(...args); 13 | } 14 | 15 | export function debugTable(...args: any) { 16 | if (!isDebug) return; 17 | console.table(...args); 18 | } 19 | 20 | export function debugWarn(...args: any) { 21 | if (!isDebug) return; 22 | console.warn(...args); 23 | } 24 | 25 | export function debugError(...args: any) { 26 | if (!isDebug) return; 27 | console.error(...args); 28 | } 29 | -------------------------------------------------------------------------------- /src/core/dom.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | export function valueFromEventTarget(target: EventTarget): string { 4 | if (target instanceof HTMLInputElement) { 5 | return target.value; 6 | } 7 | 8 | return ''; 9 | } 10 | -------------------------------------------------------------------------------- /src/core/errorReporting.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | let Sentry; 4 | const buffer = []; 5 | 6 | function logError(error, errorInfo) { 7 | Sentry.withScope((scope) => { 8 | if (errorInfo) { 9 | Object.keys(errorInfo).forEach((key) => { 10 | scope.setExtra(key, errorInfo[key]); 11 | }); 12 | } 13 | Sentry.captureException(error); 14 | }); 15 | } 16 | 17 | if (process.env.REACT_APP_SENTRY_DSN) { 18 | import('@sentry/browser').then((s) => { 19 | Sentry = s; 20 | 21 | Sentry.init({ 22 | dsn: process.env.REACT_APP_SENTRY_DSN, 23 | debug: process.env.NODE_ENV !== 'production', 24 | environment: process.env.NODE_ENV, 25 | release: process.env.REACT_APP_VERSION, 26 | }); 27 | 28 | if (buffer.length > 0) { 29 | buffer.forEach((item) => logError(item.error, item.errorInfo)); 30 | } 31 | }); 32 | } 33 | 34 | export default (error: any, errorInfo: any) => { 35 | if (!Sentry) { 36 | buffer.push({ error, errorInfo }); 37 | return; 38 | } 39 | 40 | logError(error, errorInfo); 41 | }; 42 | -------------------------------------------------------------------------------- /src/core/extensions/chrome.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { 4 | extensionConnected, 5 | onChangeTimer, 6 | onChangeState, 7 | startTimer, 8 | stopTimer, 9 | receiveTimer, 10 | addEntry, 11 | } from './events'; 12 | 13 | const extensionId = process.env.REACT_APP_CHROME_EXTENSION_ID; 14 | 15 | const port = window.chrome.runtime.connect(extensionId); 16 | 17 | function handleMessage(msg: { type: string } & any) { 18 | switch (msg.type) { 19 | case 'connected': 20 | extensionConnected(); 21 | break; 22 | case 'changeTimer': 23 | receiveTimer(msg.entry); 24 | break; 25 | case 'startTimer': 26 | startTimer(); 27 | break; 28 | case 'stopTimer': 29 | stopTimer(); 30 | break; 31 | case 'addEntry': 32 | addEntry(msg.entry); 33 | break; 34 | default: 35 | // eslint-disable-next-line no-console 36 | console.error('Unable to handle message from extension', msg); 37 | } 38 | } 39 | 40 | port.onMessage.addListener(handleMessage); 41 | 42 | function postMessage(msg: { type: string } & any) { 43 | try { 44 | port.postMessage(msg); 45 | } catch (e) { 46 | // fail silently 47 | } 48 | } 49 | 50 | onChangeTimer((timer) => { 51 | postMessage({ 52 | type: 'changeTimer', 53 | entry: timer, 54 | }); 55 | }); 56 | 57 | onChangeState((state) => { 58 | postMessage({ 59 | type: 'changeState', 60 | state, 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /src/core/extensions/firefox.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { 4 | addEntry, 5 | extensionConnected, onChangeState, onChangeTimer, 6 | receiveTimer, 7 | startTimer, 8 | stopTimer, 9 | } from './events'; 10 | 11 | function postToExtension(msg: { type: string } & any) { 12 | return window.postMessage({ 13 | from: 'page', 14 | ...msg, 15 | }, '*'); 16 | } 17 | 18 | function handleMessage(msg: { type: string } & any) { 19 | switch (msg.type) { 20 | case 'connected': 21 | extensionConnected(); 22 | break; 23 | case 'changeTimer': 24 | receiveTimer(msg.entry); 25 | break; 26 | case 'startTimer': 27 | startTimer(); 28 | break; 29 | case 'stopTimer': 30 | stopTimer(); 31 | break; 32 | case 'addEntry': 33 | addEntry(msg.entry); 34 | break; 35 | default: 36 | // eslint-disable-next-line no-console 37 | console.error('Unable to handle message from extension', msg); 38 | } 39 | } 40 | 41 | window.addEventListener('message', (event) => { 42 | if ( 43 | event.source === window 44 | && event.data 45 | && event.data.type 46 | && event.data.from === 'content' 47 | ) { 48 | handleMessage(event.data); 49 | } 50 | }); 51 | 52 | onChangeTimer((timer) => { 53 | postToExtension({ 54 | type: 'changeTimer', 55 | entry: timer, 56 | }); 57 | }); 58 | 59 | onChangeState((state) => { 60 | postToExtension({ 61 | type: 'changeState', 62 | state, 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /src/core/extensions/load.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import './socket'; 4 | 5 | const isFirefox = typeof window.InstallTrigger !== 'undefined'; 6 | const isChrome = !!window.chrome && (!!window.chrome.webstore || !!window.chrome.runtime); 7 | 8 | if (isChrome) { 9 | import('./chrome'); 10 | } 11 | 12 | if (isFirefox) { 13 | import('./firefox'); 14 | } 15 | -------------------------------------------------------------------------------- /src/core/extensions/socket.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import io from 'socket.io-client'; 4 | import type { Socket } from 'socket.io-client'; 5 | 6 | import { hasPremium } from 'sections/Account/selectors'; 7 | 8 | import { onChangeTimer, onChangeState, receiveTimer } from './events'; 9 | 10 | function canSync(state: StateShape): boolean { 11 | return state && hasPremium(state); 12 | } 13 | 14 | function jwt(state: StateShape): string | null { 15 | if (state && state.account && state.account.jwt) { 16 | return state.account.jwt; 17 | } 18 | 19 | return null; 20 | } 21 | 22 | function startSocketConnection(socket: Socket) { 23 | let communicatedConnection = false; 24 | let state: StateShape; 25 | 26 | onChangeState((newState) => { 27 | state = newState; 28 | 29 | if (!communicatedConnection && canSync(state)) { 30 | communicatedConnection = true; 31 | 32 | const token = jwt(state); 33 | 34 | socket.emit('connectUser', { token }); 35 | } 36 | }); 37 | 38 | onChangeTimer((item) => { 39 | if (canSync(state)) { 40 | socket.emit('changeItem', { item }); 41 | } 42 | }); 43 | 44 | socket.on('changeItem', (data) => { 45 | if (data.socket !== socket.id) { 46 | receiveTimer(data.item, false); 47 | } 48 | }); 49 | } 50 | 51 | if (process.env.REACT_APP_API_ROOT) { 52 | startSocketConnection(io(process.env.REACT_APP_API_ROOT)); 53 | } 54 | -------------------------------------------------------------------------------- /src/core/fetch.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { getApiRoot } from 'sections/Settings/selectors'; 4 | 5 | let getState = () => undefined; 6 | 7 | function createUrl(url: string) { 8 | const state = getState(); 9 | 10 | if (!state) { 11 | throw new Error('Tried to get state before available'); 12 | } 13 | 14 | const apiRoot = getApiRoot(state); 15 | 16 | return `${apiRoot}${url}`; 17 | } 18 | 19 | function jwt(): string | null { 20 | const state = getState(); 21 | 22 | if (state && state.account && state.account.jwt) { 23 | return state.account.jwt; 24 | } 25 | 26 | return null; 27 | } 28 | 29 | export function setupStateResolver(stateResolver: () => any) { 30 | getState = stateResolver; 31 | } 32 | 33 | function request(method: 'POST' | 'GET' = 'GET', url: string, data?: any) { 34 | const token = jwt(); 35 | 36 | const authorization = token ? { 37 | Authorization: `Bearer ${token}`, 38 | } : {}; 39 | 40 | return fetch( 41 | createUrl(url), 42 | { 43 | method, 44 | body: data ? JSON.stringify(data) : undefined, 45 | cache: 'no-cache', 46 | credentials: 'same-origin', 47 | headers: { 48 | ...authorization, 49 | 'content-type': 'application/json', 50 | }, 51 | mode: 'cors', 52 | }, 53 | ) 54 | .then((response) => { 55 | if (!response.ok) { 56 | return response.json() 57 | .then((err) => { 58 | throw new Error(err.message); 59 | }) 60 | .catch((err) => { 61 | // if json failed to parse 62 | if (err instanceof SyntaxError) { 63 | throw new Error(response.statusText); 64 | } 65 | 66 | throw err; 67 | }); 68 | } 69 | 70 | return response.json(); 71 | }); 72 | } 73 | 74 | export function post(url: string, data: any) { 75 | return request('POST', url, data); 76 | } 77 | 78 | export function get(url: string) { 79 | return request('GET', url); 80 | } 81 | 82 | export function isValidThymeApi(url: string): Promise { 83 | return fetch(url) 84 | .then((response) => response.headers.get('API-Consumer') === 'Thyme') 85 | .catch(() => false); 86 | } 87 | -------------------------------------------------------------------------------- /src/core/intl.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import parse from 'date-fns/parse'; 4 | 5 | // determine locale to use 6 | const navigatorLocales = Array.isArray(navigator.languages) ? navigator.languages : []; 7 | const locales = ['i-default', ...navigatorLocales]; 8 | const locale = locales.find((l) => { 9 | try { 10 | Intl.NumberFormat(l); 11 | return true; 12 | } catch (e) { 13 | // silent 14 | } 15 | return false; 16 | }); 17 | 18 | export function formatCurrency(currency: string, value: number) { 19 | return new Intl 20 | .NumberFormat(locale, { style: 'currency', currency }) 21 | .format(value); 22 | } 23 | 24 | type Weekday = 'narrow' | 'short' | 'long'; 25 | 26 | export function formatShortDate( 27 | date: Date | string, 28 | numberOfDays: number, 29 | weekday: Weekday = 'short', 30 | ) { 31 | const parsedDate = parse(date); 32 | 33 | if (numberOfDays > 14 && parsedDate.getDate() === 1) { 34 | return parsedDate.toLocaleDateString(locale, { month: 'short' }); 35 | } 36 | 37 | return parsedDate.toLocaleDateString( 38 | locale, 39 | { 40 | month: numberOfDays < 15 ? '2-digit' : undefined, 41 | day: '2-digit', 42 | weekday: numberOfDays < 9 ? weekday : undefined, 43 | }, 44 | ); 45 | } 46 | 47 | export function formatDate(date: Date | number) { 48 | const parsedDate = parse(date); 49 | 50 | return parsedDate.toLocaleDateString(locale); 51 | } 52 | 53 | export function formatTime(date: Date | number) { 54 | const parsedDate = parse(date); 55 | 56 | return parsedDate.toLocaleTimeString( 57 | locale, 58 | { hour: '2-digit', minute: '2-digit' }, 59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /src/core/jwt.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | export default function parseJwt(token: string, position: number = 1) { 4 | try { 5 | return JSON.parse(atob(token.split('.')[position])); 6 | } catch (e) { 7 | return {}; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/core/localStorage.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import debounce from 'lodash/debounce'; 4 | 5 | function loadItem(key: string): any | typeof undefined { 6 | try { 7 | const serializedState = localStorage.getItem(key); 8 | 9 | if (serializedState === null) { 10 | return undefined; 11 | } 12 | 13 | return JSON.parse(serializedState || '{}'); 14 | } catch (e) { 15 | return undefined; 16 | } 17 | } 18 | 19 | function saveItem(state: any, key): void { 20 | try { 21 | const serializedState = JSON.stringify(state); 22 | localStorage.setItem(key, serializedState); 23 | } catch (e) { 24 | // silently fail 25 | } 26 | } 27 | 28 | function removeItem(key: string) { 29 | try { 30 | localStorage.removeItem(key); 31 | } catch (e) { 32 | // silently fail 33 | } 34 | } 35 | 36 | export function loadState(): {} | typeof undefined { 37 | return loadItem('ThymeState'); 38 | } 39 | 40 | export function saveState(state: StateShape): void { 41 | // persist everything but the app and form state 42 | saveItem({ 43 | ...state, 44 | account: { 45 | ...state.account, 46 | isLoaded: false, 47 | isPremium: [], 48 | }, 49 | reports: { 50 | ...state.reports, 51 | filters: undefined, 52 | from: undefined, 53 | to: undefined, 54 | }, 55 | app: undefined, 56 | form: undefined, 57 | }, 'ThymeState'); 58 | } 59 | 60 | export function loadTemporaryItem(): TempTimePropertyType | typeof undefined { 61 | return loadItem('ThymeTempItem'); 62 | } 63 | 64 | export function saveTemporaryItem(item: TempTimePropertyType): void { 65 | saveItem(item, 'ThymeTempItem'); 66 | } 67 | 68 | export function clearTemporaryItem() { 69 | removeItem('ThymeTempItem'); 70 | } 71 | 72 | export function saveOnStoreChange(store: ThymeStore) { 73 | // save changes from store to localStorage 74 | store.subscribe(debounce(() => { 75 | saveState(store.getState()); 76 | }, 1000)); 77 | } 78 | -------------------------------------------------------------------------------- /src/core/projects.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | export function isDescendant(from: string, to: string | null, projects: Array) { 4 | if (from === to) { 5 | return true; 6 | } 7 | 8 | const toProject = projects.find((project) => project.id === to); 9 | 10 | if (!toProject) { 11 | return false; 12 | } 13 | 14 | if (toProject.parent === from) { 15 | return true; 16 | } 17 | 18 | if (toProject.parent === null) { 19 | return false; 20 | } 21 | 22 | return isDescendant(from, toProject.parent, projects); 23 | } 24 | 25 | function getProjectTree( 26 | project: ProjectType, 27 | projects: Array, 28 | current: Array = [], 29 | ): Array { 30 | const projectNames = [project.name, ...current]; 31 | 32 | if (project.parent) { 33 | const parent = projects.find((item) => item.id === project.parent); 34 | 35 | if (parent) { 36 | return getProjectTree( 37 | parent, 38 | projects, 39 | projectNames, 40 | ); 41 | } 42 | } 43 | 44 | return projectNames; 45 | } 46 | 47 | export function sortProjects( 48 | projects: Array, 49 | project: ?ProjectType, 50 | ): Array { 51 | let sortedByName = projects; 52 | 53 | if (!project) { 54 | sortedByName = [...projects]; 55 | 56 | sortedByName.sort((a, b) => { 57 | if (a.name > b.name) { 58 | return 1; 59 | } 60 | 61 | if (a.name < b.name) { 62 | return -1; 63 | } 64 | 65 | return 0; 66 | }); 67 | } 68 | 69 | const parent = (project && project.id) || null; 70 | 71 | return sortedByName 72 | .filter((item) => item.parent === parent) 73 | .reduce((acc, item) => [ 74 | ...acc, 75 | item, 76 | ...sortProjects(sortedByName, item), 77 | ], []) 78 | .filter((item) => !!item) 79 | .map((item) => ({ 80 | ...item, 81 | nameTree: getProjectTree(item, projects), 82 | })); 83 | } 84 | 85 | export function treeDisplayName( 86 | project: ProjectTreeType, 87 | delimiter: string = ' > ', 88 | ): string { 89 | return project.nameTree.join(delimiter); 90 | } 91 | -------------------------------------------------------------------------------- /src/core/reportQueryString.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import qs from 'qs'; 4 | import type { RouterHistory } from 'react-router'; 5 | 6 | import endOfWeek from 'date-fns/end_of_week'; 7 | import startOfWeek from 'date-fns/start_of_week'; 8 | import parse from 'date-fns/parse'; 9 | import format from 'date-fns/format'; 10 | 11 | function currentQueryString() { 12 | return qs.parse(window.location.search, { ignoreQueryPrefix: true, strictNullHandling: true }); 13 | } 14 | 15 | export function queryStringFilters(): Array | typeof undefined { 16 | const filters = currentQueryString().filter; 17 | 18 | if (filters) { 19 | return !Array.isArray(filters) ? [filters] : filters; 20 | } 21 | 22 | return filters; 23 | } 24 | 25 | export function queryStringFrom(): Date { 26 | const { from } = currentQueryString(); 27 | 28 | if (!from) { 29 | return startOfWeek(new Date(), { weekStartsOn: 1 }); 30 | } 31 | 32 | return parse(from); 33 | } 34 | 35 | export function queryStringTo(): Date { 36 | const { to } = currentQueryString(); 37 | 38 | if (!to) { 39 | return endOfWeek(new Date(), { weekStartsOn: 1 }); 40 | } 41 | 42 | return parse(to); 43 | } 44 | 45 | export function updateReport( 46 | filter: Array, 47 | from: Date | string = queryStringFrom(), 48 | to: Date | string = queryStringTo(), 49 | history: RouterHistory, 50 | ) { 51 | const queryString = qs.stringify({ 52 | filter, 53 | from: format(from), 54 | to: format(to), 55 | }, { 56 | strictNullHandling: true, 57 | }); 58 | 59 | history.push(`/reports?${queryString}`); 60 | } 61 | -------------------------------------------------------------------------------- /src/core/sync.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import debounce from 'lodash/debounce'; 4 | import isEqual from 'lodash/isEqual'; 5 | import startOfDay from 'date-fns/start_of_day'; 6 | 7 | import { sync, syncFailed, syncSuccess } from 'actions/app'; 8 | 9 | import { hasPremium } from 'sections/Account/selectors'; 10 | 11 | import { post } from './fetch'; 12 | import { stateToExport } from './importExport'; 13 | import type { exportType } from './importExport'; 14 | 15 | let prevState: exportType = { 16 | temporaryItem: { 17 | start: startOfDay(new Date()), 18 | end: startOfDay(new Date()), 19 | project: null, 20 | notes: '', 21 | tracking: false, 22 | }, 23 | time: [], 24 | projects: [], 25 | reports: [], 26 | }; 27 | 28 | function syncWithApi(state: StateShape, dispatch: ThymeDispatch) { 29 | if (!hasPremium(state)) { 30 | return; 31 | } 32 | 33 | const newState = stateToExport(state); 34 | 35 | if (isEqual(prevState, newState)) { 36 | return; 37 | } 38 | 39 | dispatch(sync()); 40 | 41 | post('/save-state', newState) 42 | .then(() => { 43 | prevState = newState; 44 | dispatch(syncSuccess()); 45 | }) 46 | .catch((e) => { 47 | dispatch(syncFailed(e)); 48 | }); 49 | } 50 | 51 | export default function syncOnUpdate(store: ThymeStore) { 52 | // save changes from store to localStorage 53 | store.subscribe(debounce(() => { 54 | syncWithApi(store.getState(), store.dispatch); 55 | }, 2000)); 56 | } 57 | -------------------------------------------------------------------------------- /src/core/useActions.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { bindActionCreators } from 'redux'; 4 | import { useDispatch } from 'react-redux'; 5 | import { useMemo } from 'react'; 6 | 7 | export function useActions(actions: any) { 8 | const dispatch = useDispatch(); 9 | return useMemo(() => { 10 | if (Array.isArray(actions)) { 11 | return actions.map((a) => bindActionCreators(a, dispatch)); 12 | } 13 | return bindActionCreators(actions, dispatch); 14 | }, [dispatch, actions]); 15 | } 16 | -------------------------------------------------------------------------------- /src/core/validate.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | type ValidateType = { 4 | [fieldName: string]: { 5 | required?: string; 6 | email?: string; 7 | matches?: { 8 | field: string; 9 | error: string; 10 | }; 11 | }; 12 | }; 13 | type ValuesType = { [key: string]: any }; 14 | 15 | export default function createValidator( 16 | fields: ValidateType, 17 | ): (values: ValuesType) => { [key: string]: string } { 18 | return (values: ValuesType) => { 19 | const errors = {}; 20 | 21 | Object.keys(fields).forEach((key: string) => { 22 | const field = fields[key]; 23 | const value = values[key]; 24 | 25 | if (field.required && ( 26 | !value 27 | || (typeof value === 'string' && value.trim().length === 0) 28 | )) { 29 | errors[key] = field.required; 30 | } else if (field.email && ( 31 | (typeof value === 'string' && value.trim().length > 0) 32 | && !/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$/i.test(value) 33 | )) { 34 | errors[key] = field.email; 35 | } else if (field.matches && value !== values[field.matches.field]) { 36 | errors[key] = field.matches.error; 37 | } 38 | }); 39 | 40 | return errors; 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /src/createStore.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { createStore, applyMiddleware } from 'redux'; 4 | import { createEpicMiddleware } from 'redux-observable'; 5 | import { composeWithDevTools } from 'redux-devtools-extension/developmentOnly'; 6 | 7 | import createReducers from './reducers'; 8 | import epics from './epics'; 9 | 10 | export default (initialState: any = {}) => { 11 | const epicMiddleware = createEpicMiddleware(); 12 | 13 | const store = createStore( 14 | createReducers(), 15 | initialState, 16 | composeWithDevTools( 17 | applyMiddleware( 18 | epicMiddleware, 19 | ), 20 | ), 21 | ); 22 | 23 | epicMiddleware.run(epics); 24 | 25 | return store; 26 | }; 27 | -------------------------------------------------------------------------------- /src/epics/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { BehaviorSubject } from 'rxjs'; 4 | import { mergeMap } from 'rxjs/operators'; 5 | import { combineEpics } from 'redux-observable'; 6 | 7 | import accountEpics from 'sections/Account/epics'; 8 | 9 | import pluginEpics from './plugins'; 10 | import updateEpics from './update'; 11 | import trackingEpics from './tracking'; 12 | 13 | const epics = combineEpics( 14 | ...accountEpics, 15 | ...pluginEpics, 16 | ...updateEpics, 17 | ...trackingEpics, 18 | ); 19 | 20 | export const epic$ = new BehaviorSubject(epics); 21 | const rootEpic = (action$: ActionsObservable, state$: StateObservable) => epic$.pipe( 22 | mergeMap((epic) => epic(action$, state$)), 23 | ); 24 | 25 | export default rootEpic; 26 | -------------------------------------------------------------------------------- /src/epics/plugins.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { ofType } from 'redux-observable'; 4 | import { tap, ignoreElements } from 'rxjs/operators'; 5 | 6 | export const announcePlugin = (action$: ActionsObservable) => action$.pipe( 7 | ofType('PLUGIN_INIT'), 8 | tap((action) => { 9 | // eslint-disable-next-line no-console 10 | console.info(`Loaded plugin: ${action.name}`); 11 | }), 12 | ignoreElements(), 13 | ); 14 | 15 | export default [announcePlugin]; 16 | -------------------------------------------------------------------------------- /src/epics/tracking.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { ofType } from 'redux-observable'; 4 | import { tap, ignoreElements } from 'rxjs/operators'; 5 | 6 | import { trackEvent } from 'core/analytics'; 7 | 8 | export const createTracking = ( 9 | actionType: string, 10 | onTrack: (action: any) => { category: string, action: string, name?: string, value?: string }, 11 | ) => (action$: ActionsObservable) => action$.pipe( 12 | ofType(actionType), 13 | tap((action) => { 14 | const result = onTrack(action); 15 | 16 | if (result && result.category && result.action) { 17 | trackEvent(result.category, result.action, result.name, result.value); 18 | } 19 | }), 20 | ignoreElements(), 21 | ); 22 | 23 | export default [ 24 | createTracking('APP_INIT', () => ({ 25 | category: 'App', 26 | action: 'init', 27 | name: 'version', 28 | value: process.env.REACT_APP_VERSION || '', 29 | })), 30 | createTracking('IMPORT_JSON_DATA', () => ({ category: 'App', action: 'import' })), 31 | 32 | createTracking('ACCOUNT_LOGIN', () => ({ category: 'Account', action: 'login' })), 33 | createTracking('ACCOUNT_REGISTER', () => ({ category: 'Account', action: 'register' })), 34 | createTracking('LOGOUT', () => ({ category: 'Account', action: 'logout' })), 35 | 36 | createTracking('ADD_PROJECT', () => ({ category: 'Projects', action: 'add' })), 37 | createTracking('UPDATE_PROJECT', () => ({ category: 'Projects', action: 'update' })), 38 | createTracking('ARCHIVE_PROJECT', () => ({ category: 'Projects', action: 'archive' })), 39 | createTracking('REMOVE_PROJECT', () => ({ category: 'Projects', action: 'remove' })), 40 | 41 | createTracking('ADD_REPORT', () => ({ category: 'Reports', action: 'add' })), 42 | createTracking('REMOVE_REPORT', () => ({ category: 'Reports', action: 'remove' })), 43 | 44 | createTracking('ADD_TIME', () => ({ category: 'TimeSheet', action: 'add' })), 45 | createTracking('UPDATE_TIME', () => ({ category: 'TimeSheet', action: 'update' })), 46 | createTracking('REMOVE_TIME', () => ({ category: 'TimeSheet', action: 'remove' })), 47 | createTracking('CHANGE_DATE_RANGE', (action) => ({ 48 | category: 'TimeSheet', 49 | action: 'change date range', 50 | name: 'dateRange', 51 | value: action.dateRange, 52 | })), 53 | ]; 54 | -------------------------------------------------------------------------------- /src/epics/update.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { ofType } from 'redux-observable'; 4 | import { mergeMap, filter } from 'rxjs/operators'; 5 | 6 | import getAppVersion from 'api/app'; 7 | 8 | import isNewerVersion from 'core/compareVersions'; 9 | 10 | import { updateAvailable } from 'actions/app'; 11 | 12 | import { unregister } from '../serviceWorker'; 13 | 14 | export const announcePlugin = (action$: ActionsObservable) => action$.pipe( 15 | ofType('APP_INIT', 'APP_CHECK_UPDATE'), 16 | mergeMap(() => getAppVersion() 17 | .then((version) => { 18 | if (isNewerVersion(version, process.env.REACT_APP_VERSION || '')) { 19 | unregister(); 20 | return updateAvailable(); 21 | } 22 | 23 | return false; 24 | })), 25 | filter((needsAction) => !!needsAction), 26 | ); 27 | 28 | export default [announcePlugin]; 29 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React from 'react'; 4 | import ReactDOM from 'react-dom'; 5 | import { Provider } from 'react-redux'; 6 | import { BrowserRouter } from 'react-router-dom'; 7 | 8 | import 'semantic-ui-css/semantic.min.css'; 9 | 10 | import './core/errorReporting'; 11 | import { loadState, saveOnStoreChange } from './core/localStorage'; 12 | import syncOnUpdate from './core/sync'; 13 | import './core/analytics'; 14 | import { setupStateResolver } from './core/fetch'; 15 | import './core/extensions/load'; 16 | import { onExtensionConnected, changeState } from './core/extensions/events'; 17 | 18 | import { updateAvailable } from './actions/app'; 19 | 20 | import createStore from './createStore'; 21 | import runMigrations from './migrations'; 22 | 23 | import App from './components/App'; 24 | import ErrorBoundary from './components/ErrorBoundary'; 25 | import Routes from './Routes'; 26 | import { register, unregister } from './serviceWorker'; 27 | 28 | import { registerStore } from './register/reducer'; 29 | import RegisterProvider from './register/Provider'; 30 | import loadPlugins from './plugins/load'; 31 | 32 | const initialState = runMigrations(loadState()); 33 | 34 | const store: ThymeStore = createStore(initialState); 35 | 36 | registerStore(store); 37 | saveOnStoreChange(store); 38 | syncOnUpdate(store); 39 | setupStateResolver(() => store.getState()); 40 | 41 | // broadcast state changes to extensions 42 | store.subscribe(() => changeState(store.getState())); 43 | onExtensionConnected(() => changeState(store.getState())); 44 | 45 | ReactDOM.render( 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | , 57 | document.getElementById('root') || document.createElement('div'), 58 | ); 59 | 60 | loadPlugins(store.dispatch); 61 | 62 | register({ 63 | onUpdate() { 64 | unregister(); 65 | store.dispatch(updateAvailable()); 66 | }, 67 | }); 68 | -------------------------------------------------------------------------------- /src/plugins/Insights/async.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import registerInjectables from './injectables'; 4 | 5 | export default function registerPlugin({ pluginInit }: {pluginInit: (name: string) => void}) { 6 | registerInjectables(); 7 | 8 | pluginInit('Insights'); 9 | } 10 | -------------------------------------------------------------------------------- /src/plugins/Insights/components/BarItem/BarItem.css: -------------------------------------------------------------------------------- 1 | .Insights__Bar { 2 | width: 100%; 3 | max-width: 30px; 4 | margin-bottom: 4px; 5 | } 6 | -------------------------------------------------------------------------------- /src/plugins/Insights/components/BarItem/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React from 'react'; 4 | 5 | import Popup from 'semantic-ui-react/dist/commonjs/modules/Popup'; 6 | 7 | import { formatDuration } from 'core/thyme'; 8 | import { treeDisplayName } from 'core/projects'; 9 | 10 | import { projectColour } from 'sections/Projects/colours'; 11 | 12 | import './BarItem.css'; 13 | 14 | type BarItemProps = { 15 | project: ProjectTreeWithTimeType; 16 | hours: number; 17 | barHeight: number; 18 | }; 19 | 20 | function BarItem({ project, hours, barHeight }: BarItemProps) { 21 | return ( 22 | 35 | )} 36 | > 37 | {treeDisplayName(project)} 38 | {` (${formatDuration(project.time * 60)})`} 39 | 40 | ); 41 | } 42 | 43 | export default BarItem; 44 | -------------------------------------------------------------------------------- /src/plugins/Insights/components/Insights/Insights.css: -------------------------------------------------------------------------------- 1 | .Insights { 2 | display: flex; 3 | position: relative; 4 | } 5 | 6 | .Insights__Scrollable { 7 | margin-left: 25px; 8 | margin-bottom: 2em; 9 | overflow-x: auto; 10 | -webkit-overflow-scrolling: touch; 11 | z-index: 2; 12 | } 13 | 14 | .Insights__Hours { 15 | display: flex; 16 | flex-direction: column; 17 | padding: 0; 18 | margin: 0; 19 | width: 100%; 20 | position: absolute; 21 | top: 0; 22 | left: 0; 23 | z-index: 1; 24 | } 25 | 26 | .Insights__Hours-Item { 27 | list-style-type: none; 28 | flex-grow: 1; 29 | position: relative; 30 | line-height: 20px; 31 | top: -10px; 32 | color: rgba(34, 36, 38, 0.6); 33 | font-size: 0.9em; 34 | } 35 | 36 | .Insights__Hours-Item--last::before, 37 | .Insights__Hours-Item::after { 38 | content: ""; 39 | position: absolute; 40 | top: 10px; 41 | right: 0; 42 | width: calc(100% - 25px); 43 | border-top: 1px solid rgba(34, 36, 38, 0.2); 44 | } 45 | 46 | .Insights__Hours-Item--last::before { 47 | top: auto; 48 | bottom: -6px; 49 | } 50 | 51 | .Insights__Table { 52 | width: 100%; 53 | table-layout: fixed; 54 | border-collapse: collapse; 55 | } 56 | 57 | .Insights__Days { 58 | padding: 0; 59 | } 60 | 61 | .Insights__Days-Item { 62 | padding: 0; 63 | min-width: 30px; 64 | } 65 | 66 | .Insights__Days-ItemBars { 67 | display: flex; 68 | flex-direction: column; 69 | height: 200px; 70 | align-items: center; 71 | justify-content: flex-end; 72 | padding: 0 2px; 73 | } 74 | 75 | .Insights__Days--dates { 76 | font-size: 0.9em; 77 | text-align: center; 78 | } 79 | -------------------------------------------------------------------------------- /src/plugins/Insights/helpers.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | export function hoursAndDivisions(longestDay: number): number[] { 4 | const roundedLongestDay = Math.ceil(longestDay); 5 | 6 | if (roundedLongestDay < 1) { 7 | throw new Error('Calculation not possible'); 8 | } 9 | 10 | if (roundedLongestDay === 1) { 11 | return [1, 1]; 12 | } 13 | 14 | const hours = [2, 3, 4].some((n) => roundedLongestDay % n === 0) 15 | ? roundedLongestDay 16 | : roundedLongestDay + 1; 17 | const dividers = [4, 3, 2].find((n) => hours % n === 0) || 1; 18 | 19 | return [hours, dividers]; 20 | } 21 | -------------------------------------------------------------------------------- /src/plugins/Insights/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { loadOnPremium } from 'register/plugin'; 4 | 5 | export default function registerInsights(pluginInit: (name: string) => void) { 6 | loadOnPremium(() => import('./async'), { pluginInit }); 7 | } 8 | -------------------------------------------------------------------------------- /src/plugins/Insights/injectables.js: -------------------------------------------------------------------------------- 1 | import { register as registerComponent } from 'register/component'; 2 | 3 | import Insights from './components/Insights'; 4 | 5 | export default () => { 6 | registerComponent('reports.afterCharts', 'ReportInsights', Insights); 7 | }; 8 | -------------------------------------------------------------------------------- /src/plugins/ProjectRates/actions.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | export function updateCurrency(currency: string) { 4 | return { 5 | type: 'UPDATE_PROJECT_RATES_CURRENCY', 6 | currency, 7 | }; 8 | } 9 | -------------------------------------------------------------------------------- /src/plugins/ProjectRates/async.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import registerReducers from './reducers'; 4 | import registerInjectables from './injectables'; 5 | 6 | export default function registerPlugin({ pluginInit }: {pluginInit: (name: string) => void}) { 7 | registerReducers(); 8 | registerInjectables(); 9 | 10 | pluginInit('ProjectRates'); 11 | } 12 | -------------------------------------------------------------------------------- /src/plugins/ProjectRates/components/Projects/ProjectHourlyRate.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React, { useCallback } from 'react'; 4 | import { useSelector } from 'react-redux'; 5 | 6 | import Table from 'semantic-ui-react/dist/commonjs/collections/Table/Table'; 7 | import Input from 'semantic-ui-react/dist/commonjs/elements/Input/Input'; 8 | 9 | import { valueFromEventTarget } from 'core/dom'; 10 | 11 | import type { ProjectItemProps } from 'sections/Projects/components/ProjectsList/ProjectItem'; 12 | 13 | import { getRatesCurrencySign } from '../../selectors'; 14 | 15 | import type { ProjectWithRate } from '../../types'; 16 | 17 | type ProjectHourlyRatePassedProps = { 18 | isMobile: boolean; 19 | } & ProjectItemProps; 20 | 21 | type ProjectHourlyRateProps = { 22 | project: ProjectWithRate; 23 | } & ProjectHourlyRatePassedProps; 24 | 25 | function ProjectHourlyRate({ 26 | isMobile, 27 | project, 28 | onUpdateProject, 29 | }: ProjectHourlyRateProps) { 30 | const currencySign = useSelector(getRatesCurrencySign); 31 | 32 | const onChange = useCallback((e: Event) => { 33 | const rate = parseInt(valueFromEventTarget(e.target), 10); 34 | 35 | onUpdateProject({ 36 | ...project, 37 | rate: Number.isNaN(rate) ? 0 : rate, 38 | }); 39 | }, [project, onUpdateProject]); 40 | 41 | return ( 42 | 43 | {isMobile && ( 44 | 47 | )} 48 | 56 | 57 | ); 58 | } 59 | 60 | export default ProjectHourlyRate; 61 | -------------------------------------------------------------------------------- /src/plugins/ProjectRates/components/Projects/ProjectTableHeader.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React from 'react'; 4 | 5 | import Table from 'semantic-ui-react/dist/commonjs/collections/Table/Table'; 6 | 7 | export default () => ( 8 | 9 | Hourly Rate 10 | 11 | ); 12 | -------------------------------------------------------------------------------- /src/plugins/ProjectRates/components/Reports/ReportTableRow.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React from 'react'; 4 | import { useSelector } from 'react-redux'; 5 | 6 | import { formatCurrency } from 'core/intl'; 7 | 8 | import { totalProjectPrice } from '../../helpers'; 9 | 10 | import { getRatesCurrency } from '../../selectors'; 11 | 12 | import type { ProjectRatesReportProject } from '../../types'; 13 | 14 | type ReportTableRowProps = { 15 | project: ProjectRatesReportProject; 16 | }; 17 | 18 | function ReportTableRow({ project }: ReportTableRowProps) { 19 | const currency = useSelector(getRatesCurrency); 20 | 21 | return formatCurrency(currency, totalProjectPrice(project)); 22 | } 23 | 24 | export default (project: ProjectRatesReportProject) => ; 25 | -------------------------------------------------------------------------------- /src/plugins/ProjectRates/components/Reports/ReportTableTotal.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React from 'react'; 4 | import { useSelector } from 'react-redux'; 5 | 6 | import { formatCurrency } from 'core/intl'; 7 | 8 | import { totalProjectPrice } from '../../helpers'; 9 | 10 | import { getRatesCurrency } from '../../selectors'; 11 | 12 | import type { ProjectRatesReportProject } from '../../types'; 13 | 14 | type ReportTableTotalProps = { 15 | projects: ProjectRatesReportProject[]; 16 | }; 17 | 18 | function ReportTableTotal({ projects }: ReportTableTotalProps) { 19 | const currency = useSelector(getRatesCurrency); 20 | 21 | return formatCurrency( 22 | currency, 23 | projects.reduce((acc, project) => acc + totalProjectPrice(project), 0), 24 | ); 25 | } 26 | 27 | export default (projects: ProjectRatesReportProject[]) => ; 28 | -------------------------------------------------------------------------------- /src/plugins/ProjectRates/components/Settings/ProjectRatesSettings.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React from 'react'; 4 | import { useSelector } from 'react-redux'; 5 | 6 | import Form from 'semantic-ui-react/dist/commonjs/collections/Form'; 7 | import Dropdown from 'semantic-ui-react/dist/commonjs/modules/Dropdown'; 8 | 9 | import { useActions } from 'core/useActions'; 10 | 11 | import { popular, other } from '../../currencies'; 12 | 13 | import { getRatesCurrency } from '../../selectors'; 14 | 15 | import { updateCurrency } from '../../actions'; 16 | 17 | function transformToOption(currencies) { 18 | return Object.keys(currencies).map((value: string) => ({ 19 | value, 20 | text: currencies[value] ? `${value} - ${currencies[value]}` : value, 21 | })); 22 | } 23 | 24 | const currencyOptions = [ 25 | { text: 'Popular currencies', value: 'popular', disabled: true }, 26 | ...transformToOption(popular), 27 | { text: 'Other currencies', value: 'other', disabled: true }, 28 | ...transformToOption(other), 29 | ]; 30 | 31 | function ProjectRatesSettings() { 32 | const currency = useSelector(getRatesCurrency); 33 | const onCurrencyChange = useActions((e, data) => updateCurrency(data.value)); 34 | 35 | return ( 36 |
37 | 38 | 39 | 48 | 49 |
50 | ); 51 | } 52 | 53 | export default ; 54 | -------------------------------------------------------------------------------- /src/plugins/ProjectRates/currencies.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | export const popular = { 4 | EUR: '€', 5 | GBP: '£', 6 | USD: '$', 7 | JPY: '¥', 8 | }; 9 | 10 | export const other = { 11 | ALL: 'Lek', 12 | AFN: '؋', 13 | ARS: '$', 14 | AWG: 'ƒ', 15 | AUD: '$', 16 | AZN: '₼', 17 | BSD: '$', 18 | BBD: '$', 19 | BYN: 'Br', 20 | BZD: 'BZ$', 21 | BMD: '$', 22 | BOB: '$b', 23 | BAM: 'KM', 24 | BWP: 'P', 25 | BGN: 'лв', 26 | BRL: 'R$', 27 | BND: '$', 28 | KHR: '៛', 29 | CAD: '$', 30 | KYD: '$', 31 | CLP: '$', 32 | CNY: '¥', 33 | COP: '$', 34 | CRC: '₡', 35 | HRK: 'kn', 36 | CUP: '₱', 37 | CZK: 'Kč', 38 | DKK: 'kr', 39 | DOP: 'RD$', 40 | XCD: '$', 41 | EGP: '£', 42 | SVC: '$', 43 | FKP: '£', 44 | FJD: '$', 45 | GHS: '¢', 46 | GIP: '£', 47 | GTQ: 'Q', 48 | GGP: '£', 49 | GYD: '$', 50 | HNL: 'L', 51 | HKD: '$', 52 | HUF: 'Ft', 53 | ISK: 'kr', 54 | INR: 'INR', 55 | IDR: 'Rp', 56 | IRR: '﷼', 57 | IMP: '£', 58 | ILS: '₪', 59 | JMD: 'J$', 60 | JEP: '£', 61 | KZT: 'лв', 62 | KPW: '₩', 63 | KRW: '₩', 64 | KGS: 'лв', 65 | LAK: '₭', 66 | LBP: '£', 67 | LRD: '$', 68 | MKD: 'ден', 69 | MYR: 'RM', 70 | MUR: '₨', 71 | MXN: '$', 72 | MNT: '₮', 73 | MZN: 'MT', 74 | NAD: '$', 75 | NPR: '₨', 76 | ANG: 'ƒ', 77 | NZD: '$', 78 | NIO: 'C$', 79 | NGN: '₦', 80 | NOK: 'kr', 81 | OMR: '﷼', 82 | PKR: '₨', 83 | PAB: 'B/.', 84 | PYG: 'Gs', 85 | PEN: 'S/.', 86 | PHP: '₱', 87 | PLN: 'zł', 88 | QAR: '﷼', 89 | RON: 'lei', 90 | RUB: '₽', 91 | SHP: '£', 92 | SAR: '﷼', 93 | RSD: 'Дин.', 94 | SCR: '₨', 95 | SGD: '$', 96 | SBD: '$', 97 | SOS: 'S', 98 | ZAR: 'R', 99 | LKR: '₨', 100 | SEK: 'kr', 101 | CHF: 'CHF', 102 | SRD: '$', 103 | SYP: '£', 104 | TWD: 'NT$', 105 | THB: '฿', 106 | TTD: 'TT$', 107 | TRY: 'TRY', 108 | TVD: '$', 109 | UAH: '₴', 110 | UYU: '$U', 111 | UZS: 'лв', 112 | VEF: 'Bs', 113 | VND: '₫', 114 | YER: '﷼', 115 | ZWD: 'Z$', 116 | }; 117 | -------------------------------------------------------------------------------- /src/plugins/ProjectRates/helpers.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import type { ProjectRatesReportProject } from './types'; 4 | 5 | export function totalProjectPrice(project: ProjectRatesReportProject) { 6 | if (project.time === 0 || !project.rate) { 7 | return 0; 8 | } 9 | 10 | return project.rate * (project.time / 60); 11 | } 12 | -------------------------------------------------------------------------------- /src/plugins/ProjectRates/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { loadOnPremium } from 'register/plugin'; 4 | 5 | export default function registerProjectRates(pluginInit: (name: string) => void) { 6 | loadOnPremium(() => import('./async'), { pluginInit }); 7 | } 8 | -------------------------------------------------------------------------------- /src/plugins/ProjectRates/injectables.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { register as registerComponent } from 'register/component'; 4 | import { register as registerSettingsPanel } from 'register/settings'; 5 | import { registerColumn } from 'register/table'; 6 | 7 | import ProjectTableHeader from './components/Projects/ProjectTableHeader'; 8 | import ProjectHourlyRate from './components/Projects/ProjectHourlyRate'; 9 | 10 | import ProjectRatesSettings from './components/Settings/ProjectRatesSettings'; 11 | 12 | import ReportTableRow from './components/Reports/ReportTableRow'; 13 | import ReportTableTotal from './components/Reports/ReportTableTotal'; 14 | 15 | export default () => { 16 | // Projects page 17 | registerComponent('projects.tableheader.parent', 'RatesProjectsTableHeader', ProjectTableHeader); 18 | registerComponent('projects.tablerow.parent', 'RatesProjectsRateInput', ProjectHourlyRate); 19 | 20 | // Settings page 21 | registerSettingsPanel({ 22 | url: 'project-rates', 23 | name: 'Project rates', 24 | content: ProjectRatesSettings, 25 | }); 26 | 27 | // Reports page 28 | registerColumn('reports', { 29 | name: 'Total price', 30 | header: () => 'Total price', 31 | footer: ReportTableTotal, 32 | row: ReportTableRow, 33 | textAlign: 'right', 34 | collapsing: true, 35 | style: { whiteSpace: 'no-wrap' }, 36 | }); 37 | }; 38 | -------------------------------------------------------------------------------- /src/plugins/ProjectRates/reducers.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { combineReducers } from 'redux'; 4 | 5 | import { register } from 'register/reducer'; 6 | 7 | import type { ProjectRateCurrency } from './types'; 8 | 9 | function rate(state = 0, action) { 10 | switch (action.type) { 11 | case 'UPDATE_PROJECT': 12 | return action.rate || 0; 13 | default: 14 | return state; 15 | } 16 | } 17 | 18 | function currency(state: ProjectRateCurrency = 'EUR', action): ProjectRateCurrency { 19 | switch (action.type) { 20 | case 'UPDATE_PROJECT_RATES_CURRENCY': 21 | return action.currency; 22 | default: 23 | return state; 24 | } 25 | } 26 | 27 | const projectRates = combineReducers({ currency }); 28 | 29 | export default () => { 30 | // Project reducers 31 | register('projects.project', { rate }); 32 | 33 | // Settings reducers 34 | register('settings', { projectRates }); 35 | }; 36 | -------------------------------------------------------------------------------- /src/plugins/ProjectRates/selectors.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { createSelector } from 'reselect'; 4 | 5 | import { popular, other } from './currencies'; 6 | 7 | import type { StoreShapeWithRates, ProjectRatesSettings } from './types'; 8 | 9 | export const projectRatesSettings = (state: StoreShapeWithRates) => state.settings.projectRates; 10 | 11 | export const getRatesCurrency = createSelector( 12 | projectRatesSettings, 13 | (state: ProjectRatesSettings) => state.currency, 14 | ); 15 | 16 | export const getRatesCurrencySign = createSelector( 17 | getRatesCurrency, 18 | (currency: string) => popular[currency] || other[currency] || '', 19 | ); 20 | -------------------------------------------------------------------------------- /src/plugins/ProjectRates/types.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | export type ProjectRateCurrency = string; 4 | 5 | export type ProjectWithRate = { rate?: number } & ProjectTreeType; 6 | 7 | export type ProjectRatesSettings = { 8 | currency: string; 9 | }; 10 | 11 | export type StoreShapeWithRates = { 12 | settings: { 13 | projectRates: ProjectRatesSettings; 14 | } & SettingsShape; 15 | } & StateShape; 16 | 17 | export type ProjectRatesReportProject = { rate?: number } & ProjectTreeWithTimeType; 18 | -------------------------------------------------------------------------------- /src/plugins/load.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { pluginInit } from 'actions/app'; 4 | 5 | const resolveFalse = () => Promise.resolve(false); 6 | 7 | function loadPlugin(name: string, importModule: () => Promise<*>): () => Promise<*> { 8 | if (!process.env.REACT_APP_THYME_PLUGINS) { 9 | return resolveFalse; 10 | } 11 | 12 | return process.env.REACT_APP_THYME_PLUGINS.split(',').indexOf(name) > -1 ? importModule 13 | : resolveFalse; 14 | } 15 | 16 | function pluginsList() { 17 | return [ 18 | loadPlugin('ProjectRates', () => import('./ProjectRates')), 19 | loadPlugin('Insights', () => import('./Insights')), 20 | ]; 21 | } 22 | 23 | const createPluginInitDispatcher = (dispatch: ThymeDispatch) => (pluginName: string) => { 24 | dispatch(pluginInit(pluginName)); 25 | }; 26 | 27 | export default function loadPlugins(dispatch: ThymeDispatch) { 28 | Promise.all(pluginsList().map((p) => p())) 29 | .then((modules) => { 30 | modules 31 | .filter((m) => !!m) 32 | .forEach((m) => { 33 | if (typeof m !== 'boolean' && typeof m.default === 'function') { 34 | const dispatchPluginInit = createPluginInitDispatcher(dispatch); 35 | m.default(dispatchPluginInit); 36 | } 37 | }); 38 | }); 39 | } 40 | -------------------------------------------------------------------------------- /src/reducers/app.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { create } from 'register/reducer'; 4 | 5 | function alert(state: string = '', action) { 6 | switch (action.type) { 7 | case 'SET_ALERT': 8 | return action.message; 9 | case 'CLEAR_ALERT': 10 | return ''; 11 | default: 12 | return state; 13 | } 14 | } 15 | 16 | function update(state: boolean = false, action) { 17 | switch (action.type) { 18 | case 'UPDATE_AVAILABLE': 19 | return true; 20 | default: 21 | return state; 22 | } 23 | } 24 | 25 | function syncing(state: boolean = false, action) { 26 | switch (action.type) { 27 | case 'SYNC_FAILED': 28 | case 'SYNC_SUCCESS': 29 | return false; 30 | case 'SYNC': 31 | return true; 32 | default: 33 | return state; 34 | } 35 | } 36 | 37 | function lastSync(state: Date = new Date(), action) { 38 | switch (action.type) { 39 | case 'SYNC_SUCCESS': 40 | return new Date(); 41 | default: 42 | return state; 43 | } 44 | } 45 | 46 | function plugins(state: string[] = [], action) { 47 | switch (action.type) { 48 | case 'PLUGIN_INIT': 49 | return [...state, action.name]; 50 | default: 51 | return state; 52 | } 53 | } 54 | 55 | export default () => create('app', { 56 | alert, 57 | update, 58 | syncing, 59 | lastSync, 60 | plugins, 61 | }); 62 | -------------------------------------------------------------------------------- /src/reducers/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { create, registerCreateReducers } from 'register/reducer'; 4 | 5 | import createReportsReducers from 'sections/Reports/reducers'; 6 | import createSettingsReducers from 'sections/Settings/reducers'; 7 | import createProjectsReducers from 'sections/Projects/reducers'; 8 | import createAccountReducers from 'sections/Account/reducers'; 9 | import createTimeReducers from 'sections/TimeSheet/reducers'; 10 | 11 | import runMigrations from '../migrations'; 12 | 13 | import createAppReducers from './app'; 14 | 15 | const createReducers = () => { 16 | const combinedReducers = create('thyme', { 17 | account: createAccountReducers(), 18 | app: createAppReducers(), 19 | projects: createProjectsReducers(), 20 | reports: createReportsReducers(), 21 | time: createTimeReducers(), 22 | settings: createSettingsReducers(), 23 | }); 24 | 25 | return (state: any, action: { type: string }) => { 26 | // allow to run migrations on store data by action 27 | if (action.type === 'MIGRATE_STORE_DATA') { 28 | return runMigrations(state); 29 | } 30 | 31 | return combinedReducers(state, action); 32 | }; 33 | }; 34 | 35 | registerCreateReducers(createReducers); 36 | 37 | export default createReducers; 38 | -------------------------------------------------------------------------------- /src/register/Actions.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import store from './Store'; 4 | import type { UpdateActions } from './Store'; 5 | 6 | function dispatch(action: UpdateActions) { 7 | store.dispatch(action); 8 | } 9 | 10 | export function registerSettingsPanel(item: SettingsPanel) { 11 | dispatch({ type: 'ADD_SETTINGS_PANEL', item }); 12 | } 13 | 14 | export function registerComponent(name: string, key: string, renderProp: (...any) => any) { 15 | dispatch({ 16 | type: 'ADD_COMPONENT', 17 | name, 18 | key, 19 | renderProp, 20 | }); 21 | } 22 | 23 | export function registerTableColumn(name: string, column: TableColumn) { 24 | dispatch({ 25 | type: 'ADD_TABLE_COLUMN', 26 | name, 27 | column, 28 | }); 29 | } 30 | 31 | export function toggleTableColumn(name: string, column: string) { 32 | dispatch({ 33 | type: 'TOGGLE_TABLE_COLUMN', 34 | name, 35 | column, 36 | }); 37 | } 38 | -------------------------------------------------------------------------------- /src/register/Consumer.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React, { useContext } from 'react'; 4 | 5 | import RegisterContext from './Context'; 6 | 7 | export function useRegisterConsumer(key?: string) { 8 | const context = useContext(RegisterContext); 9 | 10 | return key ? context[key] : context; 11 | } 12 | 13 | export default function RegisterConsumer({ children }: { children: (items: ContextType) => any }) { 14 | return ( 15 | 16 | {(state: ContextType) => children(state)} 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/register/Context.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { createContext } from 'react'; 4 | 5 | import { defaultState } from './Store'; 6 | 7 | const RegisterContext = createContext(defaultState); 8 | 9 | export default RegisterContext; 10 | -------------------------------------------------------------------------------- /src/register/Provider.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React, { useState, useEffect } from 'react'; 4 | 5 | import store, { defaultState } from './Store'; 6 | 7 | import Context from './Context'; 8 | 9 | function RegisterProvider({ children }: any) { 10 | const [state, setState] = useState(defaultState); 11 | 12 | useEffect(() => { 13 | const updateState = () => setState(store.getState()); 14 | 15 | const unsubscribe = store.subscribe(updateState); 16 | updateState(); 17 | 18 | return () => unsubscribe(); 19 | }, []); 20 | 21 | return ( 22 | 23 | {children} 24 | 25 | ); 26 | } 27 | 28 | export default RegisterProvider; 29 | -------------------------------------------------------------------------------- /src/register/component.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React from 'react'; 4 | import { invoke } from 'thyme-connect'; 5 | 6 | import { useRegisterConsumer } from './Consumer'; 7 | import { registerComponent } from './Actions'; 8 | 9 | export function register(name: string, key: string, renderProp: (...any) => any) { 10 | registerComponent(name, key, renderProp); 11 | } 12 | 13 | function RenderComponent({ name, props }: { name: string, props: any }) { 14 | const components = useRegisterConsumer('components'); 15 | 16 | if (!components[name]) { 17 | return null; 18 | } 19 | 20 | return components[name].map((c) => ); 21 | } 22 | 23 | export function render(name: string, props: any) { 24 | return ; 25 | } 26 | 27 | // register method on thyme-connect 28 | invoke('registerComponent', register); 29 | -------------------------------------------------------------------------------- /src/register/epic.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { epic$ } from 'epics'; 4 | import { invoke } from 'thyme-connect'; 5 | 6 | export function inject( 7 | asyncEpic: (action$: ActionsObservable, state$: StateObservable) => any, 8 | ) { 9 | epic$.next(asyncEpic); 10 | } 11 | 12 | // register method on thyme-connect 13 | invoke('injectEpic', inject); 14 | -------------------------------------------------------------------------------- /src/register/plugin.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { ofType } from 'redux-observable'; 4 | import { filter, mergeMap } from 'rxjs/operators'; 5 | 6 | import { inject } from 'register/epic'; 7 | import { getState } from 'register/reducer'; 8 | 9 | import { hasPremium } from 'sections/Account/selectors'; 10 | 11 | export function loadOnPremium( 12 | importModule: () => Promise<*>, 13 | args: any, 14 | ) { 15 | const onLoadPlugin = () => importModule() 16 | .then((module) => module && typeof module.default === 'function' && module.default(args)); 17 | const state = getState(); 18 | const isPremium = state ? hasPremium(state) : false; 19 | 20 | if (isPremium) { 21 | // if already possible, don't wait for receiving information 22 | onLoadPlugin(); 23 | } else { 24 | // if not premium yet, wait until accounts goes premium 25 | const asyncEpic = ( 26 | action$: ActionsObservable, 27 | state$: StateObservable, 28 | ) => action$.pipe( 29 | ofType('ACCOUNT_RECEIVE_INFORMATION'), 30 | mergeMap((): any[] | Promise<*> => { 31 | if (!hasPremium(state$.value)) { 32 | return []; 33 | } 34 | 35 | return onLoadPlugin(); 36 | }), 37 | // prevent new actions 38 | filter(() => false), 39 | ); 40 | 41 | inject(asyncEpic); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/register/reducer.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { combineReducers } from 'redux'; 4 | import type { Reducer } from 'redux'; 5 | import { invoke } from 'thyme-connect'; 6 | 7 | type Reducers = { [key: string]: Reducer }; 8 | 9 | const registeredReducers: { [path: string]: Reducers[] } = {}; 10 | let registeredStore: ?ThymeStore; 11 | let createReducers: () => any; 12 | 13 | export function registerCreateReducers(f: () => any) { 14 | createReducers = f; 15 | } 16 | 17 | export function registerStore(store: ThymeStore) { 18 | registeredStore = store; 19 | } 20 | 21 | export function getState() { 22 | return registeredStore && registeredStore.getState(); 23 | } 24 | 25 | export function register(path: string, reducers: Reducers, store: ?ThymeStore) { 26 | if (!registeredReducers[path]) { 27 | registeredReducers[path] = []; 28 | } 29 | 30 | registeredReducers[path] = [ 31 | ...registeredReducers[path], 32 | reducers, 33 | ]; 34 | 35 | if (!store && !registeredStore) { 36 | throw new Error('No store registered, please provide a store or register one first.'); 37 | } 38 | 39 | // default to provided store, or fallback to registered store 40 | const storeToUse = store || registeredStore; 41 | 42 | if (storeToUse) { 43 | storeToUse.replaceReducer(createReducers()); 44 | } 45 | } 46 | 47 | export function create(path: string, reducers: Reducers) { 48 | const extraReducers = registeredReducers[path] || []; 49 | 50 | const newReducers = { 51 | ...reducers, 52 | ...extraReducers.reduce((accReducers, reducer) => ({ 53 | ...accReducers, 54 | ...reducer, 55 | }), {}), 56 | }; 57 | 58 | return combineReducers<*, *>(newReducers); 59 | } 60 | 61 | // register method on thyme-connect 62 | invoke('registerReducer', register); 63 | -------------------------------------------------------------------------------- /src/register/settings.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { invoke } from 'thyme-connect'; 4 | 5 | import { registerSettingsPanel } from './Actions'; 6 | 7 | export function register(item: SettingsPanel) { 8 | registerSettingsPanel(item); 9 | } 10 | 11 | // register method on thyme-connect 12 | invoke('registerSettingsPanel', register); 13 | -------------------------------------------------------------------------------- /src/sections/Account/Account.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React from 'react'; 4 | import { Switch, Route } from 'react-router-dom'; 5 | 6 | import Premium from './screens/Premium'; 7 | import Settings from './screens/Settings'; 8 | 9 | export default function AccountRoutes() { 10 | return ( 11 | 12 | 13 | 14 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /src/sections/Account/actions.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | export function registerAccount(token: string | null = null) { 4 | return { 5 | type: 'ACCOUNT_REGISTER', 6 | token, 7 | }; 8 | } 9 | 10 | export function updateToken(token: string | null = null) { 11 | return { 12 | type: 'ACCOUNT_UPDATE_TOKEN', 13 | token, 14 | }; 15 | } 16 | 17 | export function loginAccount(token: string | null = null) { 18 | return { 19 | type: 'ACCOUNT_LOGIN', 20 | token, 21 | }; 22 | } 23 | 24 | export function logout() { 25 | return { type: 'LOG_OUT' }; 26 | } 27 | 28 | export function accountChecked() { 29 | return { type: 'ACCOUNT_CHECKED' }; 30 | } 31 | 32 | export function accountInit() { 33 | return { type: 'ACCOUNT_INIT' }; 34 | } 35 | 36 | export function receiveAccountInformation(information: AccountInformation) { 37 | return { 38 | type: 'ACCOUNT_RECEIVE_INFORMATION', 39 | information, 40 | }; 41 | } 42 | 43 | export function getAccountInformation() { 44 | return { type: 'ACCOUNT_FETCH_INFORMATION' }; 45 | } 46 | 47 | export function updateAccountInformation() { 48 | return { type: 'ACCOUNT_UPDATE_INFORMATION' }; 49 | } 50 | -------------------------------------------------------------------------------- /src/sections/Account/api.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { post, get } from 'core/fetch'; 4 | import type { exportType } from 'core/importExport'; 5 | 6 | export function login(email: string, password: string): Promise { 7 | return post('/login', { email, password }); 8 | } 9 | 10 | export function registerUser(email: string, password: string): Promise { 11 | return post('/register', { email, password }); 12 | } 13 | 14 | export function refreshToken(): Promise { 15 | return post('/refresh-token'); 16 | } 17 | 18 | export function getState(): Promise { 19 | return get('/get-state'); 20 | } 21 | 22 | export function getAccountInformation(): Promise { 23 | return get('/account-information'); 24 | } 25 | 26 | export function buySubscription(token: string, values: any): Promise { 27 | return post('/buy-subscription', { token, values }); 28 | } 29 | 30 | export function getSubscriptions(): Promise { 31 | return get('/list-subscriptions'); 32 | } 33 | 34 | export function changePassword(currentPassword: string, password: string): Promise { 35 | return post('/change-password', { currentPassword, password }); 36 | } 37 | -------------------------------------------------------------------------------- /src/sections/Account/components/ListSubscription.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React, { useState, useCallback, useEffect } from 'react'; 4 | 5 | import format from 'date-fns/format'; 6 | 7 | import Button from 'semantic-ui-react/dist/commonjs/elements/Button'; 8 | import Message from 'semantic-ui-react/dist/commonjs/collections/Message'; 9 | 10 | import { useActions } from 'core/useActions'; 11 | 12 | import Loading from 'components/Loading'; 13 | 14 | import { alert } from 'actions/app'; 15 | 16 | import { getSubscriptions } from '../api'; 17 | 18 | function ListSubscription() { 19 | const [isLoading, setLoading] = useState(true); 20 | const [subscriptions, setSubscriptions] = useState([]); 21 | 22 | useEffect(() => { 23 | getSubscriptions() 24 | .then((result) => { 25 | setLoading(false); 26 | setSubscriptions(result); 27 | }); 28 | }, []); 29 | 30 | const showAlert = useActions(alert); 31 | 32 | const onCancel = useCallback(() => { 33 | showAlert('Please send an email to support@usethyme.com and add that you would like to cancel your subscription.'); 34 | }, [showAlert]); 35 | 36 | if (isLoading) { 37 | return ; 38 | } 39 | 40 | if (subscriptions.length === 0) { 41 | return Something went wrong, please try again later.; 42 | } 43 | 44 | return ( 45 |
46 | {subscriptions.map((plan: SubscriptionInfo) => ( 47 |
55 |

56 | Your Thyme Premium 57 | {` (in ${plan.plan}) `} 58 | will renew on 59 | {' '} 60 | {format(plan.periodEnd * 1000, 'MMMM D, YYYY')} 61 |

62 | 65 |
66 | ))} 67 |
68 | ); 69 | } 70 | 71 | export default ListSubscription; 72 | -------------------------------------------------------------------------------- /src/sections/Account/components/MenuItem/MenuItem.css: -------------------------------------------------------------------------------- 1 | .Account__Submit-Bar { 2 | display: flex; 3 | align-items: center; 4 | justify-content: space-between; 5 | } 6 | 7 | .Account__Sub-Bar { 8 | margin: 2em 0 1em 0; 9 | text-align: center; 10 | } 11 | 12 | .Account__Sub-Bar .ui.basic.button, 13 | .Account__Sub-Bar .ui.basic.button:hover { 14 | box-shadow: none !important; 15 | margin-left: 5px; 16 | } 17 | 18 | .Account__Sub-Bar .ui.basic.button:hover { 19 | text-decoration: underline; 20 | } 21 | 22 | .ui.form .Account__Submit-Bar .field, 23 | .Account__Submit-Bar .button.basic { 24 | margin: 0; 25 | width: 100%; 26 | } 27 | 28 | .Account-PopUp-Content { 29 | overflow: hidden; 30 | display: flex; 31 | width: 300px; 32 | } 33 | 34 | .ui.menu .item.Account-BackUp { 35 | line-height: 1.4em; 36 | font-weight: 600; 37 | text-align: center; 38 | } 39 | 40 | .Account-BackUp .ui.button { 41 | margin: 1em 0 0 0; 42 | } 43 | 44 | .Login { 45 | width: 100%; 46 | flex-shrink: 0; 47 | } 48 | 49 | .Account-PopUp .message.small { 50 | padding: 1em; 51 | } 52 | 53 | .ui.popup.Account-PopUp { 54 | padding: 0; 55 | } 56 | 57 | .ui.popup .Account-PopUp-Content { 58 | margin: 0.833em 1em; 59 | } 60 | -------------------------------------------------------------------------------- /src/sections/Account/components/Status/Status.css: -------------------------------------------------------------------------------- 1 | .Status--connected::before, 2 | .Status--offline::before, 3 | .Status--syncing::before { 4 | content: "•"; 5 | margin-right: 5px; 6 | transition: color 0.2s ease; 7 | } 8 | 9 | .Status--connected::before { 10 | color: #00cc68; 11 | } 12 | 13 | .Status--syncing::before { 14 | color: #ff9000; 15 | } 16 | 17 | .Status--offline { 18 | color: #ff0060; 19 | } 20 | 21 | .Status--offline::before { 22 | color: #ff0060; 23 | } 24 | 25 | .Status i.icon { 26 | margin: 0 0 0 0.2em; 27 | } 28 | -------------------------------------------------------------------------------- /src/sections/Account/components/Status/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React, { useState, useEffect } from 'react'; 4 | import classnames from 'classnames'; 5 | import { useSelector } from 'react-redux'; 6 | 7 | import Icon from 'semantic-ui-react/dist/commonjs/elements/Icon'; 8 | 9 | import { isSyncing } from 'selectors/app'; 10 | 11 | import { hasPremium, isLoaded } from '../../selectors'; 12 | 13 | import './Status.css'; 14 | 15 | type ConnectionStates = 'connected' | 'syncing' | 'offline'; 16 | 17 | type StatusProps = { 18 | closePopup: () => void; 19 | } 20 | 21 | const selectors = (state) => ({ 22 | connectionState: isSyncing(state) ? 'syncing' : 'connected', 23 | isPremium: hasPremium(state), 24 | loaded: isLoaded(state), 25 | }); 26 | 27 | function Status({ closePopup }: StatusProps) { 28 | const [isOnline, setIsOnline] = useState(navigator.onLine); 29 | const { connectionState, isPremium, loaded } = useSelector(selectors); 30 | 31 | useEffect(() => { 32 | closePopup(); 33 | 34 | const goOnline = () => setIsOnline(true); 35 | const goOffline = () => setIsOnline(false); 36 | 37 | window.addEventListener('online', goOnline); 38 | window.addEventListener('offline', goOffline); 39 | 40 | return () => { 41 | window.removeEventListener('online', goOnline); 42 | window.removeEventListener('offline', goOffline); 43 | }; 44 | }, [closePopup]); 45 | 46 | const status: ConnectionStates = isOnline ? connectionState : 'offline'; 47 | 48 | return ( 49 |
50 | {!loaded && 'connecting'} 51 | {isPremium && (isOnline ? 'connected' : 'offline')} 52 | {loaded && !isPremium && ( 53 | <> 54 | subscribe to sync 55 | 56 | 57 | )} 58 |
59 | ); 60 | } 61 | 62 | export default Status; 63 | -------------------------------------------------------------------------------- /src/sections/Account/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React, { lazy, Suspense } from 'react'; 4 | 5 | import Loading from 'components/Loading'; 6 | 7 | const Account = lazy(() => import('./Account.js')); 8 | 9 | function SettingsPage() { 10 | return ( 11 | }> 12 | 13 | 14 | ); 15 | } 16 | 17 | export default SettingsPage; 18 | -------------------------------------------------------------------------------- /src/sections/Account/reducers.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { create } from 'register/reducer'; 4 | 5 | function jwt(state: string | null = null, action) { 6 | switch (action.type) { 7 | case 'LOG_OUT': 8 | return null; 9 | case 'ACCOUNT_UPDATE_TOKEN': 10 | case 'ACCOUNT_REGISTER': 11 | case 'ACCOUNT_LOGIN': 12 | return action.token; 13 | default: 14 | return state; 15 | } 16 | } 17 | 18 | function isPremium(state: boolean = false, action) { 19 | switch (action.type) { 20 | case 'LOG_OUT': 21 | case 'APP_INIT': 22 | return false; 23 | case 'ACCOUNT_RECEIVE_INFORMATION': 24 | return action.information.capabilities.indexOf('premium') > -1; 25 | default: 26 | return state; 27 | } 28 | } 29 | 30 | function isLoaded(state: boolean = false, action) { 31 | switch (action.type) { 32 | case 'ACCOUNT_CHECKED': 33 | case 'ACCOUNT_RECEIVE_INFORMATION': 34 | return true; 35 | case 'ACCOUNT_FETCH_INFORMATION': 36 | return false; 37 | default: 38 | return state; 39 | } 40 | } 41 | 42 | export default () => create('account', { 43 | jwt, 44 | isLoaded, 45 | isPremium, 46 | }); 47 | -------------------------------------------------------------------------------- /src/sections/Account/screens/Premium/Premium.css: -------------------------------------------------------------------------------- 1 | .Premium--Submit { 2 | display: flex; 3 | justify-content: space-between; 4 | align-items: center; 5 | margin-top: 2em; 6 | } 7 | 8 | .Premium--Submit .ui.basic.button, 9 | .Premium--Submit .ui.basic.button:hover { 10 | font-size: 1.14285714rem; 11 | box-shadow: none !important; 12 | margin-left: 5px; 13 | } 14 | 15 | .Premium--Submit .ui.basic.button:hover { 16 | text-decoration: underline; 17 | } 18 | 19 | .StripeContainer { 20 | margin-top: 1em; 21 | } 22 | 23 | .StripeContainer .StripeElement { 24 | border: 1px solid rgba(34, 36, 38, 0.15); 25 | padding: 0.67857143em 1em; 26 | border-radius: 0.28571429rem; 27 | } 28 | 29 | .CenterButtons { 30 | display: flex; 31 | align-items: center; 32 | justify-content: center; 33 | margin: 3em; 34 | } 35 | 36 | .CenterButtons .ui.button { 37 | margin: 0 1em; 38 | } 39 | -------------------------------------------------------------------------------- /src/sections/Account/screens/Premium/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React from 'react'; 4 | import { useSelector } from 'react-redux'; 5 | 6 | import Loading from 'components/Loading'; 7 | 8 | import { hasPremium, isLoggedIn, isLoaded } from '../../selectors'; 9 | 10 | import Subscribe from './Subscribe'; 11 | import SignUp from './SignUp'; 12 | import Completed from './Completed'; 13 | 14 | import './Premium.css'; 15 | 16 | const selectors = (state) => ({ 17 | isPremium: hasPremium(state), 18 | loggedIn: isLoggedIn(state), 19 | loadingDone: isLoaded(state), 20 | }); 21 | 22 | function Premium() { 23 | const { loadingDone, isPremium, loggedIn } = useSelector(selectors); 24 | 25 | if (loggedIn && !loadingDone) { 26 | return ; 27 | } 28 | 29 | if (isPremium) { 30 | return ; 31 | } 32 | 33 | if (loggedIn) { 34 | return ; 35 | } 36 | 37 | return ; 38 | } 39 | 40 | export default Premium; 41 | -------------------------------------------------------------------------------- /src/sections/Account/screens/Settings/Subscription.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React from 'react'; 4 | import { useSelector } from 'react-redux'; 5 | 6 | import Message from 'semantic-ui-react/dist/commonjs/collections/Message'; 7 | 8 | import Loading from 'components/Loading'; 9 | import Button from 'components/BuySubscription/Button'; 10 | 11 | import { hasPremium, isLoaded } from '../../selectors'; 12 | 13 | import ListSubscription from '../../components/ListSubscription'; 14 | 15 | const selectors = (state) => ({ 16 | isPremium: hasPremium(state), 17 | isLoading: !isLoaded(state), 18 | }); 19 | 20 | function Subscription() { 21 | const { isPremium, isLoading } = useSelector(selectors); 22 | 23 | if (isLoading) { 24 | return ( 25 | 26 | ); 27 | } 28 | 29 | if (!isPremium) { 30 | return ( 31 |
32 | 33 |

34 | You do not have an active subscription to Thyme. Buy a subscription now and enjoy 35 | premium features. 36 |

37 |

38 | Read more about 39 | Thyme Premium 40 | on the website. 41 |

42 |
43 |
45 | ); 46 | } 47 | 48 | return ; 49 | } 50 | 51 | export default Subscription; 52 | -------------------------------------------------------------------------------- /src/sections/Account/screens/Settings/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React from 'react'; 4 | 5 | import Header from 'semantic-ui-react/dist/commonjs/elements/Header'; 6 | import Segment from 'semantic-ui-react/dist/commonjs/elements/Segment'; 7 | import Container from 'semantic-ui-react/dist/commonjs/elements/Container'; 8 | 9 | import { useTrackPageview } from 'core/analytics'; 10 | 11 | import Account from './Account'; 12 | import Subscription from './Subscription'; 13 | 14 | function SettingsPage() { 15 | useTrackPageview('Account Settings'); 16 | 17 | return ( 18 | 19 |
20 | Account Settings 21 |
22 | 23 |
24 | Change Password 25 |
26 | 27 |
28 | 29 |
30 | Subscription Status 31 |
32 | 33 |
34 |
35 | ); 36 | } 37 | 38 | export default SettingsPage; 39 | -------------------------------------------------------------------------------- /src/sections/Account/selectors.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { createSelector } from 'reselect'; 4 | 5 | export const getJwt = (state: StateShape) => state.account.jwt; 6 | export const isLoaded = (state: StateShape) => state.account.isLoaded; 7 | export const isPremium = (state: StateShape) => state.account.isPremium; 8 | 9 | export const isLoggedIn = createSelector(getJwt, (jwt) => !!jwt); 10 | export const hasPremium = createSelector( 11 | [isLoaded, isPremium], 12 | (loaded, premium) => loaded && premium, 13 | ); 14 | -------------------------------------------------------------------------------- /src/sections/Projects/actions.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import shortid from 'shortid'; 4 | 5 | export function addProject( 6 | entry: { colour: ProjectColour | null, parent: string | null, name: string }, 7 | ) { 8 | return { 9 | type: 'ADD_PROJECT', 10 | id: shortid.generate(), 11 | ...entry, 12 | }; 13 | } 14 | 15 | export function updateProject(entry: ProjectProps) { 16 | return { 17 | type: 'UPDATE_PROJECT', 18 | ...entry, 19 | }; 20 | } 21 | 22 | export function removeProject(id: string) { 23 | return { 24 | type: 'REMOVE_PROJECT', 25 | id, 26 | }; 27 | } 28 | 29 | export function archiveProject(id: string) { 30 | return { 31 | type: 'ARCHIVE_PROJECT', 32 | id, 33 | }; 34 | } 35 | 36 | export function truncateProjects() { 37 | return { type: 'TRUNCATE_PROJECTS' }; 38 | } 39 | -------------------------------------------------------------------------------- /src/sections/Projects/colours.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | export const colourMap: { [key: ProjectColour]: string } = { 4 | red: '#db2828', 5 | blue: '#2185d0', 6 | yellow: '#fbbd08', 7 | green: '#21ba45', 8 | pink: '#e03997', 9 | teal: '#00b5ad', 10 | olive: '#b5cc18', 11 | orange: '#f2711c', 12 | purple: '#a333c8', 13 | brown: '#a5673f', 14 | violet: '#6435c9', 15 | black: '#1b1c1d', 16 | grey: '#767676', 17 | neutral: '#e8e8e8', 18 | }; 19 | 20 | export const colours: ProjectColour[] = [ 21 | 'red', 22 | 'blue', 23 | 'yellow', 24 | 'green', 25 | 'pink', 26 | 'teal', 27 | 'olive', 28 | 'orange', 29 | 'purple', 30 | 'brown', 31 | 'violet', 32 | 'black', 33 | 'grey', 34 | ]; 35 | 36 | export const coloursSorted: ProjectColour[] = [ 37 | 'red', 38 | 'orange', 39 | 'yellow', 40 | 'olive', 41 | 'green', 42 | 'teal', 43 | 'blue', 44 | 'violet', 45 | 'purple', 46 | 'pink', 47 | 'brown', 48 | 'grey', 49 | 'black', 50 | 'neutral', 51 | ]; 52 | 53 | export const defaultColour = 'neutral'; 54 | 55 | export function colourValue(colour: ProjectColour | null) { 56 | return colour === 'neutral' ? null : colour; 57 | } 58 | 59 | export function projectColour(project: ProjectType, index?: number): string { 60 | if (typeof index !== 'undefined') { 61 | return colourMap[project.colour || colours[index % colours.length]]; 62 | } 63 | 64 | return colourMap[project.colour || defaultColour]; 65 | } 66 | -------------------------------------------------------------------------------- /src/sections/Projects/components/ProjectColourPicker/ProjectColourPicker.css: -------------------------------------------------------------------------------- 1 | .ProjectColourPicker.ui.compact.button { 2 | display: flex; 3 | flex-direction: row; 4 | flex-wrap: nowrap; 5 | padding: 6px; 6 | align-items: center; 7 | } 8 | 9 | .ProjectColourPicker .ui.label { 10 | padding: 0; 11 | width: 26px; 12 | height: 26px; 13 | } 14 | 15 | .ProjectColourPicker.ui.button:not(.icon) > .icon:not(.button):not(.dropdown) { 16 | margin: 0; 17 | } 18 | 19 | .ProjectColourPicker__Popup .ui.button { 20 | width: 42px; 21 | height: 42px; 22 | padding: 0; 23 | margin: 2px; 24 | border: 2px solid transparent; 25 | } 26 | 27 | .ProjectColourPicker__Popup .ui.button.selected { 28 | border-color: #000; 29 | } 30 | 31 | .ProjectColourPicker__Popup .content { 32 | display: flex; 33 | flex-wrap: wrap; 34 | width: 230px; 35 | } 36 | -------------------------------------------------------------------------------- /src/sections/Projects/components/ProjectColourPicker/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React, { useState } from 'react'; 4 | 5 | import Button from 'semantic-ui-react/dist/commonjs/elements/Button'; 6 | import Label from 'semantic-ui-react/dist/commonjs/elements/Label'; 7 | import Icon from 'semantic-ui-react/dist/commonjs/elements/Icon'; 8 | import Popup from 'semantic-ui-react/dist/commonjs/modules/Popup'; 9 | 10 | import { coloursSorted, colourValue } from '../../colours'; 11 | 12 | import './ProjectColourPicker.css'; 13 | 14 | type ProjectColourPickerProps = { 15 | colour: ProjectColour; 16 | onChange: (colour: ProjectColour) => void; 17 | }; 18 | 19 | function ProjectColourPicker({ colour, onChange }: ProjectColourPickerProps) { 20 | const [isOpened, setOpened] = useState(false); 21 | 22 | return ( 23 | setOpened(false)} 27 | onOpen={() => setOpened(true)} 28 | trigger={( 29 | 33 | )} 34 | position="bottom left" 35 | on="click" 36 | content={coloursSorted.map((c) => ( 37 |