From 9f9930e0d8caeb951590a55fa1e990d7e0042595 Mon Sep 17 00:00:00 2001 From: b263 <57222509+b263@users.noreply.github.com> Date: Sun, 4 Feb 2024 18:56:00 +0100 Subject: [PATCH] Release 0.8.0 (#14) --- .gitignore | 1 + README.md | 2 +- .../manifest.json | 2 +- src/js/package-lock.json | 16 ++ src/js/package.json | 1 + src/js/src/app.ts | 9 +- src/js/src/lib/api/api.ts | 4 +- .../lib/api/kimai-api-tracker-connector.ts | 32 ++- src/js/src/lib/api/kimai-api.test.ts | 184 ++++++++++++++++++ src/js/src/lib/api/kimai-api.ts | 66 +++++-- src/js/src/lib/error-reporter.ts | 9 + src/js/src/lib/tracker.ts | 19 +- 12 files changed, 315 insertions(+), 30 deletions(-) create mode 100644 src/js/src/lib/api/kimai-api.test.ts create mode 100644 src/js/src/lib/error-reporter.ts diff --git a/.gitignore b/.gitignore index 3b0292c..a7bc9c2 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ DistributionTool +.idea .DS_Store /*.svg diff --git a/README.md b/README.md index f5a90eb..1e9bb2c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Time tracker plugin for Elgato Stream Deck -[![Download](https://img.shields.io/badge/Download-v0.7.0-yellow?style=for-the-badge)](https://github.com/b263/stream-deck-time-tracker/releases/latest/download/dev.b263.time-tracker.streamDeckPlugin) +[![Download](https://img.shields.io/badge/Download-v0.8.0-yellow?style=for-the-badge)](https://github.com/b263/stream-deck-time-tracker/releases/latest/download/dev.b263.time-tracker.streamDeckPlugin) ![Status](https://img.shields.io/badge/Release_status-beta-red?style=for-the-badge) ![Last commit](https://img.shields.io/github/last-commit/b263/stream-deck-time-tracker/main?style=for-the-badge) diff --git a/src/dev.b263.time-tracker.sdPlugin/manifest.json b/src/dev.b263.time-tracker.sdPlugin/manifest.json index 41170ad..49d25d2 100644 --- a/src/dev.b263.time-tracker.sdPlugin/manifest.json +++ b/src/dev.b263.time-tracker.sdPlugin/manifest.json @@ -5,7 +5,7 @@ "Description": "Time tracker using the Tracking Time platform. Requires a free account.", "Icon": "assets/category", "URL": "https://github.com/b263/stream-deck-time-tracker/issues", - "Version": "0.6.0", + "Version": "0.8.0", "CodePath": "app/app.html", "Software": { "MinimumVersion": "5.0" diff --git a/src/js/package-lock.json b/src/js/package-lock.json index 3bc04cc..fe9202e 100644 --- a/src/js/package-lock.json +++ b/src/js/package-lock.json @@ -32,6 +32,7 @@ "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "node-jq": "^4.2.2", + "prettier": "^3.2.5", "rollup": "^4.9.0", "ts-jest": "^29.1.1", "ts-node": "^10.9.2", @@ -10988,6 +10989,21 @@ "node": ">=0.10.0" } }, + "node_modules/prettier": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz", + "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", diff --git a/src/js/package.json b/src/js/package.json index f289d42..fd20465 100644 --- a/src/js/package.json +++ b/src/js/package.json @@ -49,6 +49,7 @@ "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "node-jq": "^4.2.2", + "prettier": "^3.2.5", "rollup": "^4.9.0", "ts-jest": "^29.1.1", "ts-node": "^10.9.2", diff --git a/src/js/src/app.ts b/src/js/src/app.ts index b7e709d..dd6dbe0 100644 --- a/src/js/src/app.ts +++ b/src/js/src/app.ts @@ -1,12 +1,14 @@ import { initTrackerAction } from "./lib/action/tracker-action"; import { KimaiApi } from "./lib/api/kimai-api"; import { AppEvent, StateKey } from "./lib/constants"; +import { registerGlobalErrorReporter } from "./lib/error-reporter"; import { Store } from "./lib/store/store"; -import { AppState, GlobalSettings } from "./lib/types"; +import { AppState, GlobalSettings, SDConnectionInfo } from "./lib/types"; const store = new Store(); -$SD.onConnected(() => { +$SD.onConnected((args: SDConnectionInfo) => { + registerGlobalErrorReporter(args); $SD.getGlobalSettings(); $SD.onDidReceiveGlobalSettings( (event: { payload: { settings: GlobalSettings } }) => { @@ -23,6 +25,9 @@ $SD.onConnected(() => { } } ); + // setTimeout(() => { + // throw new Error("test error " + new Date().toISOString()); + // }, 1000); }); EventEmitter.on(AppEvent.actionSuccess, (context: string) => diff --git a/src/js/src/lib/api/api.ts b/src/js/src/lib/api/api.ts index 5a89e34..18893d6 100644 --- a/src/js/src/lib/api/api.ts +++ b/src/js/src/lib/api/api.ts @@ -13,10 +13,8 @@ export type ApiResponse = export type TrackingItem = { id: string | number; -}; - -export type TimeEntry = { duration: number; + begin: string; }; export type Category = { diff --git a/src/js/src/lib/api/kimai-api-tracker-connector.ts b/src/js/src/lib/api/kimai-api-tracker-connector.ts index 0764a30..3778c36 100644 --- a/src/js/src/lib/api/kimai-api-tracker-connector.ts +++ b/src/js/src/lib/api/kimai-api-tracker-connector.ts @@ -1,9 +1,10 @@ +import { parse } from "date-fns"; import { AppEvent, StateKey } from "../constants"; import { Store } from "../store/store"; import { Tracker, TrackerEvent } from "../tracker"; import { AppState, KimaiBackendProviderPluginConfig } from "../types"; import { ApiTrackerConnector } from "./api"; -import { KimaiApi } from "./kimai-api"; +import { KimaiApi, kimaiDateFormatTz } from "./kimai-api"; export class KimaiApiTrackerConnector implements ApiTrackerConnector { #api: KimaiApi; @@ -13,6 +14,7 @@ export class KimaiApiTrackerConnector implements ApiTrackerConnector { onStart: this.onStart.bind(this), onStop: this.onStop.bind(this), onRequestWorkedToday: this.onRequestWorkedToday.bind(this), + onCheckIfCurrentlyActive: this.onCheckIfCurrentlyActive.bind(this), }; backendProvider = "kimai" as const; @@ -70,6 +72,30 @@ export class KimaiApiTrackerConnector implements ApiTrackerConnector { } } + async onCheckIfCurrentlyActive() { + console.log( + "KimaiApiTrackerConnector.onCheckIfCurrentlyActive()", + this.settings(this.#tracker) + ); + const projectId = this.settings(this.#tracker)?.projectId; + const activityId = this.settings(this.#tracker)?.activityId; + if (this.#tracker.running) { + throw new Error( + "Tracker has already been started in Stream Deck. Check if there's an active event in the backend is too late." + ); + } + if (projectId && activityId) { + const event = await this.#api.getCurrentlyActive(projectId, activityId); + if (event) { + this.#store.patchState({ + [StateKey.currentEvent]: event, + }); + const startTime = parse(event.begin, kimaiDateFormatTz, new Date()); + this.#tracker.start(startTime); + } + } + } + connect(tracker: Tracker) { this.#tracker = tracker; @@ -79,6 +105,10 @@ export class KimaiApiTrackerConnector implements ApiTrackerConnector { TrackerEvent.requestWorkedToday, this.#bound.onRequestWorkedToday ); + tracker.addEventListener( + TrackerEvent.checkIfCurrentlyActive, + this.#bound.onCheckIfCurrentlyActive + ); return this; } diff --git a/src/js/src/lib/api/kimai-api.test.ts b/src/js/src/lib/api/kimai-api.test.ts new file mode 100644 index 0000000..5bf1f0d --- /dev/null +++ b/src/js/src/lib/api/kimai-api.test.ts @@ -0,0 +1,184 @@ +import { format } from "date-fns"; +import { KimaiApi, ApiConfig, kimaiDateFormat } from "./kimai-api"; +import { KimaiBackendProviderPluginConfig } from "../types"; + +describe("KimaiApi", () => { + let api: KimaiApi; + let config: ApiConfig; + + beforeEach(() => { + KimaiApi.instance = null; + api = KimaiApi.get(); + config = { + url: "https://www.example.com", + user: "user", + token: "token", + }; + }); + + test("config() should set the config if provided", () => { + KimaiApi.config(config); + expect(api.config).toEqual(config); + }); + + test("config() should set the config to emptyConfig if null is provided", () => { + KimaiApi.config(null as unknown as ApiConfig); + expect(api.config).toEqual(KimaiApi.emptyConfig); + }); + + describe("get()", () => { + test("should return an instance of KimaiApi", () => { + const instance = KimaiApi.get(); + expect(instance).toBeInstanceOf(KimaiApi); + }); + + test("should always return the same instance", () => { + const instance1 = KimaiApi.get(); + const instance2 = KimaiApi.get(); + expect(instance1).toBe(instance2); + }); + + test("should create a new instance if one does not already exist", () => { + const instance = KimaiApi.get(); + expect(instance).toBeInstanceOf(KimaiApi); + }); + }); + + test("getCurrentUser() should fetch the current user data", async () => { + const baseUrl = "https://www.example.com/"; + const user = "user"; + const token = "token"; + const expectedUrl = `${baseUrl}api/users/me`; + const expectedHeaders = { + "X-AUTH-USER": user, + "X-AUTH-TOKEN": token, + }; + const expectedResponse = { name: "John Doe", email: "john@example.com" }; + const mockFetch = jest.fn().mockResolvedValue({ + json: jest.fn().mockResolvedValue(expectedResponse), + }); + global.fetch = mockFetch; + const result = await KimaiApi.getCurrentUser(baseUrl, user, token); + expect(mockFetch).toHaveBeenCalledWith(expectedUrl, { + headers: expectedHeaders, + }); + expect(result).toEqual(expectedResponse); + }); + + test("getCurrentUser() should return null if an error occurs", async () => { + const baseUrl = "https://www.example.com/"; + const user = "user"; + const token = "token"; + const expectedUrl = `${baseUrl}api/users/me`; + const mockFetch = jest.fn().mockRejectedValue(new Error("Network error")); + global.fetch = mockFetch; + const result = await KimaiApi.getCurrentUser(baseUrl, user, token); + expect(mockFetch).toHaveBeenCalledWith(expectedUrl, { + headers: { + "X-AUTH-USER": user, + "X-AUTH-TOKEN": token, + }, + }); + expect(result).toBeNull(); + }); + + test("config setter should correctly set the config", () => { + const config = { + url: "https://www.example.com/", + user: "user", + token: "token", + }; + api.config = config; + expect(api.config).toEqual(config); + }); + + test("config getter should correctly get the config", () => { + const config = { + url: "https://www.example.com/", + user: "user", + token: "token", + }; + api.config = config; + const retrievedConfig = api.config; + expect(retrievedConfig).toEqual(config); + }); + + test("config getter should return default values when config is not set", () => { + const defaultConfig = { url: "", user: "", token: "" }; + expect(api.config).toEqual(defaultConfig); + }); + + test("fetchOptions should return correct headers", () => { + const config = { + url: "https://www.example.com/", + user: "user", + token: "token", + }; + api.config = config; + const expectedHeaders = { + "X-AUTH-USER": config.user, + "X-AUTH-TOKEN": config.token, + "Content-Type": "application/json", + }; + expect(api.fetchOptions).toEqual({ headers: expectedHeaders }); + }); + + test("assertValidConfig should throw error when user is not set", () => { + const config = { + url: "https://www.example.com/", + user: "", + token: "token", + }; + api.config = config; + expect(() => api.assertValidConfig()).toThrow( + "Invalid config. User must not be empty." + ); + }); + + test("assertValidConfig should throw error when baseUrl is not set", () => { + const config = { url: "", user: "user", token: "token" }; + api.config = config; + expect(() => api.assertValidConfig()).toThrow( + "Invalid config. BaseUrl must not be empty." + ); + }); + + test("assertValidConfig should throw error when token is not set", () => { + const config = { url: "https://www.example.com/", user: "user", token: "" }; + api.config = config; + expect(() => api.assertValidConfig()).toThrow( + "Invalid config. Token must not be empty." + ); + }); + + test("startTracking should send a POST request with correct parameters", async () => { + const config = { + url: "https://www.example.com/", + user: "user", + token: "token", + }; + api.config = config; + const mockFetch = jest.fn().mockResolvedValue({ + json: jest.fn().mockResolvedValue({}), + }); + global.fetch = mockFetch; + const trackingConfig = { projectId: 1, activityId: 2 }; + await api.startTracking(trackingConfig as KimaiBackendProviderPluginConfig); + const expectedUrl = `${config.url}api/timesheets`; + const expectedOptions = { + headers: { + "X-AUTH-USER": config.user, + "X-AUTH-TOKEN": config.token, + "Content-Type": "application/json", + }, + method: "POST", + body: JSON.stringify({ + begin: format(new Date(), kimaiDateFormat), + project: trackingConfig.projectId, + activity: trackingConfig.activityId, + description: "", + }), + }; + expect(mockFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions); + }); +}); diff --git a/src/js/src/lib/api/kimai-api.ts b/src/js/src/lib/api/kimai-api.ts index 1530b43..18121c0 100644 --- a/src/js/src/lib/api/kimai-api.ts +++ b/src/js/src/lib/api/kimai-api.ts @@ -1,19 +1,16 @@ import { format, startOfToday } from "date-fns"; -import { - ApiResponse, - Category, - TimeEntry, - TrackingItem, - tryFetch, -} from "./api"; +import { ApiResponse, Category, TrackingItem, tryFetch } from "./api"; import { KimaiBackendProviderPluginConfig } from "../types"; -type ApiConfig = { +export type ApiConfig = { url: string; user: string; token: string; }; +export const kimaiDateFormat = "yyyy-MM-dd'T'HH:mm:ss"; +export const kimaiDateFormatTz = "yyyy-MM-dd'T'HH:mm:ssxx"; + export class KimaiApi { static instance: KimaiApi | null = null; @@ -58,6 +55,14 @@ export class KimaiApi { this.#token = token; } + get config(): ApiConfig { + return { + url: this.#baseUrl ?? "", + user: this.#user ?? "", + token: this.#token ?? "", + }; + } + get fetchOptions() { return { headers: { @@ -80,14 +85,26 @@ export class KimaiApi { } } - async startTracking({ - projectId, - activityId, - }: KimaiBackendProviderPluginConfig): Promise> { + async startTracking( + config: KimaiBackendProviderPluginConfig, + ): Promise> { + if (!config?.activityId || !config?.projectId) { + $SD.logMessage( + "Invalid config. projectId and activityId must be defined. Current values: " + + JSON.stringify(config), + ); + return { + success: false, + error: `Invalid config. projectId and activityId must be defined. Current values: ${JSON.stringify( + config, + )}`, + }; + } + const { projectId, activityId } = config; this.assertValidConfig(); const url = `${this.#baseUrl}api/timesheets`; const body = { - begin: format(new Date(), "yyyy-MM-dd'T'HH:mm:ss"), + begin: format(new Date(), kimaiDateFormat), project: projectId, activity: activityId, description: "", @@ -110,6 +127,19 @@ export class KimaiApi { return tryFetch(url, options); } + async getCurrentlyActive(projectId: number, activityId: number) { + this.assertValidConfig(); + const params = new URLSearchParams({ + active: "1", + project: String(projectId), + activity: String(activityId), + }); + const url = `${this.#baseUrl}api/timesheets?${params.toString()}`; + const response = await tryFetch(url, this.fetchOptions); + if (!response.success) return null; + return response.body.length ? response.body[0] : null; + } + async getProjects() { this.assertValidConfig(); const url = `${this.#baseUrl}api/projects`; @@ -127,23 +157,23 @@ export class KimaiApi { async listTodaysTimeEntries( projectId: number, - activityId: number - ): Promise> { + activityId: number, + ): Promise> { this.assertValidConfig(); if (!projectId || !activityId) { return { success: false, error: `projectId and activityId must be defined. Current values: ${JSON.stringify( - { projectId, activityId } + { projectId, activityId }, )}`, }; } const params = new URLSearchParams({ - begin: format(startOfToday(), "yyyy-MM-dd'T'HH:mm:ss"), + begin: format(startOfToday(), kimaiDateFormat), "projects[]": String(projectId), "activities[]": String(activityId), }); const url = `${this.#baseUrl}api/timesheets?${params.toString()}`; - return tryFetch(url, this.fetchOptions); + return tryFetch(url, this.fetchOptions); } } diff --git a/src/js/src/lib/error-reporter.ts b/src/js/src/lib/error-reporter.ts new file mode 100644 index 0000000..63550d5 --- /dev/null +++ b/src/js/src/lib/error-reporter.ts @@ -0,0 +1,9 @@ +import { SDConnectionInfo } from "./types"; + +export function registerGlobalErrorReporter(info: SDConnectionInfo) { + window.addEventListener("error", ({ error }: ErrorEvent) => { + const message = `Error in ${info?.appInfo?.plugin?.uuid} (${info?.appInfo?.plugin?.version}): ${error?.message}\n${error?.stack}`; + console.log("Writing error log:", message); + $SD.logMessage(message); + }); +} diff --git a/src/js/src/lib/tracker.ts b/src/js/src/lib/tracker.ts index 623b94e..d342017 100644 --- a/src/js/src/lib/tracker.ts +++ b/src/js/src/lib/tracker.ts @@ -5,6 +5,7 @@ export const TrackerEvent = { start: "start", stop: "stop", requestWorkedToday: "requestWorkedToday", + checkIfCurrentlyActive: "checkIfCurrentlyActive", } as const; export class Tracker extends EventTarget { @@ -37,6 +38,7 @@ export class Tracker extends EventTarget { public running: boolean = false; public workedToday: number | undefined; private interval: number | undefined; + private checkedIfCurrentlyActive: boolean = false; constructor(context: string, running: boolean) { console.log("Tracker.constructor(context, running)", { context, running }); @@ -52,6 +54,10 @@ export class Tracker extends EventTarget { if (typeof this.workedToday !== "number") { this.dispatchEvent(new Event(TrackerEvent.requestWorkedToday)); } + if (!this.checkedIfCurrentlyActive) { + this.dispatchEvent(new Event(TrackerEvent.checkIfCurrentlyActive)); + this.checkedIfCurrentlyActive = true; + } } public get settings(): PluginSettings | undefined { return this.#settings; @@ -62,14 +68,19 @@ export class Tracker extends EventTarget { return Math.floor((new Date().getTime() - this.startTime?.getTime()) / 1e3); } - start() { + start(startTime?: Date) { + console.log("Tracker.start(startTime)", { startTime }); this.running = true; - this.startTime = new Date(); + this.startTime = startTime ?? new Date(); this.render(); Tracker.pauseOtherTrackers(this); - this.dispatchEvent(new Event(TrackerEvent.requestWorkedToday)); - this.dispatchEvent(new Event(TrackerEvent.start)); this.interval = window.setInterval(this.tick(), 6e4); + if (!startTime) { + this.dispatchEvent(new Event(TrackerEvent.requestWorkedToday)); + this.dispatchEvent(new Event(TrackerEvent.start)); + } else { + // A given start time means that the tracker was started in the backend. + } } tick() {