@knpkv/clockify-api-client 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/.specs/VERSION +1 -0
  2. package/.specs/clockify-v1.json +31670 -0
  3. package/.specs/clockify-v1.patch.json +48 -0
  4. package/CHANGELOG.md +11 -0
  5. package/LICENSE +21 -0
  6. package/README.md +121 -0
  7. package/dist/ClockifyApiClient.d.ts +67 -0
  8. package/dist/ClockifyApiClient.d.ts.map +1 -0
  9. package/dist/ClockifyApiClient.js +141 -0
  10. package/dist/ClockifyApiClient.js.map +1 -0
  11. package/dist/ClockifyApiConfig.d.ts +25 -0
  12. package/dist/ClockifyApiConfig.d.ts.map +1 -0
  13. package/dist/ClockifyApiConfig.js +16 -0
  14. package/dist/ClockifyApiConfig.js.map +1 -0
  15. package/dist/ClockifyApiError.d.ts +11 -0
  16. package/dist/ClockifyApiError.d.ts.map +1 -0
  17. package/dist/ClockifyApiError.js +14 -0
  18. package/dist/ClockifyApiError.js.map +1 -0
  19. package/dist/OpenApiFetchClient.d.ts +55 -0
  20. package/dist/OpenApiFetchClient.d.ts.map +1 -0
  21. package/dist/OpenApiFetchClient.js +63 -0
  22. package/dist/OpenApiFetchClient.js.map +1 -0
  23. package/dist/generated/index.d.ts +2 -0
  24. package/dist/generated/index.d.ts.map +1 -0
  25. package/dist/generated/index.js +2 -0
  26. package/dist/generated/index.js.map +1 -0
  27. package/dist/generated/schema.d.ts +16204 -0
  28. package/dist/index.d.ts +12 -0
  29. package/dist/index.d.ts.map +1 -0
  30. package/dist/index.js +10 -0
  31. package/dist/index.js.map +1 -0
  32. package/package.json +73 -0
  33. package/scripts/regenerate.ts +138 -0
  34. package/src/ClockifyApiClient.ts +278 -0
  35. package/src/ClockifyApiConfig.ts +25 -0
  36. package/src/ClockifyApiError.ts +17 -0
  37. package/src/OpenApiFetchClient.ts +92 -0
  38. package/src/generated/index.ts +1 -0
  39. package/src/generated/schema.d.ts +16204 -0
  40. package/src/index.ts +35 -0
  41. package/tsconfig.json +11 -0
  42. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Root barrel export for `@knpkv/clockify-api-client` -- openapi-fetch + Effect Clockify REST client.
