@patimweb/pi-zep 1.0.1 → 1.0.2

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.
package/README.md CHANGED
@@ -107,17 +107,17 @@ Other message texts observed:
107
107
  ```
108
108
  zep_setup name=work userid=patrick.weppelmann password=… mandant=zepneoimpulse
109
109
  zep_status probe=true
110
- zep_projects projekt=Julabo
110
+ zep_projects projekt=Contoso
111
111
  zep_week
112
- zep_book datum=2026-09-17 von=08:00 bis=12:00 projekt=413 vorgang=90-01 taetigkeit=co bemerkung="3447 Ladeanzeige nach Pick"
112
+ zep_book datum=2026-09-17 von=08:00 bis=12:00 projekt=413 vorgang=90-01 taetigkeit=co bemerkung="4711 Beispiel-Ticket"
113
113
  zep_book datum=2026-09-17 entries=[{von:"08:00",bis:"12:00",bemerkung:"…"},{von:"13:00",bis:"17:00",bemerkung:"…"}]
114
114
  ```
115
115
 
116
116
  `projekt` and `vorgang` accept either an id (`413`, `3284`) or part of the label
117
- (`Julabo`, `90-01`). A full example, `dryRun` first:
117
+ (`Contoso`, `90-01`). A full example, `dryRun` first:
118
118
 
119
119
  ```
120
- zep_book projekt=Julabo vorgang=90-01 datum=2026-09-17 von=08:00 bis=12:00 dryRun=true
120
+ zep_book projekt=Contoso vorgang=90-01 datum=2026-09-17 von=08:00 bis=12:00 dryRun=true
121
121
  ```
122
122
 
123
123
  ## Important behaviour
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patimweb/pi-zep",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "ZEP time-tracking for pi coding agent — book project times (Projektzeiten) on zep-online.de over plain HTTP, without the paid ZEP API",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -491,8 +491,8 @@ export class ZepClient {
491
491
  letzterReiseOrt: "D-Office",
492
492
  ortProjektrelevant: "1",
493
493
  reise: "0",
494
- startort: "Gottmadingen Im Brühl 1",
495
- zielort: "Ismaningen Neoimpulse",
494
+ startort: "Beispielweg 1",
495
+ zielort: "Beispielort",
496
496
  fahrzeug: "Firmenwagen",
497
497
  km: "285",
498
498
  mitfahrer: "1",
@@ -297,13 +297,51 @@ function resolveDayDate(dayLabel: string, kwDate: string): string | null {
297
297
  return monday.toISOString().slice(0, 10);
298
298
  }
299
299
 
300
+ /**
301
+ * Label of a day row: `Do 17.09.`.
302
+ *
303
+ * The label cell also carries the meals/breaks icons, whose text content is
304
+ * literally `restaurant` / `local_cafe`; taking the plain text of the cell
305
+ * would render as `Do 17.09. restaurant`. Prefer the dedicated weekday and
306
+ * day/month divs and only fall back to text surgery.
307
+ */
308
+ function dayRowLabel(cellHtml: string, joined: string): string {
309
+ const weekday = /class="modimidofrsaso"[^>]*>\s*([A-Za-z]{2})\s*</.exec(cellHtml)?.[1];
310
+ const dayMonth = /class="tag_monat"[^>]*>\s*([0-9]{2}\.[0-9]{2}\.)\s*</.exec(cellHtml)?.[1];
311
+ if (weekday && dayMonth) return `${weekday} ${dayMonth}`;
312
+
313
+ return joined
314
+ .split(/\s{2,}/)[0]!
315
+ .replace(/\s*(restaurant|local_cafe)\b.*$/i, "")
316
+ .trim();
317
+ }
318
+
319
+ /**
320
+ * Place of work of a booking row, or null when ZEP does not render it.
321
+ *
322
+ * The Ort cell is the row's last column (`von` + 9) and, unlike the other
323
+ * metadata columns, it carries no `data-columnid` of its own. ZEP only emits
324
+ * it when there is something to report, so an absent cell means the booking
325
+ * sits on the default place of work (`NULL`). A trailing `*` marks a
326
+ * deviation from that default and is not part of the value.
327
+ */
328
+ function bookingOrt(flat: ReadonlyArray<string>, from: number): string | null {
329
+ return (flat[from + 9] ?? "").replace(/[\s*]+$/, "").trim() || null;
330
+ }
331
+
300
332
  /**
301
333
  * Parse the week table rendered into `#ProjektzeitTableDiv`.
302
334
  *
303
- * Row shapes observed on the live system:
304
- * day row `["Do 17.09. restaurant", ...]` (icons = meals/breaks)
305
- * booking row `["editdelete", von, bis, Dauer, "", Projekt, Vorgang, Taet, "payments", Bemerkung]`
306
- * sum row `["Summe", "8,00", "payments 8,00"]`
335
+ * Row shapes observed on the live system (2026-09-17, ZEP v7.13.88):
336
+ *
337
+ * day, no bookings `["Do 17.09. restaurant", ""]`
338
+ * day, with bookings `["Do 17.09. restaurant", "", "editdelete", von, bis, Dauer, "", Projekt, Vorgang, Taet, fakturierbar, Bemerkung, Ort]`
339
+ * further bookings `["editdelete", von, bis, Dauer, "", Projekt, Vorgang, Taet, "payments", Bemerkung, Ort]`
340
+ * sum row `["Summe", "4,00", ...]`
341
+ *
342
+ * The day's FIRST booking is rendered inside the day row itself (the label
343
+ * cell then has `rowspan=N`), which is why the booking columns must be read
344
+ * relative to the first `HH:MM` cell rather than at a fixed offset.
307
345
  */
