feat(linkedin-thought-leadership): v1.1.0 — Q2 2026 feature release
9 improvements across 3 tracks: Onboarding: /linkedin:onboarding wizard, README Quick Start rewrite Content Quality: voice drift scoring, industry angle variants, /linkedin:carousel, /linkedin:react multi-URL comparison Analytics: automated week-rollover, day-of-week heatmap, month-over-month reports 25→27 commands. All Q2 ROADMAP items completed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
abf7322200
commit
1a8cc1942c
33 changed files with 1726 additions and 236 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "linkedin-thought-leadership",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"description": "Build LinkedIn thought leadership with algorithmic understanding, strategic consistency, and authentic engagement. Updated for the January 2026 360Brew algorithm change.",
|
||||
"author": {
|
||||
"name": "Kjell Tore Guttormsen"
|
||||
|
|
|
|||
|
|
@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.1.0] - 2026-04-08
|
||||
|
||||
### Summary
|
||||
Q2 2026 feature release. 9 improvements across onboarding, content quality, and analytics pipeline.
|
||||
|
||||
### Added
|
||||
- **`/linkedin:onboarding`** — multi-step onboarding wizard: profile → setup → first-post as one guided flow
|
||||
- **`/linkedin:carousel`** — structured multi-slide carousel generator with 5 templates and design specs
|
||||
- **Voice drift scoring** — 6-dimension rubric (sentence structure, word choice, openings, storytelling, tone, formatting) with AUTHENTIC/CAUTION/ALERT/REWRITE verdicts in voice-guardian hook
|
||||
- **Industry angle variants** — 48 concrete variants (6 industries × 8 angles) in thought-leadership-angles reference
|
||||
- **Multi-URL comparison** — `/linkedin:react` now supports 2-3 URL synthesis with contrarian and pattern analysis angles
|
||||
- **Day-of-week heatmap** — `heatmap` CLI command and `HeatmapReport` type in analytics pipeline
|
||||
- **Month-over-month reports** — `report --month YYYY-MM` CLI command with MoM deltas, weekly breakdown, top performers
|
||||
- **Automated week-rollover** — session-start hook now writes `posts_this_week: 0` and updates `current_week` on ISO week change
|
||||
- **Collected Post Samples** — Stop hook passively accumulates published posts in voice-samples file for drift scoring
|
||||
|
||||
### Changed
|
||||
- **README Quick Start** — replaced 4-step manual flow with single `/linkedin:onboarding` entry point
|
||||
- **`/linkedin:report`** — Step 2 now offers report type choice (weekly/monthly/heatmap)
|
||||
- **`/linkedin:post`** — Step 2 shows industry-specific angles when user-profile has industry set; Step 3 redirects to carousel when appropriate
|
||||
- **`/linkedin` router** — added onboarding and carousel to menus and direct routing
|
||||
- **Command count** — 25 → 27 (onboarding, carousel)
|
||||
|
||||
## [1.0.0] - 2026-04-07
|
||||
|
||||
### Summary
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# LinkedIn Thought Leadership Plugin (v1.0.0)
|
||||
# LinkedIn Thought Leadership Plugin (v1.1.0)
|
||||
|
||||
Build LinkedIn thought leadership with algorithmic understanding, strategic consistency, and authentic engagement. January 2026 360Brew algorithm update integrated.
|
||||
|
||||
|
|
@ -27,11 +27,12 @@ Build LinkedIn thought leadership with algorithmic understanding, strategic cons
|
|||
|
||||
**Hook editing:** Edit `hooks/hooks.template.json` + `hooks/prompts/*.md`, then run `python3 hooks/scripts/compile-hooks.py`. Do not edit `hooks.json` directly. Prompts are loaded at runtime by gatekeeper scripts; the compile step is only needed when adding `type: prompt` hooks.
|
||||
|
||||
## Commands (25)
|
||||
## Commands (27)
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `/linkedin` | Router — status line + command menu |
|
||||
| `/linkedin:onboarding` | Multi-step onboarding wizard (profile → setup → first-post) |
|
||||
| `/linkedin:first-post` | First-post accelerator (10 min) |
|
||||
| `/linkedin:setup` | Guided personalization setup |
|
||||
| `/linkedin:react` | URL-to-post pipeline |
|
||||
|
|
@ -42,6 +43,7 @@ Build LinkedIn thought leadership with algorithmic understanding, strategic cons
|
|||
| `/linkedin:batch` | Create a full week of content |
|
||||
| `/linkedin:calendar` | View/manage post scheduling queue |
|
||||
| `/linkedin:publish` | Mark scheduled posts as published |
|
||||
| `/linkedin:carousel` | Structured multi-slide carousel generator |
|
||||
| `/linkedin:video` | Video script generator (30s-2min) |
|
||||
| `/linkedin:multiplatform` | Adapt content for other platforms |
|
||||
| `/linkedin:analyze` | Content/performance analysis |
|
||||
|
|
|
|||
|
|
@ -4,15 +4,15 @@
|
|||
|
||||
*Built for my own Claude Code workflow and shared openly for anyone who finds it useful. This is a solo project — bug reports and feature requests are welcome, but pull requests are not accepted.*
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
A comprehensive Claude Code plugin that turns LinkedIn from a chore into a system. It covers the full content lifecycle — from ideation and drafting through publishing, analytics, and growth strategy — with 25 slash commands, 16 specialized agents, 9 automated hooks, and a 24-document knowledge base grounded in LinkedIn's actual algorithm signals. Updated for the January 2026 **360Brew** algorithm change, where LinkedIn now validates your profile before distributing content.
|
||||
A comprehensive Claude Code plugin that turns LinkedIn from a chore into a system. It covers the full content lifecycle — from ideation and drafting through publishing, analytics, and growth strategy — with 27 slash commands, 16 specialized agents, 9 automated hooks, and a 24-document knowledge base grounded in LinkedIn's actual algorithm signals. Updated for the January 2026 **360Brew** algorithm change, where LinkedIn now validates your profile before distributing content.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -82,55 +82,39 @@ Or add to your `~/.claude/settings.json`:
|
|||
}
|
||||
```
|
||||
|
||||
### First Conversation
|
||||
### Get Started (5 minutes)
|
||||
|
||||
#### 1. Optimize Your Profile (Critical)
|
||||
|
||||
With the 360Brew update, profile optimization is no longer optional — LinkedIn validates your profile before distributing content:
|
||||
Run the onboarding wizard — it walks you through profile, setup, and your first post in one flow:
|
||||
|
||||
```
|
||||
/linkedin:profile
|
||||
/linkedin:onboarding
|
||||
```
|
||||
|
||||
#### 2. Personalize the Plugin
|
||||
The wizard handles everything: 360Brew profile checklist, voice and user profile setup, and a guided first post.
|
||||
|
||||
```bash
|
||||
cp config/user-profile.template.md config/user-profile.local.md
|
||||
# Edit with your name, expertise, audience, voice, and goals
|
||||
```
|
||||
### Already Set Up?
|
||||
|
||||
Then run the guided setup to populate all asset templates:
|
||||
|
||||
```
|
||||
/linkedin:setup
|
||||
```
|
||||
|
||||
#### 3. Create Your First Post
|
||||
|
||||
```
|
||||
/linkedin:post
|
||||
> I want to write about how AI is changing public sector procurement
|
||||
```
|
||||
|
||||
For a faster start, try the 5-minute quick post:
|
||||
|
||||
```
|
||||
/linkedin:quick
|
||||
```
|
||||
|
||||
#### 4. Explore All Commands
|
||||
|
||||
```
|
||||
/linkedin
|
||||
```
|
||||
|
||||
The router shows your current posting status (streak, weekly progress) and lists all available commands.
|
||||
| Goal | Command |
|
||||
|------|---------|
|
||||
| Write a post | `/linkedin:post` |
|
||||
| Quick 5-min post | `/linkedin:quick` |
|
||||
| React to an article | `/linkedin:react` |
|
||||
| View your stats | `/linkedin:report` |
|
||||
| See all commands | `/linkedin` |
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
All 25 commands use colon notation: `/linkedin:post`, `/linkedin:quick`, etc.
|
||||
All 26 commands use colon notation: `/linkedin:post`, `/linkedin:quick`, etc.
|
||||
|
||||
### Onboarding
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/linkedin:onboarding` | Multi-step onboarding wizard — guides you through profile optimization, plugin personalization, and your first post in one flow. |
|
||||
| `/linkedin:first-post` | First-post accelerator — zero to published in 10 minutes with guided hand-holding. |
|
||||
| `/linkedin:setup` | Guided setup to populate empty asset templates with your real voice, case studies, and audience data. |
|
||||
|
||||
### Content Creation
|
||||
|
||||
|
|
@ -146,7 +130,6 @@ All 25 commands use colon notation: `/linkedin:post`, `/linkedin:quick`, etc.
|
|||
| `/linkedin:video` | Video script generator for 30s, 60s, 90s, or 2-minute LinkedIn videos with pacing and visual cues. |
|
||||
| `/linkedin:multiplatform` | Adapt LinkedIn content for Twitter/X threads, newsletter sections, blog posts, presentation slides, and YouTube scripts. |
|
||||
| `/linkedin:react` | URL-to-post pipeline — paste an article, research paper, or news link and generate a reaction post. |
|
||||
| `/linkedin:first-post` | First-post accelerator — zero to published in 10 minutes with guided hand-holding. |
|
||||
|
||||
### Analytics
|
||||
|
||||
|
|
@ -518,6 +501,7 @@ Scheduled posts are tracked in `assets/drafts/queue.json`:
|
|||
|
||||
| Version | Date | Highlights |
|
||||
|---------|------|-----------|
|
||||
| **1.1.0** | 2026-04-08 | Q2 feature release. 27 commands (+onboarding, +carousel). Week-rollover automation, voice drift scoring, industry content matrix, multi-URL react, day-of-week heatmap, month-over-month reports. |
|
||||
| **1.0.0** | 2026-04-07 | Public release. 25 commands, 16 agents, 9 hooks, 6 skills, 24 reference docs. Agent model tiering (Sonnet/Haiku), all scripts Node.js, comprehensive documentation. |
|
||||
| **0.6.0** | 2026-02-07 | First formal version. 20 commands, 15 agents, 8 hooks, analytics system, 360Brew profile optimization, content matrix system, personalization engine, 20 reference documents. |
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# LinkedIn Thought Leadership Plugin — Roadmap
|
||||
|
||||
**Current version:** v1.0.0 (April 2026)
|
||||
**Current version:** v1.1.0 (April 2026)
|
||||
**Scope:** Planned improvements through Q4 2026
|
||||
|
||||
Items organized by quarter and track. Priority = Impact / Effort (High/Medium/Low). Items within each quarter ordered by priority.
|
||||
|
|
@ -16,23 +16,23 @@ Items organized by quarter and track. Priority = Impact / Effort (High/Medium/Lo
|
|||
- [x] Condensed getting-started menu for zero-post users in `/linkedin` router
|
||||
- [x] Readiness check in `/linkedin:post` and `/linkedin:quick` for unpersonalized state
|
||||
- [x] Inline 5x5x5 engagement ritual explanation
|
||||
- [ ] `/linkedin:onboarding` — dedicated multi-step onboarding command that guides profile → setup → first-post as one flow
|
||||
- [ ] README Quick Start refinement — 5-minute getting-started path with screenshots
|
||||
- [x] `/linkedin:onboarding` — dedicated multi-step onboarding command that guides profile → setup → first-post as one flow
|
||||
- [x] README Quick Start refinement — 5-minute getting-started path with single `/linkedin:onboarding` entry point
|
||||
|
||||
### Content Quality
|
||||
**Priority: High** | **Effort: Medium**
|
||||
|
||||
- [ ] Enhanced voice-trainer agent: automatic drift scoring on every post draft (compare against voice samples)
|
||||
- [ ] Content Matrix improvements: add industry-specific angle variants
|
||||
- [ ] Carousel post support: structured multi-slide content generation with visual layout guidance
|
||||
- [ ] `/linkedin:react` enhancement: multi-URL comparison posts (compare 2-3 articles)
|
||||
- [x] Enhanced voice-trainer agent: automatic drift scoring on every post draft (compare against voice samples)
|
||||
- [x] Content Matrix improvements: add industry-specific angle variants
|
||||
- [x] Carousel post support: structured multi-slide content generation with visual layout guidance
|
||||
- [x] `/linkedin:react` enhancement: multi-URL comparison posts (compare 2-3 articles)
|
||||
|
||||
### Analytics Pipeline
|
||||
**Priority: Medium** | **Effort: Medium**
|
||||
|
||||
- [ ] Automated week-rollover: session-start hook resets `posts_this_week` and updates `current_week` on week change (currently warn-only)
|
||||
- [ ] Post-level heatmap generation: day-of-week x time-of-day performance matrix from imported CSV data
|
||||
- [ ] `/linkedin:report` month-over-month comparison view
|
||||
- [x] Automated week-rollover: session-start hook resets `posts_this_week` and updates `current_week` on week change
|
||||
- [x] Post-level heatmap generation: day-of-week performance matrix from imported CSV data (time-of-day not available in CSV export)
|
||||
- [x] `/linkedin:report` month-over-month comparison view
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -183,7 +183,7 @@ APRIL
|
|||
|
||||
MAY
|
||||
- Microsoft Build (typically May) → AI announcements
|
||||
- [National/regional holiday] → Cultural content
|
||||
- 17. mai (Norwegian National Day) → Cultural content
|
||||
- End of spring conference season wrap-ups
|
||||
|
||||
JUNE
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ description: |
|
|||
who to engage with, tracks engagement history, and guides the 5x5x5 method with
|
||||
specific people and posts to target. Includes connection request templates (300-char limit),
|
||||
collaboration pitch templates, follow-up sequences (day 1-30), and connection scoring
|
||||
criteria.
|
||||
criteria. Inherits DM template functionality from cancelled UPYOU-2078.
|
||||
|
||||
Use when the user says:
|
||||
- "who should I connect with", "networking strategy", "build my network"
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ Before scanning, load the user's content pillars and expertise areas:
|
|||
### Tier 1: Breaking News (daily, respond within 24-48h)
|
||||
|
||||
- **OpenAI**, **Anthropic**, **Microsoft AI**, **Google AI** -- blog posts and announcements
|
||||
- **EU/[your region's] government** AI regulatory decisions
|
||||
- **EU/Norwegian government** AI regulatory decisions
|
||||
|
||||
### Tier 2: Analysis & Research (2-3x/week, post within a week)
|
||||
|
||||
|
|
|
|||
|
|
@ -14,58 +14,145 @@ Claude will study these to understand your successful patterns and apply them to
|
|||
|
||||
---
|
||||
|
||||
<!-- Add your posts here using this format:
|
||||
## Post 1: Ralph Wiggum / Vibe Coding (BASELINE)
|
||||
|
||||
## Post 1: [Title/Topic]
|
||||
|
||||
**Posted:** [Date, time, timezone]
|
||||
**Engagement:** Likes: [N] | Comments: [N] | Shares: [N]
|
||||
**Reach:** [N] impressions
|
||||
**Engagement Rate:** [N]%
|
||||
**Your Follower Count:** ~[N]
|
||||
**Posted:** 2026-01-23, 23:13 CET (suboptimal timing)
|
||||
**Engagement:** Likes: 19 | Comments: 6 | Shares: 0
|
||||
**Reach:** 502 impressions
|
||||
**Engagement Rate:** 4.98%
|
||||
**Your Follower Count:** ~1,000
|
||||
|
||||
**The Post:**
|
||||
```
|
||||
[Paste your full post text here]
|
||||
𝗘𝗻 𝗱𝗮𝗴. 𝟭𝟬 𝟬𝟬𝟬 𝗹𝗶𝗻𝗷𝗲𝗿. 𝗨𝘁𝗲𝗻 å 𝘃æ𝗿𝗲 𝘂𝘁𝘃𝗶𝗸𝗹𝗲𝗿.
|
||||
|
||||
Jeg er ikke utvikler. Jeg er KI-rådgiver. Jeg kan ikke skrive kode fra bunnen av.
|
||||
|
||||
Men jeg kan kommunisere med Claude Code. Og det viser seg at det er nok.
|
||||
|
||||
𝗛𝘃𝗼𝗿𝗱𝗮𝗻 𝗱𝗲𝘁 𝘀𝘁𝗮𝗿𝘁𝗲𝘁
|
||||
|
||||
Denne uken var jeg på Claude Code Meetup i Oslo. 250+ deltakere. Arrangert av Aleksander Stensby og Mesh Oslo.
|
||||
|
||||
Aleksander nevnte "Ralph Wiggum-teknikken" som er en metode for å la AI bygge applikasjoner helt på egen hånd.
|
||||
|
||||
På spørsmål om hvem som faktisk hadde fullført en hel slik prosess, rakk én person opp hånden. Av 250.
|
||||
|
||||
Den kvelden bestemte jeg meg: I morgen tester jeg dette.
|
||||
|
||||
𝗞𝗼𝗻𝘀𝗲𝗽𝘁𝗲𝘁
|
||||
|
||||
Du blir intervjuet og ender opp med en liste med oppgaver. Starter en prosess. Går og lager kaffe, eller sover.
|
||||
|
||||
Når du kommer tilbake er applikasjonen bygget.
|
||||
|
||||
𝗠𝗶𝗻 𝗱𝗮𝗴
|
||||
|
||||
Klokken 08:00 fant jeg et enkelt Ralph Wiggum script på 100 linjer. Klokken 23:00 hadde jeg 10 000 linjer og et komplett rammeverk.
|
||||
|
||||
Ikke ved å skrive kode selv — men ved å forklare hva jeg ville ha:
|
||||
|
||||
"Claude, stopp etter fem feil på rad."
|
||||
"Claude, send meg Slack-melding når du er ferdig."
|
||||
"Claude, lag en AI som vurderer om ting ser bra ut visuelt."
|
||||
|
||||
Claude foreslo løsninger. Jeg sa ja. Ferdig.
|
||||
|
||||
𝗙ø𝗹𝗲𝗹𝘀𝗲𝗻
|
||||
|
||||
Starte prosessen med 30 oppgaver. Gjør noe annet. Komme tilbake og se oppgavene tikke av. Én etter én.
|
||||
|
||||
Å våkne til en Slack-melding: "🎉 Ferdig. Alle 30 oppgaver fullført."
|
||||
|
||||
Å åpne mappen og se en fungerende app. Som jeg ikke skrev. Men som jeg 𝘥𝘦𝘧𝘪𝘯𝘦𝘳𝘵𝘦.
|
||||
|
||||
𝗥𝗲𝘀𝘂𝗹𝘁𝗮𝘁
|
||||
|
||||
Tre prototyper i dag; booking-app, dashbord, skjemaverktøy. Hver tok én time. Null linjer kode. Bare beskrivelser.
|
||||
|
||||
𝗗𝗲𝗻 æ𝗿𝗹𝗶𝗴𝗲 𝗱𝗲𝗹𝗲𝗻
|
||||
|
||||
Alt dette tok én dag. Og jeg skraper bare i overflaten.
|
||||
|
||||
Det ryktes at Anthropic bygde Claude Cowork, et helt produkt, med fire personer på ti dager. Vi er i starten av noe stort.
|
||||
|
||||
De som eksperimenterer nå kommer til å ha et forsprang. Det er ikke lenger AI som er begrensningen, det er deg og meg.
|
||||
|
||||
𝗦å 𝗷𝗮. 𝗥𝗮𝗹𝗽𝗵 𝗪𝗶𝗴𝗴𝘂𝗺.
|
||||
|
||||
Oppkalt etter Simpsons-karakteren som sier: "I'm learnding!"
|
||||
|
||||
Det føles passende :-)
|
||||
|
||||
—
|
||||
|
||||
Jeg jobber i KI-seksjonen i Statens vegvesen. Mer om dette og andre eksperimenter i kommende innlegg.
|
||||
|
||||
𝗧𝗶𝗽𝘀: Claude Code Meetup i Oslo arrangeres jevnlig, sjekk [lenke]
|
||||
|
||||
#AI #ClaudeCode #VibeCoding #StatensVegvesen #Innovasjon
|
||||
```
|
||||
|
||||
**Why It Worked:**
|
||||
- **Hook:** [What made people stop scrolling?]
|
||||
- **Angle:** [What framing did you use?]
|
||||
- **Timing:** [Was the timing good/bad?]
|
||||
- **CTA:** [Did you include a call-to-action?]
|
||||
**Why It Worked (Despite Mistakes):**
|
||||
- **Hook:** Strong - "En dag. 10 000 linjer. Uten å være utvikler." Creates immediate curiosity gap with specific numbers and contrast
|
||||
- **Angle:** Personal Lesson + Discovery narrative - "I tried this, here's what happened"
|
||||
- **Timing:** FAILED - Posted 23:13, missed Golden Hour entirely
|
||||
- **CTA:** MISSING - No engagement prompt at end
|
||||
- **Key insight:** Concrete numbers (10,000 lines, 250 people, 1 person raised hand) create credibility
|
||||
|
||||
**Mistakes Made:**
|
||||
1. Posted at 23:13 (should be 08:00)
|
||||
2. Link in post body (should be in first comment)
|
||||
3. 5 hashtags (should be 3-4)
|
||||
4. No CTA (should ask question or invite discussion)
|
||||
5. Em dash used (should avoid)
|
||||
6. Post was in Norwegian (strategy says English)
|
||||
|
||||
**Pattern to Replicate:**
|
||||
- [What can you reuse in future posts?]
|
||||
- Hook with specific numbers + contrast works well
|
||||
- "I'm not X, but I did Y" framing creates relatability
|
||||
- Concrete timeline (08:00 to 23:00) adds credibility
|
||||
- "Følelsen" section (emotional payoff) resonates
|
||||
- Bold-formatted section headers improve readability
|
||||
|
||||
**Audience Response Themes:**
|
||||
- [What did people comment about?]
|
||||
- Interest in the technical process
|
||||
- Questions about Ralph Wiggum technique
|
||||
- Recognition from Claude Code community
|
||||
|
||||
**What to Test Next:**
|
||||
- Same quality content, but posted at 08:00
|
||||
- With proper CTA
|
||||
- Without link in body
|
||||
- In English
|
||||
|
||||
---
|
||||
|
||||
-->
|
||||
|
||||
## Patterns Across All High-Performing Posts
|
||||
|
||||
**Common Elements:**
|
||||
- [ ] Specific numbers in hook
|
||||
- [ ] Personal story structure (I did X, here's what happened)
|
||||
- [ ] Concrete timeline and details
|
||||
- [ ] Strong CTA
|
||||
- [ ] Optimal timing
|
||||
- [x] Specific numbers in hook (10,000 lines, 250 people)
|
||||
- [x] Personal story structure (I did X, here's what happened)
|
||||
- [x] Concrete timeline and details
|
||||
- [ ] Strong CTA (not yet tested)
|
||||
- [ ] Optimal timing (not yet tested)
|
||||
|
||||
**Audience Preferences (What YOUR Audience Responds To):**
|
||||
- Format: [Discover from your data]
|
||||
- Length: [Your typical length]
|
||||
- Tone: [Your tone pattern]
|
||||
- CTAs: [What works for your audience?]
|
||||
- Format: Story-based posts with concrete details
|
||||
- Length: ~2,100 characters (slightly over optimal 1,800)
|
||||
- Tone: Professional but personal, showing vulnerability ("I'm not a developer")
|
||||
- CTAs: Unknown - need to test
|
||||
|
||||
**Topics That Resonate:**
|
||||
1. [Add after 3+ posts]
|
||||
1. AI-assisted coding / Vibe coding
|
||||
2. [More data needed]
|
||||
3. [More data needed]
|
||||
|
||||
**Best Posting Times (Based on YOUR Data):**
|
||||
- Primary: [Test and record]
|
||||
- Secondary: [Test and record]
|
||||
- **Avoid:** [Based on your data]
|
||||
- Primary: Unknown - need to test 08:00 CET
|
||||
- Secondary: Unknown - need to test
|
||||
- **Avoid:** After 21:00 (confirmed by Ralph Wiggum failure)
|
||||
|
||||
## Update Log
|
||||
|
||||
- 2026-01-24: Added Ralph Wiggum post as baseline reference. Note: Post had good engagement rate (4.98%) despite multiple mistakes, suggesting content quality is strong. Focus on fixing timing, CTA, and link placement for next posts.
|
||||
|
|
|
|||
|
|
@ -1,71 +1,100 @@
|
|||
# Authentic Voice Samples - [Your Name]
|
||||
# Authentic Voice Samples - Kjell Tore Guttormsen
|
||||
|
||||
These guidelines help Claude understand and replicate [Your Name]'s natural writing style for LinkedIn content.
|
||||
These guidelines help Claude understand and replicate Kjell Tore's natural writing style for LinkedIn content.
|
||||
|
||||
## Voice Profile Summary
|
||||
|
||||
Fill in this section with your own writing characteristics. Run `/linkedin:setup` to build your voice profile interactively, or edit this file directly.
|
||||
Kjell Tore does not have traditional writing samples to share. Instead, his voice is defined by the following characteristics which Claude should internalize and apply consistently.
|
||||
|
||||
---
|
||||
|
||||
## Core Voice Characteristics
|
||||
|
||||
<!-- Replace these with your own voice traits. The examples below are common defaults - keep what fits, remove what doesn't, add your own. -->
|
||||
### 1. Solution-Oriented Mindset
|
||||
- Every problem is presented as an opportunity
|
||||
- Never complains without offering a path forward
|
||||
- Focuses on "what can be done" rather than "what went wrong"
|
||||
- Sees challenges as interesting puzzles to solve
|
||||
|
||||
### 1. [Your Primary Trait]
|
||||
- [Describe how this trait shows up in your writing]
|
||||
- [What makes your approach distinctive?]
|
||||
### 2. Factual Grounding
|
||||
- Statements are based on facts, not assumptions
|
||||
- If uncertain, acknowledges uncertainty openly
|
||||
- Prefers data and evidence over opinions
|
||||
- Avoids speculation presented as fact
|
||||
|
||||
### 2. [Your Secondary Trait]
|
||||
- [Describe how this trait shows up in your writing]
|
||||
### 3. Non-Judgmental Tone
|
||||
- Observes and explains without criticizing others
|
||||
- Builds up, never tears down
|
||||
- Avoids negative commentary about people, companies, or decisions
|
||||
- When discussing alternatives, frames as "different approaches" not "better/worse"
|
||||
|
||||
### 3. [Your Third Trait]
|
||||
- [Describe how this trait shows up in your writing]
|
||||
### 4. Curiosity and Openness
|
||||
- Genuinely interested in learning new things
|
||||
- Open to new ideas and approaches
|
||||
- Asks questions to understand, not to challenge
|
||||
- Embraces "I don't know" as a starting point for exploration
|
||||
|
||||
<!-- Add more traits as needed. Most voice profiles have 4-6 core characteristics. -->
|
||||
### 5. Storytelling Approach
|
||||
- Uses narrative techniques to make points memorable
|
||||
- Varies storytelling patterns based on content:
|
||||
- Hero's journey (transformation stories)
|
||||
- Problem-solution (practical content)
|
||||
- Before-after (showing change/improvement)
|
||||
- Discovery narrative (learning something new)
|
||||
- Day-in-the-life (practical application)
|
||||
- Shows rather than tells
|
||||
|
||||
### 6. Actionable Conclusions
|
||||
- Ends with something the reader can do
|
||||
- The more actionable, the better
|
||||
- If no clear action, provides a clear summary/takeaway
|
||||
- Never ends on a vague note
|
||||
|
||||
---
|
||||
|
||||
## Cross-Sample Analysis
|
||||
|
||||
### Do's (Things that sound like [Your Name])
|
||||
### Do's (Things that sound like Kjell Tore)
|
||||
|
||||
<!-- Replace these with patterns from YOUR best-performing posts. Run /linkedin:setup to analyze your writing samples. The items below are common best practices you can keep as defaults. -->
|
||||
- ✅ Start with stories or concrete examples before explaining concepts
|
||||
- ✅ Use clear, accessible language even for technical topics
|
||||
- ✅ Explain technical concepts thoroughly - assume intelligence, not knowledge
|
||||
- ✅ Show rather than tell - demonstrate with examples
|
||||
- ✅ End with actionable takeaways - what can the reader do NOW?
|
||||
- ✅ Vary storytelling techniques based on the content
|
||||
- ✅ Be genuinely helpful and supportive
|
||||
- ✅ Acknowledge complexity before simplifying
|
||||
- ✅ Use transitions like "What I've learned is..." to share insights
|
||||
- ✅ Frame discoveries as shared learning, not lecturing
|
||||
- ✅ Keep posts concise - short to medium length (800-1500 characters)
|
||||
|
||||
- Start with stories or concrete examples before explaining concepts
|
||||
- Use clear, accessible language even for technical topics
|
||||
- Explain technical concepts thoroughly - assume intelligence, not knowledge
|
||||
- Show rather than tell - demonstrate with examples
|
||||
- End with actionable takeaways - what can the reader do NOW?
|
||||
- Vary storytelling techniques based on the content
|
||||
- Be genuinely helpful and supportive
|
||||
- Acknowledge complexity before simplifying
|
||||
- Frame discoveries as shared learning, not lecturing
|
||||
- Keep posts concise - short to medium length (800-1500 characters)
|
||||
### Don'ts (Things Kjell Tore would NEVER say)
|
||||
|
||||
### Don'ts (Things [Your Name] would NEVER say)
|
||||
|
||||
<!-- Replace these with your personal anti-patterns. The items below are universal LinkedIn best practices. -->
|
||||
|
||||
- Don't use buzzwords: "game-changer", "leverage", "synergy", "disrupt", "revolutionize"
|
||||
- Don't criticize people, companies, or decisions
|
||||
- Don't make assumptions without facts
|
||||
- Don't write overly long posts (stay under 1500 characters for posts)
|
||||
- Don't use more than 1-2 emojis per post
|
||||
- Don't start with "Let's dive deep into..."
|
||||
- Don't use excessive exclamation marks
|
||||
- Don't use generic motivational phrases
|
||||
- Don't be preachy or lecture the reader
|
||||
- ❌ Don't use buzzwords: "game-changer", "leverage", "synergy", "disrupt", "revolutionize"
|
||||
- ❌ Don't criticize people, companies, or decisions
|
||||
- ❌ Don't use self-deprecating humor
|
||||
- ❌ Don't make assumptions without facts
|
||||
- ❌ Don't write overly long posts (stay under 1500 characters for posts)
|
||||
- ❌ Don't use more than 1-2 emojis per post
|
||||
- ❌ Don't discuss politics, religion, or personal matters
|
||||
- ❌ Don't use em dashes (—) - use hyphens or alternatives instead
|
||||
- ❌ Don't start with "Let's dive deep into..."
|
||||
- ❌ Don't use excessive exclamation marks!!!
|
||||
- ❌ Don't use generic motivational phrases
|
||||
- ❌ Don't be preachy or lecture the reader
|
||||
- ❌ Don't use "we" when you mean "I" (be direct about personal experience)
|
||||
|
||||
---
|
||||
|
||||
## Signature Phrases
|
||||
|
||||
<!-- Add 3-5 phrases that are distinctly yours. These help Claude maintain your voice. -->
|
||||
Use these naturally when appropriate - don't force them:
|
||||
|
||||
- "[Your phrase 1]"
|
||||
- "[Your phrase 2]"
|
||||
- "[Your phrase 3]"
|
||||
- "Let me show you..."
|
||||
- "What I've learned is..."
|
||||
- "Here is the secret to..."
|
||||
|
||||
These phrases signal a transition to insight or demonstration. Use them to introduce key points or revelations.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -73,11 +102,11 @@ Fill in this section with your own writing characteristics. Run `/linkedin:setup
|
|||
|
||||
### Technical Terms - How to Handle
|
||||
|
||||
<!-- Replace with your domain-specific terms. Examples: -->
|
||||
|
||||
- **[Term 1]:** [How to explain/use it]
|
||||
- **[Term 2]:** [How to explain/use it]
|
||||
- **[Term 3]:** [How to explain/use it]
|
||||
- **RAG (Retrieval-Augmented Generation):** Always explain on first use
|
||||
- **MCP (Model Context Protocol):** Explain what it enables, not just the acronym
|
||||
- **Copilot Studio:** Can assume some familiarity with Microsoft ecosystem
|
||||
- **Skills (Claude):** Explain as "reusable instruction sets" or similar
|
||||
- **Low-code:** Generally understood, but clarify scope if needed
|
||||
|
||||
**Principle:** Assume intelligence, not knowledge. Explain jargon without being condescending.
|
||||
|
||||
|
|
@ -100,10 +129,10 @@ Fill in this section with your own writing characteristics. Run `/linkedin:setup
|
|||
|
||||
## Humor and Personality
|
||||
|
||||
- **Humor style:** [Describe your humor approach - absent, dry, observational, etc.]
|
||||
- **Self-deprecation:** [Your preference]
|
||||
- **Cultural references:** [Your approach]
|
||||
- **Analogies:** [What kind of analogies work for your audience?]
|
||||
- **Humor style:** Mostly absent in professional content. If humor appears, it's observational and gentle - never at anyone's expense
|
||||
- **Self-deprecation:** Never. Don't undermine your own credibility.
|
||||
- **Cultural references:** Avoid pop culture references. Stick to professional/work context.
|
||||
- **Analogies:** Use when helpful for explanation. Prefer technical or universal analogies over sports/culture-specific ones.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -136,14 +165,14 @@ Match technical depth to the target audience:
|
|||
- ROI and outcomes
|
||||
- Avoid implementation details
|
||||
|
||||
### For Practitioners
|
||||
### For Low-Code Developers
|
||||
- Practical tips and patterns
|
||||
- Step-by-step guidance
|
||||
- Tool-specific insights
|
||||
- Common pitfalls and solutions
|
||||
- Can include some technical detail
|
||||
|
||||
### For Technical Experts
|
||||
### For AI Architects
|
||||
- Technical depth welcome
|
||||
- Architecture patterns
|
||||
- Integration approaches
|
||||
|
|
@ -163,24 +192,36 @@ Match technical depth to the target audience:
|
|||
|
||||
## Language Guidelines
|
||||
|
||||
- Choose ONE language for all LinkedIn content and stick with it
|
||||
- **Always English** for all LinkedIn content
|
||||
- Clear, international English accessible to non-native speakers
|
||||
- Avoid idioms that don't translate well internationally
|
||||
- Prefer simple sentence structures for complex ideas
|
||||
- Never use em dashes (—) - use hyphens, commas, or separate sentences instead
|
||||
|
||||
---
|
||||
|
||||
## Instructions for Claude
|
||||
|
||||
When generating LinkedIn content for [Your Name]:
|
||||
When generating LinkedIn content for Kjell Tore:
|
||||
|
||||
1. **Start with the voice profile** (from this document)
|
||||
1. **Start with his voice profile** (from this document)
|
||||
2. **Check the content pillar** - which audience is this for?
|
||||
3. **Choose appropriate storytelling technique** for the content type
|
||||
4. **Ensure actionable conclusion** - what can the reader DO?
|
||||
5. **Verify against Don'ts list** - no buzzwords, no criticism, no assumptions
|
||||
6. **Keep length in check** - 800-1500 characters for posts
|
||||
|
||||
**Priority:** Sound like [Your Name] > Optimize for algorithm
|
||||
**Priority:** Sound like Kjell Tore > Optimize for algorithm
|
||||
|
||||
**Exception:** If a phrase or approach would harm reach (external links, engagement bait), flag it but maintain the voice in everything else.
|
||||
**Exception:** If a phrase or approach would harm reach (external links, engagement bait), flag it but maintain his voice in everything else.
|
||||
|
||||
---
|
||||
|
||||
## Update Log
|
||||
|
||||
- 2025-11-30: Initial voice profile created based on interview
|
||||
|
||||
## Collected Post Samples
|
||||
|
||||
<!-- Posts are saved here automatically by the Stop hook after each session where content is created. -->
|
||||
<!-- The voice-trainer agent uses these for 6-dimension drift scoring. Needs 5+ samples for reliable results. -->
|
||||
|
|
|
|||
138
plugins/linkedin-thought-leadership/commands/carousel.md
Normal file
138
plugins/linkedin-thought-leadership/commands/carousel.md
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
---
|
||||
name: linkedin:carousel
|
||||
description: |
|
||||
Create a LinkedIn carousel post with structured slide-by-slide content and visual layout guidance.
|
||||
Carousels have the highest engagement rate (6.6%) on LinkedIn. Guides template selection,
|
||||
topic definition, and generates copy for each slide plus caption.
|
||||
Triggers on: "carousel", "slide deck", "pdf post", "swipe post", "multi-slide",
|
||||
"linkedin carousel", "document post", "create slides".
|
||||
allowed-tools:
|
||||
- Read
|
||||
- AskUserQuestion
|
||||
---
|
||||
|
||||
# Carousel Post Generator
|
||||
|
||||
You are a LinkedIn carousel content specialist. Create high-engagement carousel posts with structured slide content and visual layout guidance.
|
||||
|
||||
## Step 0: Load Context
|
||||
|
||||
- Read `~/.claude/linkedin-thought-leadership.local.md` for posting state and expertise areas
|
||||
- Read `assets/voice-samples/authentic-voice-samples.md` for voice profile
|
||||
- Check recent posts to avoid topic repetition
|
||||
|
||||
## Step 1: Choose Template
|
||||
|
||||
Read `assets/templates/carousel-templates.md` for the 5 templates.
|
||||
|
||||
Present the options:
|
||||
|
||||
```
|
||||
LinkedIn carousels get 6.6% average engagement — highest of all formats.
|
||||
|
||||
Choose a template:
|
||||
|
||||
1. How-To Guide — Teach a process step-by-step (8-10 slides)
|
||||
2. Listicle / Top N — Curated list of tips, tools, or lessons (8-12 slides)
|
||||
3. Story / Before-After — Personal narrative with transformation (8-10 slides)
|
||||
4. Comparison / vs. — Side-by-side analysis of two approaches (8-10 slides)
|
||||
5. Framework / Mental Model — Present an original framework (8-10 slides)
|
||||
```
|
||||
|
||||
Use AskUserQuestion for selection.
|
||||
|
||||
## Step 2: Define Topic and Audience
|
||||
|
||||
Ask:
|
||||
1. "What's the core topic or insight for this carousel?"
|
||||
2. "Who is the primary audience? (e.g., developers, managers, executives)"
|
||||
|
||||
If the user's expertise areas are set in the state file, suggest topics aligned with their pillars.
|
||||
|
||||
## Step 3: Generate Slide Content
|
||||
|
||||
Using the selected template structure from `carousel-templates.md`, generate content for each slide.
|
||||
|
||||
**Output format for each slide:**
|
||||
|
||||
```
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
SLIDE [N] of [TOTAL] — [Purpose from template]
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
HEADER:
|
||||
[Bold headline text — max 8 words]
|
||||
|
||||
BODY:
|
||||
[Line 1 — max 50 chars]
|
||||
[Line 2 — max 50 chars]
|
||||
[Line 3 — max 50 chars]
|
||||
[Line 4 — max 50 chars (optional)]
|
||||
[Line 5 — max 50 chars (optional)]
|
||||
|
||||
VISUAL NOTE:
|
||||
[Layout suggestion: e.g., "Icon: lightbulb left of header",
|
||||
"Before/After split layout", "Numbered list with accent color",
|
||||
"Summary table with checkmarks"]
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
```
|
||||
|
||||
**Slide content rules:**
|
||||
- Max 5-7 lines of body text per slide (mobile readability)
|
||||
- One idea per slide — if it needs two points, it needs two slides
|
||||
- Use the template's formula for each slide type (cover, step, item, CTA, etc.)
|
||||
- Headlines in sentence case, not ALL CAPS
|
||||
- Include the template-specific patterns (Pro tip, Before/After, Winner, etc.)
|
||||
|
||||
## Step 4: Generate Caption
|
||||
|
||||
Generate a caption following the carousel caption structure from the template file:
|
||||
|
||||
1. **Hook** (first line): Question, bold claim, or surprising stat — 110-140 chars
|
||||
2. **Context** (1-2 lines): Why this matters to the audience
|
||||
3. **Swipe prompt**: Reference a specific slide to create curiosity
|
||||
4. **Engagement CTA**: Question that invites comments
|
||||
5. **Hashtags**: 3-4 maximum
|
||||
|
||||
Target: 300-500 characters total.
|
||||
|
||||
Match the user's voice profile — check against avoid-list and tone markers.
|
||||
|
||||
## Step 5: Quality Check
|
||||
|
||||
Run against the Carousel Quality Checklist from `carousel-templates.md`:
|
||||
|
||||
- [ ] Cover slide has a clear promise or question
|
||||
- [ ] Each slide has one point (not multiple ideas)
|
||||
- [ ] Text is readable on mobile (keep lines short)
|
||||
- [ ] 8-10 slides total
|
||||
- [ ] Last slide has a clear CTA
|
||||
- [ ] Caption hooks attention and prompts swipe
|
||||
- [ ] Consistent structure across all slides
|
||||
|
||||
If any item fails, fix before presenting.
|
||||
|
||||
## Step 6: Present Complete Deck
|
||||
|
||||
Show all slides in order, then the caption, then design guidance:
|
||||
|
||||
```
|
||||
DESIGN GUIDE
|
||||
━━━━━━━━━━━━
|
||||
Dimensions: 1080 × 1350 px (4:5 portrait)
|
||||
Font: Sans-serif, 24pt+ body, 36pt+ headlines
|
||||
Colors: Pick 3 — background, text, accent
|
||||
Export: PDF format, under 100 MB
|
||||
Tools: Canva, PowerPoint, Figma, or Keynote
|
||||
|
||||
Create one slide per page using the content above.
|
||||
Export as PDF and upload directly to LinkedIn.
|
||||
```
|
||||
|
||||
Use AskUserQuestion: "Want to refine any slides, or is this ready for design?"
|
||||
|
||||
## Step 7: State Update
|
||||
|
||||
If the user confirms the carousel is ready:
|
||||
- Note in state file: topic, format=carousel, slide count
|
||||
- Suggest: "After publishing, run the 5x5x5 engagement method for maximum reach."
|
||||
|
|
@ -27,23 +27,6 @@ The follower segment only appears if `follower_count > 0` in the state file.
|
|||
|
||||
If the state file doesn't exist, show: "No LinkedIn state tracked yet. State tracking starts when you create your first post."
|
||||
|
||||
## New User Detection
|
||||
|
||||
After reading the state file, check if `first_post_date` is null/empty AND `posts_this_week` is 0. If so, this is a new user. Show a condensed getting-started menu INSTEAD of the full command list:
|
||||
|
||||
**You haven't posted yet! Here's where to start:**
|
||||
|
||||
| # | Action | Time |
|
||||
|---|--------|------|
|
||||
| 0 | **Profile audit** — optimize for 360Brew algorithm | 10 min |
|
||||
| 1 | **Personalize** — set up your voice, audience, and goals | 15 min |
|
||||
| 2 | **First post** — guided creation with hand-holding | 10 min |
|
||||
| 3 | **Show all commands** — I know what I'm doing |
|
||||
|
||||
Use AskUserQuestion with these 4 options. Route 0 → `/linkedin:profile`, 1 → `/linkedin:setup`, 2 → `/linkedin:first-post`, 3 → continue to the full command list below.
|
||||
|
||||
**Skip this section entirely if `first_post_date` is set or `posts_this_week` > 0.** Proceed to Upcoming Posts and the full command list.
|
||||
|
||||
## Upcoming Posts
|
||||
|
||||
After the status line, show upcoming scheduled posts from the queue:
|
||||
|
|
@ -81,6 +64,7 @@ Present these options to the user:
|
|||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `/linkedin:onboarding` | Full onboarding wizard — profile, setup, and first post in one flow |
|
||||
| `/linkedin:first-post` | First-post accelerator — zero to published in under 10 minutes |
|
||||
| `/linkedin:setup` | Guided setup to populate empty asset templates with your real voice, case studies, and audience data |
|
||||
|
||||
|
|
@ -93,6 +77,7 @@ Present these options to the user:
|
|||
| `/linkedin:quick` | Fast 5-minute post using the 3-line formula |
|
||||
| `/linkedin:templates` | Browse and apply proven post templates |
|
||||
| `/linkedin:pipeline` | Full end-to-end workflow from idea to post-publish analysis |
|
||||
| `/linkedin:carousel` | Create structured multi-slide carousel with visual layout guidance |
|
||||
| `/linkedin:video` | Create video scripts with hook, body, CTA, captions, and thumbnail suggestions |
|
||||
| `/linkedin:batch` | Create a full week of content in one session |
|
||||
| `/linkedin:calendar` | View and manage your post scheduling queue |
|
||||
|
|
@ -133,7 +118,7 @@ Use AskUserQuestion to ask:
|
|||
|
||||
**What would you like to do?**
|
||||
|
||||
0. **First post** — Never posted? Start here (10 min)
|
||||
0. **Onboarding wizard** — Just installed? Full guided flow: profile → setup → first post
|
||||
1. **Setup & personalize** — Guided setup to populate voice, case studies, frameworks, and audience data
|
||||
2. **Create a post** — Full post workflow with angle selection
|
||||
3. **React to a URL** — Turn an article/news into a post
|
||||
|
|
@ -170,6 +155,7 @@ If the user already has content they want to turn into a post:
|
|||
## Direct Routing
|
||||
|
||||
If the user's intent is clear from context:
|
||||
- Mentions "onboarding" or "just installed" or "walk me through" or "setup wizard" or "start from scratch" → Route to `/linkedin:onboarding`
|
||||
- Mentions "first post" or "never posted" or "get started" or "new to linkedin" or "help me start" → Route to `/linkedin:first-post`
|
||||
- Mentions "setup" or "personalize" or "templates empty" or "score" or "fill in assets" or "configure plugin" → Route to `/linkedin:setup`
|
||||
- Mentions "react" or "this article" or "this url" or "turn this into" or "share this news" → Route to `/linkedin:react`
|
||||
|
|
@ -182,6 +168,7 @@ If the user's intent is clear from context:
|
|||
- Mentions "profile" or "360Brew" → Route to `/linkedin:profile`
|
||||
- Mentions "not working" or "low reach" → Route to `/linkedin:analyze`
|
||||
- Mentions "strategy" or "growth plan" → Route to `/linkedin:strategy`
|
||||
- Mentions "carousel" or "slides" or "slide deck" or "pdf post" or "swipe" or "document post" → Route to `/linkedin:carousel`
|
||||
- Mentions "template" → Route to `/linkedin:templates`
|
||||
- Mentions "audit" or "review strategy" → Route to `/linkedin:audit`
|
||||
- Mentions "authority" or "signature content" → Route to `/linkedin:authority`
|
||||
|
|
|
|||
182
plugins/linkedin-thought-leadership/commands/onboarding.md
Normal file
182
plugins/linkedin-thought-leadership/commands/onboarding.md
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
---
|
||||
name: linkedin:onboarding
|
||||
description: |
|
||||
Multi-step onboarding wizard that guides new users through profile → setup → first-post
|
||||
as one cohesive flow. Designed for users who have just installed the plugin and want a
|
||||
single guided path instead of navigating 25 commands on their own.
|
||||
Triggers on: "onboarding", "get started", "new user", "setup wizard", "start from scratch",
|
||||
"just installed", "how do I start", "walk me through", "linkedin onboarding".
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Bash
|
||||
- AskUserQuestion
|
||||
---
|
||||
|
||||
# LinkedIn Onboarding Wizard
|
||||
|
||||
You are a LinkedIn thought leadership onboarding guide. Walk the user through profile optimization, plugin personalization, and their first post — all in one session.
|
||||
|
||||
## Step 0: Load Context and Check State
|
||||
|
||||
Read `~/.claude/linkedin-thought-leadership.local.md` for current state.
|
||||
|
||||
**Already onboarded check:** If `first_post_date` is set (not null) AND personalization score > 50:
|
||||
- Show: "You've already completed onboarding (first post: [date], personalization: [score]%)."
|
||||
- Use AskUserQuestion: "Would you like to re-run a specific phase?"
|
||||
1. Re-optimize profile (360Brew) → jump to Phase 1
|
||||
2. Improve personalization → jump to Phase 2
|
||||
3. Create another post → suggest `/linkedin:post` or `/linkedin:quick`
|
||||
4. Exit
|
||||
|
||||
If not already onboarded, continue to Phase 1.
|
||||
|
||||
## Phase 1: Profile Optimization (360Brew)
|
||||
|
||||
```
|
||||
╔═══════════════════════════════════════╗
|
||||
║ ONBOARDING — Phase 1 of 3: Profile ║
|
||||
╚═══════════════════════════════════════╝
|
||||
```
|
||||
|
||||
Explain briefly:
|
||||
- LinkedIn's 360Brew algorithm (January 2026) validates your profile BEFORE distributing your content
|
||||
- A weak profile means even great posts get suppressed
|
||||
- This takes 5 minutes and has outsized impact on everything else
|
||||
|
||||
Use AskUserQuestion:
|
||||
1. **Guide me through profile optimization** — I want the full 360Brew checklist
|
||||
2. **Already optimized** — I've already done this, skip ahead
|
||||
3. **Do it later** — Skip for now, I'll run `/linkedin:profile` later
|
||||
|
||||
**If option 1:** Walk through the core 360Brew checklist (condensed from `/linkedin:profile`):
|
||||
- [ ] Professional headshot (face visible, good lighting)
|
||||
- [ ] Headline with expertise + value prop (not just job title)
|
||||
- [ ] About section with story arc + CTA (not a resume)
|
||||
- [ ] Banner image related to expertise
|
||||
- [ ] Featured section with best content or lead magnet
|
||||
- [ ] Creator mode ON (if available)
|
||||
|
||||
After each item, ask if done or needs to skip. Don't block — mark skipped items as "recommended later."
|
||||
|
||||
**If option 2 or 3:** Move to Phase 2.
|
||||
|
||||
## Phase 2: Plugin Personalization
|
||||
|
||||
```
|
||||
╔═════════════════════════════════════════════╗
|
||||
║ ONBOARDING — Phase 2 of 3: Personalization ║
|
||||
╚═════════════════════════════════════════════╝
|
||||
```
|
||||
|
||||
Calculate personalization score:
|
||||
```bash
|
||||
node --input-type=module -e "
|
||||
import { calculateScore } from '${CLAUDE_PLUGIN_ROOT}/hooks/scripts/personalization-score.mjs';
|
||||
const result = calculateScore('${CLAUDE_PLUGIN_ROOT}');
|
||||
console.log(JSON.stringify(result));
|
||||
"
|
||||
```
|
||||
|
||||
Show the score dashboard:
|
||||
```
|
||||
Personalization Score: [XX]%
|
||||
|
||||
Category Weight Status
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
Voice samples 25 [✓ Done / ○ Empty]
|
||||
User profile 20 [✓ Done / ○ Empty]
|
||||
Case studies 15 [✓ Done / ○ Empty]
|
||||
Frameworks 10 [✓ Done / ○ Empty]
|
||||
High-eng. posts 10 [✓ Done / ○ Empty]
|
||||
Demographics 8 [✓ Done / ○ Empty]
|
||||
Engagement patterns 7 [✓ Done / ○ Empty]
|
||||
Post templates 5 [✓ Done / ○ Empty]
|
||||
```
|
||||
|
||||
Identify the **top 2 incomplete categories by weight** and guide through those:
|
||||
|
||||
**Priority setup (2 categories only — keep it focused):**
|
||||
|
||||
Use AskUserQuestion:
|
||||
1. **Set up voice profile** (weight: 25) — 5 questions about your writing style, or paste 3 examples
|
||||
2. **Set up user profile** (weight: 20) — Your name, industry, expertise areas, audience
|
||||
3. **Both** — Do voice + user profile now
|
||||
4. **Skip for now** — I'll run `/linkedin:setup` later for the full setup
|
||||
|
||||
**If voice selected:** Run a quick 5-question voice interview:
|
||||
1. "How would you describe your communication style in one sentence?"
|
||||
2. "What words or phrases do you naturally use?" (give examples)
|
||||
3. "What tone turns you off in LinkedIn content?"
|
||||
4. "Paste a paragraph you've written that sounds like YOU (email, doc, anything)"
|
||||
5. "Any words or phrases you'd NEVER use?"
|
||||
|
||||
Save responses to `assets/voice-samples/authentic-voice-samples.md` under a new section `## Quick Voice Interview` (append, don't overwrite existing content).
|
||||
|
||||
**If user profile selected:** Ask for:
|
||||
1. Full name
|
||||
2. Industry
|
||||
3. Job title / role
|
||||
4. 3-5 expertise areas (these become your content pillars)
|
||||
5. Target audience description
|
||||
|
||||
Save to `config/user-profile.local.md`.
|
||||
|
||||
After setup, recalculate and show updated score.
|
||||
|
||||
## Phase 3: First Post
|
||||
|
||||
```
|
||||
╔═══════════════════════════════════════════╗
|
||||
║ ONBOARDING — Phase 3 of 3: First Post ║
|
||||
╚═══════════════════════════════════════════╝
|
||||
```
|
||||
|
||||
Check `first_post_date` in state file:
|
||||
|
||||
**If null (no first post yet):**
|
||||
- "You're ready to create your first post! This is the most important step — your first post doesn't need to be perfect, it needs to EXIST."
|
||||
- Use AskUserQuestion:
|
||||
1. **Guided first post** (10 min) — Maximum hand-holding, simple format → routes to `/linkedin:first-post` workflow
|
||||
2. **Quick post** (5 min) — You already know what to say → routes to `/linkedin:quick` workflow
|
||||
3. **Not now** — I'll post later
|
||||
|
||||
**If first_post_date is set:**
|
||||
- "You already have your first post (published [date]). Ready to create your next one?"
|
||||
- Use AskUserQuestion:
|
||||
1. **Create a new post** → suggest `/linkedin:post`
|
||||
2. **Quick post** → suggest `/linkedin:quick`
|
||||
3. **Exit onboarding**
|
||||
|
||||
**If user chooses to post (option 1 or 2):** Don't invoke the sub-command directly — instead, tell them:
|
||||
"Run `/linkedin:first-post` to start the guided first-post flow."
|
||||
or
|
||||
"Run `/linkedin:quick` to create a quick post."
|
||||
|
||||
This keeps the onboarding context clean and lets the post commands manage their own workflow.
|
||||
|
||||
## Phase 4: Summary and Next Steps
|
||||
|
||||
```
|
||||
╔═══════════════════════════════════════════╗
|
||||
║ ONBOARDING COMPLETE ║
|
||||
╚═══════════════════════════════════════════╝
|
||||
```
|
||||
|
||||
Show final status:
|
||||
```
|
||||
Profile: [Optimized / Skipped — run /linkedin:profile later]
|
||||
Personalization: [XX]% [↑ from YY% if improved]
|
||||
First post: [Published DATE / Pending — run /linkedin:first-post]
|
||||
```
|
||||
|
||||
**What's next — your first week:**
|
||||
1. Create 2-3 posts this week (`/linkedin:post` or `/linkedin:quick`)
|
||||
2. Engage with 5 posts in your niche before and after publishing (5x5x5 method)
|
||||
3. Import your first analytics data after 7 days (`/linkedin:import`)
|
||||
4. Run `/linkedin:report` after your first week to see what's working
|
||||
|
||||
**Power commands to explore:**
|
||||
- `/linkedin:batch` — Plan a full week of content in one session
|
||||
- `/linkedin:react` — Turn articles and news into posts
|
||||
- `/linkedin:strategy` — Growth strategy tailored to your follower level
|
||||
- `/linkedin` — See all 25 commands anytime
|
||||
|
|
@ -23,16 +23,6 @@ You are a LinkedIn thought leadership content creator. Guide the user through cr
|
|||
First, load persistent state and personalization:
|
||||
- Read `~/.claude/linkedin-thought-leadership.local.md` for posting state (streak, weekly progress, recent topics)
|
||||
- Read `skills/linkedin-thought-leadership/SKILL.md` for user profile, voice settings, and preferences
|
||||
- Read `config/user-profile.local.md` (if it exists) for expertise areas and audience
|
||||
|
||||
### Readiness Check
|
||||
|
||||
If `config/user-profile.local.md` doesn't exist OR `assets/voice-samples/authentic-voice-samples.md` contains `[Your Name]` in the title line, show this non-blocking notice:
|
||||
|
||||
> This plugin isn't personalized yet. Content will use generic best practices.
|
||||
> Run `/linkedin:setup` after this session to unlock voice-matched content.
|
||||
|
||||
Then proceed normally — do not block content creation.
|
||||
|
||||
Check state for topic planning:
|
||||
- Compare intended topic against "Recent Posts" in state file
|
||||
|
|
@ -73,6 +63,8 @@ If they provide a URL, use WebFetch to extract the content first.
|
|||
|
||||
Read `references/thought-leadership-angles.md` for the 8 universal angles.
|
||||
|
||||
**Industry-specific angles:** If `config/user-profile.local.md` exists and has an `industry` field, check the "Industry Angle Variants" section in `thought-leadership-angles.md` for the matching industry table. Use the industry-specific starter questions and example hooks to generate more targeted angle suggestions.
|
||||
|
||||
Present 2-3 possible angles for their content:
|
||||
|
||||
```
|
||||
|
|
@ -101,7 +93,7 @@ Based on content type, recommend a format:
|
|||
| Frameworks/processes | Carousel or Native document |
|
||||
| Opinions/takes | Text-only medium post |
|
||||
|
||||
If carousel, outline the slide structure.
|
||||
If carousel is the best format, recommend: "This topic works great as a carousel. Run `/linkedin:carousel` for the full slide-by-slide generator with 5 proven templates."
|
||||
|
||||
## Step 4: Structure and Write
|
||||
|
||||
|
|
|
|||
|
|
@ -27,17 +27,6 @@ Read `skills/linkedin-thought-leadership/SKILL.md` for:
|
|||
- Core expertise areas (for topical alignment)
|
||||
- Phrases they commonly use
|
||||
|
||||
Read `config/user-profile.local.md` (if it exists) for expertise areas and audience.
|
||||
|
||||
### Readiness Check
|
||||
|
||||
If `config/user-profile.local.md` doesn't exist OR `assets/voice-samples/authentic-voice-samples.md` contains `[Your Name]` in the title line, show this non-blocking notice:
|
||||
|
||||
> This plugin isn't personalized yet. Content will use generic best practices.
|
||||
> Run `/linkedin:setup` after this session to unlock voice-matched content.
|
||||
|
||||
Then proceed normally — do not block content creation.
|
||||
|
||||
Read `assets/quick-post-resources.md` for:
|
||||
- Hooks bank
|
||||
- CTAs bank
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ First, load persistent state and personalization:
|
|||
- Read `assets/voice-samples/authentic-voice-samples.md` for voice profile
|
||||
- Check recent posts to avoid topic repetition within 7 days
|
||||
|
||||
## Step 1: Get the URL
|
||||
## Step 1: Get URL(s)
|
||||
|
||||
If the user hasn't provided a URL, ask for one. Accept:
|
||||
- News articles
|
||||
|
|
@ -36,6 +36,17 @@ If the user hasn't provided a URL, ask for one. Accept:
|
|||
- Company announcements
|
||||
- Social media threads
|
||||
|
||||
**Multiple URLs:** If the user provides 2-3 URLs, or if you detect multiple links, use AskUserQuestion:
|
||||
|
||||
```
|
||||
I see multiple URLs. Would you like to:
|
||||
1. React to a single article (pick the most interesting one)
|
||||
2. Compare and contrast 2-3 articles into one post
|
||||
```
|
||||
|
||||
If option 2 → jump to **Comparison Path** (Step 1b below).
|
||||
If option 1 or single URL → continue to Step 2.
|
||||
|
||||
## Step 2: Fetch and Analyze Content
|
||||
|
||||
Use WebFetch to extract the content from the URL. Ask WebFetch to extract:
|
||||
|
|
@ -143,6 +154,96 @@ After the post is finalized, update `~/.claude/linkedin-thought-leadership.local
|
|||
- Update `longest_streak` if current exceeds it
|
||||
- Add entry to "## Recent Posts": [YYYY-MM-DD] "Hook text..." (char count) - topic
|
||||
|
||||
---
|
||||
|
||||
## Comparison Path (Multi-URL)
|
||||
|
||||
When the user wants to compare 2-3 articles into one post.
|
||||
|
||||
### Step 1b: Collect URLs
|
||||
|
||||
Collect 2-3 URLs. Minimum 2, maximum 3. If the user provided them already, confirm the list.
|
||||
|
||||
### Step 2b: Fetch All Sources
|
||||
|
||||
Use WebFetch on each URL. For each, extract:
|
||||
- **Title** and author/source
|
||||
- **Key claims** (3-5 bullet points)
|
||||
- **Stance/argument** — what position does the author take?
|
||||
- **Data points** — any statistics or evidence cited
|
||||
|
||||
### Step 3b: Synthesis Analysis
|
||||
|
||||
Analyze across all sources:
|
||||
|
||||
| Dimension | Analysis |
|
||||
|-----------|----------|
|
||||
| **Common ground** | Where do the sources agree? |
|
||||
| **Tension points** | Where do they disagree or contradict? |
|
||||
| **Blind spots** | What are ALL of them missing? |
|
||||
| **Your unique angle** | Given your expertise, what perspective do you add? |
|
||||
|
||||
### Step 4b: Choose Comparison Angle
|
||||
|
||||
Present 3 angles via AskUserQuestion:
|
||||
|
||||
1. **Synthesis** — "These perspectives seem opposed, but the truth is more nuanced. Here's how I connect them."
|
||||
2. **Contrarian to all** — "Both/all articles miss the real issue. Here's what actually matters."
|
||||
3. **Pattern analysis** — "The fact that [N] experts are all writing about [X] tells us something about [Y]."
|
||||
|
||||
### Step 5b: Generate Comparison Draft
|
||||
|
||||
Structure:
|
||||
|
||||
**Hook (110-140 chars):** Your synthesized perspective — NOT "I read 3 articles about..." Avoid mentioning the number of sources in the hook.
|
||||
|
||||
**The conversation (1-2 sentences):** Briefly describe the debate or trend ("There's a growing conversation about [X]. Perspectives range from [A] to [B].")
|
||||
|
||||
**Your lens (main body):**
|
||||
- What the synthesis reveals that individual pieces miss
|
||||
- Concrete example from your experience that connects the dots
|
||||
- Where you agree and where you push back
|
||||
|
||||
**Implication (1-2 sentences):** What this convergence/divergence means for the audience.
|
||||
|
||||
**CTA:** Question that invites people to take a side or share their own synthesis.
|
||||
|
||||
### Critical Rules (comparison-specific):
|
||||
- **NO URLs in post body** — all links go in first comment
|
||||
- Post must stand alone without reading any of the sources
|
||||
- Don't summarize each article — synthesize across them
|
||||
- Your perspective is the star, not the articles
|
||||
- Character target: 1,200-1,800 chars
|
||||
|
||||
### Step 6b: Quality Check
|
||||
|
||||
Same as Step 6, plus:
|
||||
- [ ] Post is a synthesis, not a summary of each article
|
||||
- [ ] Hook doesn't mention number of sources read
|
||||
- [ ] Each source is credited in the first comment, not the post
|
||||
|
||||
### Step 7b: Present Draft
|
||||
|
||||
Show:
|
||||
1. The main draft with character count
|
||||
2. 2 alternative hooks
|
||||
3. Suggested first comment with ALL URLs:
|
||||
```
|
||||
Sources referenced:
|
||||
1. "[Title]" by [Author] — [URL]
|
||||
2. "[Title]" by [Author] — [URL]
|
||||
3. "[Title]" by [Author] — [URL] (if applicable)
|
||||
```
|
||||
4. Recommended posting time
|
||||
|
||||
Offer same refinement options as Step 7.
|
||||
|
||||
### Step 8b: State Update
|
||||
|
||||
Same as Step 8 — update state file with topic, increment counts, etc.
|
||||
|
||||
---
|
||||
|
||||
## Reference Files
|
||||
|
||||
- `assets/voice-samples/authentic-voice-samples.md` — Voice matching
|
||||
|
|
|
|||
|
|
@ -37,18 +37,32 @@ You need to import your LinkedIn analytics first:
|
|||
1. Run `/linkedin:import` to import CSV data
|
||||
2. Then come back to generate reports
|
||||
|
||||
## Step 2: Determine Week to Report On
|
||||
|
||||
If no week specified, default to current week or most recent available data.
|
||||
## Step 2: Choose Report Type
|
||||
|
||||
**Ask the user** using AskUserQuestion:
|
||||
|
||||
```
|
||||
What kind of report would you like?
|
||||
|
||||
1. Weekly report (default) — performance for a specific ISO week
|
||||
2. Monthly report — month summary with month-over-month comparison
|
||||
3. Day-of-week heatmap — which days perform best
|
||||
|
||||
Enter your choice:
|
||||
```
|
||||
|
||||
**If monthly (option 2):** Ask for month (YYYY-MM format, default to current month), then jump to **Step 2b**.
|
||||
**If heatmap (option 3):** Run the heatmap CLI command and jump to **Step 6c**.
|
||||
**If weekly (option 1 or default):** Continue below.
|
||||
|
||||
### Weekly: Determine Week
|
||||
|
||||
```
|
||||
Which week would you like a report for?
|
||||
|
||||
Available options:
|
||||
- "current" or "this week" - Current ISO week (2026-W05)
|
||||
- "last week" - Previous ISO week (2026-W04)
|
||||
- "current" or "this week" - Current ISO week
|
||||
- "last week" - Previous ISO week
|
||||
- Specific week: "2026-W03", "2025-W52", etc.
|
||||
- "latest" - Most recent week with data
|
||||
|
||||
|
|
@ -62,6 +76,26 @@ To get current ISO week:
|
|||
date +%Y-W%V
|
||||
```
|
||||
|
||||
### Step 2b: Monthly Report
|
||||
|
||||
If the user chose monthly:
|
||||
|
||||
```bash
|
||||
ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" node --import tsx "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" report --month <YYYY-MM>
|
||||
```
|
||||
|
||||
Read the generated JSON from `assets/analytics/monthly-reports/<YYYY-MM>.json`. Present the monthly summary with MoM comparison deltas, weekly breakdown, and top performers. Then jump to Step 7 for deep-dive options.
|
||||
|
||||
### Step 2c: Heatmap
|
||||
|
||||
If the user chose heatmap:
|
||||
|
||||
```bash
|
||||
ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" node --import tsx "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" heatmap
|
||||
```
|
||||
|
||||
Present the day-of-week matrix and best-day findings. Then jump to Step 7 for deep-dive options.
|
||||
|
||||
## Step 3: Run Report Generation
|
||||
|
||||
Execute the report CLI command:
|
||||
|
|
|
|||
|
|
@ -16,14 +16,7 @@ Based on the day of the week, suggest the next optimal posting window:
|
|||
- Weekend: 10-11 AM CET (lower reach but less competition)
|
||||
|
||||
**3. 5x5x5 Engagement Reminder**
|
||||
Remind the user about the 5x5x5 engagement ritual:
|
||||
|
||||
> **5x5x5 Engagement Ritual** (15-20 min before AND in the first hour after posting):
|
||||
> - **5 comments** — find 5 people with overlapping audiences and leave thoughtful comments on their recent posts
|
||||
> - **5 connection requests** — send personalized requests to people who engaged with your niche today
|
||||
> - **5 replies** — reply to every comment on YOUR post within the first hour
|
||||
>
|
||||
> This signals active participation to LinkedIn's algorithm and boosts your post's initial distribution.
|
||||
Remind: 'Before posting, spend 15-20 minutes on 5x5x5 pre-engagement: find 5 people with overlapping audiences, comment thoughtfully on their recent posts.'
|
||||
|
||||
**4. Content Logging**
|
||||
Note: The post topic and hook should be logged to the state file when the session ends (handled by Stop hook).
|
||||
|
|
|
|||
|
|
@ -38,19 +38,20 @@ If a scheduled post was published during this session:
|
|||
|
||||
Provide reminders naturally based on what was done in the session. If no LinkedIn content was created, skip the reminders and just ensure state is consistent.
|
||||
|
||||
**4. Voice Sample Extraction** (if a post was created)
|
||||
**4. Voice Sample Collection** (if a post was created)
|
||||
|
||||
If a LinkedIn post was created or finalized in this session, consider extracting the hook line as a voice sample:
|
||||
If a LinkedIn post was created or finalized in this session, save the full post text as a voice sample:
|
||||
|
||||
- Read the hook line from the post that was just created
|
||||
- Read the full post text from the draft that was just created
|
||||
- Check if `assets/voice-samples/authentic-voice-samples.md` exists
|
||||
- If it does, suggest appending a new entry to the "## Update Log" section at the bottom:
|
||||
- Append the full post to the `## Collected Post Samples` section:
|
||||
```
|
||||
- [YYYY-MM-DD]: "[Hook text]" — [post type] (extracted from session post)
|
||||
### [YYYY-MM-DD] — [post type] ([char count] chars)
|
||||
[Full post text exactly as written]
|
||||
```
|
||||
- **Ask the user for approval before writing.** Say: "Would you like me to save this hook as a voice sample for future reference?"
|
||||
- Only write if the user approves
|
||||
- This passively grows the voice profile over time, improving personalization score
|
||||
- **Ask the user for confirmation** before writing: "I'll save this post as a voice sample for drift detection. OK?"
|
||||
- This builds the voice sample library that enables automatic drift scoring (needs 5+ samples for reliable scoring)
|
||||
- The more samples collected, the more accurate the voice-trainer's drift detection becomes
|
||||
|
||||
**5. Content History Log** (if a post was created)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
VOICE GUARDIAN — AI AUTHENTICITY CHECK: If the file being written/edited is LinkedIn content (post draft, article, or content file — NOT config, state, scripts, docs), check for AI-sounding patterns:
|
||||
VOICE GUARDIAN — DRIFT SCORING & AI AUTHENTICITY CHECK: If the file being written/edited is LinkedIn content (post draft, article, or content file — NOT config, state, scripts, docs), perform both AI detection and voice drift scoring:
|
||||
|
||||
## 1. AI Pattern Detection
|
||||
|
||||
**AI Pattern Detection:**
|
||||
Scan for these common AI writing patterns:
|
||||
- Generic openings: 'In today's rapidly evolving...', 'As we navigate...', 'In the ever-changing landscape...'
|
||||
- Filler phrases: 'It's worth noting that', 'It goes without saying', 'At the end of the day'
|
||||
|
|
@ -10,13 +11,42 @@ Scan for these common AI writing patterns:
|
|||
- Hedging language: 'It could be argued', 'One might say', 'Perhaps'
|
||||
- Perfect structure: Every paragraph exactly the same length
|
||||
|
||||
**Authenticity Score:**
|
||||
If 3+ AI patterns detected, flag: 'Voice Guardian Alert: This content scores below authenticity threshold. AI patterns found: [list specific patterns]. Suggested fixes: [specific rewrites using natural language].'
|
||||
|
||||
**Voice Matching:**
|
||||
If voice samples exist at `${CLAUDE_PLUGIN_ROOT}/assets/voice-samples/`, compare the writing style against the user's authentic voice patterns. Flag deviations.
|
||||
## 2. Six-Dimension Voice Drift Scoring
|
||||
|
||||
Read the voice profile and collected post samples from `${CLAUDE_PLUGIN_ROOT}/assets/voice-samples/authentic-voice-samples.md`.
|
||||
|
||||
Score the draft against these 6 dimensions (0 = perfect match, 1 = minor drift per dimension):
|
||||
|
||||
| Dimension | What to Compare |
|
||||
|-----------|----------------|
|
||||
| **Sentence structure** | Average length, complexity, use of fragments vs. compound sentences |
|
||||
| **Word choice** | Vocabulary level, preferred/avoided words from voice profile |
|
||||
| **Opening patterns** | Hook style — does it match the user's signature openers? |
|
||||
| **Storytelling** | Anecdote usage, narrative arc, concrete vs. abstract |
|
||||
| **Tone markers** | Humor, directness, formality level, empathy signals |
|
||||
| **Formatting** | Paragraph length, whitespace, emoji usage, punctuation habits |
|
||||
|
||||
**Sum the 6 scores (0-6 total) and output a verdict:**
|
||||
|
||||
| Score | Verdict | Action |
|
||||
|-------|---------|--------|
|
||||
| 0-1 | AUTHENTIC | No changes needed |
|
||||
| 2-3 | CAUTION | Flag specific dimensions that drifted, suggest fixes |
|
||||
| 4-5 | ALERT | Significant drift — list all deviating dimensions with rewrites |
|
||||
| 6 | REWRITE | Content doesn't sound like the user — recommend starting over |
|
||||
|
||||
**Confidence gate:** If `## Collected Post Samples` has fewer than 5 posts, output: "Voice drift: LOW CONFIDENCE (X/5 samples). Scoring based on voice profile only." and score only against the profile description (dimensions 1-2 and 4-6), skipping opening patterns (dimension 3) which needs real samples.
|
||||
|
||||
**Output format (always include at end of system message):**
|
||||
```
|
||||
Voice Drift: [VERDICT] ([score]/6) [confidence: HIGH/LOW]
|
||||
[If CAUTION+: list dimensions that scored 1 with brief fix suggestion]
|
||||
```
|
||||
|
||||
## 3. Humanization Tips (for CAUTION or higher)
|
||||
|
||||
**Humanization Tips:**
|
||||
- Add specific personal anecdotes or observations
|
||||
- Use conversational contractions (I've, don't, it's)
|
||||
- Include imperfect/real-world examples
|
||||
|
|
|
|||
|
|
@ -0,0 +1,102 @@
|
|||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyWeekRollover } from '../week-rollover.mjs';
|
||||
|
||||
const SAMPLE_STATE = `---
|
||||
last_post_date: "2026-04-05"
|
||||
first_post_date: "2026-01-15"
|
||||
last_post_topic: "AI strategy"
|
||||
posts_this_week: 3
|
||||
weekly_goal: 3
|
||||
current_streak: 5
|
||||
longest_streak: 12
|
||||
current_week: "2026-W14"
|
||||
last_import_date: "2026-04-01"
|
||||
follower_count: 850
|
||||
follower_target: 10000
|
||||
target_date: "2026-12-31"
|
||||
---
|
||||
|
||||
## Recent Posts
|
||||
- 2026-04-05: AI strategy post
|
||||
`;
|
||||
|
||||
describe('applyWeekRollover', () => {
|
||||
test('resets posts_this_week to 0 on week change', () => {
|
||||
const result = applyWeekRollover(SAMPLE_STATE, '2026-W14', '2026-W15');
|
||||
assert.notEqual(result, null);
|
||||
assert.match(result.content, /^posts_this_week: 0$/m);
|
||||
});
|
||||
|
||||
test('updates current_week to new week', () => {
|
||||
const result = applyWeekRollover(SAMPLE_STATE, '2026-W14', '2026-W15');
|
||||
assert.notEqual(result, null);
|
||||
assert.match(result.content, /^current_week: "2026-W15"$/m);
|
||||
});
|
||||
|
||||
test('returns descriptive message on rollover', () => {
|
||||
const result = applyWeekRollover(SAMPLE_STATE, '2026-W14', '2026-W15');
|
||||
assert.notEqual(result, null);
|
||||
assert.ok(result.message.includes('2026-W15'));
|
||||
assert.ok(result.message.includes('2026-W14'));
|
||||
});
|
||||
|
||||
test('returns null when week matches (no change needed)', () => {
|
||||
const result = applyWeekRollover(SAMPLE_STATE, '2026-W14', '2026-W14');
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test('preserves all other YAML fields unchanged', () => {
|
||||
const result = applyWeekRollover(SAMPLE_STATE, '2026-W14', '2026-W15');
|
||||
assert.notEqual(result, null);
|
||||
assert.match(result.content, /^last_post_date: "2026-04-05"$/m);
|
||||
assert.match(result.content, /^current_streak: 5$/m);
|
||||
assert.match(result.content, /^weekly_goal: 3$/m);
|
||||
assert.match(result.content, /^follower_count: 850$/m);
|
||||
});
|
||||
|
||||
test('preserves markdown body after frontmatter', () => {
|
||||
const result = applyWeekRollover(SAMPLE_STATE, '2026-W14', '2026-W15');
|
||||
assert.notEqual(result, null);
|
||||
assert.ok(result.content.includes('## Recent Posts'));
|
||||
assert.ok(result.content.includes('AI strategy post'));
|
||||
});
|
||||
|
||||
test('initializes current_week when empty without resetting posts', () => {
|
||||
const stateWithEmptyWeek = SAMPLE_STATE.replace(
|
||||
'current_week: "2026-W14"',
|
||||
'current_week: ""'
|
||||
);
|
||||
const result = applyWeekRollover(stateWithEmptyWeek, '', '2026-W15');
|
||||
assert.notEqual(result, null);
|
||||
assert.match(result.content, /^current_week: "2026-W15"$/m);
|
||||
// posts_this_week should NOT be reset (user may have manually tracked)
|
||||
assert.match(result.content, /^posts_this_week: 3$/m);
|
||||
});
|
||||
|
||||
test('returns null when actualWeek is empty', () => {
|
||||
const result = applyWeekRollover(SAMPLE_STATE, '2026-W14', '');
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test('returns null when actualWeek is null/undefined', () => {
|
||||
assert.equal(applyWeekRollover(SAMPLE_STATE, '2026-W14', null), null);
|
||||
assert.equal(applyWeekRollover(SAMPLE_STATE, '2026-W14', undefined), null);
|
||||
});
|
||||
|
||||
test('handles year boundary rollover (W52 → W01)', () => {
|
||||
const yearEndState = SAMPLE_STATE.replace('2026-W14', '2025-W52');
|
||||
const result = applyWeekRollover(yearEndState, '2025-W52', '2026-W01');
|
||||
assert.notEqual(result, null);
|
||||
assert.match(result.content, /^posts_this_week: 0$/m);
|
||||
assert.match(result.content, /^current_week: "2026-W01"$/m);
|
||||
});
|
||||
|
||||
test('handles posts_this_week already at 0', () => {
|
||||
const zeroPostsState = SAMPLE_STATE.replace('posts_this_week: 3', 'posts_this_week: 0');
|
||||
const result = applyWeekRollover(zeroPostsState, '2026-W14', '2026-W15');
|
||||
assert.notEqual(result, null);
|
||||
assert.match(result.content, /^posts_this_week: 0$/m);
|
||||
assert.match(result.content, /^current_week: "2026-W15"$/m);
|
||||
});
|
||||
});
|
||||
|
|
@ -7,6 +7,7 @@ import { join, dirname } from 'node:path';
|
|||
import { fileURLToPath } from 'node:url';
|
||||
import { calculateScore } from './personalization-score.mjs';
|
||||
import { queueToday, queueOverdue, queueUpcoming } from './queue-manager.mjs';
|
||||
import { applyWeekRollover } from './week-rollover.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const PLUGIN_ROOT = join(__dirname, '..', '..');
|
||||
|
|
@ -135,11 +136,17 @@ if (existsSync(STATE_FILE)) {
|
|||
}
|
||||
}
|
||||
|
||||
// Week rollover check
|
||||
// Week rollover — auto-reset posts_this_week on week change
|
||||
const actualWeek = isoWeek();
|
||||
let weekResetNote = '';
|
||||
if (currentWeek && currentWeek !== actualWeek) {
|
||||
weekResetNote = `Note: Week has changed from ${currentWeek} to ${actualWeek}. posts_this_week should be reset to 0.`;
|
||||
try {
|
||||
const rollover = applyWeekRollover(stateContent, currentWeek, actualWeek);
|
||||
if (rollover) {
|
||||
writeFileSync(STATE_FILE, rollover.content, 'utf-8');
|
||||
weekResetNote = rollover.message;
|
||||
}
|
||||
} catch (err) {
|
||||
weekResetNote = `Warning: Week rollover failed (${err.message}). Manual reset may be needed.`;
|
||||
}
|
||||
|
||||
// Build status line
|
||||
|
|
@ -253,9 +260,7 @@ if (existsSync(STATE_FILE)) {
|
|||
}
|
||||
|
||||
// Personalization score check
|
||||
if (pScore !== null && pScore === 0) {
|
||||
context += '## Quick Win\\nPersonalization: 0%. Run /linkedin:setup (15 min) to unlock voice-matched, audience-specific content.\\n\\n';
|
||||
} else if (pScore !== null && pScore < 50) {
|
||||
if (pScore !== null && pScore < 50) {
|
||||
reminders += `- Personalization score is ${pScore}%. Run /linkedin:setup to improve content quality with your real voice, case studies, and audience data.\\n`;
|
||||
}
|
||||
|
||||
|
|
@ -369,13 +374,8 @@ if (existsSync(STATE_FILE)) {
|
|||
content = content.replace(/^current_week: .*/m, `current_week: "${actualWeek}"`);
|
||||
writeFileSync(STATE_FILE, content);
|
||||
context = `LinkedIn state file auto-initialized from template at ${STATE_FILE}.\\n`;
|
||||
context += `Current ISO week set to ${actualWeek}.\\n\\n`;
|
||||
context += '## Welcome to LinkedIn Thought Leadership\\n\\n';
|
||||
context += 'Your state file has been initialized. Here is how to get started:\\n\\n';
|
||||
context += '1. Run /linkedin:profile — Optimize your LinkedIn profile for 360Brew (critical before first post)\\n';
|
||||
context += '2. Run /linkedin:setup — Personalize with your voice, case studies, and audience data\\n';
|
||||
context += '3. Run /linkedin:first-post — Create your first post in under 10 minutes\\n\\n';
|
||||
context += 'Your personalization score is 0%. Content quality improves as you fill in your profile.\\n';
|
||||
context += `Current ISO week set to ${actualWeek}.\\n`;
|
||||
context += 'Edit the file to set your expertise_areas and weekly_goal.\\n';
|
||||
} else {
|
||||
context = `No LinkedIn state file found at ${STATE_FILE} and template missing.\\n`;
|
||||
context += `Expected template at: ${templateFile}\\n`;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
// Pure function for week-rollover logic.
|
||||
// Exported separately for testability.
|
||||
|
||||
/**
|
||||
* Apply week rollover to state file content.
|
||||
* Returns updated content string if rollover was applied, null otherwise.
|
||||
*
|
||||
* @param {string} stateContent - Full state file content (with YAML frontmatter)
|
||||
* @param {string} currentWeek - Week value from state file (e.g. "2026-W14")
|
||||
* @param {string} actualWeek - Computed current ISO week (e.g. "2026-W15")
|
||||
* @returns {{ content: string, message: string } | null}
|
||||
*/
|
||||
export function applyWeekRollover(stateContent, currentWeek, actualWeek) {
|
||||
if (!actualWeek) return null;
|
||||
|
||||
// Case 1: current_week is empty — initialize without resetting posts
|
||||
if (!currentWeek) {
|
||||
const updated = stateContent.replace(
|
||||
/^current_week: .*/m,
|
||||
`current_week: "${actualWeek}"`
|
||||
);
|
||||
if (updated === stateContent) return null;
|
||||
return {
|
||||
content: updated,
|
||||
message: `Initialized current_week to ${actualWeek}.`
|
||||
};
|
||||
}
|
||||
|
||||
// Case 2: week matches — no action needed
|
||||
if (currentWeek === actualWeek) return null;
|
||||
|
||||
// Case 3: week changed — reset posts_this_week and update current_week
|
||||
let updated = stateContent;
|
||||
updated = updated.replace(
|
||||
/^posts_this_week: .*/m,
|
||||
'posts_this_week: 0'
|
||||
);
|
||||
updated = updated.replace(
|
||||
/^current_week: .*/m,
|
||||
`current_week: "${actualWeek}"`
|
||||
);
|
||||
|
||||
if (updated === stateContent) return null;
|
||||
|
||||
return {
|
||||
content: updated,
|
||||
message: `Auto-reset: posts_this_week → 0 for new week ${actualWeek} (was ${currentWeek}).`
|
||||
};
|
||||
}
|
||||
|
|
@ -122,6 +122,88 @@ These angles work across all industries because they're about **types of thinkin
|
|||
- **Education:** Personal Lesson + Human Story
|
||||
- **Consulting:** Pattern Recognition + Practical Breakdown
|
||||
|
||||
## Industry Angle Variants
|
||||
|
||||
Concrete starter questions and example hooks per industry. When the user's industry is known (from `config/user-profile.local.md`), surface the relevant table during angle selection.
|
||||
|
||||
### Tech / Software / AI
|
||||
|
||||
| Angle | Starter Question | Example Hook |
|
||||
|-------|-----------------|--------------|
|
||||
| Contrarian | "What does everyone assume about [tech trend] that data disproves?" | "Everyone says AI will replace developers. Our team shipped 40% more code WITH AI — and hired 3 more engineers." |
|
||||
| Pattern Recognition | "What pattern across AI/cloud/DevOps haven't others connected?" | "I've noticed every team that fails at AI adoption makes the same infrastructure mistake first." |
|
||||
| Uncomfortable Truth | "What is the industry avoiding saying about [tool/trend]?" | "We spent 6 months fine-tuning an LLM. A prompt template outperformed it in 2 hours." |
|
||||
| Future Implication | "If [current trend] continues, what changes in 2-3 years?" | "If AI coding assistants keep improving at this rate, the most valuable developer skill in 2028 won't be coding." |
|
||||
| Personal Lesson | "What did your last failed project teach you about [topic]?" | "Our AI pilot looked perfect in the demo. Here's what happened when real users touched it." |
|
||||
| Reframe | "What common tech term means something different than people think?" | "We call it 'technical debt.' I call it 'decisions that were right then and wrong now.'" |
|
||||
| Practical Breakdown | "What complex concept can you make actionable in 5 steps?" | "Everyone talks about RAG. Here's the 4-step checklist I use before building any retrieval system." |
|
||||
| Human Story | "What moment with a colleague or user changed your perspective?" | "Our senior architect said 'I don't understand this AI stuff' in a meeting. What happened next changed our entire approach." |
|
||||
|
||||
### Healthcare / Life Sciences
|
||||
|
||||
| Angle | Starter Question | Example Hook |
|
||||
|-------|-----------------|--------------|
|
||||
| Contrarian | "What healthcare 'best practice' actually slows patient outcomes?" | "We digitized all our patient records. Patient satisfaction dropped. Here's why paper had one advantage we overlooked." |
|
||||
| Pattern Recognition | "What pattern connects clinical and operational challenges?" | "I've worked with 12 hospitals this year. The ones with the best patient outcomes all share one non-clinical habit." |
|
||||
| Uncomfortable Truth | "What is healthcare leadership not willing to discuss openly?" | "The biggest barrier to healthcare AI isn't regulation. It's that clinicians don't trust their own data." |
|
||||
| Future Implication | "If [health tech trend] succeeds, what changes for patients?" | "If ambient clinical documentation works as promised, the doctor-patient relationship fundamentally changes." |
|
||||
| Personal Lesson | "What did a patient interaction teach you about [system/process]?" | "A patient told me: 'Your portal has 47 clicks to book an appointment.' That sentence restructured our entire digital strategy." |
|
||||
| Reframe | "What healthcare metric measures the wrong thing?" | "We measure 'patient throughput.' What if we measured 'patient understanding' instead?" |
|
||||
| Practical Breakdown | "What regulatory/compliance challenge can you simplify?" | "HIPAA compliance for AI tools sounds impossible. Here are the 3 questions that solve 80% of the uncertainty." |
|
||||
| Human Story | "What patient story illustrates a systemic issue?" | "A nurse spent 4 hours on documentation for every 1 hour of patient care. She quit. Her exit interview should be mandatory reading for every CIO." |
|
||||
|
||||
### Finance / Banking / Insurance
|
||||
|
||||
| Angle | Starter Question | Example Hook |
|
||||
|-------|-----------------|--------------|
|
||||
| Contrarian | "What financial 'innovation' is actually recycled risk?" | "Everyone's excited about embedded finance. The banks that remember 2008 are asking different questions." |
|
||||
| Pattern Recognition | "What pattern connects fintech disruption and traditional banking?" | "I've noticed every fintech that struggles at scale hits the same wall — the one banks solved 30 years ago." |
|
||||
| Uncomfortable Truth | "What is the industry avoiding about [regulation/risk/AI]?" | "Banks are spending millions on AI fraud detection. The fraud teams say the biggest vulnerability is still a phone call." |
|
||||
| Future Implication | "If [regulatory change] passes, what does banking look like?" | "If open banking delivers on its promise, the most valuable asset in finance won't be capital — it'll be consent." |
|
||||
| Personal Lesson | "What did a risk event teach you that no framework captures?" | "We built a perfect risk model. It missed the one variable that mattered: human panic." |
|
||||
| Reframe | "What financial concept needs a new definition?" | "We call it 'customer acquisition cost.' But in financial services, the real cost is trust — and trust doesn't have a line item." |
|
||||
| Practical Breakdown | "What compliance requirement can you make less painful?" | "RegTech sounds complex. Here's the 3-layer approach that cut our compliance reporting time by 60%." |
|
||||
| Human Story | "What client interaction revealed a blind spot?" | "A small business owner asked me: 'Why does your app need to know my mother's maiden name to send an invoice?' Fair point." |
|
||||
|
||||
### Public Sector / Government
|
||||
|
||||
| Angle | Starter Question | Example Hook |
|
||||
|-------|-----------------|--------------|
|
||||
| Contrarian | "What public sector 'modernization' approach actually creates more bureaucracy?" | "We 'digitized' our forms by turning PDFs into web forms. Citizens still needed to visit the office. That's not digital transformation." |
|
||||
| Pattern Recognition | "What pattern connects successful government IT projects?" | "I've studied 20 public sector IT projects. The 5 that succeeded all broke the same procurement rule." |
|
||||
| Uncomfortable Truth | "What is the sector avoiding about [digital transformation/AI/procurement]?" | "The biggest obstacle to government AI isn't budget or policy. It's that we measure success by project completion, not citizen outcome." |
|
||||
| Future Implication | "If [policy/tech] is adopted, what changes for citizens?" | "If government agencies actually share data across departments, we can stop asking citizens to prove who they are 47 times." |
|
||||
| Personal Lesson | "What did a failed initiative teach you about public sector change?" | "We launched a citizen portal. 6 months later, the call center was busier than ever. The lesson wasn't about technology." |
|
||||
| Reframe | "What government process looks different from the citizen's perspective?" | "We call it 'case processing.' Citizens call it 'waiting to hear if I can keep my home.'" |
|
||||
| Practical Breakdown | "What complex regulation/process can you make tangible?" | "Government procurement for AI services sounds impossible. Here are 3 contract clauses that unlock 80% of the innovation." |
|
||||
| Human Story | "What citizen interaction changed how you think about service delivery?" | "A retired teacher spent 3 hours navigating our website for a pension form. She said: 'I taught 2,000 students to learn. Your website taught me to give up.'" |
|
||||
|
||||
### Education / EdTech
|
||||
|
||||
| Angle | Starter Question | Example Hook |
|
||||
|-------|-----------------|--------------|
|
||||
| Contrarian | "What education 'innovation' actually hurts learning outcomes?" | "We gave every student a laptop. Test scores didn't change. Classroom engagement dropped. Here's what we missed." |
|
||||
| Pattern Recognition | "What do successful learning programs have in common?" | "I've observed 15 AI-in-education pilots. The ones students actually use all share one design principle." |
|
||||
| Uncomfortable Truth | "What is the sector avoiding about [AI/assessment/equity]?" | "Personalized learning algorithms optimize for engagement. But engagement and learning aren't the same thing." |
|
||||
| Future Implication | "If [AI/policy trend] continues, how does education change?" | "If AI tutors become genuinely good, the teacher's most valuable skill won't be content delivery — it'll be asking the right question at the right moment." |
|
||||
| Personal Lesson | "What did a student/classroom experience teach you?" | "I watched a student use ChatGPT to write an essay, then spent 2 hours explaining it to a classmate. That's when I realized the assignment was wrong, not the student." |
|
||||
| Reframe | "What education metric measures the wrong thing?" | "We measure 'time on task.' What if the best indicator of learning is how quickly a student can teach it to someone else?" |
|
||||
| Practical Breakdown | "What complex pedagogical concept can you make actionable?" | "Bloom's Taxonomy is in every education textbook. Here's how I actually use it to design a single lesson in 15 minutes." |
|
||||
| Human Story | "What student moment illustrates a bigger truth?" | "A 10-year-old told me: 'Why do I have to learn this if I can just ask AI?' My answer surprised both of us." |
|
||||
|
||||
### Consulting / Professional Services
|
||||
|
||||
| Angle | Starter Question | Example Hook |
|
||||
|-------|-----------------|--------------|
|
||||
| Contrarian | "What consulting 'framework' actually prevents insight?" | "The best strategy I ever delivered had zero frameworks. It had one question the CEO couldn't answer." |
|
||||
| Pattern Recognition | "What pattern connects client problems across industries?" | "I've worked with 30 organizations on AI strategy. The ones that succeed all start with the same non-technical conversation." |
|
||||
| Uncomfortable Truth | "What is the industry avoiding about [value delivery/pricing/AI]?" | "Most consulting engagements solve the stated problem. The real problem — the one nobody mentioned in the RFP — stays unsolved." |
|
||||
| Future Implication | "If [AI/market trend] continues, how does consulting change?" | "If AI can generate a strategy deck in 10 minutes, the consulting industry needs to answer one question: what are we actually selling?" |
|
||||
| Personal Lesson | "What project failure taught you something the methodology didn't?" | "I delivered a perfect change management plan. The client implemented 10% of it. My methodology was right. My assumption about people was wrong." |
|
||||
| Reframe | "What consulting term means something different than clients think?" | "Clients ask for 'digital transformation.' What they actually need is 'permission to stop doing things that don't work.'" |
|
||||
| Practical Breakdown | "What complex client challenge can you simplify?" | "AI readiness assessments take 6 weeks and cost €200K. Here are the 5 questions that tell you 80% of what you need in one meeting." |
|
||||
| Human Story | "What client moment changed your consulting approach?" | "A CTO told me: 'Your recommendation is brilliant. My team will ignore it by Thursday.' That conversation changed how I deliver every project." |
|
||||
|
||||
## Red Flags (Avoid These)
|
||||
|
||||
- **Echo chamber:** Repeating what everyone already says
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import {
|
|||
import { detectAlerts } from "./utils/alerts.js";
|
||||
import { mean, standardDeviation } from "./utils/stats.js";
|
||||
import { generateWeeklyReport, getCurrentISOWeek } from "./reports/weekly.js";
|
||||
import { generateHeatmap } from "./reports/heatmap.js";
|
||||
import { generateMonthlyReport } from "./reports/monthly.js";
|
||||
import { join } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import type { PostMetrics } from "./models/types.js";
|
||||
|
|
@ -27,7 +29,9 @@ LinkedIn Analytics CLI
|
|||
Usage:
|
||||
node build/cli.js import <filename> Import a CSV export
|
||||
node build/cli.js report [--week W] Generate weekly report
|
||||
node build/cli.js report --month YYYY-MM Generate monthly report with MoM comparison
|
||||
node build/cli.js trends [--period P] [--metric M] Show trends and alerts
|
||||
node build/cli.js heatmap Day-of-week performance matrix
|
||||
|
||||
Options:
|
||||
--week W ISO week (e.g., 2026-W05), defaults to current week
|
||||
|
|
@ -95,6 +99,11 @@ async function handleImport(root: string, args: string[]) {
|
|||
}
|
||||
|
||||
async function handleReport(root: string, args: string[]) {
|
||||
const monthOption = parseOption(args, "--month");
|
||||
if (monthOption) {
|
||||
return handleMonthlyReport(root, monthOption);
|
||||
}
|
||||
|
||||
const weekOption = parseOption(args, "--week");
|
||||
const week = weekOption || getCurrentISOWeek();
|
||||
|
||||
|
|
@ -285,6 +294,130 @@ async function handleTrends(root: string, args: string[]) {
|
|||
}
|
||||
}
|
||||
|
||||
async function handleMonthlyReport(root: string, month: string) {
|
||||
console.log(`Generating monthly report for ${month}...`);
|
||||
|
||||
try {
|
||||
const report = generateMonthlyReport(root, month);
|
||||
|
||||
console.log("\nMonthly Report");
|
||||
console.log("═════════════════════════════════════");
|
||||
console.log(`Month: ${report.month}`);
|
||||
console.log(`Generated at: ${new Date(report.generatedAt).toLocaleString()}`);
|
||||
console.log();
|
||||
|
||||
console.log("Summary");
|
||||
console.log("─────────────────────────────────────");
|
||||
const s = report.summary;
|
||||
const fmtDelta = (val: number | null, suffix = "%") =>
|
||||
val !== null ? ` (${val > 0 ? "+" : ""}${val}${suffix})` : "";
|
||||
|
||||
console.log(`Posts: ${s.totalPosts}${fmtDelta(report.trends.percentChange.postCount)}`);
|
||||
console.log(`Impressions: ${s.totalImpressions.toLocaleString()}${fmtDelta(report.trends.percentChange.impressions)}`);
|
||||
console.log(`Avg per post: ${s.avgImpressionsPerPost.toLocaleString()}`);
|
||||
console.log(`Avg engagement: ${s.avgEngagementRate.toFixed(2)}%${fmtDelta(report.trends.percentChange.engagement)}`);
|
||||
console.log(`Reactions: ${s.totalReactions.toLocaleString()}`);
|
||||
console.log(`Comments: ${s.totalComments.toLocaleString()}`);
|
||||
console.log(`Shares: ${s.totalShares.toLocaleString()}`);
|
||||
console.log(`Clicks: ${s.totalClicks.toLocaleString()}`);
|
||||
console.log();
|
||||
|
||||
if (report.byWeek.length > 0) {
|
||||
console.log("Week Breakdown");
|
||||
console.log("─────────────────────────────────────");
|
||||
for (const w of report.byWeek) {
|
||||
console.log(`${w.week}: ${w.postCount} posts | ${w.avgImpressions.toLocaleString()} avg impr | ${w.avgEngagementRate.toFixed(1)}% eng`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
if (report.topPerformers.length > 0) {
|
||||
console.log("Top Performers");
|
||||
console.log("─────────────────────────────────────");
|
||||
for (const post of report.topPerformers.slice(0, 5)) {
|
||||
const title = post.title.length > 50 ? post.title.substring(0, 47) + "..." : post.title;
|
||||
console.log(`• ${title}`);
|
||||
console.log(` ${post.metrics.impressions.toLocaleString()} impressions | ${post.metrics.engagementRate.toFixed(2)}% eng | ${post.publishedDate}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
if (report.trends.comparedTo) {
|
||||
console.log(`Compared to: ${report.trends.comparedTo}`);
|
||||
} else {
|
||||
console.log("No previous month data for comparison.");
|
||||
}
|
||||
console.log();
|
||||
|
||||
if (report.alerts.length > 0) {
|
||||
console.log("Alerts");
|
||||
console.log("─────────────────────────────────────");
|
||||
for (const alert of report.alerts) {
|
||||
const icon = alert.severity === "critical" ? "🔴" : alert.severity === "warning" ? "⚠️" : "ℹ️";
|
||||
console.log(`${icon} [${alert.severity.toUpperCase()}] ${alert.message}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
console.log(`Report saved to: monthly-reports/${month}.json`);
|
||||
} catch (err) {
|
||||
console.error(`Error generating monthly report: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleHeatmap(root: string) {
|
||||
console.log("Generating day-of-week heatmap...");
|
||||
|
||||
try {
|
||||
const allPosts = loadAllPosts(root);
|
||||
|
||||
if (allPosts.length === 0) {
|
||||
console.error("Error: No posts found. Import some data first.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const report = generateHeatmap(allPosts);
|
||||
|
||||
console.log("\nDay-of-Week Performance Heatmap");
|
||||
console.log("═════════════════════════════════════");
|
||||
console.log(`Posts analyzed: ${report.postsAnalyzed}`);
|
||||
console.log(`Date range: ${report.dateRange.from} to ${report.dateRange.to}`);
|
||||
console.log();
|
||||
|
||||
// Print table header
|
||||
const days = report.byDayOfWeek.map(d => d.dayName.slice(0, 3).padStart(7));
|
||||
console.log(` ${days.join("")}`);
|
||||
console.log(` ${"───────".repeat(7)}`);
|
||||
|
||||
// Posts row
|
||||
const postCounts = report.byDayOfWeek.map(d => String(d.postCount).padStart(7));
|
||||
console.log(`Posts: ${postCounts.join("")}`);
|
||||
|
||||
// Impressions row
|
||||
const impressions = report.byDayOfWeek.map(d =>
|
||||
d.postCount > 0 ? d.avgImpressions.toLocaleString().padStart(7) : " -"
|
||||
);
|
||||
console.log(`Impr: ${impressions.join("")}`);
|
||||
|
||||
// Engagement rate row
|
||||
const engRates = report.byDayOfWeek.map(d =>
|
||||
d.postCount > 0 ? `${d.avgEngagementRate.toFixed(1)}%`.padStart(7) : " -"
|
||||
);
|
||||
console.log(`Eng: ${engRates.join("")}`);
|
||||
|
||||
console.log();
|
||||
console.log(`Best day for impressions: ${report.bestDayImpressions}`);
|
||||
console.log(`Best day for engagement: ${report.bestDayEngagement}`);
|
||||
|
||||
console.log("\nNote: LinkedIn CSV exports do not include publish time.");
|
||||
console.log("This heatmap shows day-of-week only.");
|
||||
} catch (err) {
|
||||
console.error(`Error generating heatmap: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const root = getAnalyticsRoot();
|
||||
ensureDirectories(root);
|
||||
|
|
@ -299,6 +432,9 @@ async function main() {
|
|||
case "trends":
|
||||
await handleTrends(root, args);
|
||||
break;
|
||||
case "heatmap":
|
||||
await handleHeatmap(root);
|
||||
break;
|
||||
default:
|
||||
printUsage();
|
||||
process.exit(command ? 1 : 0);
|
||||
|
|
|
|||
|
|
@ -65,6 +65,55 @@ export interface Alert {
|
|||
deviations: number;
|
||||
}
|
||||
|
||||
export interface DayOfWeekMetrics {
|
||||
dayName: string; // "Monday" through "Sunday"
|
||||
dayIndex: number; // 1=Monday, 7=Sunday (ISO weekday)
|
||||
postCount: number;
|
||||
avgImpressions: number;
|
||||
avgEngagementRate: number;
|
||||
bestPost?: PostAnalytics;
|
||||
}
|
||||
|
||||
export interface HeatmapReport {
|
||||
generatedAt: string;
|
||||
postsAnalyzed: number;
|
||||
dateRange: { from: string; to: string };
|
||||
byDayOfWeek: DayOfWeekMetrics[]; // 7 entries, Mon-Sun ordered
|
||||
bestDayImpressions: string;
|
||||
bestDayEngagement: string;
|
||||
}
|
||||
|
||||
export interface MonthlyReport {
|
||||
month: string; // "YYYY-MM"
|
||||
generatedAt: string;
|
||||
summary: {
|
||||
totalPosts: number;
|
||||
totalImpressions: number;
|
||||
totalReactions: number;
|
||||
totalComments: number;
|
||||
totalShares: number;
|
||||
totalClicks: number;
|
||||
avgEngagementRate: number;
|
||||
avgImpressionsPerPost: number;
|
||||
};
|
||||
topPerformers: PostAnalytics[];
|
||||
byWeek: {
|
||||
week: string;
|
||||
postCount: number;
|
||||
avgImpressions: number;
|
||||
avgEngagementRate: number;
|
||||
}[];
|
||||
trends: {
|
||||
comparedTo: string | null;
|
||||
percentChange: {
|
||||
impressions: number | null;
|
||||
engagement: number | null;
|
||||
postCount: number | null;
|
||||
};
|
||||
};
|
||||
alerts: Alert[];
|
||||
}
|
||||
|
||||
export const ALERT_THRESHOLDS = {
|
||||
spike: 2.0,
|
||||
drop: -1.5,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,85 @@
|
|||
import type { PostAnalytics, DayOfWeekMetrics, HeatmapReport } from "../models/types.js";
|
||||
|
||||
const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
|
||||
// Convert JS getDay() (0=Sun) to ISO weekday (1=Mon, 7=Sun)
|
||||
function toISOWeekday(jsDay: number): number {
|
||||
return jsDay === 0 ? 7 : jsDay;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a day-of-week performance heatmap from post analytics data.
|
||||
* Groups posts by day of week and calculates average metrics per day.
|
||||
*/
|
||||
export function generateHeatmap(posts: PostAnalytics[]): HeatmapReport {
|
||||
// Initialize buckets for all 7 days (ISO: 1=Mon to 7=Sun)
|
||||
const buckets: Map<number, PostAnalytics[]> = new Map();
|
||||
for (let i = 1; i <= 7; i++) {
|
||||
buckets.set(i, []);
|
||||
}
|
||||
|
||||
// Group posts by ISO weekday
|
||||
for (const post of posts) {
|
||||
const jsDay = new Date(post.publishedDate).getUTCDay();
|
||||
const isoDay = toISOWeekday(jsDay);
|
||||
buckets.get(isoDay)!.push(post);
|
||||
}
|
||||
|
||||
// Build metrics per day
|
||||
const byDayOfWeek: DayOfWeekMetrics[] = [];
|
||||
for (let isoDay = 1; isoDay <= 7; isoDay++) {
|
||||
const dayPosts = buckets.get(isoDay)!;
|
||||
const jsDay = isoDay === 7 ? 0 : isoDay;
|
||||
const dayName = DAY_NAMES[jsDay];
|
||||
|
||||
if (dayPosts.length === 0) {
|
||||
byDayOfWeek.push({
|
||||
dayName,
|
||||
dayIndex: isoDay,
|
||||
postCount: 0,
|
||||
avgImpressions: 0,
|
||||
avgEngagementRate: 0,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const totalImpressions = dayPosts.reduce((sum, p) => sum + p.metrics.impressions, 0);
|
||||
const totalEngagement = dayPosts.reduce((sum, p) => sum + p.metrics.engagementRate, 0);
|
||||
const bestPost = dayPosts.reduce((best, p) =>
|
||||
p.metrics.impressions > best.metrics.impressions ? p : best
|
||||
);
|
||||
|
||||
byDayOfWeek.push({
|
||||
dayName,
|
||||
dayIndex: isoDay,
|
||||
postCount: dayPosts.length,
|
||||
avgImpressions: Math.round(totalImpressions / dayPosts.length),
|
||||
avgEngagementRate: parseFloat((totalEngagement / dayPosts.length).toFixed(1)),
|
||||
bestPost,
|
||||
});
|
||||
}
|
||||
|
||||
// Find best days
|
||||
const daysWithPosts = byDayOfWeek.filter(d => d.postCount > 0);
|
||||
const bestDayImpressions = daysWithPosts.length > 0
|
||||
? daysWithPosts.reduce((best, d) => d.avgImpressions > best.avgImpressions ? d : best).dayName
|
||||
: "N/A";
|
||||
const bestDayEngagement = daysWithPosts.length > 0
|
||||
? daysWithPosts.reduce((best, d) => d.avgEngagementRate > best.avgEngagementRate ? d : best).dayName
|
||||
: "N/A";
|
||||
|
||||
// Date range
|
||||
const sortedDates = posts.map(p => p.publishedDate).sort();
|
||||
const dateRange = posts.length > 0
|
||||
? { from: sortedDates[0], to: sortedDates[sortedDates.length - 1] }
|
||||
: { from: "", to: "" };
|
||||
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
postsAnalyzed: posts.length,
|
||||
dateRange,
|
||||
byDayOfWeek,
|
||||
bestDayImpressions,
|
||||
bestDayEngagement,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
import type { PostAnalytics, MonthlyReport } from "../models/types.js";
|
||||
import { loadAllPosts, loadMonthlyReport, saveMonthlyReport } from "../utils/storage.js";
|
||||
import { mean } from "../utils/stats.js";
|
||||
import { detectAlerts } from "../utils/alerts.js";
|
||||
import { getISOWeek } from "./weekly.js";
|
||||
|
||||
/**
|
||||
* Get previous month string (e.g., "2026-03" → "2026-02")
|
||||
*/
|
||||
function getPreviousMonth(month: string): string {
|
||||
const [year, m] = month.split("-").map(Number);
|
||||
if (m === 1) return `${year - 1}-12`;
|
||||
return `${year}-${String(m - 1).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a monthly report with optional MoM comparison.
|
||||
* Saves the report to disk and returns it.
|
||||
*/
|
||||
export function generateMonthlyReport(root: string, month: string): MonthlyReport {
|
||||
const allPosts = loadAllPosts(root);
|
||||
const monthPosts = allPosts.filter(p => p.publishedDate.startsWith(month));
|
||||
|
||||
// Summary
|
||||
const totalPosts = monthPosts.length;
|
||||
const totalImpressions = monthPosts.reduce((s, p) => s + p.metrics.impressions, 0);
|
||||
const totalReactions = monthPosts.reduce((s, p) => s + p.metrics.reactions, 0);
|
||||
const totalComments = monthPosts.reduce((s, p) => s + p.metrics.comments, 0);
|
||||
const totalShares = monthPosts.reduce((s, p) => s + p.metrics.shares, 0);
|
||||
const totalClicks = monthPosts.reduce((s, p) => s + p.metrics.clicks, 0);
|
||||
const avgEngagementRate = totalPosts > 0
|
||||
? parseFloat(mean(monthPosts.map(p => p.metrics.engagementRate)).toFixed(2))
|
||||
: 0;
|
||||
const avgImpressionsPerPost = totalPosts > 0
|
||||
? Math.round(totalImpressions / totalPosts)
|
||||
: 0;
|
||||
|
||||
// Top performers (sorted by impressions desc)
|
||||
const topPerformers = [...monthPosts]
|
||||
.sort((a, b) => b.metrics.impressions - a.metrics.impressions)
|
||||
.slice(0, 5);
|
||||
|
||||
// Weekly breakdown
|
||||
const weekBuckets = new Map<string, PostAnalytics[]>();
|
||||
for (const post of monthPosts) {
|
||||
const week = getISOWeek(new Date(post.publishedDate + "T00:00:00Z"));
|
||||
if (!weekBuckets.has(week)) weekBuckets.set(week, []);
|
||||
weekBuckets.get(week)!.push(post);
|
||||
}
|
||||
|
||||
const byWeek = Array.from(weekBuckets.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([week, posts]) => ({
|
||||
week,
|
||||
postCount: posts.length,
|
||||
avgImpressions: Math.round(mean(posts.map(p => p.metrics.impressions))),
|
||||
avgEngagementRate: parseFloat(mean(posts.map(p => p.metrics.engagementRate)).toFixed(1)),
|
||||
}));
|
||||
|
||||
// MoM comparison
|
||||
const prevMonth = getPreviousMonth(month);
|
||||
const prevReport = loadMonthlyReport(root, prevMonth);
|
||||
|
||||
let trends: MonthlyReport["trends"];
|
||||
if (prevReport && prevReport.summary.totalPosts > 0) {
|
||||
const pctImpr = prevReport.summary.totalImpressions > 0
|
||||
? parseFloat(((totalImpressions - prevReport.summary.totalImpressions) / prevReport.summary.totalImpressions * 100).toFixed(1))
|
||||
: null;
|
||||
const pctEng = prevReport.summary.avgEngagementRate > 0
|
||||
? parseFloat(((avgEngagementRate - prevReport.summary.avgEngagementRate) / prevReport.summary.avgEngagementRate * 100).toFixed(1))
|
||||
: null;
|
||||
const pctPosts = prevReport.summary.totalPosts > 0
|
||||
? parseFloat(((totalPosts - prevReport.summary.totalPosts) / prevReport.summary.totalPosts * 100).toFixed(1))
|
||||
: null;
|
||||
|
||||
trends = {
|
||||
comparedTo: prevMonth,
|
||||
percentChange: {
|
||||
impressions: pctImpr,
|
||||
engagement: pctEng,
|
||||
postCount: pctPosts,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
trends = {
|
||||
comparedTo: null,
|
||||
percentChange: { impressions: null, engagement: null, postCount: null },
|
||||
};
|
||||
}
|
||||
|
||||
// Alerts
|
||||
const alerts = totalPosts > 0 ? detectAlerts(monthPosts, "impressions") : [];
|
||||
|
||||
const report: MonthlyReport = {
|
||||
month,
|
||||
generatedAt: new Date().toISOString(),
|
||||
summary: {
|
||||
totalPosts,
|
||||
totalImpressions,
|
||||
totalReactions,
|
||||
totalComments,
|
||||
totalShares,
|
||||
totalClicks,
|
||||
avgEngagementRate,
|
||||
avgImpressionsPerPost,
|
||||
},
|
||||
topPerformers,
|
||||
byWeek,
|
||||
trends,
|
||||
alerts,
|
||||
};
|
||||
|
||||
// Save report
|
||||
saveMonthlyReport(root, report);
|
||||
|
||||
return report;
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { join, resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { AnalyticsBatch, WeeklyReport, PostAnalytics } from "../models/types.js";
|
||||
import type { AnalyticsBatch, WeeklyReport, MonthlyReport, PostAnalytics } from "../models/types.js";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
|
|
@ -25,7 +25,7 @@ export function getAnalyticsRoot(): string {
|
|||
* Ensure required subdirectories exist under analytics root
|
||||
*/
|
||||
export function ensureDirectories(root: string): void {
|
||||
const directories = ["exports", "posts", "weekly-reports"];
|
||||
const directories = ["exports", "posts", "weekly-reports", "monthly-reports"];
|
||||
|
||||
if (!existsSync(root)) {
|
||||
mkdirSync(root, { recursive: true });
|
||||
|
|
@ -252,3 +252,39 @@ export function loadAllWeeklyReports(root: string): WeeklyReport[] {
|
|||
b.week.localeCompare(a.week)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize month string to only allow YYYY-MM format
|
||||
*/
|
||||
function sanitizeMonth(month: string): string {
|
||||
if (!/^\d{4}-\d{2}$/.test(month)) {
|
||||
throw new Error(`Invalid month format: ${month}. Expected YYYY-MM`);
|
||||
}
|
||||
return month;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a monthly report to disk
|
||||
*/
|
||||
export function saveMonthlyReport(root: string, report: MonthlyReport): string {
|
||||
ensureDirectories(root);
|
||||
const reportsDir = join(root, "monthly-reports");
|
||||
const month = sanitizeMonth(report.month);
|
||||
const filename = `${month}.json`;
|
||||
const filepath = join(reportsDir, filename);
|
||||
verifyPathWithinDirectory(filepath, reportsDir);
|
||||
writeFileSync(filepath, JSON.stringify(report, null, 2), "utf-8");
|
||||
return filename;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a specific monthly report by month identifier
|
||||
*/
|
||||
export function loadMonthlyReport(root: string, month: string): MonthlyReport | null {
|
||||
month = sanitizeMonth(month);
|
||||
const reportsDir = join(root, "monthly-reports");
|
||||
const filepath = join(reportsDir, `${month}.json`);
|
||||
if (!existsSync(filepath)) return null;
|
||||
const content = readFileSync(filepath, "utf-8");
|
||||
return JSON.parse(content) as MonthlyReport;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,113 @@
|
|||
import { describe, test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { generateHeatmap } from "../src/reports/heatmap.js";
|
||||
import type { PostAnalytics } from "../src/models/types.js";
|
||||
|
||||
function createPost(date: string, impressions: number, engagementRate: number): PostAnalytics {
|
||||
return {
|
||||
id: `post-${date}`,
|
||||
title: `Post on ${date}`,
|
||||
publishedDate: date,
|
||||
metrics: {
|
||||
impressions,
|
||||
reactions: 10,
|
||||
comments: 5,
|
||||
shares: 2,
|
||||
clicks: 3,
|
||||
engagementRate,
|
||||
},
|
||||
importedAt: "2026-04-01T00:00:00Z",
|
||||
exportSource: "test.csv",
|
||||
};
|
||||
}
|
||||
|
||||
describe("generateHeatmap", () => {
|
||||
// Verified days: 2026-04-06=Mon, 07=Tue, 08=Wed, 09=Thu, 12=Sun, 13=Mon, 14=Tue, 15=Wed
|
||||
const posts: PostAnalytics[] = [
|
||||
createPost("2026-04-06", 1000, 3.0), // Monday
|
||||
createPost("2026-04-07", 2000, 4.0), // Tuesday
|
||||
createPost("2026-04-08", 1500, 3.5), // Wednesday
|
||||
createPost("2026-04-13", 3000, 5.0), // Monday
|
||||
createPost("2026-04-14", 2500, 4.5), // Tuesday
|
||||
createPost("2026-04-12", 800, 2.0), // Sunday
|
||||
];
|
||||
|
||||
test("groups posts by day of week correctly", () => {
|
||||
const report = generateHeatmap(posts);
|
||||
const monday = report.byDayOfWeek.find(d => d.dayName === "Monday");
|
||||
const tuesday = report.byDayOfWeek.find(d => d.dayName === "Tuesday");
|
||||
const sunday = report.byDayOfWeek.find(d => d.dayName === "Sunday");
|
||||
|
||||
assert.equal(monday?.postCount, 2);
|
||||
assert.equal(tuesday?.postCount, 2);
|
||||
assert.equal(sunday?.postCount, 1);
|
||||
});
|
||||
|
||||
test("calculates correct averages per day", () => {
|
||||
const report = generateHeatmap(posts);
|
||||
const monday = report.byDayOfWeek.find(d => d.dayName === "Monday")!;
|
||||
const tuesday = report.byDayOfWeek.find(d => d.dayName === "Tuesday")!;
|
||||
|
||||
assert.equal(monday.avgImpressions, 2000); // (1000+3000)/2
|
||||
assert.equal(tuesday.avgImpressions, 2250); // (2000+2500)/2
|
||||
assert.equal(monday.avgEngagementRate, 4.0); // (3.0+5.0)/2
|
||||
});
|
||||
|
||||
test("handles days with no posts", () => {
|
||||
const report = generateHeatmap(posts);
|
||||
const friday = report.byDayOfWeek.find(d => d.dayName === "Friday")!;
|
||||
|
||||
assert.equal(friday.postCount, 0);
|
||||
assert.equal(friday.avgImpressions, 0);
|
||||
assert.equal(friday.avgEngagementRate, 0);
|
||||
});
|
||||
|
||||
test("returns 7 entries ordered Mon-Sun", () => {
|
||||
const report = generateHeatmap(posts);
|
||||
assert.equal(report.byDayOfWeek.length, 7);
|
||||
assert.equal(report.byDayOfWeek[0].dayName, "Monday");
|
||||
assert.equal(report.byDayOfWeek[6].dayName, "Sunday");
|
||||
assert.deepEqual(
|
||||
report.byDayOfWeek.map(d => d.dayIndex),
|
||||
[1, 2, 3, 4, 5, 6, 7]
|
||||
);
|
||||
});
|
||||
|
||||
test("identifies best day for impressions", () => {
|
||||
const report = generateHeatmap(posts);
|
||||
assert.equal(report.bestDayImpressions, "Tuesday");
|
||||
});
|
||||
|
||||
test("identifies best day for engagement", () => {
|
||||
const report = generateHeatmap(posts);
|
||||
assert.equal(report.bestDayEngagement, "Tuesday"); // (4.0+4.5)/2 = 4.25
|
||||
});
|
||||
|
||||
test("sets correct postsAnalyzed count", () => {
|
||||
const report = generateHeatmap(posts);
|
||||
assert.equal(report.postsAnalyzed, 6);
|
||||
});
|
||||
|
||||
test("handles empty post list", () => {
|
||||
const report = generateHeatmap([]);
|
||||
assert.equal(report.postsAnalyzed, 0);
|
||||
assert.equal(report.byDayOfWeek.length, 7);
|
||||
assert.equal(report.bestDayImpressions, "N/A");
|
||||
assert.equal(report.bestDayEngagement, "N/A");
|
||||
for (const day of report.byDayOfWeek) {
|
||||
assert.equal(day.postCount, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test("identifies best post per day", () => {
|
||||
const report = generateHeatmap(posts);
|
||||
const monday = report.byDayOfWeek.find(d => d.dayName === "Monday")!;
|
||||
assert.equal(monday.bestPost?.publishedDate, "2026-04-13"); // 3000 impressions
|
||||
});
|
||||
|
||||
test("calculates correct date range", () => {
|
||||
const report = generateHeatmap(posts);
|
||||
assert.equal(report.dateRange.from, "2026-04-06");
|
||||
assert.equal(report.dateRange.to, "2026-04-14");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
import { describe, test, afterEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { generateMonthlyReport } from "../src/reports/monthly.js";
|
||||
import { saveBatch } from "../src/utils/storage.js";
|
||||
import type { PostAnalytics, AnalyticsBatch, MonthlyReport } from "../src/models/types.js";
|
||||
|
||||
function createPost(date: string, impressions: number, engagementRate: number): PostAnalytics {
|
||||
return {
|
||||
id: `post-${date}-${impressions}`,
|
||||
title: `Post on ${date}`,
|
||||
publishedDate: date,
|
||||
metrics: {
|
||||
impressions,
|
||||
reactions: Math.round(impressions * 0.05),
|
||||
comments: Math.round(impressions * 0.01),
|
||||
shares: Math.round(impressions * 0.005),
|
||||
clicks: Math.round(impressions * 0.02),
|
||||
engagementRate,
|
||||
},
|
||||
importedAt: "2026-04-01T00:00:00Z",
|
||||
exportSource: "test.csv",
|
||||
};
|
||||
}
|
||||
|
||||
function createBatch(posts: PostAnalytics[]): AnalyticsBatch {
|
||||
const dates = posts.map(p => p.publishedDate).sort();
|
||||
return {
|
||||
batchId: "test-batch-" + Math.random().toString(36).slice(2, 10),
|
||||
importedAt: "2026-04-01T00:00:00Z",
|
||||
exportFilename: "test.csv",
|
||||
dateRange: { from: dates[0], to: dates[dates.length - 1] },
|
||||
postCount: posts.length,
|
||||
posts,
|
||||
};
|
||||
}
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
function setupTestRoot(posts: PostAnalytics[]): string {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), "monthly-test-"));
|
||||
for (const dir of ["exports", "posts", "weekly-reports", "monthly-reports"]) {
|
||||
mkdirSync(join(tmpDir, dir), { recursive: true });
|
||||
}
|
||||
if (posts.length > 0) {
|
||||
saveBatch(tmpDir, createBatch(posts));
|
||||
}
|
||||
return tmpDir;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("generateMonthlyReport", () => {
|
||||
const marchPosts: PostAnalytics[] = [
|
||||
createPost("2026-03-03", 1000, 3.0),
|
||||
createPost("2026-03-05", 2000, 4.0),
|
||||
createPost("2026-03-10", 1500, 3.5),
|
||||
createPost("2026-03-17", 3000, 5.0),
|
||||
createPost("2026-03-24", 2500, 4.5),
|
||||
];
|
||||
|
||||
const febPosts: PostAnalytics[] = [
|
||||
createPost("2026-02-03", 800, 2.5),
|
||||
createPost("2026-02-10", 1200, 3.0),
|
||||
createPost("2026-02-17", 900, 2.8),
|
||||
];
|
||||
|
||||
test("filters posts to correct month", () => {
|
||||
const root = setupTestRoot([...marchPosts, ...febPosts]);
|
||||
const report = generateMonthlyReport(root, "2026-03");
|
||||
assert.equal(report.summary.totalPosts, 5);
|
||||
});
|
||||
|
||||
test("calculates correct monthly totals", () => {
|
||||
const root = setupTestRoot(marchPosts);
|
||||
const report = generateMonthlyReport(root, "2026-03");
|
||||
assert.equal(report.summary.totalImpressions, 10000); // 1000+2000+1500+3000+2500
|
||||
assert.equal(report.summary.totalPosts, 5);
|
||||
assert.equal(report.summary.avgImpressionsPerPost, 2000);
|
||||
});
|
||||
|
||||
test("generates weekly breakdown within month", () => {
|
||||
const root = setupTestRoot(marchPosts);
|
||||
const report = generateMonthlyReport(root, "2026-03");
|
||||
assert.ok(report.byWeek.length > 0);
|
||||
const totalPostsInWeeks = report.byWeek.reduce((sum, w) => sum + w.postCount, 0);
|
||||
assert.equal(totalPostsInWeeks, 5);
|
||||
});
|
||||
|
||||
test("calculates MoM deltas when previous month exists", () => {
|
||||
const root = setupTestRoot([...febPosts, ...marchPosts]);
|
||||
// First generate February report so it exists for comparison
|
||||
generateMonthlyReport(root, "2026-02");
|
||||
const report = generateMonthlyReport(root, "2026-03");
|
||||
|
||||
assert.notEqual(report.trends.comparedTo, null);
|
||||
assert.equal(report.trends.comparedTo, "2026-02");
|
||||
assert.notEqual(report.trends.percentChange.impressions, null);
|
||||
assert.notEqual(report.trends.percentChange.postCount, null);
|
||||
});
|
||||
|
||||
test("handles no previous month data", () => {
|
||||
const root = setupTestRoot(marchPosts);
|
||||
const report = generateMonthlyReport(root, "2026-03");
|
||||
assert.equal(report.trends.comparedTo, null);
|
||||
assert.equal(report.trends.percentChange.impressions, null);
|
||||
assert.equal(report.trends.percentChange.engagement, null);
|
||||
});
|
||||
|
||||
test("handles month with no posts", () => {
|
||||
const root = setupTestRoot(marchPosts);
|
||||
const report = generateMonthlyReport(root, "2026-01");
|
||||
assert.equal(report.summary.totalPosts, 0);
|
||||
assert.equal(report.summary.totalImpressions, 0);
|
||||
assert.equal(report.summary.avgImpressionsPerPost, 0);
|
||||
assert.equal(report.byWeek.length, 0);
|
||||
});
|
||||
|
||||
test("identifies top performers", () => {
|
||||
const root = setupTestRoot(marchPosts);
|
||||
const report = generateMonthlyReport(root, "2026-03");
|
||||
assert.ok(report.topPerformers.length > 0);
|
||||
assert.equal(report.topPerformers[0].metrics.impressions, 3000);
|
||||
});
|
||||
|
||||
test("sets correct month field", () => {
|
||||
const root = setupTestRoot(marchPosts);
|
||||
const report = generateMonthlyReport(root, "2026-03");
|
||||
assert.equal(report.month, "2026-03");
|
||||
});
|
||||
});
|
||||
|
|
@ -34,6 +34,7 @@ This plugin uses **6 focused skills**. This main skill contains shared knowledge
|
|||
|
||||
| User Intent | Route To |
|
||||
|-------------|----------|
|
||||
| "Just installed" / "Walk me through" | `/linkedin:onboarding` |
|
||||
| "Set up plugin" | `/linkedin:setup` |
|
||||
| "Personalize" | `/linkedin:setup` |
|
||||
| "Improve personalization" | `/linkedin:setup` |
|
||||
|
|
@ -115,6 +116,7 @@ These rules apply to ALL content created by any skill or command:
|
|||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `/linkedin` | Router -- shows status line + command menu |
|
||||
| `/linkedin:onboarding` | Multi-step onboarding wizard (profile → setup → first-post) |
|
||||
| `/linkedin:first-post` | First-post accelerator (zero to published in 10 min) |
|
||||
| `/linkedin:setup` | Guided setup to populate asset templates with real data |
|
||||
| `/linkedin:react` | URL-to-post pipeline -- react to articles, news, research |
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ Transform each role with impact statements, not task lists:
|
|||
|
||||
WHO you help + RESULT you deliver
|
||||
|
||||
Strong: "Helping [target audience] achieve [specific result] | [Your Role] @ [Your Organization]"
|
||||
Strong: "Helping public sector leaders implement AI that actually works | AI Advisor @ Statens vegvesen"
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue