qw/timer (#23)

* Rename logo and add 32x32 version

* Set timer icon if a timer is running

* Do not query activities on initialization

* Show timer in bubble if timed activity exists

* Pass timed activity to App

* Code cleanup

* Show timer view and stop timer

* Make hours optional

* Use booked seconds instead of hours

* Add type submit to form button

* Define colors as sass variables⎄

* Style timer view

* Show start timer submit label

* Update view layouts and content

* Update version and changelog

* Dyanically set iframe height

* Reduce h1 font size

* Add svg webpack loader

* Parse empty string (TimeInputParser)

* Forward ref in Popup component

* Start time on current day only, format buttons

* Improve styling

* Set standard height as iframe default height, validate form

* Upgrade packages to supress react warning

* Show activity form in popup after timer was stoped

* Use stop-watch icon in timer view

* Fix empty description

* Close TimerView if timer stopped for current service

* Style timerview

* Improve timer view styling

* qw/setting-time-tracking-hh-mm (#24)

* Format duration depending on settingTimeTrackingHHMM

* Fix formatDuation without second argument

* Fix time format after updating bubble

* Add tests for formatDuration
This commit is contained in:
Manuel Bouza
2019-10-10 14:57:01 +02:00
committed by GitHub
parent 7023b4b482
commit 72626a6c42
38 changed files with 788 additions and 437 deletions

View File

@@ -7,17 +7,21 @@ button.moco-bx-btn {
white-space: nowrap;
color: white;
background-image: none;
background-color: #7dc332;
border-color: #7dc332;
background-color: $green;
border-color: $green;
border-radius: 0;
border-style: solid;
box-shadow: none;
font-size: 100%;
cursor: pointer;
&:focus {
outline: none;
}
&:hover:not(:disabled) {
background-color: #639a28;
border-color: #639a28;
background-color: $green-dark;
border-color: $green-dark;
}
&:disabled {

View File

@@ -1,3 +1,4 @@
@import "variables";
@import "button";
input {
@@ -15,7 +16,8 @@ input {
margin-bottom: 0.25rem;
}
input, textarea {
input,
textarea {
padding: 6px 12px;
background-color: white;
border-color: #cccccc;
@@ -31,13 +33,14 @@ input {
}
&.has-error {
input, textarea {
border-color: #FB3A2F;
input,
textarea {
border-color: $red;
}
}
.form-error {
color: #FB3A2F;
color: $red;
}
.input-group {
@@ -71,8 +74,8 @@ input[name="hours"] {
outline: 0 !important;
&:focus {
border: 1px solid #38b5eb;
box-shadow: 0 0 0 1px #38b5eb;
border: 1px solid $blue;
box-shadow: 0 0 0 1px $blue;
}
}
@@ -84,8 +87,7 @@ textarea[name="description"] {
outline: 0 !important;
&:focus {
border: 1px solid #38b5eb;
box-shadow: 0 0 0 1px #38b5eb;
border: 1px solid $blue;
box-shadow: 0 0 0 1px $blue;
}
}

View File

@@ -1,5 +1,10 @@
$font-family: Arial, sans-serif;
$font-family: Roboto, Arial, sans-serif;
$font-color: #191919;
$popup-width: 420px;
$popup-height: 463px;
$green: #7dc332;
$green-dark: #639a28;
$blue: #38b5eb;
$red: #fb3a2f;
$gray-base: #a3a3a3;

View File

@@ -6,6 +6,10 @@
color: $font-color;
pointer-events: all;
.text-red {
color: $red;
}
.moco-bx-bubble {
box-sizing: content-box;
position: fixed;

View File

@@ -32,11 +32,11 @@
}
.text-success {
color: #7DC332;
color: $green;
}
.text-danger {
color: #FB3A2F;
color: $red;
}
}
}

View File

@@ -1,6 +1,6 @@
@import "variables";
@import "form";
@import "spinner";
@import "variables";
html {
overflow: hidden;
@@ -14,33 +14,48 @@ html {
#moco-bx-root {
min-width: 516px;
h1 {
font-size: 24px;
font-weight: normal;
line-height: 1.5;
margin-top: 1rem;
margin-bottom: 3rem;
}
h2 {
font-size: 20px;
font-weight: normal;
line-height: 1.5;
margin-top: 1rem;
margin-bottom: 3rem;
}
.text-red {
color: $red;
}
.text-secondary {
color: $gray-base;
}
.moco-bx-app-container {
width: 324px;
padding: 3rem 6rem;
.moco-bx-logo__container {
display: flex;
justify-content: center;
margin-bottom: 3rem;
text-align: center;
img.moco-bx-logo {
flex: 0 0 48px;
width: 48px;
height: 48px;
}
h1 {
line-height: 48px;
margin: 0;
}
}
.moco-bx-calendar {
display: flex;
justify-content: space-between;
margin-bottom: 3rem;
margin-bottom: 2rem;
.moco-bx-calendar__day {
display: flex;
@@ -66,12 +81,11 @@ html {
flex: 0 0 42px;
color: white;
background-color: #eee;
}
&.moco-bx-calendar__day--filled {
.moco-bx-calendar__hours {
background-color: #7dc332;
background-color: $green;
}
}
@@ -84,43 +98,73 @@ html {
&.moco-bx-calendar__day--active {
.moco-bx-calendar__hours {
background-color: #38b5eb;
background-color: $blue;
}
}
}
}
.moco-bx-timer-view {
text-align: center;
margin-top: 3rem;
h2 {
margin-top: 2rem;
max-height: 90px;
overflow: hidden;
}
p {
margin-top: 1rem;
margin-bottom: 1rem;
line-height: 1.2rem;
}
span.moco-bx-single-line {
display: inline-block;
max-height: 19px;
overflow: hidden;
}
.timer {
margin-top: 2rem;
}
.btn-stop-timer {
margin-top: 1.5rem;
background-color: $red;
border: none;
border-radius: 50%;
width: 60px;
height: 60px;
}
}
}
.moco-bx-error-container {
font-size: 18px;
line-height: 1.5;
width: 420px;
padding: 3rem;
text-align: center;
h1 {
font-size: 35px;
font-weight: normal;
margin-top: 0;
line-height: 1.3;
}
img {
width: auto;
max-width: 100%;
margin-top: 1.5rem;
margin-bottom: 2rem;
&.moco-bx-logo {
width: 48px;
margin-bottom: 2rem;
}
}
ol {
text-align: left;
&.firefox-addons {
margin-top: 0;
width: auto;
}
}
button {
margin-top: 1.5rem;
margin-bottom: 1.5rem;
}
}
}

View File

@@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fal" data-icon="stopwatch" class="svg-inline--fa fa-stopwatch fa-w-14" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M393.3 141.3l17.5-17.5c4.7-4.7 4.7-12.3 0-17l-5.7-5.7c-4.7-4.7-12.3-4.7-17 0l-17.5 17.5c-35.8-31-81.5-50.9-131.7-54.2V32h25c6.6 0 12-5.4 12-12v-8c0-6.6-5.4-12-12-12h-80c-6.6 0-12 5.4-12 12v8c0 6.6 5.4 12 12 12h23v32.6C91.2 73.3 0 170 0 288c0 123.7 100.3 224 224 224s224-100.3 224-224c0-56.1-20.6-107.4-54.7-146.7zM224 480c-106.1 0-192-85.9-192-192S117.9 96 224 96s192 85.9 192 192-85.9 192-192 192zm4-128h-8c-6.6 0-12-5.4-12-12V172c0-6.6 5.4-12 12-12h8c6.6 0 12 5.4 12 12v168c0 6.6-5.4 12-12 12z"></path></svg>

After

Width:  |  Height:  |  Size: 733 B

View File

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

BIN
src/images/moco-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -46,14 +46,16 @@ export default class Client {
params: { date: `${formatDate(fromDate)}:${formatDate(toDate)}` },
})
bookedHours = service => {
activitiesStatus = service => {
if (!service) {
return Promise.resolve({ data: { hours: 0 } })
}
return this.#client.get("activities/tags", {
params: { selection: [service.id], remote_service: service.name },
return this.#client.get("activities/status", {
params: { remote_id: service.id, remote_service: service.name },
})
}
createActivity = activity => this.#client.post("activities", { activity })
stopTimer = timedActivity => this.#client.get(`activities/${timedActivity.id}/stop_timer`)
}

View File

@@ -2,10 +2,39 @@ import "@babel/polyfill"
import ApiClient from "api/Client"
import { isChrome, getCurrentTab, getSettings, isBrowserTab } from "utils/browser"
import { BackgroundMessenger } from "utils/messaging"
import { tabUpdated, settingsChanged, togglePopup } from "utils/messageHandlers"
import { tabUpdated, settingsChanged, togglePopup, openPopup } from "utils/messageHandlers"
import { isNil } from "lodash"
const messenger = new BackgroundMessenger()
function timerStoppedForCurrentService(service, timedActivity) {
return timedActivity.service_id && timedActivity.service_id === service?.id
}
function resetBubble({ tab, settings, service, timedActivity }) {
const apiClient = new ApiClient(settings)
apiClient
.activitiesStatus(service)
.then(({ data }) => {
messenger.postMessage(tab, {
type: "showBubble",
payload: {
bookedSeconds: data.seconds,
timedActivity: data.timed_activity,
settingTimeTrackingHHMM: settings.settingTimeTrackingHHMM,
service,
},
})
})
.then(() => {
if (isNil(timedActivity) || timerStoppedForCurrentService(service, timedActivity)) {
messenger.postMessage(tab, { type: "closePopup" })
} else {
openPopup(tab, { service, messenger })
}
})
}
messenger.on("togglePopup", () => {
getCurrentTab().then(tab => {
if (tab && !isBrowserTab(tab)) {
@@ -31,18 +60,7 @@ chrome.runtime.onMessage.addListener(action => {
const apiClient = new ApiClient(settings)
apiClient
.createActivity(activity)
.then(() => {
messenger.postMessage(tab, { type: "closePopup" })
apiClient.bookedHours(service).then(({ data }) => {
messenger.postMessage(tab, {
type: "showBubble",
payload: {
bookedHours: parseFloat(data[0]?.hours) || 0,
service,
},
})
})
})
.then(() => resetBubble({ tab, settings, service }))
.catch(error => {
if (error.response?.status === 422) {
chrome.runtime.sendMessage({
@@ -55,6 +73,19 @@ chrome.runtime.onMessage.addListener(action => {
})
}
if (action.type === "stopTimer") {
const { timedActivity, service } = action.payload
getCurrentTab().then(tab => {
getSettings().then(settings => {
const apiClient = new ApiClient(settings)
apiClient
.stopTimer(timedActivity)
.then(() => resetBubble({ tab, settings, service, timedActivity }))
.catch(() => null)
})
})
}
if (action.type === "openOptions") {
let url
if (isChrome()) {

View File

@@ -3,6 +3,7 @@ import PropTypes from "prop-types"
import Spinner from "components/Spinner"
import Form from "components/Form"
import Calendar from "components/Calendar"
import TimerView from "components/App/TimerView"
import { observable, computed } from "mobx"
import { Observer, observer } from "mobx-react"
import { Spring, animated, config } from "react-spring/renderprops"
@@ -41,9 +42,15 @@ class App extends Component {
activities: PropTypes.array,
schedules: PropTypes.array,
projects: PropTypes.array,
timedActivity: PropTypes.shape({
customer_name: PropTypes.string.isRequired,
assignment_name: PropTypes.string.isRequired,
task_name: PropTypes.string.isRequired,
timer_started_at: PropTypes.string.isRequired,
seconds: PropTypes.number.isRequired,
}),
lastProjectId: PropTypes.number,
lastTaskId: PropTypes.number,
roundTimeEntries: PropTypes.bool,
fromDate: PropTypes.string,
toDate: PropTypes.string,
errorType: PropTypes.string,
@@ -54,7 +61,6 @@ class App extends Component {
activities: [],
schedules: [],
projects: [],
roundTimeEntries: false,
}
@observable changeset = {}
@@ -94,8 +100,8 @@ class App extends Component {
task_id: this.task?.value,
billable: this.billable,
hours: "",
seconds: this.changeset.hours && new TimeInputParser(this.changeset.hours).parseSeconds(),
description: service?.description,
seconds: new TimeInputParser(this.changeset.hours).parseSeconds(),
description: service?.description || "",
tag: "",
}
@@ -104,6 +110,7 @@ class App extends Component {
componentDidMount() {
window.addEventListener("keydown", this.handleKeyDown)
parent.postMessage({ __mocoBX: { iFrameHeight: window.document.body.scrollHeight } }, "*")
chrome.runtime.onMessage.addListener(this.handleSetFormErrors)
}
@@ -130,6 +137,15 @@ class App extends Component {
this.changeset.date = formatDate(date)
}
handleStopTimer = timedActivity => {
const { service } = this.props
chrome.runtime.sendMessage({
type: "stopTimer",
payload: { timedActivity, service },
})
}
handleSubmit = event => {
event.preventDefault()
const { service } = this.props
@@ -161,6 +177,7 @@ class App extends Component {
loading,
subdomain,
projects,
timedActivity,
activities,
schedules,
fromDate,
@@ -191,25 +208,29 @@ class App extends Component {
<animated.div className="moco-bx-app-container" style={props}>
<Header subdomain={subdomain} />
<Observer>
{() => (
<>
<Calendar
fromDate={parseISO(fromDate)}
toDate={parseISO(toDate)}
activities={activities}
schedules={schedules}
selectedDate={new Date(this.changesetWithDefaults.date)}
onChange={this.handleSelectDate}
/>
<Form
changeset={this.changesetWithDefaults}
projects={projects}
errors={this.formErrors}
onChange={this.handleChange}
onSubmit={this.handleSubmit}
/>
</>
)}
{() =>
timedActivity ? (
<TimerView timedActivity={timedActivity} onStopTimer={this.handleStopTimer} />
) : (
<>
<Calendar
fromDate={parseISO(fromDate)}
toDate={parseISO(toDate)}
activities={activities}
schedules={schedules}
selectedDate={new Date(this.changesetWithDefaults.date)}
onChange={this.handleSelectDate}
/>
<Form
changeset={this.changesetWithDefaults}
projects={projects}
errors={this.formErrors}
onChange={this.handleChange}
onSubmit={this.handleSubmit}
/>
</>
)
}
</Observer>
</animated.div>
)}

View File

@@ -0,0 +1,45 @@
import React from "react"
import PropTypes from "prop-types"
import Timer from "components/shared/Timer"
import { parseISO } from "date-fns"
import StopWatch from "components/shared/StopWatch"
export default function TimerView({ timedActivity, onStopTimer }) {
const handleStopTimer = () => {
onStopTimer(timedActivity)
}
return (
<div className="moco-bx-timer-view">
<p>
<span className="moco-bx-single-line text-secondary">{timedActivity.customer_name}</span>
<br />
<span className="moco-bx-single-line">{timedActivity.assignment_name}</span>
<br />
<span className="moco-bx-single-line">{timedActivity.task_name}</span>
</p>
<h2>{timedActivity.description}</h2>
<Timer
className="timer text-red"
startedAt={parseISO(timedActivity.timer_started_at)}
offset={timedActivity.seconds}
style={{ fontSize: "36px", display: "inline-block" }}
/>
<button className="moco-bx-btn btn-stop-timer" onClick={handleStopTimer}>
<StopWatch />
</button>
</div>
)
}
TimerView.propTypes = {
timedActivity: PropTypes.shape({
customer_name: PropTypes.string.isRequired,
assignment_name: PropTypes.string.isRequired,
task_name: PropTypes.string.isRequired,
description: PropTypes.string,
timer_started_at: PropTypes.string.isRequired,
seconds: PropTypes.number.isRequired,
}).isRequired,
onStopTimer: PropTypes.func.isRequired,
}

View File

@@ -1,22 +1,46 @@
import React from "react"
import PropTypes from "prop-types"
import logoUrl from "images/logo.png"
import mocoLogo from "images/moco-32x32.png"
import mocoTimerLogo from "images/moco-timer-32x32.png"
import { parseISO } from "date-fns"
import { formatDuration } from "utils"
import Timer from "./shared/Timer"
const Bubble = ({ bookedHours }) => (
<div className="moco-bx-bubble-inner">
<img className="moco-bx-logo" src={chrome.extension.getURL(logoUrl)} />
{bookedHours > 0 ? (
<span className="moco-bx-booked-hours">{bookedHours.toFixed(2)}</span>
) : null}
</div>
)
const Bubble = ({ bookedSeconds, timedActivity, settingTimeTrackingHHMM }) => {
const logo = timedActivity ? mocoTimerLogo : mocoLogo
return (
<div className="moco-bx-bubble-inner">
<img className="moco-bx-logo" src={chrome.extension.getURL(logo)} />
{!timedActivity && bookedSeconds > 0 && (
<span className="moco-bx-booked-hours">
{formatDuration(bookedSeconds, { settingTimeTrackingHHMM, showSeconds: false })}
</span>
)}
{timedActivity && (
<Timer
className="text-red"
startedAt={parseISO(timedActivity.timer_started_at)}
offset={timedActivity.seconds}
style={{ marginBottom: "3px", fontSize: "12px" }}
/>
)}
</div>
)
}
Bubble.propTypes = {
bookedHours: PropTypes.number,
bookedSeconds: PropTypes.number,
timedActivity: PropTypes.shape({
timer_started_at: PropTypes.string.isRequired,
seconds: PropTypes.number.isRequired,
}),
settingTimeTrackingHHMM: PropTypes.bool,
}
Bubble.defaultProps = {
bookedHours: 0,
bookedSeconds: 0,
settingTimeTrackingHHMM: false,
}
export default Bubble

View File

@@ -3,25 +3,23 @@ import settingsUrl from "images/settings.png"
const InvalidConfigurationError = () => (
<div className="moco-bx-error-container">
<h1>Bitte Einstellungen aktualisieren</h1>
<ol>
<li>Internetadresse eintragen</li>
<li>Persönlichen API-Schlüssel eintragen</li>
</ol>
<h1>MOCO verbinden</h1>
<p>
Dazu trägst Du in den Einstellungen Deine Account-Internetadresse und Deinen API-Schlüssel
ein.
</p>
<img
src={chrome.extension.getURL(settingsUrl)}
alt="Browser extension configuration settings"
style={{ cursor: "pointer", width: "185px", height: "195px" }}
onClick={() => chrome.runtime.sendMessage({ type: "openOptions" })}
/>
<button
className="moco-bx-btn"
onClick={() => chrome.runtime.sendMessage({ type: "openOptions" })}
>
Einstellungen öffnen
Weiter zu den Einstellungen
</button>
<br />
<br />
<img
src={chrome.extension.getURL(settingsUrl)}
alt="Browser extension configuration settings"
style={{ cursor: "pointer" }}
onClick={() => chrome.runtime.sendMessage({ type: "openOptions" })}
/>
</div>
)

View File

@@ -1,14 +1,17 @@
import React from "react"
import PropTypes from "prop-types"
import logo from "images/logo.png"
import logo from "images/moco-159x159.png"
const UnknownError = ({ message = "Unbekannter Fehler" }) => (
<div className="moco-bx-error-container">
<img className="moco-bx-logo" src={logo} alt="MOCO logo" />
<img
className="moco-bx-logo"
src={logo}
style={{ width: "48px", height: "48px" }}
alt="MOCO logo"
/>
<h1>Ups, es ist ein Fehler passiert!</h1>
<p>Bitte überprüfe deine Internetverbindung.</p>
<p>Wir wurden per Email benachrichtigt und untersuchen den Vorfall.</p>
<br />
<p>Fehlermeldung:</p>
<pre>{message}</pre>
</div>

View File

@@ -1,12 +1,17 @@
import React from "react"
import { isChrome } from "utils/browser"
import logo from "images/logo.png"
import logo from "images/moco-159x159.png"
import firefoxAddons from "images/firefox_addons.png"
const UpgradeRequiredError = () => (
<div className="moco-bx-error-container">
<img className="moco-bx-logo" src={logo} alt="MOCO logo" />
<h1>Upgrade erforderlich</h1>
<img
className="moco-bx-logo"
src={logo}
style={{ width: "48px", height: "48px" }}
alt="MOCO logo"
/>
<h1>Bitte aktualisieren</h1>
<p>Die installierte MOCO Browser-Erweiterung ist veraltet &mdash; bitte aktualisieren.</p>
{isChrome() ? (
<button
@@ -17,9 +22,13 @@ const UpgradeRequiredError = () => (
</button>
) : (
<>
<br />
<p>Unter folgender URL:</p>
<img className="firefox-addons" src={firefoxAddons} alt="about:addons" />
<img
className="firefox-addons"
src={firefoxAddons}
style={{ width: "292px", height: "40px" }}
alt="about:addons"
/>
</>
)}
</div>

View File

@@ -1,13 +1,22 @@
import React, { Component } from "react"
import PropTypes from "prop-types"
import Select from "components/Select"
import { formatDate } from "utils"
import cn from "classnames"
import StopWatch from "components/shared/StopWatch"
class Form extends Component {
static propTypes = {
changeset: PropTypes.shape({
project: PropTypes.object,
task: PropTypes.object,
assignment_id: PropTypes.number.isRequired,
billable: PropTypes.bool.isRequired,
date: PropTypes.string.isRequired,
task_id: PropTypes.number.isRequired,
description: PropTypes.string,
remote_id: PropTypes.string,
remote_service: PropTypes.string,
remote_url: PropTypes.string,
seconds: PropTypes.number,
hours: PropTypes.string,
}).isRequired,
errors: PropTypes.object,
@@ -20,9 +29,42 @@ class Form extends Component {
inline: true,
}
isValid = () => {
isValid() {
const { changeset } = this.props
return ["assignment_id", "task_id", "hours"].map(prop => changeset[prop]).every(Boolean)
return (
["assignment_id", "task_id"].map(prop => changeset[prop]).every(Boolean) &&
(changeset.date === formatDate(new Date()) || changeset.seconds > 0)
)
}
get isTimerStartable() {
const {
changeset: { seconds, date },
} = this.props
return date === formatDate(new Date()) && seconds === 0
}
buttonStyle() {
const styleMap = {
true: {
border: "none",
borderRadius: "50%",
width: "60px",
height: "60px",
marginTop: "22px",
transition: "all 0.2s ease-in-out",
},
false: {
border: "none",
width: "50px",
height: "36px",
marginTop: "35px",
transition: "all 0.2s ease-in-out",
},
}
return styleMap[this.isTimerStartable]
}
handleTextareaKeyDown = event => {
@@ -96,8 +138,13 @@ class Form extends Component {
) : null}
</div>
<button className="moco-bx-btn" disabled={!this.isValid()}>
OK
<button
type="submit"
className="moco-bx-btn"
disabled={!this.isValid()}
style={this.buttonStyle()}
>
{this.isTimerStartable ? <StopWatch /> : "OK"}
</button>
</form>
)

View File

@@ -25,7 +25,11 @@ class Options extends Component {
handleSubmit = _event => {
this.isSuccess = false
this.errorMessage = null
setStorage({ subdomain: this.subdomain, apiKey: this.apiKey }).then(() => {
setStorage({
subdomain: this.subdomain,
apiKey: this.apiKey,
settingTimeTrackingHHMM: false,
}).then(() => {
const { version } = chrome.runtime.getManifest()
const apiClient = new ApiClient({
subdomain: this.subdomain,
@@ -34,6 +38,9 @@ class Options extends Component {
})
apiClient
.login()
.then(({ data }) =>
setStorage({ settingTimeTrackingHHMM: data.setting_time_tracking_hh_mm }),
)
.then(() => {
this.isSuccess = true
this.closeWindow()

View File

@@ -1,39 +1,24 @@
import React, { Component } from "react"
import React, { useEffect, useRef, forwardRef } from "react"
import PropTypes from "prop-types"
import queryString from "query-string"
import { ERROR_UNKNOWN, ERROR_UNAUTHORIZED, ERROR_UPGRADE_REQUIRED, serializeProps } from "utils"
import { isChrome } from "utils/browser"
import { serializeProps } from "utils"
function getStyles(errorType) {
return {
width: "516px",
height:
errorType === ERROR_UNAUTHORIZED
? "834px"
: errorType === ERROR_UPGRADE_REQUIRED
? isChrome()
? "369px"
: "461px"
: errorType === ERROR_UNKNOWN
? "550px"
: "558px",
}
}
const Popup = forwardRef((props, ref) => {
const iFrameRef = useRef()
class Popup extends Component {
static propTypes = {
service: PropTypes.object,
errorType: PropTypes.string,
onRequestClose: PropTypes.func.isRequired,
}
handleRequestClose = event => {
const handleRequestClose = event => {
if (event.target.classList.contains("moco-bx-popup")) {
this.props.onRequestClose()
props.onRequestClose()
}
}
componentDidMount() {
const handleMessage = event => {
if (iFrameRef.current && event.data?.__mocoBX?.iFrameHeight > 300) {
iFrameRef.current.style.height = `${event.data.__mocoBX.iFrameHeight}px`
}
}
useEffect(() => {
// Document might lose focus when clicking the browser action.
// Document might be out of focus when hitting the shortcut key.
// This puts the focus back to the document and ensures that:
@@ -41,41 +26,47 @@ class Popup extends Component {
// - the ESC key closes the popup without closing anything else
window.focus()
document.activeElement?.blur()
}
window.addEventListener("message", handleMessage)
return () => {
window.removeEventListener("message", handleMessage)
}
}, [])
render() {
const serializedProps = serializeProps([
"loading",
"service",
"subdomain",
"lastProjectId",
"lastTaskId",
"roundTimeEntries",
"projects",
"activities",
"schedules",
"lastProjectId",
"lastTaskId",
"fromDate",
"toDate",
"errorType",
"errorMessage",
])(this.props)
const serializedProps = serializeProps([
"loading",
"service",
"subdomain",
"projects",
"activities",
"schedules",
"timedActivity",
"lastProjectId",
"lastTaskId",
"fromDate",
"toDate",
"errorType",
"errorMessage",
])(props)
const styles = getStyles(this.props.errorType)
return (
<div className="moco-bx-popup" onClick={this.handleRequestClose}>
<div className="moco-bx-popup-content" style={styles}>
<iframe
src={chrome.extension.getURL(`popup.html?${queryString.stringify(serializedProps)}`)}
width={styles.width}
height={styles.height}
/>
</div>
return (
<div ref={ref} className="moco-bx-popup" onClick={handleRequestClose}>
<div className="moco-bx-popup-content" style={{ width: "516px" }}>
<iframe
ref={iFrameRef}
src={chrome.extension.getURL(`popup.html?${queryString.stringify(serializedProps)}`)}
style={{ width: "516px", height: "576px", transition: "height 0.1s ease-in-out" }}
/>
</div>
)
}
</div>
)
})
Popup.displayName = "Popup"
Popup.propTypes = {
service: PropTypes.object,
errorType: PropTypes.string,
onRequestClose: PropTypes.func.isRequired,
}
export default Popup

View File

@@ -36,7 +36,7 @@ const customTheme = theme => ({
const customStyles = props => ({
control: (base, _state) => ({
...base,
borderColor: props.hasError ? "#FB3A2F" : base.borderColor,
borderColor: props.hasError ? "#fb3a2f" : base.borderColor,
}),
valueContainer: base => ({
...base,

View File

@@ -1,6 +1,6 @@
import React from "react"
import PropTypes from "prop-types"
import logoUrl from "images/logo.png"
import logoUrl from "images/moco-159x159.png"
const Header = ({ subdomain }) => (
<div className="moco-bx-logo__container">

View File

@@ -0,0 +1,11 @@
import React from "react"
import stopWatch from "images/icons/stopwatch-light.svg"
export default function StopWatch() {
return (
<i
dangerouslySetInnerHTML={{ __html: stopWatch }}
style={{ width: "22px", color: "white", display: "inline-block" }}
/>
)
}

View File

@@ -0,0 +1,29 @@
import React, { useState } from "react"
import PropTypes from "prop-types"
import { useInterval } from "./hooks"
import { differenceInSeconds } from "date-fns"
import { formatDuration } from "utils"
Timer.propTypes = {
startedAt: PropTypes.instanceOf(Date).isRequired,
offset: PropTypes.number,
onTick: PropTypes.func,
}
function Timer({ startedAt, offset = 0, onTick, ...domProps }) {
const [timerLabel, setTimerLabel] = useState(formattedTimerLabel(startedAt, offset))
useInterval(() => {
setTimerLabel(formattedTimerLabel(startedAt, offset))
onTick && onTick()
}, 1000)
return <span {...domProps}>{timerLabel}</span>
}
function formattedTimerLabel(startedAt, offset) {
const seconds = differenceInSeconds(new Date(), startedAt) + offset
return formatDuration(seconds)
}
export default Timer

View File

@@ -0,0 +1,18 @@
import { useEffect, useRef } from "react"
export function useInterval(callback, delay) {
const savedCallback = useRef()
useEffect(() => {
savedCallback.current = callback
})
useEffect(() => {
function tick() {
savedCallback.current()
}
let id = setInterval(tick, delay)
return () => clearInterval(id)
}, [delay])
}

View File

@@ -25,7 +25,7 @@ chrome.runtime.onConnect.addListener(function(port) {
document.removeEventListener("click", clickHandler, true)
})
function updateBubble({ service, bookedHours } = {}) {
function updateBubble({ service, bookedSeconds, settingTimeTrackingHHMM, timedActivity } = {}) {
if (!document.getElementById("moco-bx-root")) {
const domRoot = document.createElement("div")
domRoot.setAttribute("id", "moco-bx-root")
@@ -47,7 +47,12 @@ chrome.runtime.onConnect.addListener(function(port) {
// eslint-disable-next-line react/display-name
(props => (
<animated.div className="moco-bx-bubble" style={{ ...props, ...service.position }}>
<Bubble key={service.url} bookedHours={bookedHours} />
<Bubble
key={service.url}
bookedSeconds={bookedSeconds}
settingTimeTrackingHHMM={settingTimeTrackingHHMM}
timedActivity={timedActivity}
/>
</animated.div>
))
}
@@ -86,8 +91,8 @@ chrome.runtime.onConnect.addListener(function(port) {
})
})
messenger.on("showBubble", ({ payload: { service, bookedHours } }) => {
updateBubble({ service, bookedHours })
messenger.on("showBubble", ({ payload }) => {
updateBubble(payload)
})
messenger.on("hideBubble", () => {
@@ -101,8 +106,4 @@ chrome.runtime.onConnect.addListener(function(port) {
messenger.on("closePopup", () => {
closePopup()
})
messenger.on("activityCreated", () => {
closePopup()
})
})

View File

@@ -12,9 +12,7 @@ const parsedProps = parseProps([
"projects",
"activities",
"schedules",
"lastProjectId",
"lastTaskId",
"roundTimeEntries",
"timedActivity",
"lastProjectId",
"lastTaskId",
"fromDate",

View File

@@ -2,11 +2,13 @@ export default class TimeInputParser {
#input
constructor(input) {
this.#input = input.toLowerCase().replace(/[\s()]/g, "")
this.#input = (input ?? "").toLowerCase().replace(/[\s()]/g, "")
}
parseSeconds() {
if (this.#isDecimal()) {
if (this.#input === "") {
return 0
} else if (this.#isDecimal()) {
return Math.round(parseFloat(this.#parseDecimal()) * 3600)
} else if (this.#isTime()) {
return this.#parseTimeAsSeconds()

View File

@@ -5,7 +5,7 @@ export const isFirefox = () => typeof browser !== "undefined" && chrome
const DEFAULT_SUBDOMAIN = "unset"
export const getSettings = (withDefaultSubdomain = true) => {
const keys = ["subdomain", "apiKey"]
const keys = ["subdomain", "apiKey", "settingTimeTrackingHHMM"]
const { version } = chrome.runtime.getManifest()
if (isChrome()) {
return new Promise(resolve => {

View File

@@ -12,7 +12,9 @@ import {
pick,
head,
defaultTo,
padCharsStart,
} from "lodash/fp"
import { startOfWeek, endOfWeek } from "date-fns"
import { format } from "date-fns"
const nilToArray = input => input || []
@@ -105,6 +107,8 @@ export const trace = curry((tag, value) => {
export const weekStartsOn = 1
export const formatDate = date => format(date, "yyyy-MM-dd")
export const getStartOfWeek = () => startOfWeek(new Date(), { weekStartsOn })
export const getEndOfWeek = () => endOfWeek(new Date(), { weekStartsOn })
export const extensionSettingsUrl = () => `chrome://extensions/?id=${chrome.runtime.id}`
@@ -120,3 +124,22 @@ export const extractAndSetTag = changeset => {
tag: match[1],
}
}
export const formatDuration = (
durationInSeconds,
{ settingTimeTrackingHHMM = true, showSeconds = true } = {},
) => {
if (settingTimeTrackingHHMM) {
const hours = Math.floor(durationInSeconds / 3600)
const minutes = Math.floor((durationInSeconds % 3600) / 60)
const result = `${hours}:${padCharsStart("0", 2, minutes)}`
if (!showSeconds) {
return result
} else {
const seconds = durationInSeconds % 60
return result + `:${padCharsStart("0", 2, seconds)}`
}
} else {
return (durationInSeconds / 3600).toFixed(2)
}
}

View File

@@ -4,34 +4,35 @@ import {
ERROR_UPGRADE_REQUIRED,
ERROR_UNKNOWN,
groupedProjectOptions,
weekStartsOn,
getStartOfWeek,
getEndOfWeek,
} from "utils"
import { get, forEach, reject, isNil } from "lodash/fp"
import { startOfWeek, endOfWeek } from "date-fns"
import { createMatcher } from "utils/urlMatcher"
import remoteServices from "remoteServices"
import { queryTabs, isBrowserTab, getSettings } from "utils/browser"
import { queryTabs, isBrowserTab, getSettings, setStorage } from "utils/browser"
const getStartOfWeek = () => startOfWeek(new Date(), { weekStartsOn })
const getEndOfWeek = () => endOfWeek(new Date(), { weekStartsOn })
const matcher = createMatcher(remoteServices)
export function tabUpdated(tab, { messenger, settings }) {
messenger.connectTab(tab)
const service = matcher(tab.url)
const apiClient = new ApiClient(settings)
if (service?.match?.id) {
messenger.postMessage(tab, { type: "requestService" })
messenger.once("newService", ({ payload: { service } }) => {
const apiClient = new ApiClient(settings)
apiClient
.bookedHours(service)
.activitiesStatus(service)
.then(({ data }) => {
messenger.postMessage(tab, {
type: "showBubble",
payload: {
bookedHours: parseFloat(data[0]?.hours) || 0,
bookedSeconds: data.seconds,
settingTimeTrackingHHMM: settings.settingTimeTrackingHHMM,
timedActivity: data.timed_activity,
service,
},
})
@@ -40,7 +41,8 @@ export function tabUpdated(tab, { messenger, settings }) {
messenger.postMessage(tab, {
type: "showBubble",
payload: {
bookedHours: 0,
bookedSeconds: 0,
settingTimeTrackingHHMM: settings.settingTimeTrackingHHMM,
service,
},
})
@@ -76,25 +78,36 @@ export function togglePopup(tab, { messenger }) {
}
}
async function openPopup(tab, { service, messenger }) {
export async function openPopup(tab, { service, messenger }) {
messenger.postMessage(tab, { type: "openPopup", payload: { loading: true } })
const fromDate = getStartOfWeek()
const toDate = getEndOfWeek()
const settings = await getSettings()
const apiClient = new ApiClient(settings)
const responses = []
try {
const responses = await Promise.all([
apiClient.login(service),
apiClient.projects(),
apiClient.activities(fromDate, toDate),
apiClient.schedules(fromDate, toDate),
])
responses.push(await apiClient.login(service))
// we can forgo the following calls if a timed activity exists
if (!responses[0].data.timed_activity) {
responses.push(
...(await Promise.all([
apiClient.projects(),
apiClient.activities(fromDate, toDate),
apiClient.schedules(fromDate, toDate),
])),
)
}
const settingTimeTrackingHHMM = get("[0].data.setting_time_tracking_hh_mm", responses)
!isNil(settingTimeTrackingHHMM) && setStorage({ settingTimeTrackingHHMM })
const action = {
type: "openPopup",
payload: {
service,
subdomain: settings.subdomain,
timedActivity: get("[0].data.timed_activity", responses),
lastProjectId: get("[0].data.last_project_id", responses),
lastTaskId: get("[0].data.last_task_id", responses),
roundTimeEntries: get("[0].data.round_time_entries", responses),

View File

@@ -4,26 +4,21 @@
"description": "MOCO Zeiterfassung Plugin",
"version": "0.9.20",
"manifest_version": 2,
"description": "MOCO Time Tracking Plugin",
"icons": {
"16": "src/images/logo.png",
"32": "src/images/logo.png",
"48": "src/images/logo.png",
"128": "src/images/logo.png"
"16": "src/images/moco-32x32.png",
"32": "src/images/moco-32x32.png",
"48": "src/images/moco-159x159.png",
"128": "src/images/moco-159x159.png"
},
"options_ui": {
"page": "options.html"
},
"permissions": [
"https://*.mocoapp.com/*",
"storage",
"tabs"
],
"permissions": ["https://*.mocoapp.com/*", "storage", "tabs"],
"background": {
"page": "background.html"
},
"browser_action": {
"default_icon": "src/images/logo.png"
"default_icon": "src/images/moco-32x32.png"
},
"content_scripts": [
{