diff --git a/package.json b/package.json index 43d2343..f5c0197 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ "./platform-lenses/google-calendar/recurrence": "./platform-lenses/google-calendar/recurrence.ts", "./platform-lenses/github-issues/lens": "./platform-lenses/github-issues/lens/index.ts", "./platform-lenses/google-calendar/lens": "./platform-lenses/google-calendar/lens/index.ts", + "./platform-lenses/clockify": "./platform-lenses/clockify/index.ts", + "./platform-lenses/clockify/lens": "./platform-lenses/clockify/lens/index.ts", "./reflect": { "types": "./build/src/reflect/index.d.ts", "default": "./build/src/reflect/index.js" diff --git a/platform-lenses/clockify/index.ts b/platform-lenses/clockify/index.ts new file mode 100644 index 0000000..44abc76 --- /dev/null +++ b/platform-lenses/clockify/index.ts @@ -0,0 +1,2 @@ +/** Clockify lens: read-only time entry projection and project/member references. */ +export * from './lens/index.js'; diff --git a/platform-lenses/clockify/lens/index.ts b/platform-lenses/clockify/lens/index.ts new file mode 100644 index 0000000..6a46afe --- /dev/null +++ b/platform-lenses/clockify/lens/index.ts @@ -0,0 +1,3 @@ +/** Passive Clockify projection and project/member reference resolution. */ +export * from './types.js'; +export * from './projection.js'; diff --git a/platform-lenses/clockify/lens/projection.test.ts b/platform-lenses/clockify/lens/projection.test.ts new file mode 100644 index 0000000..f62c9f3 --- /dev/null +++ b/platform-lenses/clockify/lens/projection.test.ts @@ -0,0 +1,212 @@ +import { Datatype } from '@tomic/lib'; +import { describe, expect, it } from 'vitest'; +import type { FetchedPlatform } from './types.js'; +import { + clockifyFields, + clockifyProjection, + resolveClockifyReferences, +} from './projection.js'; + +const fixture = ( + records: Array>, +): FetchedPlatform => ({ + platform: 'clockify', + ontology: { + description: '', + terms: [ + { + path: 'clockify/class/timeentry', + kind: 'class', + shortname: 'timeentry', + description: '', + datatype: Datatype.JSON, + requires: [], + recommends: ['clockify/property/description'], + }, + ], + }, + records: records.map((record, index) => ({ + resource: 'timeentry', + namespace: 'ws/user', + id: `entry-${index}`, + name: `entry-${index}`, + values: {}, + ...record, + })), +}); + +describe('clockifyProjection', () => { + it('adds start/end timestamps and names entries after their description', () => { + const projected = clockifyProjection( + fixture([ + { + values: { + description: ' Fix plugin loading ', + type: 'REGULAR', + timeinterval: { + start: '2026-09-08T11:00:00Z', + end: '2026-09-08T16:00:00Z', + duration: 'PT5H', + }, + }, + }, + ]), + ); + const entry = projected.ontology.terms.find((t) => t.kind === 'class')!; + const added = projected.ontology.terms.filter((t) => + t.path.startsWith('urn:atomic:clockify:'), + ); + expect(added.map((t) => [t.shortname, t.datatype])).toEqual([ + [clockifyFields.start, Datatype.TIMESTAMP], + [clockifyFields.end, Datatype.TIMESTAMP], + ]); + expect(entry.recommends).toEqual( + expect.arrayContaining(added.map((t) => t.path)), + ); + expect(projected.records).toHaveLength(1); + expect(projected.records[0].name).toBe('Fix plugin loading'); + expect(projected.records[0].values[clockifyFields.start]).toBe( + Date.parse('2026-09-08T11:00:00Z'), + ); + expect(projected.records[0].values[clockifyFields.end]).toBe( + Date.parse('2026-09-08T16:00:00Z'), + ); + expect(projected.records[0].values.description).toBe(' Fix plugin loading '); + }); + + it('skips running timers and breaks, and falls back to a generic name', () => { + const projected = clockifyProjection( + fixture([ + { + values: { + type: 'REGULAR', + timeinterval: { start: '2026-09-08T11:00:00Z', end: null }, + }, + }, + { + values: { + type: 'BREAK', + timeinterval: { + start: '2026-09-08T11:00:00Z', + end: '2026-09-08T11:30:00Z', + }, + }, + }, + { + values: { + type: 'REGULAR', + timeinterval: { + start: '2026-09-08T12:00:00Z', + end: '2026-09-08T12:30:00Z', + }, + }, + }, + ]), + ); + expect(projected.records.map((r) => r.id)).toEqual(['entry-2']); + expect(projected.records[0].name).toBe('Time entry'); + }); + + it('rejects entries without a valid start or with an inverted interval', () => { + expect(() => + clockifyProjection( + fixture([{ values: { timeinterval: { end: '2026-09-08T12:30:00Z' } } }]), + ), + ).toThrow(/no valid start/); + expect(() => + clockifyProjection( + fixture([ + { + values: { + timeinterval: { + start: '2026-09-08T12:30:00Z', + end: '2026-09-08T12:00:00Z', + }, + }, + }, + ]), + ), + ).toThrow(/ends before it starts/); + }); + + it('leaves other platforms untouched', () => { + const other = { ...fixture([]), platform: 'notion' }; + expect(clockifyProjection(other)).toBe(other); + }); + + it('passes project and member records through unchanged', () => { + const platform = fixture([ + { + values: { + type: 'REGULAR', + projectid: 'p-1', + userid: 'u-1', + timeinterval: { + start: '2026-09-08T11:00:00Z', + end: '2026-09-08T12:00:00Z', + }, + }, + }, + ]); + platform.records.push( + { resource: 'project', namespace: 'ws', id: 'p-1', name: 'Spec review', values: { name: 'Spec review' } }, + { resource: 'member', namespace: 'ws', id: 'u-1', name: 'Alice', values: { name: 'Alice', email: 'alice@example.com' } }, + ); + const projected = clockifyProjection(platform); + expect(projected.records).toHaveLength(3); + expect(projected.records.find((r) => r.resource === 'project')).toEqual( + platform.records.find((r) => r.resource === 'project'), + ); + expect(projected.records.find((r) => r.resource === 'member')).toEqual( + platform.records.find((r) => r.resource === 'member'), + ); + }); +}); + +describe('resolveClockifyReferences', () => { + it('resolves a time entry to its fetched project and member', () => { + const platform = fixture([ + { id: 'entry-0', values: { projectid: 'p-1', userid: 'u-1' } }, + ]); + platform.records.push( + { resource: 'project', namespace: 'ws', id: 'p-1', name: 'Spec review', values: {} }, + { resource: 'member', namespace: 'ws', id: 'u-1', name: 'Alice', values: {} }, + ); + expect(resolveClockifyReferences(platform)).toEqual([ + { + timeEntryId: 'entry-0', + projectId: 'p-1', + userId: 'u-1', + project: platform.records.find((r) => r.resource === 'project'), + member: platform.records.find((r) => r.resource === 'member'), + }, + ]); + }); + + it('reports a dangling reference when the target was not fetched', () => { + const platform = fixture([ + { id: 'entry-0', values: { projectid: 'p-missing', userid: 'u-1' } }, + ]); + platform.records.push({ + resource: 'member', + namespace: 'ws', + id: 'u-1', + name: 'Alice', + values: {}, + }); + const [resolution] = resolveClockifyReferences(platform); + expect(resolution.projectId).toBe('p-missing'); + expect(resolution.project).toBeUndefined(); + expect(resolution.member).toBeDefined(); + }); + + it('omits entries with neither a projectid nor a userid', () => { + const platform = fixture([{ id: 'entry-0', values: {} }]); + expect(resolveClockifyReferences(platform)).toEqual([]); + }); + + it('returns nothing for other platforms', () => { + const other = { ...fixture([]), platform: 'notion' }; + expect(resolveClockifyReferences(other)).toEqual([]); + }); +}); diff --git a/platform-lenses/clockify/lens/projection.ts b/platform-lenses/clockify/lens/projection.ts new file mode 100644 index 0000000..bbbb2fa --- /dev/null +++ b/platform-lenses/clockify/lens/projection.ts @@ -0,0 +1,171 @@ +// @wc-ignore-file +import { Datatype } from '@tomic/lib'; +import type { FetchedPlatform, FetchedRecord, JSONValue, Term } from './types.js'; + +export const CLOCKIFY_PLATFORM = 'clockify'; +export const TIME_ENTRY_RESOURCE = 'timeentry'; +export const PROJECT_RESOURCE = 'project'; +export const MEMBER_RESOURCE = 'member'; +/** Term shortnames the projection adds next to the provider's own fields. */ +export const clockifyFields = { + start: 'start', + end: 'end', +} as const; + +const object = (value: unknown): Record => + value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {}; + +const instant = (value: unknown): number | undefined => { + if (typeof value !== 'string') return undefined; + const ms = Date.parse(value); + return Number.isFinite(ms) ? ms : undefined; +}; + +/** + * Adds a typed start/end interval to every completed `timeentry` and names + * it after its description. Entries still running (no end) and breaks are + * skipped: the Time Tracker's own timer owns anything without an end, and a + * break is not work. `project`/`member` records (see + * `resolveClockifyReferences`) and any other provider fields pass through + * untouched. + */ +export function clockifyProjection(fetched: FetchedPlatform): FetchedPlatform { + if (fetched.platform !== CLOCKIFY_PLATFORM) return fetched; + const entry = fetched.ontology.terms.find( + (t) => t.kind === 'class' && t.shortname === TIME_ENTRY_RESOURCE, + ); + if (!entry) return fetched; + const definitions: [string, string][] = [ + [clockifyFields.start, "Start instant of the entry, from Clockify's UTC interval."], + [clockifyFields.end, "End instant of the completed entry, from Clockify's UTC interval."], + ]; + const terms: Term[] = definitions.map(([shortname, description]) => ({ + path: `urn:atomic:clockify:${shortname}`, + kind: 'property', + shortname, + datatype: Datatype.TIMESTAMP, + description, + requires: [], + recommends: [], + })); + if ( + fetched.ontology.terms.some((t) => + terms.some((extra) => extra.shortname === t.shortname), + ) + ) + throw new Error('Clockify projection property collides with provider ontology'); + const records: FetchedRecord[] = []; + + for (const row of fetched.records) { + if (row.resource !== TIME_ENTRY_RESOURCE) { + records.push(row); + continue; + } + + if (row.values.type === 'BREAK') continue; + const interval = object(row.values.timeinterval); + const start = instant(interval.start); + const end = instant(interval.end); + if (start === undefined) + throw new Error(`Clockify entry ${row.id} has no valid start`); + if (end === undefined) continue; + if (end < start) + throw new Error(`Clockify entry ${row.id} ends before it starts`); + const description = + typeof row.values.description === 'string' + ? row.values.description.trim() + : ''; + const values: Record = { + ...row.values, + [clockifyFields.start]: start, + [clockifyFields.end]: end, + }; + records.push({ + ...row, + name: description || 'Time entry', + values, + }); + } + + return { + ...fetched, + ontology: { + ...fetched.ontology, + terms: [ + ...fetched.ontology.terms.map((t) => + t === entry + ? { + ...t, + recommends: [...t.recommends, ...terms.map((extra) => extra.path)], + } + : t, + ), + ...terms, + ], + }, + records, + }; +} + +export interface ClockifyReferenceResolution { + timeEntryId: string; + /** Raw id from the time entry's own projectid/userid field, if present. */ + projectId?: string; + userId?: string; + /** The matching project/member record, when one was fetched in the same batch. */ + project?: FetchedRecord; + member?: FetchedRecord; +} + +/** + * Resolves each time entry's `projectid`/`userid` against `project`/`member` + * records fetched in the same batch (localthought/overlays' Clockify + * crud-causality overlay declares these as CRUD Causality 0.3.0 + * `references` — see pondersource/openapi-extensions#24). A raw id with no + * matching record is a dangling reference: the project or member exists in + * Clockify but wasn't part of this fetch (e.g. archived, or paginated out), + * not a data error, so this returns the id alone rather than throwing. + * + * This only resolves identity, the same way the CRUD Causality Reference + * Object does — it does not allocate or write an Atomic resource for the + * referenced project/member; that belongs to whatever layer owns identity + * and durable state for the imported table (see localthought/atomic-plugins#4). + */ +export function resolveClockifyReferences( + fetched: FetchedPlatform, +): ClockifyReferenceResolution[] { + if (fetched.platform !== CLOCKIFY_PLATFORM) return []; + const projectsById = new Map( + fetched.records + .filter((r) => r.resource === PROJECT_RESOURCE) + .map((r) => [r.id, r] as const), + ); + const membersById = new Map( + fetched.records + .filter((r) => r.resource === MEMBER_RESOURCE) + .map((r) => [r.id, r] as const), + ); + const resolutions: ClockifyReferenceResolution[] = []; + for (const row of fetched.records) { + if (row.resource !== TIME_ENTRY_RESOURCE) continue; + const projectId = + typeof row.values.projectid === 'string' ? row.values.projectid : undefined; + const userId = + typeof row.values.userid === 'string' ? row.values.userid : undefined; + if (projectId === undefined && userId === undefined) continue; + resolutions.push({ + timeEntryId: row.id, + ...(projectId === undefined ? {} : { projectId }), + ...(userId === undefined ? {} : { userId }), + ...(projectId !== undefined && projectsById.has(projectId) + ? { project: projectsById.get(projectId) } + : {}), + ...(userId !== undefined && membersById.has(userId) + ? { member: membersById.get(userId) } + : {}), + }); + } + return resolutions; +} diff --git a/platform-lenses/clockify/lens/types.ts b/platform-lenses/clockify/lens/types.ts new file mode 100644 index 0000000..a627327 --- /dev/null +++ b/platform-lenses/clockify/lens/types.ts @@ -0,0 +1,26 @@ +import type { Datatype, JSONValue } from '@tomic/lib'; +export type { JSONValue } from '@tomic/lib'; + +export interface Term { + path: string; + kind: 'class' | 'property'; + shortname: string; + description: string; + datatype: Datatype; + requires: string[]; + recommends: string[]; +} +export interface FetchedRecord { + resource: string; + namespace: string; + id: string; + name: string; + values: Record; +} +export interface FetchedPlatform { + platform: string; + ontology: { description: string; terms: Term[] }; + records: FetchedRecord[]; + /** Non-fatal problems from a partial fetch, e.g. a host-imposed record cap. */ + errors?: string[]; +}