diff --git a/package.json b/package.json index 44fa99c..3ada377 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "react": "^16.8.0", "react-dom": "^16.8.0", "react-select": "^2.3.0", + "react-spring": "^8.0.7", "route-parser": "^0.0.5" }, "devDependencies": { diff --git a/src/css/_form.scss b/src/css/_form.scss index 4cd5a05..6c42f16 100644 --- a/src/css/_form.scss +++ b/src/css/_form.scss @@ -66,7 +66,7 @@ input[name="hours"] { textarea[name="description"] { resize: none; - width: calc(100% - 1rem); + width: calc(100% - 1rem - 2px); } button { diff --git a/src/css/content.scss b/src/css/content.scss index 8678241..fcf5dda 100644 --- a/src/css/content.scss +++ b/src/css/content.scss @@ -60,8 +60,8 @@ box-sizing: content-box; background-color: white; width: 600px; - height: 400px; - padding: 2rem; + height: 540px; + padding: 3rem; margin: 0 auto; } } diff --git a/src/css/popup.scss b/src/css/popup.scss index e85df2f..6c32ff4 100644 --- a/src/css/popup.scss +++ b/src/css/popup.scss @@ -5,6 +5,7 @@ html { height: 100%; body { + margin: 0; height: 100%; #moco-bx-root { @@ -27,6 +28,54 @@ html { margin: 0; } } + + .moco-bx-calendar { + display: flex; + justify-content: space-between; + margin-bottom: 3rem; + + .moco-bx-calendar__day { + display: flex; + flex-flow: column nowrap; + align-items: center; + + flex: 0 0 48px; + width: 48px; + height: 60px; + cursor: pointer; + + .moco-bx-calendar__hours { + display: flex; + justify-content: center; + align-items: center; + width: 48px; + height: 48px; + flex: 0 0 48px; + color: white; + background-color: #eee; + + } + + &.moco-bx-calendar__day--filled { + .moco-bx-calendar__hours { + background-color: #7dc332; + } + } + + &.moco-bx-calendar__day--week-day-6, + &.moco-bx-calendar__day--week-day-0 { + .moco-bx-calendar__hours { + background-color: #bbb; + } + } + + &.moco-bx-calendar__day--active { + .moco-bx-calendar__hours { + background-color: #38b5eb; + } + } + } + } } } } diff --git a/src/js/api/Client.js b/src/js/api/Client.js index 87125dd..ca9e076 100644 --- a/src/js/api/Client.js +++ b/src/js/api/Client.js @@ -1,4 +1,5 @@ import axios from "axios" +import { formatDate } from "utils" export default class Client { #client; @@ -20,14 +21,17 @@ export default class Client { }) } - login = () => this.#client.post("session", { api_key: this.#apiKey }); - projects = () => this.#client.get("projects"); + activities = (fromDate, toDate) => + this.#client.get("activities", { + params: { date: `${formatDate(fromDate)}:${formatDate(toDate)}` } + }) + bookedHours = service => this.#client.get("activities/tags", { params: { selection: [service.id], remote_service: service.name } - }); + }) createActivity = activity => this.#client.post("activities", { activity }); } diff --git a/src/js/components/App.js b/src/js/components/App.js index 9ada49e..28d2d16 100644 --- a/src/js/components/App.js +++ b/src/js/components/App.js @@ -2,17 +2,19 @@ import React, { Component } from "react" import PropTypes from "prop-types" import ApiClient from "api/Client" import Form from "components/Form" +import Calendar from "components/Calendar" import Spinner from "components/Spinner" -import { observable, computed, toJS } from "mobx" -import { observer, disposeOnUnmount } from "mobx-react" +import { observable, computed } from "mobx" +import { observer } from "mobx-react" import { findLastProject, findLastTask, groupedProjectOptions, - currentDate, + formatDate, secondsFromHours } from "utils" -import logoUrl from 'images/logo.png' +import { startOfWeek, endOfWeek } from "date-fns" +import logoUrl from "images/logo.png" import { head } from "lodash" @observer @@ -31,13 +33,14 @@ class App extends Component { apiKey: PropTypes.string, version: PropTypes.string }) - }; + } @observable projects = [] + @observable activities = [] @observable lastProjectId @observable lastTaskId - @observable changeset = {}; - @observable formErrors = {}; + @observable changeset = {} + @observable formErrors = {} @observable isLoading = true @computed get changesetWithDefaults() { @@ -55,7 +58,7 @@ class App extends Component { remote_service: service.name, remote_id: service.id, remote_url: service.url, - date: currentDate(), + date: formatDate(new Date()), assignment_id: project?.value, task_id: task?.value, billable: task?.billable, @@ -78,7 +81,8 @@ class App extends Component { } componentDidMount() { - this.fetchProjects() + Promise.all([this.fetchProjects(), this.fetchActivities()]) + .then(() => this.isLoading = false) window.addEventListener("keydown", this.handleKeyDown) } @@ -90,23 +94,31 @@ class App extends Component { this.#apiClient = new ApiClient(settings) } - fetchProjects = () => { - this.isLoading = true + fromDate = () => startOfWeek(new Date(), { weekStartsOn: 1 }) + toDate = () => endOfWeek(new Date(), { weekStartsOn: 1 }) - return this.#apiClient + fetchProjects = () => + this.#apiClient .projects() .then(({ data }) => { this.projects = groupedProjectOptions(data.projects) this.lastProjectId = data.last_project_id this.lastTaskId = data.lastTaskId }) - .catch(error => { - this.sendMessage({ type: 'closeForm' }) + .catch(() => { + this.sendMessage({ type: "closeForm" }) }) - .finally(() => { - this.isLoading = false + + fetchActivities = () => { + return this.#apiClient + .activities(this.fromDate(), this.toDate()) + .then(({ data }) => { + this.activities = data }) - }; + .catch(() => { + this.sendMessage({ type: "closeForm" }) + }) + } createActivity = () => { this.isLoading = true @@ -116,9 +128,10 @@ class App extends Component { .then(({ data }) => { this.changeset = {} this.formErrors = {} - this.sendMessage( - { type: 'activityCreated', payload: { hours: data.hours} } - ) + this.sendMessage({ + type: "activityCreated", + payload: { hours: data.hours } + }) }) .catch(error => { if (error.response?.status === 422) { @@ -128,15 +141,15 @@ class App extends Component { this.unauthorizedError = true } }) - .finally(() => this.isLoading = false) + .finally(() => (this.isLoading = false)) } handleKeyDown = event => { event.stopPropagation() if (event.keyCode === 27) { - this.sendMessage({ type: 'closeForm' }) + this.sendMessage({ type: "closeForm" }) } - }; + } handleChange = event => { const { @@ -148,7 +161,11 @@ class App extends Component { if (name === "assignment_id") { this.changeset.task_id = null } - }; + } + + handleSelectDate = date => { + this.changeset.date = formatDate(date) + } handleSubmit = event => { event.preventDefault() @@ -156,13 +173,12 @@ class App extends Component { } handleCancel = () => { - this.sendMessage({ type: 'closeForm' }) + this.sendMessage({ type: "closeForm" }) } sendMessage = action => - chrome.tabs.query( - { active: true, currentWindow: true }, - tabs => chrome.tabs.sendMessage(tabs[0].id, action) + chrome.tabs.query({ active: true, currentWindow: true }, tabs => + chrome.tabs.sendMessage(tabs[0].id, action) ) render() { @@ -170,15 +186,23 @@ class App extends Component { return } - const { service } = this.props; - return ( <>
- +

MOCO Zeiterfassung

+
{ this.#apiClient = new ApiClient(settings) } - + open = event => { if (event && event.target && event.target.classList.contains('moco-bx-popup')) { return this.close() @@ -139,20 +130,30 @@ class Bubble extends Component { const { service, settings } = this.props return ( -
- - {this.bookedHours > 0 - ? {this.bookedHours}h - : null - } + <> + + {props => ( + + + {this.bookedHours > 0 + ? {this.bookedHours}h + : null + } + + )} + {this.isOpen && ( )} -
+ ) } } diff --git a/src/js/components/Calendar/Day.js b/src/js/components/Calendar/Day.js new file mode 100644 index 0000000..f65b077 --- /dev/null +++ b/src/js/components/Calendar/Day.js @@ -0,0 +1,37 @@ +import React, { useCallback } from 'react' +import PropTypes from 'prop-types' +import { format, getDay } from 'date-fns' +import deLocale from 'date-fns/locale/de' +import cn from 'classnames' + +const Day = ({ date, hours, active, onClick }) => { + const handleClick = useCallback(() => onClick(date), [date]) + + return ( +
0 + } + )} + onClick={handleClick} + > + + {format(date, 'dd', { locale: deLocale })} + + {hours > 0 ? hours : null} +
+ ) +} + +Day.propTypes = { + date: PropTypes.instanceOf(Date).isRequired, + hours: PropTypes.number.isRequired, + active: PropTypes.bool.isRequired, + onClick: PropTypes.func.isRequired +} + +export default Day diff --git a/src/js/components/Calendar/index.js b/src/js/components/Calendar/index.js new file mode 100644 index 0000000..e5fcf14 --- /dev/null +++ b/src/js/components/Calendar/index.js @@ -0,0 +1,46 @@ +import React from 'react' +import PropTypes from 'prop-types' +import Day from './Day' +import { formatDate, trace } from 'utils' +import { eachDay } from 'date-fns' +import { pathEq } from 'lodash/fp' + +const hoursInDate = activities => date => + activities + .filter(pathEq('date', formatDate(date))) + .reduce((acc, activity) => acc + activity.hours, 0) + +const Calendar = ({ fromDate, toDate, selectedDate, activities, onChange }) => { + const getHours = hoursInDate(activities) + + return ( +
+ {eachDay(fromDate, toDate).map(date => ( + + ))} +
+ ) +} + +Calendar.propTypes = { + fromDate: PropTypes.instanceOf(Date).isRequired, + toDate: PropTypes.instanceOf(Date).isRequired, + selectedDate: PropTypes.instanceOf(Date).isRequired, + activities: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.number.isRequired, + date: PropTypes.string.isRequired, + hours: PropTypes.number.isRequired, + timer_started_at: PropTypes.string + }).isRequired + ), + onChange: PropTypes.func.isRequired +} + +export default Calendar diff --git a/src/js/components/Popup.js b/src/js/components/Popup.js index 6773946..d3724d8 100644 --- a/src/js/components/Popup.js +++ b/src/js/components/Popup.js @@ -9,7 +9,7 @@ const Popup = props => { const styles = useMemo(() => ({ width: '536px', - height: props.unauthorizedError ? '890px' : '400px' + height: props.unauthorizedError ? '890px' : '470px' }), [props.unauthorizedError]) return ( diff --git a/src/js/utils/index.js b/src/js/utils/index.js index 4a561f2..26b7d8a 100644 --- a/src/js/utils/index.js +++ b/src/js/utils/index.js @@ -73,8 +73,8 @@ export const trace = curry((tag, value) => { return value }) -export const currentDate = (locale = 'de') => - format(new Date(), 'YYYY-MM-DD', { locale }) +export const formatDate = date => + format(date, 'YYYY-MM-DD') export const extensionSettingsUrl = () => `chrome://extensions/?id=${chrome.runtime.id}` diff --git a/yarn.lock b/yarn.lock index 1ddb8f8..b4db853 100644 --- a/yarn.lock +++ b/yarn.lock @@ -667,7 +667,7 @@ "@babel/plugin-transform-react-jsx-self" "^7.0.0" "@babel/plugin-transform-react-jsx-source" "^7.0.0" -"@babel/runtime@^7.1.2": +"@babel/runtime@^7.1.2", "@babel/runtime@^7.3.1": version "7.3.1" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.3.1.tgz#574b03e8e8a9898eaf4a872a92ea20b7846f6f2a" integrity sha512-7jGW8ppV0ant637pIqAcFfQDDH1orEPGJb8aXfUozuCU3QqX7rX4DA8iwrbPrR1hcH0FTTHz47yQnk+bl5xHQA== @@ -5983,6 +5983,13 @@ react-select@^2.3.0: react-input-autosize "^2.2.1" react-transition-group "^2.2.1" +react-spring@^8.0.7: + version "8.0.7" + resolved "https://registry.yarnpkg.com/react-spring/-/react-spring-8.0.7.tgz#55b1d298be16b308388cb865ee707a1194112fe0" + integrity sha512-QHEjWLvEuRCGFRlR29o03CMc+uN1QMPt8Gxxp37szpnkJUq+HDIWNXNCacQ+kJxqzcu65JoeWV6D5q3/y5VK6Q== + dependencies: + "@babel/runtime" "^7.3.1" + react-transition-group@^2.2.1: version "2.5.3" resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-2.5.3.tgz#26de363cab19e5c88ae5dbae105c706cf953bb92"