308
346
  export function parseWeek(tableHtml: string, kwDate: string): ZepWeek {
309
347
  const days: ZepWeekDay[] = [];
@@ -341,19 +379,22 @@ export function parseWeek(tableHtml: string, kwDate: string): ZepWeek {
341
379
  // --- day header -------------------------------------------------------
342
380
  const joined = flat.join(" ").trim();
343
381
  const dayMatch = DAY_RE.exec(joined);
344
- if (dayMatch && cells.length <= 3) {
382
+ // A day row is identified by its `id="day_YYYY-MM-DD"` cell; the label
383
+ // text is only used as a fallback so that a markup change in the label
384
+ // cannot silently re-attach a day's bookings to the previous day.
385
+ const dayId =
386
+ /id="day_(\d{4}-\d{2}-\d{2})"/.exec(row.html) ??
387
+ /openMahlzeiten\s*\(\s*this\s*,\s*'(\d{4}-\d{2}-\d{2})'/.exec(row.html);
388
+ if (dayId || (dayMatch && cells.length <= 3)) {
345
389
  flush();
346
- const iso =
347
- /id="day_(\d{4}-\d{2}-\d{2})"/.exec(row.html) ??
348
- /openMahlzeiten\s*\(\s*this\s*,\s*'(\d{4}-\d{2}-\d{2})'/.exec(row.html);
349
- const label = joined.split(/\s{2,}/)[0] ?? joined;
390
+ const label = dayRowLabel(cells[0] ?? "", joined) || joined;
350
391
  current = {
351
392
  label,
352
- date: iso?.[1] ?? resolveDayDate(label, kwDate),
393
+ date: dayId?.[1] ?? resolveDayDate(label, kwDate),
353
394
  bookings: [],
354
395
  classes: row.classes,
355
396
  };
356
- continue;
397
+ // deliberately no `continue`: this row may carry the day's first booking
357
398
  }
358
399
 
359
400
  if (!current) continue;
@@ -368,22 +409,26 @@ export function parseWeek(tableHtml: string, kwDate: string): ZepWeek {
368
409
  }
369
410
 
370
411
  // --- booking row ------------------------------------------------------
371
- const from = /^\d{2}:\d{2}$/.test(flat[1] ?? "");
372
- if (!from) continue;
412
+ // Anchor on the `von` cell: cell 1 in a standalone booking row, cell 3
413
+ // behind the label in a day row that carries its first booking.
414
+ const from = flat.findIndex((c, index) => index > 0 && /^\d{2}:\d{2}$/.test(c));
415
+ if (from === -1) continue;
373
416
 
374
- const objectId = /objectId=(\d+)/.exec(row.html)?.[1] ?? null;
417
+ const objectId =
418
+ /objectId=?(\d+)/.exec(row.html)?.[1] ?? null;
375
419
  current.bookings.push({
376
420
  objectId,
377
421
  dayLabel: current.label,
378
422
  date: current.date,
379
- from: flat[1] ?? "",
380
- to: flat[2] ?? "",
381
- duration: flat[3] ?? "",
382
- project: flat[5] ?? "",
383
- vorgang: flat[6] ?? "",
384
- taetigkeit: flat[7] ?? "",
385
- billable: /payments/i.test(flat[8] ?? "") || /payments/i.test(row.html),
386
- comment: flat[9] ?? "",
423
+ from: flat[from] ?? "",
424
+ to: flat[from + 1] ?? "",
425
+ duration: flat[from + 2] ?? "",
426
+ project: flat[from + 4] ?? "",
427
+ vorgang: flat[from + 5] ?? "",
428
+ taetigkeit: flat[from + 6] ?? "",
429
+ billable: /payments/i.test(flat[from + 7] ?? "") || /payments/i.test(row.html),
430
+ comment: flat[from + 8] ?? "",
431
+ ort: bookingOrt(flat, from),
387
432
  });
388
433
  }
389
434
 
@@ -65,7 +65,8 @@ export function formatWeek(week: ZepWeek): string {
65
65
  lines.push(
66
66
  ` #${booking.objectId ?? "?"} ${booking.from}-${booking.to} (${booking.duration}) ` +
67
67
  `${booking.project} / ${booking.vorgang} / ${booking.taetigkeit}` +
68
- `${booking.billable ? " [billable]" : ""}`,
68
+ `${booking.billable ? " [billable]" : ""}` +
69
+ `${booking.ort ? ` @ ${booking.ort}` : ""}`,
69
70
  );
70
71
  if (booking.comment) lines.push(` ${booking.comment}`);
71
72
  }
@@ -64,13 +64,13 @@ export const ZepBookTool = {
64
64
  bemerkung: Type.Optional(
65
65
  Type.String({
66
66
  description:
67
- "Comment. Convention: include the ticket/story numbers (e.g. '3447 Ladeanzeige nach Pick, 3455 Fach-HU-Nummer').",
67
+ "Comment. Convention: include the ticket/story numbers (e.g. '4711 Ladeanzeige, 4712 Fach-HU-Nummer').",
68
68
  }),
69
69
  ),
70
70
  projekt: Type.Optional(
71
71
  Type.String({
72
72
  description:
73
- "Project id (e.g. '413' for Julabo) or part of its label (e.g. 'Julabo'). Falls back to the project ZEP has selected for this user.",
73
+ "Project id (e.g. '413' for Contoso) or part of its label (e.g. 'Contoso'). Falls back to the project ZEP has selected for this user.",
74
74
  }),
75
75
  ),
76
76
  vorgang: Type.Optional(
@@ -85,7 +85,10 @@ export const ZepBookTool = {
85
85
  }),
86
86
  ),
87
87
  ort: Type.Optional(
88
- Type.String({ description: "Location code, e.g. 'D-Office' or 'D'. Defaults to 'NULL' (first place of work)." }),
88
+ Type.String({
89
+ description:
90
+ "Location code, e.g. 'D-Office' (office), 'D' (inland away) or 'D-3M'. The default 'NULL' is the first place of work (Erste Tätigkeitsstätte).",
91
+ }),
89
92
  ),
90
93
  entries: Type.Optional(
91
94
  Type.Array(
@@ -228,6 +231,10 @@ export const ZepBookTool = {
228
231
  const comment = entry.bemerkung ?? params.bemerkung ?? "";
229
232
 
230
233
  const vorgangLabel = scope.vorgaenge.find((v) => v.id === vorgangId)?.label;
234
+ // Always state the place of work that goes onto the wire: 'NULL' is
235
+ // the default (Erste Tätigkeitsstätte) and is exactly what a booking
236
+ // without an explicit ort gets, so it must not be invisible here.
237
+ const ortLabel = form.orte.find((o) => o.id === ort)?.label;
231
238
 
232
239
  planned.push({
233
240
  request: {
@@ -245,7 +252,8 @@ export const ZepBookTool = {
245
252
  `${date} ${from}-${to} (${duration})\n` +
246
253
  ` project ${projectId}${scope.project ? ` "${scope.project.label}"` : ""}\n` +
247
254
  ` Vorgang ${vorgangId}${vorgangLabel ? ` "${vorgangLabel}"` : ""}\n` +
248
- ` Tätigkeit ${taetigkeit}${ort === "NULL" ? "" : `, Ort ${ort}`}` +
255
+ ` Tätigkeit ${taetigkeit}, Ort ${ort}` +
256
+ `${ortLabel && ortLabel !== ort ? ` (${ortLabel})` : ""}` +
249
257
  `${comment ? `\n "${comment}"` : ""}`,
250
258
  });
251
259
  }
package/src/types.ts CHANGED
@@ -94,6 +94,13 @@ export interface ZepBooking {
94
94
  readonly taetigkeit: string;
95
95
  readonly billable: boolean;
96
96
  readonly comment: string;
97
+ /**
98
+ * Place of work as the table renders it, e.g. `D-Office`.
99
+ *
100
+ * ZEP only draws this cell when there is something to say, so `null` means
101
+ * "no cell rendered" and therefore the default place of work (`NULL`).
102
+ */
103
+ readonly ort: string | null;
97
104
  }
98
105
 
99
106
  export interface ZepWeekDay {