Skip to content

Commit a2e30ac

Browse files
authored
Merge pull request #1 from JupiterPi/kimai-integration
Kimai integration
2 parents 3a7e1e6 + 2d45818 commit a2e30ac

25 files changed

+992
-362
lines changed

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# 🌿 Thyme
22

3-
_**Thyme** is a stopwatch in your taskbar with history._
3+
_**Thyme** is a stopwatch in your taskbar with history, notes and a [Kimai](https://www.kimai.org) integration._
44

55
## Installation
66

@@ -15,11 +15,12 @@ _**Thyme** is a stopwatch in your taskbar with history._
1515
- The **tray icon** is an activity indicator that lights up green/gray to indicate that the stopwatch is/isn't running. Click it to toggle the stopwatch. Double-click to show/hide the UI window(s).
1616
- Every time you stop the stopwatch, an **entry is saved to the history** including the start and end time.
1717
- Save small **notes** at any time to document what you're doing.
18-
- The small **dashboard** shows you whether a stopwatch is running, as well as the start time and duration.
18+
- The small **dashboard** shows you information about a running stopwatch.
1919
- The **history** page displays all entries in chronological order and grouped by day.
2020
- **Advanced editing:** Edit entries' start and end times, delete them, merge two entries, insert entries, insert pauses...
2121
- The **timeline** page displays one day's entries like a calendar.
2222
- Export your data in CSV format.
23+
- **[Kimai](https://www.kimai.org) integration:** Simply bring your domain and API key. Choose a project and activity from Kimai for new entries. All future entries will be synced to Kimai, including notes.
2324

2425
Thyme is in development, and more features are on the way.
2526

electron/ipcChannels.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,16 @@
11
export const ipcPushChannels = [
2-
"toggleActive",
3-
"reduceTimeEntries",
4-
"reduceNotes",
5-
"deleteAllTimeEntriesAndNotes",
6-
"loadMockData",
2+
"dispatch",
73
"openJSON",
84
"exportCSV",
95
"setTimelineDay",
106
"openPage",
11-
"closePage"
7+
"closePage",
128
] as const
139

1410
export const ipcPullChannels = [
11+
"errors",
1512
"state",
16-
"timelineDay"
13+
"timelineDay",
14+
"kimaiUsername",
15+
"kimaiProjectsAndActivities",
1716
] as const

electron/kimai/kimaiApi.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import dateFormat from "dateformat"
2+
import { midnight } from "../util"
3+
import { Note, TimeEntry } from "../schema"
4+
import z from "zod"
5+
6+
export class KimaiAPI {
7+
private url: string
8+
private authToken: string
9+
10+
public constructor(url: string, authToken: string) {
11+
this.url = url.endsWith("/") ? url.slice(0, -1) : url
12+
this.authToken = authToken
13+
}
14+
15+
// util
16+
17+
private async request(method: "GET" | "POST" | "DELETE", path: string, body?: unknown) {
18+
const response = await fetch(this.url + path, {
19+
method,
20+
headers: {
21+
"Authorization": `Bearer ${this.authToken}`,
22+
"Content-Type": "application/json"
23+
},
24+
body: JSON.stringify(body)
25+
})
26+
if (!response.ok) {
27+
throw new Error(`Error fetching ${method} ${path}: ${response.statusText}\nBody: ${body ?? "none"}\nResponse: ${await response.text()}`)
28+
}
29+
return response
30+
}
31+
32+
private async get<T>(path: string, schema?: z.ZodType<T>) {
33+
const response = await (await this.request("GET", path, undefined)).json() as T
34+
if (schema) return schema.parse(response)
35+
return response
36+
}
37+
private async post<T>(path: string, body?: unknown) {
38+
return (await this.request("POST", path, body)).json() as T
39+
}
40+
private async delete(path: string) {
41+
await this.request("DELETE", path, undefined)
42+
}
43+
44+
// endpoints
45+
46+
public async fetchCurrentUser() {
47+
return await this.get("/api/users/me", z.object({
48+
alias: z.string(),
49+
username: z.string(),
50+
}))
51+
}
52+
53+
public async fetchProjects() {
54+
return await this.get("/api/projects", z.array(z.object({
55+
id: z.number(),
56+
name: z.string(),
57+
globalActivities: z.boolean()
58+
})))
59+
}
60+
61+
public async fetchActivities() {
62+
return await this.get("/api/activities", z.array(z.object({
63+
id: z.number(),
64+
name: z.string(),
65+
project: z.number().nullable()
66+
})))
67+
}
68+
69+
public async fetchThymeTimesheets(day: Date) {
70+
const start = formatDate(midnight(day, false))
71+
const end = formatDate(midnight(day, true))
72+
return await this.get(`/api/timesheets?begin=${start}&end=${end}&tags%5B%5D=thyme`, z.array(z.object({
73+
id: z.string()
74+
})))
75+
}
76+
77+
public async createThymeTimesheet(projectId: number, activityId: number, entry: TimeEntry, notes: Note[]) {
78+
return await this.post<{ id: number }>("/api/timesheets", {
79+
project: projectId,
80+
activity: activityId,
81+
begin: formatDate(entry.startTime),
82+
end: formatDate(entry.endTime),
83+
description: notes.map(note => `${dateFormat(note.time, "HH:MM")}: ${note.text}`).join("\n"),
84+
tags: ["thyme"].join(","),
85+
})
86+
}
87+
88+
public async deleteTimesheet(id: number) {
89+
return await this.delete(`/api/timesheets/${id}`)
90+
}
91+
}
92+
93+
function formatDate(date: Date) {
94+
return dateFormat(date, "yyyy-mm-dd") + "T" + dateFormat(date, "HH:MM:ss")
95+
}

electron/kimai/kimaiIntegration.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { auditTime, distinctUntilChanged, filter, map, shareReplay, switchMap } from "rxjs"
2+
import { PersistentState } from "../persistentState"
3+
import { KimaiAPI } from "./kimaiApi"
4+
import { actions, Kimai } from "../schema"
5+
import { showGlobalError } from "../main"
6+
7+
export function makeKimaiIntegration(persistentState: PersistentState) {
8+
const api$ = persistentState.getState().pipe(
9+
map(state => state.kimai === undefined ? undefined : new KimaiAPI(state.kimai!.url, state.kimai!.authToken)),
10+
shareReplay(1)
11+
)
12+
13+
const username$ = api$.pipe(
14+
filter(api => api !== undefined),
15+
switchMap(async api => {
16+
try {
17+
const user = (await api.fetchCurrentUser())
18+
return user.alias ?? user.username
19+
} catch (e) {
20+
showGlobalError("Failed to fetch Kimai user", e)
21+
return undefined
22+
}
23+
}),
24+
filter(username => username !== undefined),
25+
shareReplay(1)
26+
)
27+
28+
const projectsAndActivities$ = api$.pipe(
29+
filter(api => api !== undefined),
30+
switchMap(async api => {
31+
const [projects, activities] = await Promise.all([
32+
api.fetchProjects(),
33+
api.fetchActivities()
34+
])
35+
return projects.map(project => ({
36+
project: { id: project.id, name: project.name },
37+
activities: activities
38+
.filter(activity => activity.project === project.id || (project.globalActivities && activity.project === null))
39+
.map(activity => ({ id: activity.id, name: activity.name }))
40+
}))
41+
}),
42+
shareReplay(1)
43+
)
44+
45+
// sync entries
46+
persistentState.getState().pipe(
47+
distinctUntilChanged((previous, current) => {
48+
return JSON.stringify(previous) === JSON.stringify(current)
49+
}),
50+
auditTime(1000),
51+
switchMap(async state => {
52+
if (state.kimai === undefined) return
53+
const kimai = state.kimai!
54+
const api = new KimaiAPI(kimai.url, kimai.authToken)
55+
56+
// compute entries to compare, handling cutoff and ignoring those without Kimai activity fields
57+
const currentEntries = state.timeEntries
58+
.filter(entry => entry.startTime.getTime() >= kimai.cutoff.getTime())
59+
.filter(entry => entry.activity?.kimai !== undefined)
60+
.map(entry => {
61+
const entryNotes = state.notes.filter(note => entry.startTime.getTime() <= note.time.getTime() && note.time.getTime() <= entry.endTime.getTime())
62+
return { entry, entryNotes }
63+
})
64+
const uploadedEntries = kimai.uploadedEntries.filter(entry => entry.entry.startTime.getTime() >= kimai.cutoff.getTime())
65+
66+
const promises: Promise<Kimai["uploadedEntries"][0] | undefined>[] = []
67+
68+
// write current entries
69+
for (const currentEntry of currentEntries) {
70+
promises.push((async () => {
71+
const uploadedEntry = uploadedEntries.find(uploadedEntry => uploadedEntry.entry.id === currentEntry.entry.id)
72+
if (uploadedEntry === undefined || JSON.stringify(currentEntry.entry) !== JSON.stringify(uploadedEntry.entry) || JSON.stringify(currentEntry.entryNotes) !== JSON.stringify(uploadedEntry.notes)) {
73+
if (uploadedEntry !== undefined) {
74+
try {
75+
await api.deleteTimesheet(uploadedEntry.kimaiTimesheetId)
76+
console.log("Deleted outdated entry: ", uploadedEntry.entry.startTime)
77+
} catch (e) {
78+
showGlobalError("Failed to delete Kimai entry", e)
79+
}
80+
}
81+
const kimaiActivity = currentEntry.entry.activity!.kimai! // filtered before
82+
try {
83+
const { id: timesheetId } = await api.createThymeTimesheet(kimaiActivity.projectId, kimaiActivity.activityId, currentEntry.entry, currentEntry.entryNotes)
84+
console.log("Created entry: ", currentEntry.entry.startTime)
85+
return { entry: currentEntry.entry, notes: currentEntry.entryNotes, kimaiTimesheetId: timesheetId }
86+
} catch (e) {
87+
showGlobalError("Failed to create Kimai entry", e)
88+
return uploadedEntry
89+
}
90+
} else {
91+
return uploadedEntry // keep existing uploaded entry
92+
}
93+
})())
94+
}
95+
96+
// delete uploaded entries that don't exist
97+
for (const uploadedEntry of uploadedEntries) {
98+
promises.push((async () => {
99+
const currentEntry = currentEntries.find(currentEntry => currentEntry.entry.id === uploadedEntry.entry.id)
100+
if (currentEntry === undefined) {
101+
try {
102+
await api.deleteTimesheet(uploadedEntry.kimaiTimesheetId)
103+
console.log("Deleted entry: ", uploadedEntry.entry.startTime)
104+
} catch (e) {
105+
showGlobalError("Failed to delete Kimai entry", e)
106+
}
107+
}
108+
return undefined
109+
})())
110+
}
111+
112+
return (await Promise.all(promises)).filter(entry => entry !== undefined)
113+
})
114+
).subscribe(newUploadedEntries => {
115+
if (newUploadedEntries !== undefined) persistentState.dispatch(actions.updateKimaiUploadedEntries(newUploadedEntries))
116+
})
117+
118+
return { api$, projectsAndActivities$, username$ }
119+
}

electron/main.ts

Lines changed: 29 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@ import { fileURLToPath } from "node:url"
44
import path from "node:path"
55
import { TrayIcon } from "./trayIcon"
66
import { PersistentState } from "./persistentState"
7-
import { BehaviorSubject, filter, first, Observable } from "rxjs"
7+
import { BehaviorSubject, filter, Observable } from "rxjs"
88
import fs from "node:fs"
99
import { ipcPullChannels, ipcPushChannels } from "./ipcChannels"
1010
import { pages, WindowManager } from "./windowManager"
11-
import { NotesAction, TimeEntriesAction } from "./types"
11+
import { makeKimaiIntegration } from "./kimai/kimaiIntegration"
12+
import { actions } from "./schema"
1213

1314
export const __dirname = path.dirname(fileURLToPath(import.meta.url))
1415
process.env.APP_ROOT = path.join(__dirname, "..")
@@ -20,13 +21,28 @@ export const RENDERER_DIST = path.join(process.env.APP_ROOT, "dist")
2021

2122
process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL ? path.join(process.env.APP_ROOT, "public") : RENDERER_DIST
2223

24+
const errors$ = new BehaviorSubject<string[]>([])
25+
export function showGlobalError(msg: string, err: unknown) {
26+
console.error(err)
27+
errors$.next(errors$.getValue().concat([`${msg}: ${err}`]))
28+
}
29+
2330
const userDataDir = isDev ? path.join(process.env.APP_ROOT, "dev-data") : app.getPath("userData")
2431
if (!fs.existsSync(userDataDir)) {
2532
fs.mkdirSync(userDataDir, { recursive: true })
2633
}
2734

2835
const persistentStateFile = path.join(userDataDir, "data.json")
29-
const persistentState = new PersistentState(persistentStateFile)
36+
const persistentState = (() => {
37+
try {
38+
return new PersistentState(persistentStateFile)
39+
} catch (e) {
40+
showGlobalError("Failed to load data.json", e)
41+
return null as unknown as PersistentState
42+
}
43+
})()
44+
45+
const kimaiIntegration = makeKimaiIntegration(persistentState)
3046

3147
const windowManager = new WindowManager()
3248

@@ -46,7 +62,7 @@ app.whenReady().then(() => {
4662
})
4763
Object.keys(PullIPC).forEach((channel) => {
4864
let lastValue: any
49-
PullIPC[channel as keyof typeof PullIPC].subscribe(value => {
65+
(PullIPC[channel as keyof typeof PullIPC] as Observable<any>).subscribe(value => {
5066
lastValue = value
5167
windowManager.sendAll(("listen__" + channel), value)
5268
})
@@ -60,8 +76,8 @@ app.whenReady().then(() => {
6076
// tray icon
6177
new TrayIcon({
6278
vitePublicDirectory: process.env.VITE_PUBLIC,
63-
activeStartTime$: persistentState.getActiveStartTime(),
64-
toggleActive: () => toggleActive(),
79+
state$: persistentState.getState(),
80+
toggleActive: () => persistentState.dispatch(actions.toggleActive()),
6581
toggleOpen: () => {
6682
const window = windowManager.findWindow(pages.dashboard)
6783
if (window?.isVisible()) {
@@ -76,25 +92,10 @@ app.whenReady().then(() => {
7692
})
7793
})
7894

79-
function toggleActive() {
80-
persistentState.getActiveStartTime().pipe(first()).subscribe(activeStartTime => {
81-
if (activeStartTime === null) {
82-
persistentState.setActiveStartTime(new Date())
83-
} else {
84-
persistentState.setActiveStartTime(null)
85-
persistentState.reduceTimeEntries([{ action: "create", entry: { startTime: activeStartTime, endTime: new Date() } }])
86-
}
87-
})
88-
}
89-
9095
const timelineDay$ = new BehaviorSubject<string | null>(null)
9196

9297
export const PushIPC = {
93-
toggleActive: () => toggleActive(),
94-
reduceTimeEntries: (...actions: TimeEntriesAction[]) => persistentState.reduceTimeEntries(actions),
95-
reduceNotes: (...actions: NotesAction[]) => persistentState.reduceNotes(actions),
96-
deleteAllTimeEntriesAndNotes: () => persistentState.deleteAllTimeEntriesAndNotes(),
97-
loadMockData: () => persistentState.loadMockData(),
98+
dispatch: persistentState.dispatch,
9899
openJSON: () => shell.showItemInFolder(persistentStateFile),
99100
exportCSV: async (type: "byDay" | "allEntries") => {
100101
const exportPath = await dialog.showSaveDialog({ title: "Export CSV", buttonLabel: "Export", filters: [{ name: "CSV", extensions: ["csv"] }] })
@@ -104,11 +105,14 @@ export const PushIPC = {
104105
}
105106
},
106107
setTimelineDay: (date: string) => timelineDay$.next(date),
107-
openPage: (page: "history" | "timeline" | "settings") => windowManager.openOrShowPage(pages[page]),
108+
openPage: (page: "history" | "timeline" | "settings" | "selectKimaiActivityDialog") => windowManager.openOrShowPage(pages[page]),
108109
closePage: (pageId: string) => windowManager.closeWindow(pageId),
109110
} satisfies { [key in typeof ipcPushChannels[number]]: (...args: any[]) => any }
110111

111-
export const PullIPC: { [key in typeof ipcPullChannels[number]]: Observable<any> } = {
112+
export const PullIPC = {
113+
errors: errors$.asObservable(),
112114
state: persistentState.getState(),
113115
timelineDay: timelineDay$.pipe(filter(day => day !== null)),
114-
}
116+
kimaiUsername: kimaiIntegration.username$,
117+
kimaiProjectsAndActivities: kimaiIntegration.projectsAndActivities$,
118+
} satisfies { [key in typeof ipcPullChannels[number]]: Observable<any> }

0 commit comments

Comments
 (0)