3
+ *
4
+ * @packageDocumentation
5
+ */
6
+ export { ClockifyApiClient, type ClockifyApiClientShape, layer } from "./ClockifyApiClient.js";
7
+ export type { CreateTimeEntryParams, GetTimeEntriesParams, Project, StopTimeEntryParams, Tag, TimeEntry, TimeInterval, UpdateTimeEntryParams, User, Workspace } from "./ClockifyApiClient.js";
8
+ export { ClockifyApiConfig, type ClockifyApiConfigShape } from "./ClockifyApiConfig.js";
9
+ export { ClockifyApiError } from "./ClockifyApiError.js";
10
+ export { FetchClientError, makeOpenApiFetchClient, type OpenApiFetchClient, type SuccessData, toEffect } from "./OpenApiFetchClient.js";
11
+ export type * as V1 from "./generated/index.js";
12
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,iBAAiB,EAAE,KAAK,sBAAsB,EAAE,KAAK,EAAE,MAAM,wBAAwB,CAAA;AAE9F,YAAY,EACV,qBAAqB,EACrB,oBAAoB,EACpB,OAAO,EACP,mBAAmB,EACnB,GAAG,EACH,SAAS,EACT,YAAY,EACZ,qBAAqB,EACrB,IAAI,EACJ,SAAS,EACV,MAAM,wBAAwB,CAAA;AAE/B,OAAO,EAAE,iBAAiB,EAAE,KAAK,sBAAsB,EAAE,MAAM,wBAAwB,CAAA;AAEvF,OAAO,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAA;AAExD,OAAO,EACL,gBAAgB,EAChB,sBAAsB,EACtB,KAAK,kBAAkB,EACvB,KAAK,WAAW,EAChB,QAAQ,EACT,MAAM,yBAAyB,CAAA;AAGhC,YAAY,KAAK,EAAE,MAAM,sBAAsB,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Root barrel export for `@knpkv/clockify-api-client` -- openapi-fetch + Effect Clockify REST client.
3
+ *
4
+ * @packageDocumentation
5
+ */
6
+ export { ClockifyApiClient, layer } from "./ClockifyApiClient.js";
7
+ export { ClockifyApiConfig } from "./ClockifyApiConfig.js";
8
+ export { ClockifyApiError } from "./ClockifyApiError.js";
9
+ export { FetchClientError, makeOpenApiFetchClient, toEffect } from "./OpenApiFetchClient.js";
10
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,iBAAiB,EAA+B,KAAK,EAAE,MAAM,wBAAwB,CAAA;AAe9F,OAAO,EAAE,iBAAiB,EAA+B,MAAM,wBAAwB,CAAA;AAEvF,OAAO,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAA;AAExD,OAAO,EACL,gBAAgB,EAChB,sBAAsB,EAGtB,QAAQ,EACT,MAAM,yBAAyB,CAAA"}
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "@knpkv/clockify-api-client",
3
+ "version": "0.2.0",
4
+ "description": "Effect-based Clockify REST API client",
5
+ "license": "MIT",
6
+ "author": "knpkv",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/knpkv/npm.git",
10
+ "directory": "packages/clockify-api-client"
11
+ },
12
+ "type": "module",
13
+ "sideEffects": false,
14
+ "main": "./dist/index.js",
15
+ "types": "./dist/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "import": "./dist/index.js",
19
+ "types": "./dist/index.d.ts"
20
+ },
21
+ "./ClockifyApiClient": {
22
+ "import": "./dist/ClockifyApiClient.js",
23
+ "types": "./dist/ClockifyApiClient.d.ts"
24
+ },
25
+ "./ClockifyApiConfig": {
26
+ "import": "./dist/ClockifyApiConfig.js",
27
+ "types": "./dist/ClockifyApiConfig.d.ts"
28
+ },
29
+ "./ClockifyApiError": {
30
+ "import": "./dist/ClockifyApiError.js",
31
+ "types": "./dist/ClockifyApiError.d.ts"
32
+ },
33
+ "./generated": {
34
+ "import": "./dist/generated/index.js",
35
+ "types": "./dist/generated/index.d.ts"
36
+ }
37
+ },
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "peerDependencies": {
42
+ "effect": "^3.19.3"
43
+ },
44
+ "dependencies": {
45
+ "openapi-fetch": "^0.17.0"
46
+ },
47
+ "devDependencies": {
48
+ "@effect/vitest": "latest",
49
+ "@types/node": "latest",
50
+ "effect": "latest",
51
+ "openapi-typescript": "^7.13.0",
52
+ "openapi-typescript-helpers": "^0.1.0",
53
+ "tsx": "^4.19.4",
54
+ "typescript": "~5.9.0",
55
+ "vitest": "^4.0.13"
56
+ },
57
+ "keywords": [
58
+ "effect",
59
+ "effect-ts",
60
+ "clockify",
61
+ "time-tracking",
62
+ "api-client",
63
+ "openapi"
64
+ ],
65
+ "scripts": {
66
+ "build": "tsc && cp src/generated/schema.d.ts dist/generated/schema.d.ts",
67
+ "check": "tsc --noEmit",
68
+ "test": "vitest run",
69
+ "test:watch": "vitest",
70
+ "lint": "eslint src",
71
+ "regenerate": "tsx scripts/regenerate.ts"
72
+ }
73
+ }
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Regeneration script for Clockify API client.
4
+ *
5
+ * 1. Fetches latest OpenAPI spec from Clockify docs
6
+ * 2. Applies local patches (.specs/clockify-v1.patch.json) to fix incomplete DTOs
7
+ * 3. Generates TypeScript types via openapi-typescript
8
+ */
9
+ import * as Console from "effect/Console"
10
+ import * as Effect from "effect/Effect"
11
+ import { execSync } from "node:child_process"
12
+ import * as fs from "node:fs"
13
+ import * as path from "node:path"
14
+ import { fileURLToPath } from "node:url"
15
+
16
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
17
+ const SPECS_DIR = path.join(__dirname, "..", ".specs")
18
+ const SPEC_FILE = path.join(SPECS_DIR, "clockify-v1.json")
19
+ const PATCH_FILE = path.join(SPECS_DIR, "clockify-v1.patch.json")
20
+ const OUTPUT_FILE = path.join(__dirname, "..", "src", "generated", "schema.d.ts")
21
+ const VERSION_FILE = path.join(SPECS_DIR, "VERSION")
22
+ const SPEC_URL = "https://docs.clockify.me/openapi.json"
23
+
24
+ interface SpecInfo {
25
+ version: string
26
+ title: string
27
+ }
28
+
29
+ const fetchSpecInfo = (): Effect.Effect<SpecInfo, Error> =>
30
+ Effect.tryPromise({
31
+ try: async () => {
32
+ const response = await fetch(SPEC_URL)
33
+ if (!response.ok) throw new Error(`Failed to fetch: ${response.status}`)
34
+ const spec = (await response.json()) as { info: { version: string; title: string } }
35
+ return { version: spec.info.version, title: spec.info.title }
36
+ },
37
+ catch: (e) => new Error(`Fetch failed: ${e}`)
38
+ })
39
+
40
+ const fetchAndSaveSpec = (): Effect.Effect<void, Error> =>
41
+ Effect.tryPromise({
42
+ try: async () => {
43
+ const response = await fetch(SPEC_URL)
44
+ if (!response.ok) throw new Error(`Failed to fetch: ${response.status}`)
45
+ fs.writeFileSync(SPEC_FILE, await response.text(), "utf-8")
46
+ },
47
+ catch: (e) => new Error(`Fetch/save failed: ${e}`)
48
+ })
49
+
50
+ /**
51
+ * Apply patches from clockify-v1.patch.json to the spec in-place.
52
+ * Keeps original spec clean — patches add missing fields and required arrays.
53
+ */
54
+ const applyPatches = (): Effect.Effect<void, Error> =>
55
+ Effect.try({
56
+ try: () => {
57
+ if (!fs.existsSync(PATCH_FILE)) return
58
+
59
+ const spec = JSON.parse(fs.readFileSync(SPEC_FILE, "utf-8"))
60
+ const patches = JSON.parse(fs.readFileSync(PATCH_FILE, "utf-8"))
61
+ const schemas = spec.components?.schemas ?? {}
62
+
63
+ for (const [schemaName, patch] of Object.entries(patches.schemas ?? {}) as Array<[string, Record<string, unknown>]>) {
64
+ const schema = schemas[schemaName]
65
+ if (!schema) continue
66
+
67
+ // Rename properties (e.g. get_id → id)
68
+ if (patch.renameProperties) {
69
+ for (const [from, to] of Object.entries(patch.renameProperties as Record<string, string>)) {
70
+ if (schema.properties?.[from]) {
71
+ schema.properties[to] = schema.properties[from]
72
+ delete schema.properties[from]
73
+ }
74
+ }
75
+ }
76
+
77
+ // Add new properties
78
+ if (patch.addProperties) {
79
+ schema.properties = { ...schema.properties, ...patch.addProperties }
80
+ }
81
+
82
+ // Patch existing properties (overwrite)
83
+ if (patch.patchProperties) {
84
+ for (const [name, value] of Object.entries(patch.patchProperties as Record<string, unknown>)) {
85
+ schema.properties[name] = value
86
+ }
87
+ }
88
+
89
+ // Set required fields
90
+ if (patch.required) {
91
+ schema.required = patch.required
92
+ }
93
+ }
94
+
95
+ fs.writeFileSync(SPEC_FILE, JSON.stringify(spec, null, 2), "utf-8")
96
+ },
97
+ catch: (e) => new Error(`Patch failed: ${e}`)
98
+ })
99
+
100
+ const generateTypes = (): Effect.Effect<void, Error> =>
101
+ Effect.try({
102
+ try: () => {
103
+ execSync(`npx openapi-typescript "${SPEC_FILE}" -o "${OUTPUT_FILE}"`, {
104
+ cwd: path.join(__dirname, ".."),
105
+ stdio: "inherit"
106
+ })
107
+ },
108
+ catch: (e) => new Error(`Generation failed: ${e}`)
109
+ })
110
+
111
+ const program = Effect.gen(function*() {
112
+ yield* Console.log("Fetching Clockify API spec version...")
113
+
114
+ const info = yield* fetchSpecInfo()
115
+ const current = fs.existsSync(VERSION_FILE) ? fs.readFileSync(VERSION_FILE, "utf-8").trim() : null
116
+
117
+ yield* Console.log(`Current: ${current ?? "none"}, Remote: ${info.version}`)
118
+
119
+ if (current === info.version) {
120
+ yield* Console.log("Spec up to date, regenerating types...")
121
+ } else {
122
+ if (!fs.existsSync(SPECS_DIR)) fs.mkdirSync(SPECS_DIR, { recursive: true })
123
+
124
+ yield* Console.log(`Fetching spec (${info.version})...`)
125
+ yield* fetchAndSaveSpec()
126
+ fs.writeFileSync(VERSION_FILE, info.version, "utf-8")
127
+ }
128
+
129
+ yield* Console.log("Applying patches...")
130
+ yield* applyPatches()
131
+
132
+ yield* Console.log("Generating types...")
133
+ yield* generateTypes()
134
+
135
+ yield* Console.log("Done.")
136
+ })
137
+
138
+ Effect.runPromise(program).catch(console.error)
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Effect Layer wrapping openapi-fetch Clockify v1 client with auth and base URL.
3
+ *
4
+ * **Mental model**
5
+ *
6
+ * - **Auth via X-Api-Key**: The layer reads {@link ClockifyApiConfig} to build the
7
+ * `X-Api-Key` header and derive the base URL.
8
+ * - **openapi-fetch wrapper**: Uses {@link OpenApiFetchClient} for type-safe HTTP calls.
9
+ * - **Method-based interface**: Exposes convenience methods (getUser, createTimeEntry, etc.)
10
+ * for backwards compatibility with jira-clockify consumers.
11
+ * - **Raw API access**: `.api` exposes the raw `OpenApiFetchClient<paths>` for direct access.
12
+ *
13
+ * **Common tasks**
14
+ *
15
+ * - Use the client: `const clockify = yield* ClockifyApiClient`
16
+ * - Call a method: `clockify.getProjects(workspaceId)`
17
+ * - Raw access: `toEffect(clockify.api.client.GET("/v1/user"))`
18
+ * - Provide the layer: `Effect.provide(ClockifyApiClient.layer)`
19
+ *
20
+ * @module
21
+ */
22
+ import * as Context from "effect/Context"
23
+ import * as Effect from "effect/Effect"
24
+ import * as Layer from "effect/Layer"
25
+ import * as Redacted from "effect/Redacted"
26
+ import { ClockifyApiConfig } from "./ClockifyApiConfig.js"
27
+ import type { components, paths } from "./generated/schema.js"
28
+ import {
29
+ type FetchClientError,
30
+ makeOpenApiFetchClient,
31
+ type OpenApiFetchClient,
32
+ toEffect
33
+ } from "./OpenApiFetchClient.js"
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Domain types — derived from patched OpenAPI spec schema
37
+ // ---------------------------------------------------------------------------
38
+
39
+ export type User = components["schemas"]["UserDto"]
40
+ export type Workspace = components["schemas"]["WorkspaceDtoV1"]
41
+ export type Project = components["schemas"]["ProjectDtoV1"]
42
+ export type Tag = components["schemas"]["TagDto"]
43
+ export type TimeInterval = components["schemas"]["TimeIntervalDto"]
44
+ export type TimeEntry = components["schemas"]["TimeEntryDto"]
45
+ export type CreateTimeEntryParams = components["schemas"]["CreateTimeEntryRequest"]
46
+ export type StopTimeEntryParams = components["schemas"]["StopTimeEntryRequest"]
47
+ export type UpdateTimeEntryParams = components["schemas"]["UpdateTimeEntryRequest"]
48
+
49
+ export interface GetTimeEntriesParams {
50
+ readonly start?: string | undefined
51
+ readonly end?: string | undefined
52
+ readonly page?: number | undefined
53
+ readonly pageSize?: number | undefined
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Client shape
58
+ // ---------------------------------------------------------------------------
59
+
60
+ export interface ClockifyApiClientShape {
61
+ /** Raw openapi-fetch client for direct type-safe API access. */
62
+ readonly api: OpenApiFetchClient<paths>
63
+
64
+ readonly getUser: () => Effect.Effect<User, FetchClientError>
65
+ readonly getWorkspaces: () => Effect.Effect<ReadonlyArray<Workspace>, FetchClientError>
66
+ readonly getProjects: (workspaceId: string) => Effect.Effect<ReadonlyArray<Project>, FetchClientError>
67
+ readonly getProjectByName: (workspaceId: string, name: string) => Effect.Effect<Project | null, FetchClientError>
68
+ readonly createTimeEntry: (
69
+ workspaceId: string,
70
+ params: CreateTimeEntryParams
71
+ ) => Effect.Effect<TimeEntry, FetchClientError>
72
+ readonly stopTimer: (
73
+ workspaceId: string,
74
+ userId: string,
75
+ params: StopTimeEntryParams
76
+ ) => Effect.Effect<TimeEntry, FetchClientError>
77
+ readonly getTimeEntries: (
78
+ workspaceId: string,
79
+ userId: string,
80
+ params?: GetTimeEntriesParams
81
+ ) => Effect.Effect<ReadonlyArray<TimeEntry>, FetchClientError>
82
+ readonly getRunningTimer: (
83
+ workspaceId: string,
84
+ userId: string
85
+ ) => Effect.Effect<TimeEntry | null, FetchClientError>
86
+ readonly getTags: (workspaceId: string) => Effect.Effect<ReadonlyArray<Tag>, FetchClientError>
87
+ readonly createTag: (workspaceId: string, name: string) => Effect.Effect<Tag, FetchClientError>
88
+ readonly findOrCreateTag: (workspaceId: string, name: string) => Effect.Effect<Tag, FetchClientError>
89
+ readonly getTimeEntry: (
90
+ workspaceId: string,
91
+ timeEntryId: string
92
+ ) => Effect.Effect<TimeEntry, FetchClientError>
93
+ readonly deleteTimeEntry: (workspaceId: string, timeEntryId: string) => Effect.Effect<void, FetchClientError>
94
+ readonly updateTimeEntry: (
95
+ workspaceId: string,
96
+ timeEntryId: string,
97
+ params: UpdateTimeEntryParams
98
+ ) => Effect.Effect<TimeEntry, FetchClientError>
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Service tag + layer
103
+ // ---------------------------------------------------------------------------
104
+
105
+ export class ClockifyApiClient extends Context.Tag(
106
+ "@knpkv/clockify-api-client/ClockifyApiClient"
107
+ )<ClockifyApiClient, ClockifyApiClientShape>() {
108
+ static readonly layer: Layer.Layer<ClockifyApiClient, never, ClockifyApiConfig> = Layer.effect(
109
+ ClockifyApiClient,
110
+ Effect.gen(function*() {
111
+ const config = yield* ClockifyApiConfig
112
+
113
+ const headers = {
114
+ "X-Api-Key": Redacted.value(config.apiKey),
115
+ "Content-Type": "application/json"
116
+ }
117
+
118
+ const api = makeOpenApiFetchClient<paths>(config.baseUrl, headers)
119
+ const { client } = api
120
+
121
+ return {
122
+ api,
123
+
124
+ getUser: () => toEffect(client.GET("/v1/user")) as Effect.Effect<User, FetchClientError>,
125
+
126
+ getWorkspaces: () =>
127
+ toEffect(client.GET("/v1/workspaces")) as unknown as Effect.Effect<
128
+ ReadonlyArray<Workspace>,
129
+ FetchClientError
130
+ >,
131
+
132
+ getProjects: (workspaceId) =>
133
+ toEffect(
134
+ client.GET("/v1/workspaces/{workspaceId}/projects", {
135
+ params: {
136
+ path: { workspaceId },
137
+ query: { archived: false, "page-size": 500 }
138
+ }
139
+ })
140
+ ) as Effect.Effect<ReadonlyArray<Project>, FetchClientError>,
141
+
142
+ getProjectByName: (workspaceId, name) =>
143
+ Effect.gen(function*() {
144
+ const projects = yield* toEffect(
145
+ client.GET("/v1/workspaces/{workspaceId}/projects", {
146
+ params: {
147
+ path: { workspaceId },
148
+ query: { name, archived: false }
149
+ }
150
+ })
151
+ ) as Effect.Effect<ReadonlyArray<Project>, FetchClientError>
152
+ return projects.find((p) => p.name.toLowerCase() === name.toLowerCase()) ?? null
153
+ }),
154
+
155
+ createTimeEntry: (workspaceId, params) =>
156
+ toEffect(
157
+ client.POST("/v1/workspaces/{workspaceId}/time-entries", {
158
+ params: { path: { workspaceId } },
159
+ body: {
160
+ description: params.description,
161
+ start: params.start,
162
+ ...(params.end ? { end: params.end } : {}),
163
+ ...(params.projectId ? { projectId: params.projectId } : {}),
164
+ ...(params.taskId ? { taskId: params.taskId } : {}),
165
+ ...(params.billable !== undefined ? { billable: params.billable } : {}),
166
+ ...(params.tagIds !== undefined ? { tagIds: [...params.tagIds] } : {})
167
+ }
168
+ })
169
+ ) as Effect.Effect<TimeEntry, FetchClientError>,
170
+
171
+ stopTimer: (workspaceId, userId, params) =>
172
+ toEffect(
173
+ client.PATCH("/v1/workspaces/{workspaceId}/user/{userId}/time-entries", {
174
+ params: { path: { workspaceId, userId } },
175
+ body: { end: params.end }
176
+ })
177
+ ) as Effect.Effect<TimeEntry, FetchClientError>,
178
+
179
+ getTimeEntries: (workspaceId, userId, params) =>
180
+ toEffect(
181
+ client.GET("/v1/workspaces/{workspaceId}/user/{userId}/time-entries", {
182
+ params: {
183
+ path: { workspaceId, userId },
184
+ query: {
185
+ ...(params?.start !== undefined ? { start: params.start } : {}),
186
+ ...(params?.end !== undefined ? { end: params.end } : {}),
187
+ ...(params?.page !== undefined ? { page: params.page } : {}),
188
+ ...(params?.pageSize !== undefined ? { "page-size": params.pageSize } : {})
189
+ }
190
+ }
191
+ })
192
+ ) as Effect.Effect<ReadonlyArray<TimeEntry>, FetchClientError>,
193
+
194
+ getRunningTimer: (workspaceId, userId) =>
195
+ Effect.gen(function*() {
196
+ const entries = yield* toEffect(
197
+ client.GET("/v1/workspaces/{workspaceId}/user/{userId}/time-entries", {
198
+ params: {
199
+ path: { workspaceId, userId },
200
+ query: { "in-progress": true, "page-size": 1 }
201
+ }
202
+ })
203
+ ) as Effect.Effect<ReadonlyArray<TimeEntry>, FetchClientError>
204
+ return entries.length > 0 ? entries[0]! : null
205
+ }),
206
+
207
+ getTimeEntry: (workspaceId, timeEntryId) =>
208
+ toEffect(
209
+ client.GET("/v1/workspaces/{workspaceId}/time-entries/{id}", {
210
+ params: { path: { workspaceId, id: timeEntryId } }
211
+ })
212
+ ) as Effect.Effect<TimeEntry, FetchClientError>,
213
+
214
+ updateTimeEntry: (workspaceId, timeEntryId, params) =>
215
+ toEffect(
216
+ client.PUT("/v1/workspaces/{workspaceId}/time-entries/{id}", {
217
+ params: { path: { workspaceId, id: timeEntryId } },
218
+ body: {
219
+ start: params.start,
220
+ ...(params.end !== undefined ? { end: params.end } : {}),
221
+ ...(params.description !== undefined ? { description: params.description } : {}),
222
+ ...(params.projectId !== undefined ? { projectId: params.projectId } : {}),
223
+ ...(params.billable !== undefined ? { billable: params.billable } : {}),
224
+ ...(params.tagIds !== undefined ? { tagIds: [...params.tagIds] } : {})
225
+ }
226
+ })
227
+ ) as Effect.Effect<TimeEntry, FetchClientError>,
228
+
229
+ getTags: (workspaceId) =>
230
+ toEffect(
231
+ client.GET("/v1/workspaces/{workspaceId}/tags", {
232
+ params: {
233
+ path: { workspaceId },
234
+ query: { archived: false, "page-size": 200 }
235
+ }
236
+ })
237
+ ) as Effect.Effect<ReadonlyArray<Tag>, FetchClientError>,
238
+
239
+ createTag: (workspaceId, name) =>
240
+ toEffect(
241
+ client.POST("/v1/workspaces/{workspaceId}/tags", {
242
+ params: { path: { workspaceId } },
243
+ body: { name }
244
+ })
245
+ ) as Effect.Effect<Tag, FetchClientError>,
246
+
247
+ findOrCreateTag: (workspaceId, name) =>
248
+ Effect.gen(function*() {
249
+ const tags = yield* toEffect(
250
+ client.GET("/v1/workspaces/{workspaceId}/tags", {
251
+ params: {
252
+ path: { workspaceId },
253
+ query: { name, archived: false }
254
+ }
255
+ })
256
+ ) as Effect.Effect<ReadonlyArray<Tag>, FetchClientError>
257
+ const existing = tags.find((t) => t.name.toLowerCase() === name.toLowerCase())
258
+ if (existing) return existing
259
+ return yield* toEffect(
260
+ client.POST("/v1/workspaces/{workspaceId}/tags", {
261
+ params: { path: { workspaceId } },
262
+ body: { name }
263
+ })
264
+ ) as Effect.Effect<Tag, FetchClientError>
265
+ }),
266
+
267
+ deleteTimeEntry: (workspaceId, timeEntryId) =>
268
+ toEffect(
269
+ client.DELETE("/v1/workspaces/{workspaceId}/time-entries/{id}", {
270
+ params: { path: { workspaceId, id: timeEntryId } }
271
+ })
272
+ ) as Effect.Effect<void, FetchClientError>
273
+ }
274
+ })
275
+ )
276
+ }
277
+
278
+ export const layer = ClockifyApiClient.layer
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Configuration service tag for the Clockify API client.
3
+ *
4
+ * **Mental model**
5
+ *
6
+ * - **Context.Tag for DI**: {@link ClockifyApiConfig} carries API key (as `Redacted`),
7
+ * workspace ID, user ID, and base URL. Provided via `Layer.succeed` by the consumer.
8
+ * - **Redacted API key**: The `apiKey` field uses Effect's `Redacted` type to prevent
9
+ * accidental logging of credentials.
10
+ *
11
+ * @module
12
+ */
13
+ import * as Context from "effect/Context"
14
+ import type * as Redacted from "effect/Redacted"
15
+
16
+ export interface ClockifyApiConfigShape {
17
+ readonly apiKey: Redacted.Redacted<string>
18
+ readonly workspaceId: string
19
+ readonly userId: string
20
+ readonly baseUrl: string
21
+ }
22
+
23
+ export class ClockifyApiConfig extends Context.Tag(
24
+ "@knpkv/clockify-api-client/ClockifyApiConfig"
25
+ )<ClockifyApiConfig, ClockifyApiConfigShape>() {}
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Tagged error for Clockify API failures.
3
+ *
4
+ * **Mental model**
5
+ *
6
+ * - **Catch by tag**: `Effect.catchTag("ClockifyApiError", ...)` handles all API errors.
7
+ * The `status` field distinguishes HTTP errors (>0) from network errors (0).
8
+ *
9
+ * @module
10
+ */
11
+ import * as Data from "effect/Data"
12
+
13
+ export class ClockifyApiError extends Data.TaggedError("ClockifyApiError")<{
14
+ readonly status: number
15
+ readonly message: string
16
+ readonly cause?: unknown
17
+ }> {}
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Generic Effect wrapper for openapi-fetch clients.
3
+ *
4
+ * Exposes a type-safe `Client<Paths>` and a `toEffect` helper that wraps
5
+ * any `Promise<FetchResponse>` in Effect with error mapping.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * const page = yield* toEffect(client.GET("/pages/{id}", {
10
+ * params: { path: { id: 123 } }
11
+ * }))
12
+ * ```
13
+ *
14
+ * @module
15
+ */
16
+ import * as Data from "effect/Data"
17
+ import * as Effect from "effect/Effect"
18
+ import createClient, { type Client, type FetchResponse } from "openapi-fetch"
19
+ import type { MediaType } from "openapi-typescript-helpers"
20
+
21
+ /**
22
+ * Error from openapi-fetch operations.
23
+ *
24
+ * @category Errors
25
+ */
26
+ export class FetchClientError extends Data.TaggedError("FetchClientError")<{
27
+ readonly error: unknown
28
+ readonly status: number
29
+ readonly message: string
30
+ }> {}
31
+
32
+ /** Extract success `data` from a FetchResponse discriminated union. */
33
+ export type SuccessData<T> = T extends { data: infer D; error?: undefined } ? D
34
+ : T extends { data?: infer D } ? NonNullable<D>
35
+ : never
36
+
37
+ /**
38
+ * Wrap an openapi-fetch `Promise<FetchResponse>` in Effect.
39
+ *
40
+ * Extracts `data` on success, maps errors to `FetchClientError`.
41
+ * Fully type-safe — path/body constraints come from the `Client<Paths>` call site.
42
+ *
43
+ * @example
44
+ * ```typescript
45
+ * const page = yield* toEffect(client.GET("/pages/{id}", { params: { path: { id: 123 } } }))
46
+ * ```
47
+ *
48
+ * @category Utilities
49
+ */
50
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches FetchResponse generic constraint
51
+ export const toEffect = <T extends Record<string | number, any>, O, M extends MediaType>(
52
+ promise: Promise<FetchResponse<T, O, M>>
53
+ ): Effect.Effect<SuccessData<FetchResponse<T, O, M>>, FetchClientError> =>
54
+ Effect.tryPromise({
55
+ try: () =>
56
+ promise.then(({ data, error, response }) => {
57
+ if (error !== undefined || !response.ok) {
58
+ throw { error, status: response.status }
59
+ }
60
+ return data as SuccessData<FetchResponse<T, O, M>>
61
+ }),
62
+ catch: (e) =>
63
+ new FetchClientError({
64
+ error: (e as Record<string, unknown>).error ?? e,
65
+ status: ((e as Record<string, unknown>).status as number | undefined) ?? 0,
66
+ message: typeof (e as Record<string, unknown>).error === "string"
67
+ ? (e as Record<string, unknown>).error as string
68
+ : JSON.stringify((e as Record<string, unknown>).error ?? e)
69
+ })
70
+ })
71
+
72
+ /**
73
+ * openapi-fetch client paired with the `toEffect` helper.
74
+ *
75
+ * @category Client
76
+ */
77
+ export interface OpenApiFetchClient<Paths extends {}> {
78
+ /** Type-safe openapi-fetch client. Use with `toEffect()` to get Effect. */
79
+ readonly client: Client<Paths, "application/json">
80
+ }
81
+
82
+ /**
83
+ * Create an openapi-fetch client with auth headers pre-configured.
84
+ *
85
+ * @category Constructors
86
+ */
87
+ export const makeOpenApiFetchClient = <Paths extends {}>(
88
+ baseUrl: string,
89
+ headers: Record<string, string>
90
+ ): OpenApiFetchClient<Paths> => ({
91
+ client: createClient<Paths, "application/json">({ baseUrl, headers })
92
+ })
@@ -0,0 +1 @@
1
+ export type { paths, components, operations } from "./schema.js"