From aa5cca9cf69c9da5aebf46d7bb0b59e95b151f4a Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Sat, 11 Apr 2026 00:40:16 +0200 Subject: [PATCH] =?UTF-8?q?feat(linkedin):=20add=20state-updater.mjs=20?= =?UTF-8?q?=E2=80=94=20deterministic=20state=20mutations=20with=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure functions for post tracking (streak, week rollover, first_post_date), content history pruning, and follower count updates. 19 tests green. Follows week-rollover.mjs pattern (pure functions) + queue-manager.mjs pattern (I/O wrapper with atomic writes). Co-Authored-By: Claude Opus 4.6 --- .../scripts/__tests__/state-updater.test.mjs | 295 ++++++++++++++++++ .../hooks/scripts/state-updater.mjs | 253 +++++++++++++++ 2 files changed, 548 insertions(+) create mode 100644 plugins/linkedin-thought-leadership/hooks/scripts/__tests__/state-updater.test.mjs create mode 100644 plugins/linkedin-thought-leadership/hooks/scripts/state-updater.mjs diff --git a/plugins/linkedin-thought-leadership/hooks/scripts/__tests__/state-updater.test.mjs b/plugins/linkedin-thought-leadership/hooks/scripts/__tests__/state-updater.test.mjs new file mode 100644 index 0000000..767a36d --- /dev/null +++ b/plugins/linkedin-thought-leadership/hooks/scripts/__tests__/state-updater.test.mjs @@ -0,0 +1,295 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { updatePostTracking, pruneContentHistory, updateFollowerCount } from '../state-updater.mjs'; + +const SAMPLE_STATE = `--- +last_post_date: "2026-04-05" +first_post_date: "2026-01-15" +last_post_topic: "AI strategy" +posts_this_week: 2 +weekly_goal: 3 +current_streak: 5 +longest_streak: 12 +current_week: "2026-W14" +last_import_date: "2026-04-01" +last_import_week: "2026-W14" +follower_count: 850 +follower_target: 10000 +target_date: "2026-12-31" +monthly_growth: [] +projected_10k_date: "" +growth_rate_needed: 0 +--- + +# LinkedIn Session State + +## Recent Posts + +- [2026-04-05] "AI governance is not about..." (1450) - AI strategy +- [2026-04-03] "Three things I learned..." (1200) - leadership +- [2026-03-28] "Why most teams fail at..." (1350) - team building + +## Session Notes + +## Planned Content + +## Milestone Log +`; + +describe('updatePostTracking', () => { + test('sets last_post_date to provided date', () => { + const result = updatePostTracking(SAMPLE_STATE, { + postDate: '2026-04-07', + postTopic: 'AI governance', + hookText: 'The real problem with AI governance...', + charCount: 1500, + format: 'post' + }); + assert.notEqual(result, null); + assert.match(result.content, /^last_post_date: "2026-04-07"$/m); + }); + + test('sets last_post_topic', () => { + const result = updatePostTracking(SAMPLE_STATE, { + postDate: '2026-04-07', + postTopic: 'AI governance', + hookText: 'The real problem...', + charCount: 1500, + format: 'post' + }); + assert.match(result.content, /^last_post_topic: "AI governance"$/m); + }); + + test('increments posts_this_week when same week', () => { + // 2026-04-06 is a Monday, ISO W15. current_week is W14. + // Use a date that stays in W14: 2026-04-05 is Sunday W14 — but last_post_date is already 04-05. + // Let's use a state with current_week matching the post date week. + const w15State = SAMPLE_STATE.replace('current_week: "2026-W14"', 'current_week: "2026-W15"'); + const result = updatePostTracking(w15State, { + postDate: '2026-04-07', // Tuesday W15 + postTopic: 'test', + hookText: 'Hook', + charCount: 1000, + format: 'post' + }); + assert.notEqual(result, null); + assert.match(result.content, /^posts_this_week: 3$/m); // was 2, incremented + }); + + test('increments streak when gap <= 2 days', () => { + const result = updatePostTracking(SAMPLE_STATE, { + postDate: '2026-04-06', // 1 day after last_post_date 2026-04-05 + postTopic: 'test', + hookText: 'Hook', + charCount: 1000, + format: 'post' + }); + assert.notEqual(result, null); + assert.match(result.content, /^current_streak: 6$/m); // was 5, incremented + }); + + test('resets streak to 1 when gap > 2 days', () => { + const result = updatePostTracking(SAMPLE_STATE, { + postDate: '2026-04-09', // 4 days after 2026-04-05 + postTopic: 'test', + hookText: 'Hook', + charCount: 1000, + format: 'post' + }); + assert.notEqual(result, null); + assert.match(result.content, /^current_streak: 1$/m); + }); + + test('sets first_post_date when null', () => { + const nullFirstPost = SAMPLE_STATE.replace( + 'first_post_date: "2026-01-15"', + 'first_post_date: null' + ); + const result = updatePostTracking(nullFirstPost, { + postDate: '2026-04-07', + postTopic: 'test', + hookText: 'Hook', + charCount: 1000, + format: 'post' + }); + assert.notEqual(result, null); + assert.match(result.content, /^first_post_date: "2026-04-07"$/m); + }); + + test('does NOT overwrite existing first_post_date', () => { + const result = updatePostTracking(SAMPLE_STATE, { + postDate: '2026-04-07', + postTopic: 'test', + hookText: 'Hook', + charCount: 1000, + format: 'post' + }); + assert.notEqual(result, null); + assert.match(result.content, /^first_post_date: "2026-01-15"$/m); + }); + + test('triggers week rollover when ISO week changes', () => { + // 2026-04-14 is W16, current_week is W14 + const result = updatePostTracking(SAMPLE_STATE, { + postDate: '2026-04-14', + postTopic: 'test', + hookText: 'Hook', + charCount: 1000, + format: 'post' + }); + assert.notEqual(result, null); + // After rollover, posts_this_week resets to 0 then increments to 1 + assert.match(result.content, /^posts_this_week: 1$/m); + assert.match(result.content, /^current_week: "2026-W16"$/m); + }); + + test('appends to Recent Posts section', () => { + const result = updatePostTracking(SAMPLE_STATE, { + postDate: '2026-04-06', + postTopic: 'AI governance', + hookText: 'The real problem with AI governance today...', + charCount: 1500, + format: 'post' + }); + assert.notEqual(result, null); + assert.ok(result.content.includes('- [2026-04-06] "The real problem with AI governance today..." (1500) - AI governance')); + // Existing entries should still be there + assert.ok(result.content.includes('- [2026-04-05] "AI governance is not about..."')); + }); + + test('updates longest_streak when current exceeds it', () => { + const highStreak = SAMPLE_STATE.replace('current_streak: 5', 'current_streak: 12'); + const result = updatePostTracking(highStreak, { + postDate: '2026-04-06', // 1 day gap, streak increments to 13 + postTopic: 'test', + hookText: 'Hook', + charCount: 1000, + format: 'post' + }); + assert.notEqual(result, null); + assert.match(result.content, /^current_streak: 13$/m); + assert.match(result.content, /^longest_streak: 13$/m); + }); + + test('does not update longest_streak when current is lower', () => { + const result = updatePostTracking(SAMPLE_STATE, { + postDate: '2026-04-06', + postTopic: 'test', + hookText: 'Hook', + charCount: 1000, + format: 'post' + }); + assert.notEqual(result, null); + assert.match(result.content, /^current_streak: 6$/m); + assert.match(result.content, /^longest_streak: 12$/m); // unchanged + }); + + test('returns changes array describing what changed', () => { + const result = updatePostTracking(SAMPLE_STATE, { + postDate: '2026-04-06', + postTopic: 'AI governance', + hookText: 'Hook', + charCount: 1500, + format: 'post' + }); + assert.notEqual(result, null); + assert.ok(Array.isArray(result.changes)); + assert.ok(result.changes.length > 0); + }); +}); + +describe('pruneContentHistory', () => { + test('removes entries older than 90 days', () => { + const today = new Date(); + const old = new Date(today); + old.setDate(old.getDate() - 100); + const oldDate = old.toISOString().slice(0, 10); + + const recent = new Date(today); + recent.setDate(recent.getDate() - 10); + const recentDate = recent.toISOString().slice(0, 10); + + const stateWithOld = SAMPLE_STATE.replace( + '## Recent Posts\n\n', + `## Recent Posts\n\n- [${oldDate}] "Old post..." (1000) - old topic\n- [${recentDate}] "Recent post..." (1200) - recent topic\n` + ); + + const result = pruneContentHistory(stateWithOld, 90); + assert.notEqual(result, null); + assert.equal(result.pruned, 1); + assert.ok(!result.content.includes(oldDate)); + assert.ok(result.content.includes(recentDate)); + }); + + test('preserves entries within 90 days', () => { + const today = new Date(); + const recent = new Date(today); + recent.setDate(recent.getDate() - 30); + const recentDate = recent.toISOString().slice(0, 10); + + const stateWithRecent = SAMPLE_STATE.replace( + '## Recent Posts\n\n', + `## Recent Posts\n\n- [${recentDate}] "Recent post..." (1200) - topic\n` + ); + + const result = pruneContentHistory(stateWithRecent, 90); + assert.equal(result, null); // nothing to prune + }); + + test('returns null when no entries exist', () => { + const emptyRecent = SAMPLE_STATE.replace( + /## Recent Posts\n\n[\s\S]*?(?=## Session Notes)/, + '## Recent Posts\n\n' + ); + const result = pruneContentHistory(emptyRecent, 90); + assert.equal(result, null); + }); + + test('handles custom maxAgeDays', () => { + const today = new Date(); + const old = new Date(today); + old.setDate(old.getDate() - 40); + const oldDate = old.toISOString().slice(0, 10); + + const stateWithOld = SAMPLE_STATE.replace( + '## Recent Posts\n\n', + `## Recent Posts\n\n- [${oldDate}] "Somewhat old..." (1000) - topic\n` + ); + + const result = pruneContentHistory(stateWithOld, 30); + assert.notEqual(result, null); + assert.equal(result.pruned, 1); + }); +}); + +describe('updateFollowerCount', () => { + test('updates follower_count', () => { + const result = updateFollowerCount(SAMPLE_STATE, { + count: 920, + month: '2026-04' + }); + assert.notEqual(result, null); + assert.match(result.content, /^follower_count: 920$/m); + }); + + test('recalculates growth_rate_needed', () => { + const result = updateFollowerCount(SAMPLE_STATE, { + count: 920, + month: '2026-04' + }); + assert.notEqual(result, null); + const match = result.content.match(/^growth_rate_needed: (\d+)$/m); + assert.ok(match, 'growth_rate_needed should be present'); + const rate = parseInt(match[1], 10); + assert.ok(rate > 0, 'growth_rate_needed should be positive'); + }); + + test('appends to Milestone Log section', () => { + const result = updateFollowerCount(SAMPLE_STATE, { + count: 920, + month: '2026-04' + }); + assert.notEqual(result, null); + assert.ok(result.content.includes('[2026-04] 920 (+70)')); + }); +}); diff --git a/plugins/linkedin-thought-leadership/hooks/scripts/state-updater.mjs b/plugins/linkedin-thought-leadership/hooks/scripts/state-updater.mjs new file mode 100644 index 0000000..35e06a7 --- /dev/null +++ b/plugins/linkedin-thought-leadership/hooks/scripts/state-updater.mjs @@ -0,0 +1,253 @@ +// Deterministic state mutation functions for linkedin-thought-leadership plugin. +// Pure functions operate on string content (same pattern as week-rollover.mjs). +// I/O wrapper (writeState) handles file reads/writes (same pattern as queue-manager.mjs). + +import { readFileSync, writeFileSync, renameSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { applyWeekRollover } from './week-rollover.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const HOME = process.env.HOME || process.env.USERPROFILE || ''; +const STATE_FILE = process.env.STATE_FILE || join(HOME, '.claude', 'linkedin-thought-leadership.local.md'); + +function replaceField(content, field, value) { + return content.replace( + new RegExp(`^${field}: .*`, 'm'), + `${field}: ${value}` + ); +} + +function isoWeekFromDate(dateStr) { + const d = new Date(dateStr + 'T12:00:00Z'); + const dayNum = d.getUTCDay() || 7; + d.setUTCDate(d.getUTCDate() + 4 - dayNum); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + const weekNo = Math.ceil(((d - yearStart) / 86400000 + 1) / 7); + return `${d.getUTCFullYear()}-W${String(weekNo).padStart(2, '0')}`; +} + +function daysBetween(dateA, dateB) { + const a = new Date(dateA + 'T12:00:00Z').getTime(); + const b = new Date(dateB + 'T12:00:00Z').getTime(); + if (isNaN(a) || isNaN(b)) return null; + return Math.abs(Math.round((b - a) / 86400000)); +} + +function extractField(content, field) { + const re = new RegExp(`^${field}: *"?([^"\\n]*)"?`, 'm'); + const m = content.match(re); + return m ? m[1].trim() : ''; +} + +/** + * Update post tracking fields deterministically. + * @param {string} stateContent - Full state file content + * @param {{ postDate: string, postTopic: string, hookText: string, charCount: number, format: string }} opts + * @returns {{ content: string, changes: string[] } | null} + */ +export function updatePostTracking(stateContent, { postDate, postTopic, hookText, charCount, format }) { + let content = stateContent; + const changes = []; + + // 1. Update last_post_date + content = replaceField(content, 'last_post_date', `"${postDate}"`); + changes.push(`last_post_date → ${postDate}`); + + // 2. Update last_post_topic + content = replaceField(content, 'last_post_topic', `"${postTopic}"`); + changes.push(`last_post_topic → ${postTopic}`); + + // 3. Set first_post_date if null + const existingFirst = extractField(content, 'first_post_date'); + if (!existingFirst || existingFirst === 'null') { + content = replaceField(content, 'first_post_date', `"${postDate}"`); + changes.push(`first_post_date → ${postDate} (first post!)`); + } + + // 4. Week rollover — check if ISO week changed + const currentWeek = extractField(content, 'current_week'); + const postWeek = isoWeekFromDate(postDate); + const rollover = applyWeekRollover(content, currentWeek, postWeek); + if (rollover) { + content = rollover.content; + changes.push(rollover.message); + } + + // 5. Increment posts_this_week + const currentPosts = parseInt(extractField(content, 'posts_this_week') || '0', 10); + content = replaceField(content, 'posts_this_week', String(currentPosts + 1)); + changes.push(`posts_this_week → ${currentPosts + 1}`); + + // 6. Update streak + const lastPostDate = extractField(stateContent, 'last_post_date'); + let currentStreak = parseInt(extractField(content, 'current_streak') || '0', 10); + + if (lastPostDate && lastPostDate !== 'null') { + const gap = daysBetween(lastPostDate, postDate); + if (gap !== null && gap <= 2) { + currentStreak += 1; + changes.push(`current_streak → ${currentStreak} (gap: ${gap}d)`); + } else { + currentStreak = 1; + changes.push(`current_streak → 1 (gap: ${gap}d, reset)`); + } + } else { + currentStreak = 1; + changes.push('current_streak → 1 (first post)'); + } + content = replaceField(content, 'current_streak', String(currentStreak)); + + // 7. Update longest_streak if exceeded + const longestStreak = parseInt(extractField(content, 'longest_streak') || '0', 10); + if (currentStreak > longestStreak) { + content = replaceField(content, 'longest_streak', String(currentStreak)); + changes.push(`longest_streak → ${currentStreak}`); + } + + // 8. Append to Recent Posts section + const hookPreview = hookText.length > 60 ? hookText.slice(0, 57) + '...' : hookText; + const entry = `- [${postDate}] "${hookPreview}" (${charCount}) - ${postTopic}`; + content = content.replace( + /^(## Recent Posts\n\n?)/m, + `$1${entry}\n` + ); + changes.push(`Recent Posts += ${postDate} "${hookPreview.slice(0, 30)}..."`); + + if (content === stateContent) return null; + return { content, changes }; +} + +/** + * Remove Recent Posts entries older than maxAgeDays. + * @param {string} stateContent - Full state file content + * @param {number} [maxAgeDays=90] + * @returns {{ content: string, pruned: number } | null} + */ +export function pruneContentHistory(stateContent, maxAgeDays = 90) { + const today = new Date(); + const cutoff = new Date(today); + cutoff.setDate(cutoff.getDate() - maxAgeDays); + const cutoffStr = cutoff.toISOString().slice(0, 10); + + // Find all Recent Posts entries + const entryPattern = /^- \[(\d{4}-\d{2}-\d{2})\] .+$/gm; + const recentSection = stateContent.match(/## Recent Posts\n\n?([\s\S]*?)(?=\n## [^R]|\n## $|$)/m); + if (!recentSection || !recentSection[1].trim()) return null; + + const sectionContent = recentSection[1]; + let pruned = 0; + const lines = sectionContent.split('\n'); + const kept = []; + + for (const line of lines) { + const dateMatch = line.match(/^- \[(\d{4}-\d{2}-\d{2})\]/); + if (dateMatch) { + if (dateMatch[1] < cutoffStr) { + pruned++; + continue; + } + } + kept.push(line); + } + + if (pruned === 0) return null; + + const newSection = kept.join('\n'); + const content = stateContent.replace(recentSection[1], newSection); + return { content, pruned }; +} + +/** + * Update follower count and recalculate growth metrics. + * @param {string} stateContent - Full state file content + * @param {{ count: number, month: string }} opts + * @returns {{ content: string, changes: string[] } | null} + */ +export function updateFollowerCount(stateContent, { count, month }) { + let content = stateContent; + const changes = []; + + const previousCount = parseInt(extractField(content, 'follower_count') || '0', 10); + const delta = count - previousCount; + + // Update follower_count + content = replaceField(content, 'follower_count', String(count)); + changes.push(`follower_count → ${count} (${delta >= 0 ? '+' : ''}${delta})`); + + // Recalculate growth_rate_needed + const target = parseInt(extractField(content, 'follower_target') || '10000', 10); + const targetDate = extractField(content, 'target_date'); + const remaining = target - count; + + if (targetDate && targetDate !== 'null' && targetDate !== '""') { + const [tYear, tMonth] = targetDate.split('-').map(Number); + const [mYear, mMonth] = month.split('-').map(Number); + const monthsLeft = (tYear - mYear) * 12 + (tMonth - mMonth); + const effectiveMonths = Math.max(1, monthsLeft); + const rateNeeded = Math.ceil(remaining / effectiveMonths); + content = replaceField(content, 'growth_rate_needed', String(rateNeeded)); + changes.push(`growth_rate_needed → ${rateNeeded}/month`); + } + + // Append to Milestone Log section + const logEntry = `- [${month}] ${count} (${delta >= 0 ? '+' : ''}${delta})`; + content = content.replace( + /^(## Milestone Log\n)/m, + `$1${logEntry}\n` + ); + changes.push(`Milestone Log += ${month}`); + + if (content === stateContent) return null; + return { content, changes }; +} + +/** + * I/O wrapper: read state file, apply update function, write atomically. + * @param {function(string): {content: string}|null} updateFn - Pure update function + */ +export function writeState(updateFn) { + const content = readFileSync(STATE_FILE, 'utf-8'); + const result = updateFn(content); + if (!result) { + console.log('No changes needed.'); + return; + } + const tmpPath = STATE_FILE + '.tmp'; + writeFileSync(tmpPath, result.content, 'utf-8'); + renameSync(tmpPath, STATE_FILE); + if (result.changes) { + console.log('State updated:', result.changes.join(', ')); + } else if (result.pruned !== undefined) { + console.log(`Pruned ${result.pruned} old entries.`); + } +} + +// Standalone mode +if (import.meta.url === `file://${process.argv[1]}`) { + const args = process.argv.slice(2); + if (args.includes('--update-post')) { + const getArg = (flag) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : ''; }; + writeState(content => updatePostTracking(content, { + postDate: getArg('--date') || new Date().toISOString().slice(0, 10), + postTopic: getArg('--topic') || 'unknown', + hookText: getArg('--hook') || '', + charCount: parseInt(getArg('--chars') || '0', 10), + format: getArg('--format') || 'post' + })); + } else if (args.includes('--prune')) { + const days = parseInt(args[args.indexOf('--prune') + 1] || '90', 10); + writeState(content => pruneContentHistory(content, days)); + } else if (args.includes('--update-followers')) { + const getArg = (flag) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : ''; }; + writeState(content => updateFollowerCount(content, { + count: parseInt(getArg('--count') || '0', 10), + month: getArg('--month') || new Date().toISOString().slice(0, 7) + })); + } else { + console.log('Usage:'); + console.log(' node state-updater.mjs --update-post --date YYYY-MM-DD --topic "topic" --hook "Hook text" --chars 1500 --format post'); + console.log(' node state-updater.mjs --prune [days]'); + console.log(' node state-updater.mjs --update-followers --count 920 --month 2026-04'); + } +}