project-logbook 0.3.4 → 0.4.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.
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Helpers for transforming technical log markdown into a timeline view.
3
+ */
4
+ /**
5
+ * Regex pattern to match log entries with ISO timestamps.
6
+ * Matches: "2026-05-27T14:17:02.250Z: Some message"
7
+ * Also matches bullet points: "- 2026-05-27T14:17:02.250Z: Some message"
8
+ */
9
+ const LOG_ENTRY_REGEX = /^-?\s*(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?)\s*:\s*(.+)$/;
10
+ /**
11
+ * Parse technical log markdown into structured log entries.
12
+ * @param logMarkdown - The raw markdown content of the technical log
13
+ * @returns Array of parsed log entries, or null if parsing fails
14
+ */
15
+ export function parseLogMarkdown(logMarkdown) {
16
+ if (!logMarkdown || typeof logMarkdown !== 'string') {
17
+ return null;
18
+ }
19
+ const lines = logMarkdown.split('\n');
20
+ const entries = [];
21
+ for (const line of lines) {
22
+ const match = line.match(LOG_ENTRY_REGEX);
23
+ if (match) {
24
+ const [, timestampStr, message] = match;
25
+ const timestamp = new Date(timestampStr);
26
+ if (isNaN(timestamp.getTime())) {
27
+ continue; // Skip invalid dates
28
+ }
29
+ entries.push({
30
+ timestamp,
31
+ isoTimestamp: timestampStr,
32
+ message: message.trim(),
33
+ });
34
+ }
35
+ }
36
+ // Sort entries chronologically
37
+ entries.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
38
+ return entries.length > 0 ? entries : null;
39
+ }
40
+ /**
41
+ * Transform log entries into a timeline with gap indicators.
42
+ * Shows ALL entries, but only displays timestamps for:
43
+ * - First entry
44
+ * - Entries after gaps >= threshold
45
+ * - Last entry
46
+ *
47
+ * This prevents timestamp clutter when LLM writes many entries in quick succession.
48
+ *
49
+ * @param entries - Parsed log entries (sorted chronologically)
50
+ * @param gapThresholdHours - Minimum gap in hours to show timestamp (default: 1)
51
+ * @returns Timeline items for rendering
52
+ */
53
+ export function createTimelineFromEntries(entries, gapThresholdHours = 1) {
54
+ if (entries.length === 0) {
55
+ return [];
56
+ }
57
+ const timeline = [];
58
+ const gapThresholdMs = gapThresholdHours * 60 * 60 * 1000;
59
+ // Always show first entry with timestamp
60
+ timeline.push({
61
+ type: 'entry',
62
+ timestamp: entries[0].timestamp,
63
+ isoTimestamp: entries[0].isoTimestamp,
64
+ message: entries[0].message,
65
+ showTime: true,
66
+ });
67
+ // Process middle entries
68
+ for (let i = 1; i < entries.length - 1; i++) {
69
+ const prev = entries[i - 1];
70
+ const curr = entries[i];
71
+ const gapMs = curr.timestamp.getTime() - prev.timestamp.getTime();
72
+ const gapHours = (gapMs / gapThresholdMs) * gapThresholdHours;
73
+ if (gapHours >= gapThresholdHours) {
74
+ // Add gap indicator
75
+ timeline.push({
76
+ type: 'gap',
77
+ gapHours,
78
+ });
79
+ // Show this entry with timestamp after the gap
80
+ timeline.push({
81
+ type: 'entry',
82
+ timestamp: curr.timestamp,
83
+ isoTimestamp: curr.isoTimestamp,
84
+ message: curr.message,
85
+ showTime: true,
86
+ });
87
+ }
88
+ else {
89
+ // Show entry without timestamp (close to previous)
90
+ timeline.push({
91
+ type: 'entry',
92
+ message: curr.message,
93
+ showTime: false,
94
+ });
95
+ }
96
+ }
97
+ // Always show last entry
98
+ if (entries.length > 1) {
99
+ const last = entries[entries.length - 1];
100
+ const prev = entries[entries.length - 2];
101
+ const gapMs = last.timestamp.getTime() - prev.timestamp.getTime();
102
+ const gapHours = (gapMs / gapThresholdMs) * gapThresholdHours;
103
+ if (gapHours >= gapThresholdHours) {
104
+ // Add gap indicator before last entry
105
+ timeline.push({
106
+ type: 'gap',
107
+ gapHours,
108
+ });
109
+ }
110
+ // Last entry always shown with timestamp
111
+ timeline.push({
112
+ type: 'entry',
113
+ timestamp: last.timestamp,
114
+ isoTimestamp: last.isoTimestamp,
115
+ message: last.message,
116
+ showTime: true,
117
+ });
118
+ }
119
+ return timeline;
120
+ }
121
+ /**
122
+ * Format timestamp for display in timeline.
123
+ * Shows time in HH:MM format.
124
+ */
125
+ function formatTime(timestamp) {
126
+ const hours = String(timestamp.getHours()).padStart(2, '0');
127
+ const minutes = String(timestamp.getMinutes()).padStart(2, '0');
128
+ return `${hours}:${minutes}`;
129
+ }
130
+ /**
131
+ * Escape HTML special characters.
132
+ */
133
+ function escapeHtml(text) {
134
+ const htmlEscapes = {
135
+ '&': '&amp;',
136
+ '<': '&lt;',
137
+ '>': '&gt;',
138
+ '"': '&quot;',
139
+ "'": '&#39;',
140
+ };
141
+ return text.replace(/[&<>"']/g, (char) => htmlEscapes[char]);
142
+ }
143
+ /**
144
+ * Render timeline items as HTML.
145
+ * Creates a beautiful timeline that shows:
146
+ * - ALL log entries as messages
147
+ * - Timestamps only for significant moments (start, after gaps, end)
148
+ * - Gap indicators for breaks > threshold
149
+ *
150
+ * @param timeline - Timeline items to render
151
+ * @returns HTML string for the timeline
152
+ */
153
+ export function renderTimelineHtml(timeline) {
154
+ if (timeline.length === 0) {
155
+ return '<p>No log entries available.</p>';
156
+ }
157
+ const itemsHtml = timeline
158
+ .map((item) => {
159
+ if (item.type === 'gap') {
160
+ const gapHours = item.gapHours?.toFixed(1) ?? '??';
161
+ return `
162
+ <li class="timeline-gap">
163
+ <span class="gap-indicator">
164
+ ⏸ ${gapHours}h break
165
+ </span>
166
+ </li>`;
167
+ }
168
+ if (item.type === 'entry' && item.message) {
169
+ const escapedMessage = escapeHtml(item.message);
170
+ if (item.showTime && item.timestamp && item.isoTimestamp) {
171
+ const time = formatTime(item.timestamp);
172
+ return `
173
+ <li class="timeline-entry has-time">
174
+ <time class="timeline-time" datetime="${escapeHtml(item.isoTimestamp)}">${time}</time>
175
+ <span class="timeline-message">${escapedMessage}</span>
176
+ </li>`;
177
+ }
178
+ else {
179
+ // Entry without timestamp - just the message
180
+ return `
181
+ <li class="timeline-entry no-time">
182
+ <span class="timeline-message">${escapedMessage}</span>
183
+ </li>`;
184
+ }
185
+ }
186
+ return '';
187
+ })
188
+ .join('');
189
+ return `<ul class="timeline">${itemsHtml}</ul>`;
190
+ }
191
+ /**
192
+ * Get the last log entry from technical log markdown.
193
+ * Useful for displaying a "last logged" nudge in CLI output.
194
+ *
195
+ * @param logMarkdown - The raw markdown content of the technical log
196
+ * @returns The last LogEntry, or null if none found
197
+ */
198
+ export function getLastLogEntry(logMarkdown) {
199
+ const entries = parseLogMarkdown(logMarkdown);
200
+ return entries ? entries[entries.length - 1] : null;
201
+ }
202
+ /**
203
+ * Main function to transform technical log markdown into timeline HTML.
204
+ * Falls back to plain markdown rendering if preprocessing fails.
205
+ *
206
+ * @param logMarkdown - The raw markdown content of the technical log
207
+ * @returns HTML string (timeline if successful, plain markdown otherwise)
208
+ */
209
+ export async function transformLogToTimeline(logMarkdown) {
210
+ const entries = parseLogMarkdown(logMarkdown);
211
+ if (!entries || entries.length === 0) {
212
+ // Fall back to plain markdown rendering
213
+ const fallbackContent = logMarkdown && logMarkdown.trim() ? escapeHtml(logMarkdown).replace(/\n/g, '<br>') : 'No log entries available.';
214
+ return '<div class="log-fallback">' + fallbackContent + '</div>';
215
+ }
216
+ const timeline = createTimelineFromEntries(entries);
217
+ return renderTimelineHtml(timeline);
218
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "project-logbook",
3
- "version": "0.3.4",
3
+ "version": "0.4.0",
4
4
  "description": "A command-line tool for project logbooks.",
5
5
  "workspaces": [
6
6
  "demo-app"
@@ -42,6 +42,7 @@
42
42
  "@types/fs-extra": "^11.0.4",
43
43
  "@types/hast": "^3.0.4",
44
44
  "@types/node": "^25.7.0",
45
+ "@types/rss": "^0.0.32",
45
46
  "@typescript-eslint/eslint-plugin": "^8.32.1",
46
47
  "@typescript-eslint/parser": "^8.32.1",
47
48
  "@vitest/coverage-v8": "^3.1.4",
@@ -65,6 +66,7 @@
65
66
  "remark-gfm": "^4.0.1",
66
67
  "remark-parse": "^11.0.0",
67
68
  "remark-rehype": "^11.1.2",
69
+ "rss": "^1.2.2",
68
70
  "simple-git": "^3.36.0",
69
71
  "unified": "^11.0.5"
70
72
  }
@@ -21,6 +21,10 @@ dateEnd: "[DATE_END]"
21
21
  ## Summary
22
22
  TODO: Write a polished, highly readable ticket summary that reads like an engaging technical narrative (similar to a well-written dev blog post).
23
23
 
24
+ ### Before You Start:
25
+ - **Check `log.md`**: Review your technical log for all the decisions, errors, and pivots you recorded during implementation.
26
+ - **Reference the log**: Use your real-time log entries as source material for the narrative — don't try to reconstruct from memory.
27
+
24
28
  ### Formatting & Style Rules:
25
29
  - **Maintain the narrative tone:** TODO: Keep the storytelling flair (e.g., describing how problems accumulated or how gaps surfaced), but stay strictly factual based on the provided changes.
26
30
  - **Add thematic headings:** TODO: Break the narrative down into logical chapters using Markdown headings (e.g., ### The Friction Points, ### The Fix, ### Closing the Gap).
@@ -1,4 +1,9 @@
1
1
  # Technical Log: {{id}}-{{slug}}
2
2
 
3
+ > **REMINDER**: Log your work in real-time using `logbook log "<message>"`. Don't wait until the end!
4
+ >
5
+ > Log after every significant step: investigation, errors, decisions, code changes, test runs, etc.
6
+ >
7
+
3
8
  ## Protocol
4
9
  - {{fullIso}}: Started investigation.
@@ -8,11 +8,27 @@ Phase 1: Understand
8
8
  2. Review `AGENTS.md` and `CONTRIBUTING.md` if you need architectural or workflow context.
9
9
 
10
10
  Phase 2: Execute & Trace
11
- 1. Use `log.md` as a live technical scratchpad.
12
- 2. Log your work in real-time using the `logbook log "<message>"` command. Record every major decision, error, and pivot. Do not reconstruct this at the end.
13
- Example: logbook log "Investigated root cause found issue in src/lib/config.ts"
14
- One message per call. For multiple entries, call the command once per message.
15
- 3. Stick to the active entry; never modify past entries in the logbook folder.
11
+
12
+ **CRITICAL: Real-Time Logging**
13
+ You MUST log your work continuously using `logbook log "<message>"`. This is not optional.
14
+
15
+ 1. Use `log.md` as your live technical scratchpad.
16
+ 2. After EVERY significant step, run: `logbook log "<what you just did>"`
17
+ - Investigated a file? Log it.
18
+ - Found an error? Log it.
19
+ - Made a design decision? Log it.
20
+ - Fixed a bug? Log it.
21
+ - Ran tests? Log the result.
22
+
23
+ Examples:
24
+ - `logbook log "Investigated src/lib/config.ts - found missing validation"`
25
+ - `logbook log "Created new RSS module with generateRssFeed() function"`
26
+ - `logbook log "Test failed: TypeScript error on line 42 - fixed type assertion"`
27
+ - `logbook log "All 127 tests pass, pre-commit suite successful"`
28
+
29
+ 3. **One message per command call.** For multiple entries, call the command multiple times.
30
+ 4. Do NOT reconstruct the log at the end. If the log is empty when you finish, you did it wrong.
31
+ 5. Stick to the active entry; never modify past entries in the logbook folder.
16
32
 
17
33
  Phase 3: Synthesize
18
34
  1. When implementation is finished, write the narrative in `index.md`.
@@ -696,3 +696,119 @@ article img[src$='.webp'],
696
696
  article img[src$='.svg'] {
697
697
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
698
698
  }
699
+
700
+ /* Technical Log Timeline */
701
+ #log .timeline {
702
+ list-style: none;
703
+ padding: 0;
704
+ margin: 0;
705
+ position: relative;
706
+ }
707
+
708
+ #log .timeline::before {
709
+ content: '';
710
+ position: absolute;
711
+ left: 8px;
712
+ top: 0;
713
+ bottom: 0;
714
+ width: 2px;
715
+ background: var(--border);
716
+ }
717
+
718
+ #log .timeline-entry {
719
+ position: relative;
720
+ padding-left: 2rem;
721
+ margin-bottom: 0.75rem;
722
+ min-height: 1.5rem;
723
+ display: grid;
724
+ grid-template-columns: 55px 1fr;
725
+ gap: 0.5rem;
726
+ }
727
+
728
+ #log .timeline-entry.no-time .timeline-message {
729
+ grid-column: 2;
730
+ }
731
+
732
+ #log .timeline-entry.has-time::before {
733
+ content: '';
734
+ position: absolute;
735
+ left: 5px;
736
+ top: 4px;
737
+ width: 8px;
738
+ height: 8px;
739
+ border-radius: 50%;
740
+ background: var(--primary);
741
+ border: 2px solid var(--bg);
742
+ z-index: 1;
743
+ }
744
+
745
+ #log .timeline-entry.no-time::before {
746
+ content: '';
747
+ position: absolute;
748
+ left: 7px;
749
+ top: 0.6rem;
750
+ width: 4px;
751
+ height: 4px;
752
+ border-radius: 50%;
753
+ background: var(--text-muted);
754
+ opacity: 0.6;
755
+ }
756
+
757
+ #log .timeline-time {
758
+ font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, monospace;
759
+ font-size: 0.75rem;
760
+ font-weight: 600;
761
+ color: var(--primary);
762
+ display: inline-block;
763
+ }
764
+
765
+ #log .timeline-message {
766
+ font-size: 0.9375rem;
767
+ color: var(--text);
768
+ line-height: 1.5;
769
+ }
770
+
771
+ #log .timeline-entry.no-time .timeline-message {
772
+ color: var(--text);
773
+ opacity: 0.85;
774
+ }
775
+
776
+ #log .timeline-gap {
777
+ position: relative;
778
+ padding-left: 2rem;
779
+ margin: 1.5rem 0;
780
+ min-height: 1rem;
781
+ }
782
+
783
+ #log .timeline-gap::before {
784
+ content: '';
785
+ position: absolute;
786
+ left: 7px;
787
+ top: 0.4rem;
788
+ width: 4px;
789
+ height: 4px;
790
+ border-radius: 50%;
791
+ background: var(--text-muted);
792
+ opacity: 0.5;
793
+ }
794
+
795
+ #log .gap-indicator {
796
+ font-size: 0.8125rem;
797
+ color: var(--text-muted);
798
+ font-style: italic;
799
+ display: inline-flex;
800
+ align-items: center;
801
+ gap: 0.25rem;
802
+ }
803
+
804
+ #log .log-fallback {
805
+ font-size: 0.9375rem;
806
+ color: var(--text);
807
+ line-height: 1.6;
808
+ white-space: pre-wrap;
809
+ background: var(--primary-soft);
810
+ padding: 1rem;
811
+ border-radius: 0.5rem;
812
+ font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, monospace;
813
+ font-size: 0.8125rem;
814
+ }