prjct-cli 0.21.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -179,6 +179,95 @@ export class NotionClient {
179
179
  }
180
180
  }
181
181
 
182
+ /**
183
+ * Update page content (for dashboard)
184
+ */
185
+ async updatePageContent(pageId: string, content: string): Promise<boolean> {
186
+ try {
187
+ // Convert markdown content to Notion blocks
188
+ const blocks = this.markdownToBlocks(content)
189
+
190
+ // Clear existing content and add new blocks
191
+ // First, get existing blocks to delete them
192
+ const existingBlocks = await this.apiRequest(
193
+ `/blocks/${pageId}/children`,
194
+ 'GET'
195
+ )
196
+
197
+ // Delete existing blocks
198
+ for (const block of existingBlocks.results || []) {
199
+ await this.apiRequest(`/blocks/${block.id}`, 'DELETE')
200
+ }
201
+
202
+ // Add new blocks
203
+ await this.apiRequest(`/blocks/${pageId}/children`, 'PATCH', {
204
+ children: blocks,
205
+ })
206
+
207
+ return true
208
+ } catch (error) {
209
+ console.error('[notion] Failed to update page content:', (error as Error).message)
210
+ return false
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Convert simple markdown to Notion blocks
216
+ */
217
+ private markdownToBlocks(content: string): unknown[] {
218
+ const blocks: unknown[] = []
219
+ const lines = content.split('\n')
220
+
221
+ for (const line of lines) {
222
+ if (line.startsWith('# ')) {
223
+ blocks.push({
224
+ type: 'heading_1',
225
+ heading_1: {
226
+ rich_text: [{ type: 'text', text: { content: line.slice(2) } }],
227
+ },
228
+ })
229
+ } else if (line.startsWith('## ')) {
230
+ blocks.push({
231
+ type: 'heading_2',
232
+ heading_2: {
233
+ rich_text: [{ type: 'text', text: { content: line.slice(3) } }],
234
+ },
235
+ })
236
+ } else if (line.startsWith('- ')) {
237
+ blocks.push({
238
+ type: 'bulleted_list_item',
239
+ bulleted_list_item: {
240
+ rich_text: [{ type: 'text', text: { content: line.slice(2) } }],
241
+ },
242
+ })
243
+ } else if (line.startsWith('|') && line.includes('|')) {
244
+ // Table row - skip header separator
245
+ if (!line.match(/^\|[-|]+\|$/)) {
246
+ const cells = line.split('|').filter(Boolean).map((c) => c.trim())
247
+ if (cells.length > 0) {
248
+ blocks.push({
249
+ type: 'paragraph',
250
+ paragraph: {
251
+ rich_text: [{ type: 'text', text: { content: cells.join(' | ') } }],
252
+ },
253
+ })
254
+ }
255
+ }
256
+ } else if (line.trim() === '---') {
257
+ blocks.push({ type: 'divider', divider: {} })
258
+ } else if (line.trim()) {
259
+ blocks.push({
260
+ type: 'paragraph',
261
+ paragraph: {
262
+ rich_text: [{ type: 'text', text: { content: line } }],
263
+ },
264
+ })
265
+ }
266
+ }
267
+
268
+ return blocks
269
+ }
270
+
182
271
  /**
183
272
  * Query a database
184
273
  */
@@ -211,11 +300,12 @@ export class NotionClient {
211
300
  }
212
301
 
213
302
  /**
214
- * Find page by project ID and name (for upsert)
303
+ * Find page by name (for upsert)
304
+ * Note: Since each project has its own database, we don't need to filter by project
215
305
  */
216
306
  async findPageByProjectAndName(
217
307
  databaseId: string,
218
- projectId: string,
308
+ _projectId: string,
219
309
  name: string
220
310
  ): Promise<string | null> {
221
311
  try {
@@ -224,9 +314,9 @@ export class NotionClient {
224
314
  'POST',
225
315
  {
226
316
  filter: {
227
- and: [
228
- { property: 'Project', rich_text: { equals: projectId } },
317
+ or: [
229
318
  { property: 'Name', title: { equals: name } },
319
+ { property: 'Idea', title: { equals: name } },
230
320
  ],
231
321
  },
232
322
  }
@@ -28,8 +28,11 @@ export {
28
28
  syncShippedFeature,
29
29
  syncIdea,
30
30
  fullSync,
31
+ pullShippedFeatures,
32
+ pullIdeas,
33
+ bidirectionalSync,
31
34
  } from './sync'
32
- export type { SyncResult } from './sync'
35
+ export type { SyncResult, PullResult } from './sync'
33
36
 
34
37
  // Setup
35
38
  export {
@@ -94,16 +94,21 @@ export async function createDatabases(
94
94
  const databases: NotionIntegrationConfig['databases'] = {}
95
95
 
96
96
  try {
97
- // Create each database
97
+ // Create each database with project-specific names
98
98
  for (const [key, schema] of Object.entries(ALL_DATABASE_SCHEMAS)) {
99
- const db = await notionClient.createDatabase(parentPageId, schema)
99
+ // Override title with project name prefix
100
+ const projectSchema = {
101
+ ...schema,
102
+ title: schema.title.replace('prjct:', `${projectName}:`),
103
+ }
104
+ const db = await notionClient.createDatabase(parentPageId, projectSchema)
100
105
  if (db) {
101
106
  databases[key as keyof typeof databases] = db.id
102
107
  } else {
103
108
  return {
104
109
  success: false,
105
110
  databases,
106
- error: `Failed to create ${schema.title} database`,
111
+ error: `Failed to create ${projectSchema.title} database`,
107
112
  }
108
113
  }
109
114
  }
@@ -6,6 +6,7 @@
6
6
  import type { ShippedFeature, Idea } from '../../types/storage'
7
7
  import type { NotionIntegrationConfig } from '../../types/integrations'
8
8
  import { notionClient } from './client'
9
+ import { getDashboardContent, type DashboardMetrics } from './templates'
9
10
 
10
11
  // =============================================================================
11
12
  // Types
@@ -25,17 +26,25 @@ export interface SyncResult {
25
26
 
26
27
  /**
27
28
  * Build Notion properties for a shipped feature
29
+ * Includes ALL fields for comprehensive sync
28
30
  */
29
31
  function buildShippedProperties(
30
32
  feature: ShippedFeature,
31
- projectId: string
33
+ _projectId: string
32
34
  ): Record<string, unknown> {
35
+ const now = new Date().toISOString()
36
+
37
+ // Note: Project field removed - each project now has its own database
33
38
  const props: Record<string, unknown> = {
34
39
  Name: {
35
40
  title: [{ text: { content: feature.name } }],
36
41
  },
37
- Project: {
38
- rich_text: [{ text: { content: projectId } }],
42
+ // Sync tracking fields
43
+ prjctId: {
44
+ rich_text: [{ text: { content: feature.id } }],
45
+ },
46
+ 'Last Updated': {
47
+ date: { start: now.split('T')[0] },
39
48
  },
40
49
  }
41
50
 
@@ -57,16 +66,24 @@ function buildShippedProperties(
57
66
  }
58
67
  }
59
68
 
69
+ // Full code metrics
60
70
  if (feature.codeMetrics) {
61
71
  const metrics = feature.codeMetrics
62
- props['Lines Changed'] = {
63
- number: (metrics.linesAdded || 0) + (metrics.linesRemoved || 0),
72
+ props['Lines Added'] = {
73
+ number: metrics.linesAdded || 0,
74
+ }
75
+ props['Lines Removed'] = {
76
+ number: metrics.linesRemoved || 0,
64
77
  }
65
78
  props['Files Changed'] = {
66
79
  number: metrics.filesChanged || 0,
67
80
  }
68
- props.Commits = {
69
- number: metrics.commits || 0,
81
+ }
82
+
83
+ // Commit info
84
+ if (feature.commit && typeof feature.commit === 'object') {
85
+ props.Commit = {
86
+ rich_text: [{ text: { content: feature.commit.hash || '' } }],
70
87
  }
71
88
  }
72
89
 
@@ -82,22 +99,37 @@ function buildShippedProperties(
82
99
  }
83
100
  }
84
101
 
102
+ // Impact level based on metrics
103
+ const impact = feature.codeMetrics?.linesAdded && feature.codeMetrics.linesAdded > 500 ? 'High' :
104
+ feature.codeMetrics?.linesAdded && feature.codeMetrics.linesAdded > 100 ? 'Medium' : 'Low'
105
+ props.Impact = {
106
+ select: { name: impact },
107
+ }
108
+
85
109
  return props
86
110
  }
87
111
 
88
112
  /**
89
113
  * Build Notion properties for an idea
114
+ * Includes ALL fields for comprehensive sync
90
115
  */
91
116
  function buildIdeaProperties(
92
117
  idea: Idea,
93
- projectId: string
118
+ _projectId: string
94
119
  ): Record<string, unknown> {
120
+ const now = new Date().toISOString()
121
+
122
+ // Note: Project field removed - each project now has its own database
95
123
  const props: Record<string, unknown> = {
96
124
  Idea: {
97
125
  title: [{ text: { content: idea.text } }],
98
126
  },
99
- Project: {
100
- rich_text: [{ text: { content: projectId } }],
127
+ // Sync tracking fields
128
+ prjctId: {
129
+ rich_text: [{ text: { content: idea.id } }],
130
+ },
131
+ 'Last Updated': {
132
+ date: { start: now.split('T')[0] },
101
133
  },
102
134
  }
103
135
 
@@ -138,9 +170,186 @@ function buildIdeaProperties(
138
170
  }
139
171
  }
140
172
 
173
+ // Details/notes
174
+ if (idea.details) {
175
+ props.Details = {
176
+ rich_text: [{ text: { content: idea.details.slice(0, 2000) } }],
177
+ }
178
+ }
179
+
180
+ // Impact/Effort matrix
181
+ if (idea.impactEffort) {
182
+ props.Impact = {
183
+ select: { name: idea.impactEffort.impact },
184
+ }
185
+ props.Effort = {
186
+ select: { name: idea.impactEffort.effort },
187
+ }
188
+ }
189
+
190
+ // Pain points as text
191
+ if (idea.painPoints && idea.painPoints.length > 0) {
192
+ props['Pain Points'] = {
193
+ rich_text: [{ text: { content: idea.painPoints.join(', ').slice(0, 2000) } }],
194
+ }
195
+ }
196
+
197
+ // Solutions as text
198
+ if (idea.solutions && idea.solutions.length > 0) {
199
+ props.Solutions = {
200
+ rich_text: [{ text: { content: idea.solutions.join(', ').slice(0, 2000) } }],
201
+ }
202
+ }
203
+
204
+ // Files affected
205
+ if (idea.filesAffected && idea.filesAffected.length > 0) {
206
+ props['Files Affected'] = {
207
+ rich_text: [{ text: { content: idea.filesAffected.join(', ').slice(0, 2000) } }],
208
+ }
209
+ }
210
+
211
+ // Risks count
212
+ if (idea.risksCount !== undefined) {
213
+ props.Risks = {
214
+ number: idea.risksCount,
215
+ }
216
+ }
217
+
141
218
  return props
142
219
  }
143
220
 
221
+ // =============================================================================
222
+ // Property Parsers (Notion → prjct)
223
+ // =============================================================================
224
+
225
+ /**
226
+ * Extract text from Notion rich_text property
227
+ */
228
+ function extractText(prop: unknown): string | undefined {
229
+ if (!prop || typeof prop !== 'object') return undefined
230
+ const p = prop as { rich_text?: Array<{ plain_text?: string }> }
231
+ if (!p.rich_text || p.rich_text.length === 0) return undefined
232
+ return p.rich_text.map((t) => t.plain_text || '').join('')
233
+ }
234
+
235
+ /**
236
+ * Extract title from Notion title property
237
+ */
238
+ function extractTitle(prop: unknown): string {
239
+ if (!prop || typeof prop !== 'object') return ''
240
+ const p = prop as { title?: Array<{ plain_text?: string }> }
241
+ if (!p.title || p.title.length === 0) return ''
242
+ return p.title.map((t) => t.plain_text || '').join('')
243
+ }
244
+
245
+ /**
246
+ * Extract date from Notion date property
247
+ */
248
+ function extractDate(prop: unknown): string | undefined {
249
+ if (!prop || typeof prop !== 'object') return undefined
250
+ const p = prop as { date?: { start?: string } }
251
+ return p.date?.start
252
+ }
253
+
254
+ /**
255
+ * Extract select value from Notion select property
256
+ */
257
+ function extractSelect(prop: unknown): string | undefined {
258
+ if (!prop || typeof prop !== 'object') return undefined
259
+ const p = prop as { select?: { name?: string } }
260
+ return p.select?.name
261
+ }
262
+
263
+ /**
264
+ * Extract status value from Notion status property
265
+ */
266
+ function extractStatus(prop: unknown): string | undefined {
267
+ if (!prop || typeof prop !== 'object') return undefined
268
+ const p = prop as { status?: { name?: string } }
269
+ return p.status?.name
270
+ }
271
+
272
+ /**
273
+ * Extract number from Notion number property
274
+ */
275
+ function extractNumber(prop: unknown): number | undefined {
276
+ if (!prop || typeof prop !== 'object') return undefined
277
+ const p = prop as { number?: number | null }
278
+ return p.number ?? undefined
279
+ }
280
+
281
+ /**
282
+ * Extract multi-select as string array
283
+ */
284
+ function extractMultiSelect(prop: unknown): string[] {
285
+ if (!prop || typeof prop !== 'object') return []
286
+ const p = prop as { multi_select?: Array<{ name?: string }> }
287
+ if (!p.multi_select) return []
288
+ return p.multi_select.map((s) => s.name || '').filter(Boolean)
289
+ }
290
+
291
+ /**
292
+ * Parse Notion page to ShippedFeature
293
+ */
294
+ function parseShippedFeature(
295
+ pageId: string,
296
+ props: Record<string, unknown>
297
+ ): Partial<ShippedFeature> & { notionPageId: string } {
298
+ return {
299
+ notionPageId: pageId,
300
+ id: extractText(props.prjctId) || '',
301
+ name: extractTitle(props.Name) || '',
302
+ version: extractText(props.Version) || '',
303
+ shippedAt: extractDate(props['Shipped Date']) || new Date().toISOString(),
304
+ description: extractText(props.Description),
305
+ type: extractSelect(props.Type) as ShippedFeature['type'],
306
+ duration: extractText(props.Duration),
307
+ codeMetrics: {
308
+ linesAdded: extractNumber(props['Lines Added']) || 0,
309
+ linesRemoved: extractNumber(props['Lines Removed']) || 0,
310
+ filesChanged: extractNumber(props['Files Changed']) || 0,
311
+ commits: 0,
312
+ },
313
+ lastSyncedAt: new Date().toISOString(),
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Parse Notion page to Idea
319
+ */
320
+ function parseIdea(
321
+ pageId: string,
322
+ props: Record<string, unknown>
323
+ ): Partial<Idea> & { notionPageId: string } {
324
+ const statusMap: Record<string, Idea['status']> = {
325
+ Pending: 'pending',
326
+ Converted: 'converted',
327
+ Archived: 'archived',
328
+ }
329
+
330
+ const priorityMap: Record<string, Idea['priority']> = {
331
+ high: 'high',
332
+ medium: 'medium',
333
+ low: 'low',
334
+ High: 'high',
335
+ Medium: 'medium',
336
+ Low: 'low',
337
+ }
338
+
339
+ return {
340
+ notionPageId: pageId,
341
+ id: extractText(props.prjctId) || '',
342
+ text: extractTitle(props.Idea) || '',
343
+ status: statusMap[extractStatus(props.Status) || ''] || 'pending',
344
+ priority: priorityMap[extractSelect(props.Priority) || ''] || 'medium',
345
+ tags: extractMultiSelect(props.Tags),
346
+ details: extractText(props.Details),
347
+ addedAt: extractDate(props.Created) || new Date().toISOString(),
348
+ convertedTo: extractText(props['Converted To']),
349
+ lastSyncedAt: new Date().toISOString(),
350
+ }
351
+ }
352
+
144
353
  // =============================================================================
145
354
  // Sync Functions
146
355
  // =============================================================================
@@ -309,3 +518,301 @@ export async function fullSync(
309
518
 
310
519
  return results
311
520
  }
521
+
522
+ // =============================================================================
523
+ // Pull Sync Functions (Notion → prjct)
524
+ // =============================================================================
525
+
526
+ export interface PullResult<T> {
527
+ items: T[]
528
+ newCount: number
529
+ updatedCount: number
530
+ errors: string[]
531
+ }
532
+
533
+ /**
534
+ * Pull shipped features from Notion
535
+ * Returns new and updated features from Notion that don't exist or are newer in Notion
536
+ */
537
+ export async function pullShippedFeatures(
538
+ config: NotionIntegrationConfig,
539
+ existingFeatures: ShippedFeature[]
540
+ ): Promise<PullResult<ShippedFeature>> {
541
+ const result: PullResult<ShippedFeature> = {
542
+ items: [],
543
+ newCount: 0,
544
+ updatedCount: 0,
545
+ errors: [],
546
+ }
547
+
548
+ if (!config.enabled || !config.databases.shipped) {
549
+ return result
550
+ }
551
+
552
+ if (!notionClient.isReady()) {
553
+ result.errors.push('Notion client not ready')
554
+ return result
555
+ }
556
+
557
+ try {
558
+ const pages = await notionClient.queryDatabase(config.databases.shipped)
559
+
560
+ // Create lookup map by prjctId and notionPageId
561
+ const existingByPrjctId = new Map(
562
+ existingFeatures.filter((f) => f.id).map((f) => [f.id, f])
563
+ )
564
+ const existingByNotionId = new Map(
565
+ existingFeatures.filter((f) => f.notionPageId).map((f) => [f.notionPageId, f])
566
+ )
567
+
568
+ for (const page of pages) {
569
+ const parsed = parseShippedFeature(page.id, page.properties)
570
+
571
+ // Skip if no name (invalid entry)
572
+ if (!parsed.name) continue
573
+
574
+ // Check if exists locally
575
+ const existingByPrjct = parsed.id ? existingByPrjctId.get(parsed.id) : undefined
576
+ const existingByNotion = existingByNotionId.get(page.id)
577
+ const existing = existingByPrjct || existingByNotion
578
+
579
+ if (existing) {
580
+ // Update existing - merge with local data
581
+ const merged: ShippedFeature = {
582
+ ...existing,
583
+ ...parsed,
584
+ id: existing.id || parsed.id || crypto.randomUUID(),
585
+ notionPageId: page.id,
586
+ lastSyncedAt: new Date().toISOString(),
587
+ }
588
+ result.items.push(merged)
589
+ result.updatedCount++
590
+ } else {
591
+ // New from Notion - create new entry
592
+ const newFeature: ShippedFeature = {
593
+ id: parsed.id || crypto.randomUUID(),
594
+ name: parsed.name,
595
+ shippedAt: parsed.shippedAt || new Date().toISOString(),
596
+ version: parsed.version || '0.0.0',
597
+ description: parsed.description,
598
+ type: parsed.type,
599
+ duration: parsed.duration,
600
+ codeMetrics: parsed.codeMetrics,
601
+ notionPageId: page.id,
602
+ lastSyncedAt: new Date().toISOString(),
603
+ }
604
+ result.items.push(newFeature)
605
+ result.newCount++
606
+ }
607
+ }
608
+
609
+ return result
610
+ } catch (error) {
611
+ result.errors.push((error as Error).message)
612
+ return result
613
+ }
614
+ }
615
+
616
+ /**
617
+ * Pull ideas from Notion
618
+ * Returns new and updated ideas from Notion
619
+ */
620
+ export async function pullIdeas(
621
+ config: NotionIntegrationConfig,
622
+ existingIdeas: Idea[]
623
+ ): Promise<PullResult<Idea>> {
624
+ const result: PullResult<Idea> = {
625
+ items: [],
626
+ newCount: 0,
627
+ updatedCount: 0,
628
+ errors: [],
629
+ }
630
+
631
+ if (!config.enabled || !config.databases.ideas) {
632
+ return result
633
+ }
634
+
635
+ if (!notionClient.isReady()) {
636
+ result.errors.push('Notion client not ready')
637
+ return result
638
+ }
639
+
640
+ try {
641
+ const pages = await notionClient.queryDatabase(config.databases.ideas)
642
+
643
+ // Create lookup map
644
+ const existingByPrjctId = new Map(
645
+ existingIdeas.filter((i) => i.id).map((i) => [i.id, i])
646
+ )
647
+ const existingByNotionId = new Map(
648
+ existingIdeas.filter((i) => i.notionPageId).map((i) => [i.notionPageId, i])
649
+ )
650
+
651
+ for (const page of pages) {
652
+ const parsed = parseIdea(page.id, page.properties)
653
+
654
+ // Skip if no text (invalid entry)
655
+ if (!parsed.text) continue
656
+
657
+ // Check if exists locally
658
+ const existingByPrjct = parsed.id ? existingByPrjctId.get(parsed.id) : undefined
659
+ const existingByNotion = existingByNotionId.get(page.id)
660
+ const existing = existingByPrjct || existingByNotion
661
+
662
+ if (existing) {
663
+ // Update existing
664
+ const merged: Idea = {
665
+ ...existing,
666
+ ...parsed,
667
+ id: existing.id || parsed.id || crypto.randomUUID(),
668
+ notionPageId: page.id,
669
+ lastSyncedAt: new Date().toISOString(),
670
+ }
671
+ result.items.push(merged)
672
+ result.updatedCount++
673
+ } else {
674
+ // New from Notion
675
+ const newIdea: Idea = {
676
+ id: parsed.id || crypto.randomUUID(),
677
+ text: parsed.text,
678
+ status: parsed.status || 'pending',
679
+ priority: parsed.priority || 'medium',
680
+ tags: parsed.tags || [],
681
+ addedAt: parsed.addedAt || new Date().toISOString(),
682
+ details: parsed.details,
683
+ convertedTo: parsed.convertedTo,
684
+ notionPageId: page.id,
685
+ lastSyncedAt: new Date().toISOString(),
686
+ }
687
+ result.items.push(newIdea)
688
+ result.newCount++
689
+ }
690
+ }
691
+
692
+ return result
693
+ } catch (error) {
694
+ result.errors.push((error as Error).message)
695
+ return result
696
+ }
697
+ }
698
+
699
+ /**
700
+ * Bidirectional sync - combines push and pull
701
+ * Uses "last edit wins" strategy based on lastSyncedAt
702
+ */
703
+ export async function bidirectionalSync(
704
+ projectId: string,
705
+ config: NotionIntegrationConfig,
706
+ localData: {
707
+ shipped?: ShippedFeature[]
708
+ ideas?: Idea[]
709
+ }
710
+ ): Promise<{
711
+ pushed: { shipped: number; ideas: number }
712
+ pulled: { shipped: PullResult<ShippedFeature>; ideas: PullResult<Idea> }
713
+ }> {
714
+ const results = {
715
+ pushed: { shipped: 0, ideas: 0 },
716
+ pulled: {
717
+ shipped: { items: [], newCount: 0, updatedCount: 0, errors: [] } as PullResult<ShippedFeature>,
718
+ ideas: { items: [], newCount: 0, updatedCount: 0, errors: [] } as PullResult<Idea>,
719
+ },
720
+ }
721
+
722
+ // 1. Pull from Notion first (to get latest)
723
+ if (localData.shipped) {
724
+ results.pulled.shipped = await pullShippedFeatures(config, localData.shipped)
725
+ }
726
+ if (localData.ideas) {
727
+ results.pulled.ideas = await pullIdeas(config, localData.ideas)
728
+ }
729
+
730
+ // 2. Push local items that don't have notionPageId (new local items)
731
+ if (localData.shipped && config.databases.shipped) {
732
+ for (const feature of localData.shipped) {
733
+ if (!feature.notionPageId) {
734
+ const result = await syncShippedFeature(projectId, feature, config)
735
+ if (result.success) {
736
+ results.pushed.shipped++
737
+ }
738
+ }
739
+ }
740
+ }
741
+
742
+ if (localData.ideas && config.databases.ideas) {
743
+ for (const idea of localData.ideas) {
744
+ if (!idea.notionPageId) {
745
+ const result = await syncIdea(projectId, idea, config)
746
+ if (result.success) {
747
+ results.pushed.ideas++
748
+ }
749
+ }
750
+ }
751
+ }
752
+
753
+ return results
754
+ }
755
+
756
+ // =============================================================================
757
+ // Dashboard Metrics Update
758
+ // =============================================================================
759
+
760
+ /**
761
+ * Update dashboard page with current metrics
762
+ * Called after sync operations
763
+ */
764
+ export async function updateDashboardMetrics(
765
+ config: NotionIntegrationConfig,
766
+ projectName: string,
767
+ metrics: DashboardMetrics
768
+ ): Promise<{ success: boolean; error?: string }> {
769
+ if (!config.enabled || !config.dashboardPageId) {
770
+ return { success: false, error: 'Dashboard not configured' }
771
+ }
772
+
773
+ if (!notionClient.isReady()) {
774
+ return { success: false, error: 'Notion client not ready' }
775
+ }
776
+
777
+ try {
778
+ const content = getDashboardContent(projectName, config.databases, metrics)
779
+
780
+ // Update dashboard page content
781
+ await notionClient.updatePageContent(config.dashboardPageId, content)
782
+
783
+ return { success: true }
784
+ } catch (error) {
785
+ return {
786
+ success: false,
787
+ error: (error as Error).message,
788
+ }
789
+ }
790
+ }
791
+
792
+ /**
793
+ * Calculate metrics from local data
794
+ */
795
+ export function calculateMetrics(data: {
796
+ shipped?: ShippedFeature[]
797
+ ideas?: Idea[]
798
+ tasks?: Array<{ status?: string; completed?: boolean }>
799
+ roadmap?: Array<{ progress?: number }>
800
+ }): DashboardMetrics {
801
+ const shippedCount = data.shipped?.length || 0
802
+ const ideasPending = data.ideas?.filter((i) => i.status === 'pending').length || 0
803
+ const tasksActive = data.tasks?.filter((t) => !t.completed && t.status !== 'completed').length || 0
804
+
805
+ // Calculate roadmap progress as average of all features
806
+ let roadmapProgress = 0
807
+ if (data.roadmap && data.roadmap.length > 0) {
808
+ const totalProgress = data.roadmap.reduce((sum, f) => sum + (f.progress || 0), 0)
809
+ roadmapProgress = Math.round(totalProgress / data.roadmap.length)
810
+ }
811
+
812
+ return {
813
+ shippedCount,
814
+ ideasPending,
815
+ tasksActive,
816
+ roadmapProgress,
817
+ }
818
+ }
@@ -35,7 +35,6 @@ export const SHIPPED_DATABASE_SCHEMA: NotionDatabaseSchema = {
35
35
  Commits: { type: 'number', number: { format: 'number' } },
36
36
  Duration: { type: 'rich_text', rich_text: {} },
37
37
  Description: { type: 'rich_text', rich_text: {} },
38
- Project: { type: 'rich_text', rich_text: {} },
39
38
  },
40
39
  }
41
40
 
@@ -78,7 +77,6 @@ export const ROADMAP_DATABASE_SCHEMA: NotionDatabaseSchema = {
78
77
  'Target Date': { type: 'date', date: {} },
79
78
  Description: { type: 'rich_text', rich_text: {} },
80
79
  Tasks: { type: 'number', number: { format: 'number' } },
81
- Project: { type: 'rich_text', rich_text: {} },
82
80
  },
83
81
  }
84
82
 
@@ -117,7 +115,6 @@ export const IDEAS_DATABASE_SCHEMA: NotionDatabaseSchema = {
117
115
  Tags: { type: 'multi_select', multi_select: { options: [] } },
118
116
  Created: { type: 'date', date: {} },
119
117
  'Converted To': { type: 'rich_text', rich_text: {} },
120
- Project: { type: 'rich_text', rich_text: {} },
121
118
  },
122
119
  }
123
120
 
@@ -165,7 +162,6 @@ export const TASKS_DATABASE_SCHEMA: NotionDatabaseSchema = {
165
162
  Feature: { type: 'rich_text', rich_text: {} },
166
163
  Created: { type: 'date', date: {} },
167
164
  'Completed At': { type: 'date', date: {} },
168
- Project: { type: 'rich_text', rich_text: {} },
169
165
  },
170
166
  }
171
167
 
@@ -173,8 +169,15 @@ export const TASKS_DATABASE_SCHEMA: NotionDatabaseSchema = {
173
169
  // Dashboard Template
174
170
  // =============================================================================
175
171
 
172
+ export interface DashboardMetrics {
173
+ shippedCount: number
174
+ ideasPending: number
175
+ tasksActive: number
176
+ roadmapProgress: number // 0-100
177
+ }
178
+
176
179
  /**
177
- * Dashboard page content template
180
+ * Dashboard page content template with metrics
178
181
  */
179
182
  export function getDashboardContent(
180
183
  projectName: string,
@@ -183,41 +186,50 @@ export function getDashboardContent(
183
186
  roadmap?: string
184
187
  ideas?: string
185
188
  tasks?: string
186
- }
189
+ },
190
+ metrics?: DashboardMetrics
187
191
  ): string {
188
- const sections = []
192
+ const sections: string[] = []
189
193
 
190
194
  sections.push(`# ${projectName} Dashboard`)
191
195
  sections.push('')
192
- sections.push('This dashboard is automatically synced by prjct-cli.')
193
- sections.push('')
194
196
 
195
- if (databases.shipped) {
196
- sections.push('## Shipped Features')
197
- sections.push(`View all shipped features and metrics.`)
197
+ // Metrics section
198
+ if (metrics) {
199
+ sections.push('## Metrics')
200
+ sections.push('')
201
+ sections.push('| Metric | Value |')
202
+ sections.push('|--------|-------|')
203
+ sections.push(`| Features Shipped | ${metrics.shippedCount} |`)
204
+ sections.push(`| Ideas Pending | ${metrics.ideasPending} |`)
205
+ sections.push(`| Active Tasks | ${metrics.tasksActive} |`)
206
+ sections.push(`| Roadmap Progress | ${metrics.roadmapProgress}% |`)
207
+ sections.push('')
208
+ } else {
209
+ sections.push('*Metrics will appear after first sync*')
198
210
  sections.push('')
199
211
  }
200
212
 
213
+ // Database links
214
+ sections.push('## Databases')
215
+ sections.push('')
216
+
217
+ if (databases.shipped) {
218
+ sections.push(`- **Shipped Features**: Track released features and metrics`)
219
+ }
201
220
  if (databases.roadmap) {
202
- sections.push('## Roadmap')
203
- sections.push(`Track feature progress and planning.`)
204
- sections.push('')
221
+ sections.push(`- **Roadmap**: Feature planning and progress`)
205
222
  }
206
-
207
223
  if (databases.ideas) {
208
- sections.push('## Ideas')
209
- sections.push(`Captured ideas and their status.`)
210
- sections.push('')
224
+ sections.push(`- **Ideas**: Captured ideas and status`)
211
225
  }
212
-
213
226
  if (databases.tasks) {
214
- sections.push('## Active Tasks')
215
- sections.push(`Current task queue and completion status.`)
216
- sections.push('')
227
+ sections.push(`- **Active Tasks**: Current task queue`)
217
228
  }
218
229
 
230
+ sections.push('')
219
231
  sections.push('---')
220
- sections.push('Powered by [prjct-cli](https://prjct.app)')
232
+ sections.push('Synced by [prjct-cli](https://prjct.app)')
221
233
 
222
234
  return sections.join('\n')
223
235
  }
@@ -43,6 +43,10 @@ export interface ShippedFeature {
43
43
  quantitativeImpact?: string
44
44
  tasksCompleted?: number
45
45
  featureId?: string
46
+ /** Notion page ID for bidirectional sync */
47
+ notionPageId?: string
48
+ /** Last sync timestamp for conflict resolution */
49
+ lastSyncedAt?: string
46
50
  }
47
51
 
48
52
  export interface ShipChange {
@@ -113,6 +117,10 @@ export interface Idea {
113
117
  risks?: string[]
114
118
  risksCount?: number
115
119
  createdAt?: string
120
+ /** Notion page ID for bidirectional sync */
121
+ notionPageId?: string
122
+ /** Last sync timestamp for conflict resolution */
123
+ lastSyncedAt?: string
116
124
  }
117
125
 
118
126
  export interface ImpactEffort {
@@ -11,6 +11,10 @@ export interface Task {
11
11
  completedAt?: string
12
12
  duration?: string
13
13
  metadata?: TaskMetadata
14
+ /** Notion page ID for bidirectional sync */
15
+ notionPageId?: string
16
+ /** Last sync timestamp for conflict resolution */
17
+ lastSyncedAt?: string
14
18
  }
15
19
 
16
20
  export interface TaskMetadata {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prjct-cli",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "Built for Claude - Ship fast, track progress, stay focused. Developer momentum tool for indie hackers.",
5
5
  "main": "core/index.ts",
6
6
  "bin": {
@@ -0,0 +1,116 @@
1
+ ---
2
+ allowed-tools: [Read, Bash]
3
+ description: 'Push prjct data to Notion databases'
4
+ timestamp-rule: 'GetTimestamp() for all timestamps'
5
+ ---
6
+
7
+ # /p:notion push - Push Data to Notion
8
+
9
+ ## Purpose
10
+
11
+ Manually sync all prjct data to Notion databases:
12
+ - Shipped features → Shipped Features database
13
+ - Ideas → Ideas database
14
+ - Queue tasks → Active Tasks database (optional)
15
+
16
+ ## Prerequisites
17
+
18
+ 1. Notion integration configured (`/p:notion setup`)
19
+ 2. Token stored in `~/.prjct-cli/config/notion.json`
20
+
21
+ ## Flow
22
+
23
+ ### Step 1: Check Config
24
+
25
+ READ: `.prjct/prjct.config.json` → extract `projectId`
26
+ READ: `~/.prjct-cli/projects/{projectId}/project.json`
27
+
28
+ IF `integrations.notion.enabled !== true`:
29
+ OUTPUT: "Notion not configured. Run /p:notion setup first."
30
+ STOP
31
+
32
+ ### Step 2: Load Token
33
+
34
+ READ: `~/.prjct-cli/config/notion.json` → extract `token`
35
+
36
+ IF no token:
37
+ OUTPUT: "Notion token not found. Run /p:notion setup first."
38
+ STOP
39
+
40
+ ### Step 3: Load Data
41
+
42
+ READ: `~/.prjct-cli/projects/{projectId}/storage/shipped.json`
43
+ READ: `~/.prjct-cli/projects/{projectId}/storage/ideas.json`
44
+
45
+ ### Step 4: Push to Notion
46
+
47
+ For each shipped feature:
48
+ ```bash
49
+ curl -X POST "https://api.notion.com/v1/pages" \
50
+ -H "Authorization: Bearer {token}" \
51
+ -H "Notion-Version: 2022-06-28" \
52
+ -H "Content-Type: application/json" \
53
+ -d '{
54
+ "parent": { "database_id": "{databases.shipped}" },
55
+ "properties": {
56
+ "Name": { "title": [{ "text": { "content": "{feature.name}" } }] },
57
+ "Project": { "select": { "name": "{projectName}" } },
58
+ "Type": { "select": { "name": "{feature.type}" } },
59
+ "Version": { "rich_text": [{ "text": { "content": "{feature.version}" } }] },
60
+ "Shipped Date": { "date": { "start": "{feature.shippedAt}" } }
61
+ }
62
+ }'
63
+ ```
64
+
65
+ For each idea:
66
+ ```bash
67
+ curl -X POST "https://api.notion.com/v1/pages" \
68
+ -H "Authorization: Bearer {token}" \
69
+ -H "Notion-Version: 2022-06-28" \
70
+ -H "Content-Type: application/json" \
71
+ -d '{
72
+ "parent": { "database_id": "{databases.ideas}" },
73
+ "properties": {
74
+ "Name": { "title": [{ "text": { "content": "{idea.text}" } }] },
75
+ "Project": { "select": { "name": "{projectName}" } },
76
+ "Status": { "select": { "name": "{idea.status}" } },
77
+ "Created": { "date": { "start": "{idea.addedAt}" } }
78
+ }
79
+ }'
80
+ ```
81
+
82
+ ### Step 5: Report Results
83
+
84
+ ```
85
+ ✅ Notion Push Complete
86
+
87
+ Shipped Features: {shipped.count} synced
88
+ Ideas: {ideas.count} synced
89
+
90
+ View in Notion: {dashboardUrl}
91
+ ```
92
+
93
+ ## Response (Success)
94
+
95
+ ```
96
+ ✅ Notion Push Complete
97
+
98
+ 📦 Shipped: 11 features synced
99
+ 💡 Ideas: 1 idea synced
100
+
101
+ Dashboard: https://notion.so/...
102
+ ```
103
+
104
+ ## Response (No Data)
105
+
106
+ ```
107
+ Nothing to push. Add features or ideas first.
108
+ ```
109
+
110
+ ## Error Handling
111
+
112
+ | Error | Response |
113
+ |-------|----------|
114
+ | Not configured | "Run /p:notion setup first" |
115
+ | Token invalid | "Token expired. Run /p:notion setup again" |
116
+ | API error | Show error, suggest retry |
@@ -76,7 +76,7 @@ Ensure the token starts with "ntn_" or "secret_".
76
76
  ```
77
77
  Now share a Notion page with your integration:
78
78
 
79
- 1. Open a Notion page where you want the databases
79
+ 1. Create a new page in Notion named "prjct: {projectName}"
80
80
  2. Click "..." → "Connect to" → select "prjct-cli"
81
81
  3. Copy the page URL or ID
82
82
 
@@ -87,18 +87,25 @@ Parse page ID from URL:
87
87
  - `https://notion.so/workspace/Page-Title-abc123...` → `abc123...`
88
88
  - Just the 32-character ID is also valid
89
89
 
90
- ### Step 5: Create Databases
90
+ ### Step 5: Create Project Databases
91
91
 
92
- Create 4 databases under the parent page:
92
+ Create 4 databases + 1 dashboard under the parent page.
93
+ Each project gets its own set of databases (not shared).
93
94
 
94
- | Database | Description |
95
- |----------|-------------|
96
- | prjct: Shipped Features | Track shipped features with metrics |
97
- | prjct: Roadmap | Feature planning and progress |
98
- | prjct: Ideas | Captured ideas and status |
99
- | prjct: Active Tasks | Current task queue |
95
+ | Created | Name | Description |
96
+ |---------|------|-------------|
97
+ | Dashboard | {projectName}: Dashboard | Metrics + links to all DBs |
98
+ | Database | {projectName}: Shipped Features | Track shipped features with metrics |
99
+ | Database | {projectName}: Roadmap | Feature planning and progress |
100
+ | Database | {projectName}: Ideas | Captured ideas and status |
101
+ | Database | {projectName}: Active Tasks | Current task queue |
100
102
 
101
- Each database has project-specific columns for multi-project support.
103
+ The dashboard shows:
104
+ - Total features shipped
105
+ - Ideas pendientes
106
+ - Tareas activas
107
+ - Progreso del roadmap
108
+ - Links a cada database
102
109
 
103
110
  ### Step 6: Save Config
104
111
 
@@ -150,17 +157,18 @@ Or create `~/.prjct-cli/config/notion.json`:
150
157
  ✅ Notion Connected
151
158
 
152
159
  Workspace: {workspaceName}
153
- Created: 4 databases
160
+ Project: {projectName}
161
+ Created: 1 dashboard + 4 databases
154
162
 
155
- Databases:
156
- prjct: Shipped Features
157
- prjct: Roadmap
158
- prjct: Ideas
159
- prjct: Active Tasks
163
+ • {projectName}: Dashboard (with metrics)
164
+ {projectName}: Shipped Features
165
+ {projectName}: Roadmap
166
+ {projectName}: Ideas
167
+ {projectName}: Active Tasks
160
168
 
161
169
  Auto-sync enabled:
162
- • On /p:ship → Shipped Features
163
- • On /p:idea → Ideas
170
+ • On /p:ship → Shipped Features + Dashboard metrics
171
+ • On /p:idea → Ideas + Dashboard metrics
164
172
 
165
173
  To sync all existing data: /p:notion sync
166
174
  ```
@@ -0,0 +1,290 @@
1
+ ---
2
+ allowed-tools: [Read, Bash, Write]
3
+ description: 'Bidirectional sync between prjct and Notion'
4
+ timestamp-rule: 'GetTimestamp() for all timestamps'
5
+ ---
6
+
7
+ # /p:notion sync - Bidirectional Sync
8
+
9
+ ## Purpose
10
+
11
+ Full bidirectional sync between prjct and Notion:
12
+ - **Pull**: Get new items created in Notion → prjct
13
+ - **Push**: Sync local items → Notion
14
+ - **Merge**: Update existing items with latest data
15
+
16
+ Uses "last edit wins" conflict resolution via `lastSyncedAt` timestamps.
17
+
18
+ ## Prerequisites
19
+
20
+ 1. Notion integration configured (`/p:notion setup`)
21
+ 2. Token stored in `~/.prjct-cli/config/notion.json`
22
+ 3. Databases have `prjctId` and `Last Updated` columns
23
+
24
+ ## Context Variables
25
+
26
+ - `{projectId}`: From `.prjct/prjct.config.json`
27
+ - `{globalPath}`: `~/.prjct-cli/projects/{projectId}`
28
+ - `{token}`: From `~/.prjct-cli/config/notion.json`
29
+
30
+ ## Flow
31
+
32
+ ### Step 1: Check Configuration
33
+
34
+ READ: `.prjct/prjct.config.json` → extract `projectId`
35
+ READ: `{globalPath}/project.json`
36
+
37
+ IF `integrations.notion.enabled !== true`:
38
+ OUTPUT: "Notion not configured. Run /p:notion setup first."
39
+ STOP
40
+
41
+ ### Step 2: Load Token
42
+
43
+ READ: `~/.prjct-cli/config/notion.json` → extract `token`
44
+
45
+ IF no token:
46
+ OUTPUT: "Notion token not found. Run /p:notion setup first."
47
+ STOP
48
+
49
+ ### Step 3: Load Local Data
50
+
51
+ READ: `{globalPath}/storage/shipped.json`
52
+ READ: `{globalPath}/storage/ideas.json`
53
+
54
+ SET: {localShipped} = shipped.shipped array
55
+ SET: {localIdeas} = ideas.ideas array
56
+
57
+ ### Step 4: Initialize Notion Client
58
+
59
+ ```typescript
60
+ import { notionClient } from '../core/integrations/notion/client'
61
+ import { bidirectionalSync } from '../core/integrations/notion/sync'
62
+
63
+ notionClient.initialize(config.integrations.notion, token)
64
+ ```
65
+
66
+ ### Step 5: Execute Bidirectional Sync
67
+
68
+ ```typescript
69
+ const results = await bidirectionalSync(
70
+ projectId,
71
+ config.integrations.notion,
72
+ {
73
+ shipped: localShipped,
74
+ ideas: localIdeas
75
+ }
76
+ )
77
+ ```
78
+
79
+ ### Step 6: Merge Pull Results with Local Data
80
+
81
+ For shipped features:
82
+ ```typescript
83
+ // Merge pulled items with local
84
+ const mergedShipped = [...localShipped]
85
+
86
+ for (const pulled of results.pulled.shipped.items) {
87
+ const existingIndex = mergedShipped.findIndex(
88
+ f => f.id === pulled.id || f.notionPageId === pulled.notionPageId
89
+ )
90
+
91
+ if (existingIndex >= 0) {
92
+ // Update existing
93
+ mergedShipped[existingIndex] = pulled
94
+ } else {
95
+ // Add new
96
+ mergedShipped.push(pulled)
97
+ }
98
+ }
99
+ ```
100
+
101
+ Same logic for ideas.
102
+
103
+ ### Step 7: Write Updated Data
104
+
105
+ WRITE: `{globalPath}/storage/shipped.json`
106
+ ```json
107
+ {
108
+ "shipped": {mergedShipped},
109
+ "lastUpdated": "{GetTimestamp()}"
110
+ }
111
+ ```
112
+
113
+ WRITE: `{globalPath}/storage/ideas.json`
114
+ ```json
115
+ {
116
+ "ideas": {mergedIdeas},
117
+ "lastUpdated": "{GetTimestamp()}"
118
+ }
119
+ ```
120
+
121
+ ### Step 8: Regenerate Context
122
+
123
+ After updating storage, regenerate markdown context:
124
+
125
+ WRITE: `{globalPath}/context/shipped.md` (from shipped.json)
126
+ WRITE: `{globalPath}/context/ideas.md` (from ideas.json)
127
+
128
+ ### Step 9: Log to Memory
129
+
130
+ APPEND to: `{globalPath}/memory/events.jsonl`
131
+ ```json
132
+ {"timestamp":"{GetTimestamp()}","action":"notion_sync","pulled":{"shipped":{shippedPulled},"ideas":{ideasPulled}},"pushed":{"shipped":{shippedPushed},"ideas":{ideasPushed}}}
133
+ ```
134
+
135
+ ## Response (Success)
136
+
137
+ ```
138
+ Notion Sync Complete
139
+
140
+ Pulled from Notion:
141
+ - Shipped: {newShipped} new, {updatedShipped} updated
142
+ - Ideas: {newIdeas} new, {updatedIdeas} updated
143
+
144
+ Pushed to Notion:
145
+ - Shipped: {pushedShipped} features
146
+ - Ideas: {pushedIdeas} ideas
147
+
148
+ Local storage updated.
149
+ ```
150
+
151
+ ## Response (No Changes)
152
+
153
+ ```
154
+ Notion Sync Complete
155
+
156
+ Already in sync - no changes detected.
157
+ ```
158
+
159
+ ## Response (Errors)
160
+
161
+ ```
162
+ Notion Sync Complete (with warnings)
163
+
164
+ Pulled: 5 shipped, 2 ideas
165
+ Pushed: 3 features
166
+
167
+ Errors:
168
+ - {error1}
169
+ - {error2}
170
+ ```
171
+
172
+ ## Database Field Mapping
173
+
174
+ ### Shipped Features
175
+
176
+ | prjct Field | Notion Property | Type |
177
+ |-------------|-----------------|------|
178
+ | id | prjctId | rich_text |
179
+ | name | Name | title |
180
+ | version | Version | rich_text |
181
+ | shippedAt | Shipped Date | date |
182
+ | type | Type | select |
183
+ | description | Description | rich_text |
184
+ | duration | Duration | rich_text |
185
+ | codeMetrics.linesAdded | Lines Added | number |
186
+ | codeMetrics.linesRemoved | Lines Removed | number |
187
+ | codeMetrics.filesChanged | Files Changed | number |
188
+ | commit.hash | Commit | rich_text |
189
+ | (calculated) | Impact | select |
190
+ | (auto) | Last Updated | date |
191
+
192
+ ### Ideas
193
+
194
+ | prjct Field | Notion Property | Type |
195
+ |-------------|-----------------|------|
196
+ | id | prjctId | rich_text |
197
+ | text | Idea | title |
198
+ | status | Status | status |
199
+ | priority | Priority | select |
200
+ | tags | Tags | multi_select |
201
+ | addedAt | Created | date |
202
+ | details | Details | rich_text |
203
+ | impactEffort.impact | Impact | select |
204
+ | impactEffort.effort | Effort | select |
205
+ | convertedTo | Converted To | rich_text |
206
+ | (auto) | Last Updated | date |
207
+
208
+ ## Conflict Resolution
209
+
210
+ **Strategy: Last Edit Wins**
211
+
212
+ When the same item exists in both Notion and prjct:
213
+ 1. Compare `lastSyncedAt` (prjct) with `Last Updated` (Notion)
214
+ 2. Most recent timestamp wins
215
+ 3. Update both sides to match
216
+
217
+ **Matching Logic:**
218
+ 1. First match by `prjctId` ↔ `id`
219
+ 2. If no prjctId, match by `notionPageId` ↔ Notion page ID
220
+ 3. If neither, treat as new item
221
+
222
+ ## Error Handling
223
+
224
+ | Error | Response | Action |
225
+ |-------|----------|--------|
226
+ | Not configured | "Run /p:notion setup first" | STOP |
227
+ | Token invalid | "Token expired. Re-run setup" | STOP |
228
+ | API rate limit | "Rate limited. Retry in 1 min" | WARN |
229
+ | Parse error | Log warning | CONTINUE |
230
+ | Network error | "Connection failed" | STOP |
231
+
232
+ ## Examples
233
+
234
+ ### Example 1: First Sync (Push Only)
235
+ ```
236
+ /p:notion sync
237
+
238
+ Output:
239
+ Notion Sync Complete
240
+
241
+ Pulled from Notion:
242
+ - Shipped: 0 new, 0 updated
243
+ - Ideas: 0 new, 0 updated
244
+
245
+ Pushed to Notion:
246
+ - Shipped: 11 features
247
+ - Ideas: 3 ideas
248
+
249
+ Local storage updated.
250
+ ```
251
+
252
+ ### Example 2: New Ideas from Notion
253
+ ```
254
+ /p:notion sync
255
+
256
+ Output:
257
+ Notion Sync Complete
258
+
259
+ Pulled from Notion:
260
+ - Shipped: 0 new, 0 updated
261
+ - Ideas: 2 new, 0 updated
262
+
263
+ Pushed to Notion:
264
+ - Shipped: 0 features
265
+ - Ideas: 0 ideas
266
+
267
+ Local storage updated.
268
+
269
+ New ideas added:
270
+ - "Add dark mode toggle"
271
+ - "Improve error messages"
272
+ ```
273
+
274
+ ### Example 3: Bidirectional Changes
275
+ ```
276
+ /p:notion sync
277
+
278
+ Output:
279
+ Notion Sync Complete
280
+
281
+ Pulled from Notion:
282
+ - Shipped: 1 new, 2 updated
283
+ - Ideas: 3 new, 1 updated
284
+
285
+ Pushed to Notion:
286
+ - Shipped: 2 features
287
+ - Ideas: 1 idea
288
+
289
+ Local storage updated.
290
+ ```