From db6b3bae22959938747f26032f6de918912ae2b8 Mon Sep 17 00:00:00 2001 From: Max Blum Date: Tue, 9 Apr 2024 10:10:55 +0200 Subject: [PATCH] Update personio extension - Merge pull request #26 from marcjulianschwarz/25-attendance-month-bug - fix bug by caching individual attendance combinations - cache values (#24) - add month selection for attendances command --- extensions/personio/src/api.tsx | 198 ------------------ extensions/personio/src/api/api.tsx | 87 ++++++++ extensions/personio/src/api/attendances.tsx | 106 ++++++++++ extensions/personio/src/api/cache.tsx | 56 +++++ extensions/personio/src/api/employee.tsx | 61 ++++++ extensions/personio/src/api/employeeinfo.tsx | 26 +++ extensions/personio/src/attendances.tsx | 59 +++++- extensions/personio/src/getemployeenumber.tsx | 3 +- extensions/personio/src/tracktime.tsx | 4 +- 9 files changed, 394 insertions(+), 206 deletions(-) delete mode 100644 extensions/personio/src/api.tsx create mode 100644 extensions/personio/src/api/api.tsx create mode 100644 extensions/personio/src/api/attendances.tsx create mode 100644 extensions/personio/src/api/cache.tsx create mode 100644 extensions/personio/src/api/employee.tsx create mode 100644 extensions/personio/src/api/employeeinfo.tsx diff --git a/extensions/personio/src/api.tsx b/extensions/personio/src/api.tsx deleted file mode 100644 index e10eac9c7d9d6..0000000000000 --- a/extensions/personio/src/api.tsx +++ /dev/null @@ -1,198 +0,0 @@ -import axios from "axios"; -import { getPreferenceValues, popToRoot, showHUD, showToast, Toast } from "@raycast/api"; - -const URL = "https://api.personio.de/v1"; - -// this function uses the secrets to get a short-lived (one day) token -export async function getPersonioToken() { - const url = URL + "/auth"; - const payload = { - client_secret: getPreferenceValues().clientSecret, - client_id: getPreferenceValues().clientId, - }; - - const headers = { - accept: "application/json", - "content-type": "application/json", - }; - - const res = await axios.post(url, payload, { headers }); - const data = res.data; - const token = data.data.token; - return token; -} - -export async function addTime( - employeeNumber: number, - date: string, - start_time: string, - end_time: string, - break_time: number, - token: string, -) { - const url = "https://api.personio.de/v1/company/attendances"; - - const payload = { - attendances: [ - { - employee: employeeNumber, - date: date, - start_time: start_time, - end_time: end_time, - break: break_time, - }, - ], - }; - - const headers = { - accept: "application/json", - "content-type": "application/json", - authorization: "Bearer " + token, - }; - - try { - axios.post(url, payload, { headers }); - await showHUD("Time Tracked 🎉"); - popToRoot(); - } catch (error) { - if (axios.isAxiosError(error) && error.stack) { - if (error.stack.includes("IncomingMessage.handleStreamEnd")) { - console.log("Caught the specific error: IncomingMessage.handleStreamEnd"); - await showToast({ style: Toast.Style.Failure, title: "That didn't work!" }); - } else { - // Handle other errors - console.log("Some other Axios error occurred", error); - } - } else { - console.log("An error occurred that is not an Axios error", error); - } - } -} -// the JSON structure returned by the personio API -export interface EmployeeJSON { - type: string; - attributes: { - id: { - label: string; - value: number; - type: string; - universal_id: string; - }; - preferred_name: { - label: string; - value: string; - type: string; - universal_id: string; - }; - }; -} - -export interface Employee { - id: number; - name: string; -} - -// Get a list of employees (this can be used to find your own personio employee number) -export async function getEmployees(token: string): Promise { - const url = URL + "/company/employees"; - const headers = { - accept: "application/json", - authorization: "Bearer " + token, - }; - - try { - const res = await axios.get(url, { headers }); - const data = res.data.data as EmployeeJSON[]; - // convert the JSON data to Employee objects - const employees = data.map((e) => ({ id: e.attributes.id.value, name: e.attributes.preferred_name?.value })); - await showToast({ title: "Employees loaded", message: `${employees.length} Loaded employees successfully!` }); - return employees; - } catch (error) { - await showToast({ style: Toast.Style.Failure, title: "That didn't work!", message: "Unfortunate!" }); - return []; - } -} - -export interface AttendancePeriodJSON { - id: number; - type: string; - attributes: { - employee: number; - date: string; - start_time: string; - end_time: string; - break: number; - comment: string; - updated_at: string; - status: string; - project: number; - is_holiday: boolean; - is_on_time_off: boolean; - }; -} - -export interface AttendancePeriod { - id: number; - employee: number; - date: string; - start_time: string; - end_time: string; - break: number; - comment: string; - updated_at: string; - status: string; - project: number; - is_holiday: boolean; - is_on_time_off: boolean; -} - -export async function getAttendances(employeeNumber: number, token: string): Promise { - const url = - URL + - "/company/attendances?employees[]=" + - employeeNumber + - "&start_date=2024-01-01&end_date=2024-12-31&includePending=true"; - const headers = { - accept: "application/json", - authorization: "Bearer " + token, - }; - - try { - const res = await axios.get(url, { headers }); - const data = res.data.data as AttendancePeriodJSON[]; - const attendances = data.map((a) => ({ - id: a.id, - employee: a.attributes.employee, - date: a.attributes.date, - start_time: a.attributes.start_time, - end_time: a.attributes.end_time, - break: a.attributes.break, - comment: a.attributes.comment, - updated_at: a.attributes.updated_at, - status: a.attributes.status, - project: a.attributes.project, - is_holiday: a.attributes.is_holiday, - is_on_time_off: a.attributes.is_on_time_off, - })); - await showToast({ - title: "Loaded Attendances", - message: `${attendances.length} Attendances in 2024 loaded successfully!`, - }); - return attendances; - } catch (error) { - await showToast({ style: Toast.Style.Failure, title: "That didn't work!", message: "Unfortunate!" }); - return []; - } -} - -export async function getEmployeeInfo(id: number, token: string) { - const url = URL + "/company/employees/" + id; - const headers = { - accept: "application/json", - authorization: "Bearer " + token, - }; - - const res = await axios.get(url, { headers }); - const data = res.data; - return data.data.attributes.preferred_name.value; -} diff --git a/extensions/personio/src/api/api.tsx b/extensions/personio/src/api/api.tsx new file mode 100644 index 0000000000000..379671195867b --- /dev/null +++ b/extensions/personio/src/api/api.tsx @@ -0,0 +1,87 @@ +import axios from "axios"; +import { getPreferenceValues, popToRoot, showHUD, showToast, Toast } from "@raycast/api"; +import { cache } from "./cache"; + +export const BASE_URL = "https://api.personio.de/v1"; + +export async function getTokenFromAPI() { + const url = BASE_URL + "/auth"; + const payload = { + client_secret: getPreferenceValues().clientSecret, + client_id: getPreferenceValues().clientId, + }; + const headers = { + accept: "application/json", + "content-type": "application/json", + }; + + const res = await axios.post(url, payload, { headers }); + const data = res.data; + const token = data.data.token; + + return token; +} + +// this function uses the secrets to get a short-lived (one day) token +export async function getPersonioToken(caching = true) { + if (!caching) { + return await getTokenFromAPI(); + } + + const cacheDataToken = cache.get("personioToken"); + + if (cacheDataToken) { + return cacheDataToken; + } else { + const token = await getTokenFromAPI(); + cache.set("personioToken", token, 23 * 60); // let the token expire after 23 hours + return token; + } +} + +export async function addTime( + employeeNumber: number, + date: string, + start_time: string, + end_time: string, + break_time: number, + token: string, +) { + const url = BASE_URL + "/company/attendances"; + + const payload = { + attendances: [ + { + employee: employeeNumber, + date: date, + start_time: start_time, + end_time: end_time, + break: break_time, + }, + ], + }; + + const headers = { + accept: "application/json", + "content-type": "application/json", + authorization: "Bearer " + token, + }; + + try { + axios.post(url, payload, { headers }); + await showHUD("Time Tracked 🎉"); + popToRoot(); + } catch (error) { + if (axios.isAxiosError(error) && error.stack) { + if (error.stack.includes("IncomingMessage.handleStreamEnd")) { + console.log("Caught the specific error: IncomingMessage.handleStreamEnd"); + await showToast({ style: Toast.Style.Failure, title: "That didn't work!" }); + } else { + // Handle other errors + console.log("Some other Axios error occurred", error); + } + } else { + console.log("An error occurred that is not an Axios error", error); + } + } +} diff --git a/extensions/personio/src/api/attendances.tsx b/extensions/personio/src/api/attendances.tsx new file mode 100644 index 0000000000000..3052c21613773 --- /dev/null +++ b/extensions/personio/src/api/attendances.tsx @@ -0,0 +1,106 @@ +import { showToast, Toast } from "@raycast/api"; +import axios from "axios"; +import { cache } from "./cache"; +import { BASE_URL } from "./api"; + +interface AttendancePeriodJSON { + id: number; + type: string; + attributes: { + employee: number; + date: string; + start_time: string; + end_time: string; + break: number; + comment: string; + updated_at: string; + status: string; + project: number; + is_holiday: boolean; + is_on_time_off: boolean; + }; +} + +export interface AttendancePeriod { + id: number; + employee: number; + date: string; + start_time: string; + end_time: string; + break: number; + comment: string; + updated_at: string; + status: string; + project: number; + is_holiday: boolean; + is_on_time_off: boolean; +} + +function daysInMonth(year: number, month: number) { + return new Date(year, month, 0).getDate(); +} + +export async function getAttendancesAPI( + employeeNumber: number, + token: string, + currentYear: string, + selectedMonth: string, +): Promise { + const maxDays = daysInMonth(parseInt(currentYear), parseInt(selectedMonth)); + + const url = + BASE_URL + + "/company/attendances?employees[]=" + + employeeNumber + + `&start_date=${currentYear}-${selectedMonth}-01&end_date=${currentYear}-${selectedMonth}-${maxDays}&includePending=true`; + const headers = { + accept: "application/json", + authorization: "Bearer " + token, + }; + + try { + const res = await axios.get(url, { headers }); + const data = res.data.data as AttendancePeriodJSON[]; + const attendances = data.map((a) => ({ + id: a.id, + employee: a.attributes.employee, + date: a.attributes.date, + start_time: a.attributes.start_time, + end_time: a.attributes.end_time, + break: a.attributes.break, + comment: a.attributes.comment, + updated_at: a.attributes.updated_at, + status: a.attributes.status, + project: a.attributes.project, + is_holiday: a.attributes.is_holiday, + is_on_time_off: a.attributes.is_on_time_off, + })); + await showToast({ + title: "Loaded Attendances", + message: `${attendances.length} Attendances in 2024 loaded successfully!`, + }); + return attendances; + } catch (error) { + await showToast({ style: Toast.Style.Failure, title: "That didn't work!", message: "Unfortunate!" }); + console.error(error); + return []; + } +} + +export async function getAttendances( + employeeNumber: number, + token: string, + currentYear: string, + selectedMonth: string, +) { + const key = employeeNumber.toString() + currentYear + selectedMonth; + const attendances = cache.get(key); + + if (attendances) { + return JSON.parse(attendances) as AttendancePeriod[]; + } else { + const attendances = await getAttendancesAPI(employeeNumber, token, currentYear, selectedMonth); + cache.set(key, JSON.stringify(attendances), 23 * 60); + return attendances; + } +} diff --git a/extensions/personio/src/api/cache.tsx b/extensions/personio/src/api/cache.tsx new file mode 100644 index 0000000000000..ff45f44a11bdb --- /dev/null +++ b/extensions/personio/src/api/cache.tsx @@ -0,0 +1,56 @@ +import { Cache } from "@raycast/api"; + +function getMinutesBetweenDates(date1: Date, date2: Date): number { + const diffInMilliseconds = Math.abs(date2.getTime() - date1.getTime()); + return diffInMilliseconds / (1000 * 60); +} + +// a cache entry can be used to store strings inside of the cache +// which will automatically expire after some minutes +export interface CacheEntry { + data: string; + expiresInMinutes: number; + createdAt: string; +} + +export class MyCache { + cache = new Cache(); + + set(key: string, value: string, expiresInMinutes: number) { + // create a cache entry with the current time + const data = { + data: value, + expiresInMinutes: expiresInMinutes, + createdAt: new Date().toString(), + }; + this.cache.set(key, JSON.stringify(data)); + } + + get(key: string) { + const data = this.cache.get(key); + console.log(`Trying to get cache for ${key}`); + + // Calculate whether the entry is expired (more living minutes than it should have) + // Return the entry only when it exists and when is is not yet expired + if (data) { + const cacheEntry = JSON.parse(data) as CacheEntry; + const livingMinutes = getMinutesBetweenDates(new Date(cacheEntry.createdAt), new Date()); + if (livingMinutes < cacheEntry.expiresInMinutes) { + console.log(`Found ${key} in cache`); + return cacheEntry.data; + } else { + console.log(`${key} is expired`); + return undefined; + } + } else { + console.log(`${key} NOT in cache`); + return undefined; + } + } + + clear() { + this.cache.clear(); + } +} + +export const cache = new MyCache(); diff --git a/extensions/personio/src/api/employee.tsx b/extensions/personio/src/api/employee.tsx new file mode 100644 index 0000000000000..d36ec9e05d302 --- /dev/null +++ b/extensions/personio/src/api/employee.tsx @@ -0,0 +1,61 @@ +import { showToast, Toast } from "@raycast/api"; +import axios from "axios"; +import { BASE_URL } from "./api"; +import { cache } from "./cache"; + +// the JSON structure returned by the personio API +export interface EmployeeJSON { + type: string; + attributes: { + id: { + label: string; + value: number; + type: string; + universal_id: string; + }; + preferred_name: { + label: string; + value: string; + type: string; + universal_id: string; + }; + }; +} + +export interface Employee { + id: number; + name: string; +} + +// Get a list of employees (this can be used to find your own personio employee number) +export async function getEmployeesAPI(token: string): Promise { + const url = BASE_URL + "/company/employees"; + const headers = { + accept: "application/json", + authorization: "Bearer " + token, + }; + + try { + const res = await axios.get(url, { headers }); + const data = res.data.data as EmployeeJSON[]; + // convert the JSON data to Employee objects + const employees = data.map((e) => ({ id: e.attributes.id.value, name: e.attributes.preferred_name?.value })); + await showToast({ title: "Employees loaded", message: `${employees.length} Loaded employees successfully!` }); + return employees; + } catch (error) { + console.error(error); + await showToast({ style: Toast.Style.Failure, title: "That didn't work!", message: "Unfortunate!" }); + return []; + } +} + +export async function getEmployees(token: string) { + const employees = cache.get("employees"); + if (employees) { + return JSON.parse(employees) as Employee[]; + } else { + const employees = await getEmployeesAPI(token); + cache.set("employees", JSON.stringify(employees), 100000); + return employees; + } +} diff --git a/extensions/personio/src/api/employeeinfo.tsx b/extensions/personio/src/api/employeeinfo.tsx new file mode 100644 index 0000000000000..ed4c31d45067d --- /dev/null +++ b/extensions/personio/src/api/employeeinfo.tsx @@ -0,0 +1,26 @@ +import axios from "axios"; +import { cache } from "./cache"; +import { BASE_URL } from "./api"; + +export async function getEmployeeInfoAPI(id: number, token: string) { + const url = BASE_URL + "/company/employees/" + id; + const headers = { + accept: "application/json", + authorization: "Bearer " + token, + }; + + const res = await axios.get(url, { headers }); + const data = res.data; + return data.data.attributes.preferred_name.value; +} + +export async function getEmployeeInfo(id: number, token: string) { + const preferredName = cache.get("preferredName"); + if (preferredName) { + return preferredName; + } else { + const preferredName = await getEmployeeInfoAPI(id, token); + cache.set("preferredName", preferredName, 10000000); + return preferredName; + } +} diff --git a/extensions/personio/src/attendances.tsx b/extensions/personio/src/attendances.tsx index a30495c0ee678..128124d8faa82 100644 --- a/extensions/personio/src/attendances.tsx +++ b/extensions/personio/src/attendances.tsx @@ -1,22 +1,71 @@ import { List, getPreferenceValues } from "@raycast/api"; -import { AttendancePeriod, getAttendances, getPersonioToken } from "./api"; +import { getPersonioToken } from "./api/api"; import { useEffect, useState } from "react"; +import { AttendancePeriod, getAttendances } from "./api/attendances"; + +const months = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "Septemnber", + "October", + "November", + "December", +]; export default function Attendances() { + const currentDate = new Date(); + const currentYear = currentDate.getFullYear(); + const currentMonth = currentDate.getMonth(); + const [attendances, setAttendances] = useState([]); + const [selectedMonth, setSelectedMonth] = useState(currentMonth); + + const paddedMonth = (selectedMonth + 1).toString().padStart(2, "0"); useEffect(() => { async function fetchAttendances() { const token = await getPersonioToken(); const employeeNumber = getPreferenceValues().employeeNumber; - const attendances = await getAttendances(employeeNumber, token); - setAttendances(attendances); + + // API call + const attendances = await getAttendances(employeeNumber, token, currentYear.toString(), paddedMonth); + + // sort from new to old attendances + const sortedAttendances = attendances.sort((a, b) => { + return Date.parse(b.date + " " + b.start_time) - Date.parse(a.date + " " + a.start_time); + }); + + setAttendances(sortedAttendances); } fetchAttendances(); - }, []); + }, [selectedMonth]); return ( - + { + setSelectedMonth(months.indexOf(month)); + }} + > + + {months.map((month) => ( + + ))} + + + } + > {attendances.map((attendance) => ( ([]); diff --git a/extensions/personio/src/tracktime.tsx b/extensions/personio/src/tracktime.tsx index b367a7a20a83c..f4432ad9f29f1 100644 --- a/extensions/personio/src/tracktime.tsx +++ b/extensions/personio/src/tracktime.tsx @@ -11,8 +11,9 @@ import { showToast, } from "@raycast/api"; import { useEffect, useState } from "react"; -import { addTime, getAttendances, getEmployeeInfo, getPersonioToken } from "./api"; +import { addTime, getPersonioToken } from "./api/api"; import moment from "moment-timezone"; +import { getEmployeeInfo } from "./api/employeeinfo"; export default function TrackTime() { const [token, setToken] = useState(""); @@ -27,7 +28,6 @@ export default function TrackTime() { const token = await getPersonioToken(); const employeeNumber = getPreferenceValues().employeeNumber; const employeeName = await getEmployeeInfo(employeeNumber, token); - getAttendances(employeeNumber, token); setEmployeeName(employeeName); setToken(token); }