prjct-cli 0.28.0 → 0.28.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +64 -12
  2. package/CLAUDE.md +48 -22
  3. package/core/agentic/agent-router.ts +15 -0
  4. package/core/agentic/command-executor.ts +53 -5
  5. package/core/agentic/prompt-builder.ts +83 -2
  6. package/core/agentic/template-loader.ts +107 -32
  7. package/core/commands/command-data.ts +0 -33
  8. package/core/commands/commands.ts +4 -12
  9. package/core/commands/registry.ts +0 -37
  10. package/core/domain/agent-loader.ts +7 -9
  11. package/core/domain/context-estimator.ts +15 -15
  12. package/core/index.ts +0 -2
  13. package/core/infrastructure/config-manager.ts +25 -4
  14. package/core/infrastructure/setup.ts +0 -99
  15. package/core/session/session-log-manager.ts +17 -0
  16. package/core/types/config.ts +1 -1
  17. package/core/types/index.ts +0 -2
  18. package/core/types/integrations.ts +2 -47
  19. package/core/types/storage.ts +0 -8
  20. package/core/types/task.ts +0 -4
  21. package/dist/bin/prjct.mjs +341 -316
  22. package/package.json +1 -1
  23. package/templates/agentic/subagent-generation.md +14 -1
  24. package/templates/commands/cleanup.md +15 -74
  25. package/templates/commands/init.md +1 -44
  26. package/templates/commands/ship.md +92 -12
  27. package/templates/commands/sync.md +25 -10
  28. package/templates/commands/task.md +41 -0
  29. package/templates/global/CLAUDE.md +196 -25
  30. package/templates/mcp-config.json +0 -28
  31. package/core/integrations/notion/client.ts +0 -413
  32. package/core/integrations/notion/index.ts +0 -46
  33. package/core/integrations/notion/setup.ts +0 -235
  34. package/core/integrations/notion/sync.ts +0 -818
  35. package/core/integrations/notion/templates.ts +0 -246
  36. package/core/plugin/builtin/notion.ts +0 -178
  37. package/templates/commands/feature.md +0 -46
  38. package/templates/commands/now.md +0 -53
  39. package/templates/hooks/prjct-session-start.sh +0 -50
  40. package/templates/skills/notion-push.md +0 -116
  41. package/templates/skills/notion-setup.md +0 -199
  42. package/templates/skills/notion-sync.md +0 -290
  43. package/templates/skills/prjct-done/SKILL.md +0 -97
  44. package/templates/skills/prjct-ship/SKILL.md +0 -150
  45. package/templates/skills/prjct-sync/SKILL.md +0 -108
  46. package/templates/skills/prjct-task/SKILL.md +0 -101
@@ -1,818 +0,0 @@
1
- /**
2
- * Notion Sync Logic
3
- * Sync prjct data to Notion databases.
4
- */
5
-
6
- import type { ShippedFeature, Idea } from '../../types/storage'
7
- import type { NotionIntegrationConfig } from '../../types/integrations'
8
- import { notionClient } from './client'
9
- import { getDashboardContent, type DashboardMetrics } from './templates'
10
-
11
- // =============================================================================
12
- // Types
13
- // =============================================================================
14
-
15
- export interface SyncResult {
16
- success: boolean
17
- action: 'created' | 'updated' | 'skipped'
18
- pageId?: string
19
- url?: string
20
- error?: string
21
- }
22
-
23
- // =============================================================================
24
- // Property Builders
25
- // =============================================================================
26
-
27
- /**
28
- * Build Notion properties for a shipped feature
29
- * Includes ALL fields for comprehensive sync
30
- */
31
- function buildShippedProperties(
32
- feature: ShippedFeature,
33
- _projectId: string
34
- ): Record<string, unknown> {
35
- const now = new Date().toISOString()
36
-
37
- // Note: Project field removed - each project now has its own database
38
- const props: Record<string, unknown> = {
39
- Name: {
40
- title: [{ text: { content: feature.name } }],
41
- },
42
- // Sync tracking fields
43
- prjctId: {
44
- rich_text: [{ text: { content: feature.id } }],
45
- },
46
- 'Last Updated': {
47
- date: { start: now.split('T')[0] },
48
- },
49
- }
50
-
51
- if (feature.version) {
52
- props.Version = {
53
- rich_text: [{ text: { content: feature.version } }],
54
- }
55
- }
56
-
57
- if (feature.type) {
58
- props.Type = {
59
- select: { name: feature.type },
60
- }
61
- }
62
-
63
- if (feature.shippedAt) {
64
- props['Shipped Date'] = {
65
- date: { start: feature.shippedAt.split('T')[0] },
66
- }
67
- }
68
-
69
- // Full code metrics
70
- if (feature.codeMetrics) {
71
- const metrics = feature.codeMetrics
72
- props['Lines Added'] = {
73
- number: metrics.linesAdded || 0,
74
- }
75
- props['Lines Removed'] = {
76
- number: metrics.linesRemoved || 0,
77
- }
78
- props['Files Changed'] = {
79
- number: metrics.filesChanged || 0,
80
- }
81
- }
82
-
83
- // Commit info
84
- if (feature.commit && typeof feature.commit === 'object') {
85
- props.Commit = {
86
- rich_text: [{ text: { content: feature.commit.hash || '' } }],
87
- }
88
- }
89
-
90
- if (feature.duration) {
91
- props.Duration = {
92
- rich_text: [{ text: { content: feature.duration } }],
93
- }
94
- }
95
-
96
- if (feature.description) {
97
- props.Description = {
98
- rich_text: [{ text: { content: feature.description.slice(0, 2000) } }],
99
- }
100
- }
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
-
109
- return props
110
- }
111
-
112
- /**
113
- * Build Notion properties for an idea
114
- * Includes ALL fields for comprehensive sync
115
- */
116
- function buildIdeaProperties(
117
- idea: Idea,
118
- _projectId: string
119
- ): Record<string, unknown> {
120
- const now = new Date().toISOString()
121
-
122
- // Note: Project field removed - each project now has its own database
123
- const props: Record<string, unknown> = {
124
- Idea: {
125
- title: [{ text: { content: idea.text } }],
126
- },
127
- // Sync tracking fields
128
- prjctId: {
129
- rich_text: [{ text: { content: idea.id } }],
130
- },
131
- 'Last Updated': {
132
- date: { start: now.split('T')[0] },
133
- },
134
- }
135
-
136
- if (idea.status) {
137
- const statusMap: Record<string, string> = {
138
- pending: 'Pending',
139
- converted: 'Converted',
140
- completed: 'Converted',
141
- archived: 'Archived',
142
- }
143
- props.Status = {
144
- status: { name: statusMap[idea.status] || 'Pending' },
145
- }
146
- }
147
-
148
- if (idea.priority) {
149
- props.Priority = {
150
- select: { name: idea.priority },
151
- }
152
- }
153
-
154
- if (idea.tags && idea.tags.length > 0) {
155
- props.Tags = {
156
- multi_select: idea.tags.map((tag) => ({ name: tag })),
157
- }
158
- }
159
-
160
- const createdDate = idea.createdAt || idea.addedAt
161
- if (createdDate) {
162
- props.Created = {
163
- date: { start: createdDate.split('T')[0] },
164
- }
165
- }
166
-
167
- if (idea.convertedTo) {
168
- props['Converted To'] = {
169
- rich_text: [{ text: { content: idea.convertedTo } }],
170
- }
171
- }
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
-
218
- return props
219
- }
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
-
353
- // =============================================================================
354
- // Sync Functions
355
- // =============================================================================
356
-
357
- /**
358
- * Sync a shipped feature to Notion
359
- */
360
- export async function syncShippedFeature(
361
- projectId: string,
362
- feature: ShippedFeature,
363
- config: NotionIntegrationConfig
364
- ): Promise<SyncResult> {
365
- if (!config.enabled || !config.databases.shipped) {
366
- return { success: false, action: 'skipped', error: 'Notion not configured' }
367
- }
368
-
369
- if (!notionClient.isReady()) {
370
- return { success: false, action: 'skipped', error: 'Notion client not ready' }
371
- }
372
-
373
- try {
374
- const databaseId = config.databases.shipped
375
- const properties = buildShippedProperties(feature, projectId)
376
-
377
- // Check if already exists (upsert)
378
- const existingPageId = await notionClient.findPageByProjectAndName(
379
- databaseId,
380
- projectId,
381
- feature.name
382
- )
383
-
384
- if (existingPageId) {
385
- const page = await notionClient.updatePage(existingPageId, properties)
386
- if (page) {
387
- return {
388
- success: true,
389
- action: 'updated',
390
- pageId: page.id,
391
- url: page.url,
392
- }
393
- }
394
- } else {
395
- const page = await notionClient.createPage(databaseId, properties)
396
- if (page) {
397
- return {
398
- success: true,
399
- action: 'created',
400
- pageId: page.id,
401
- url: page.url,
402
- }
403
- }
404
- }
405
-
406
- return { success: false, action: 'skipped', error: 'Failed to sync' }
407
- } catch (error) {
408
- return {
409
- success: false,
410
- action: 'skipped',
411
- error: (error as Error).message,
412
- }
413
- }
414
- }
415
-
416
- /**
417
- * Sync an idea to Notion
418
- */
419
- export async function syncIdea(
420
- projectId: string,
421
- idea: Idea,
422
- config: NotionIntegrationConfig
423
- ): Promise<SyncResult> {
424
- if (!config.enabled || !config.databases.ideas) {
425
- return { success: false, action: 'skipped', error: 'Notion not configured' }
426
- }
427
-
428
- if (!notionClient.isReady()) {
429
- return { success: false, action: 'skipped', error: 'Notion client not ready' }
430
- }
431
-
432
- try {
433
- const databaseId = config.databases.ideas
434
- const properties = buildIdeaProperties(idea, projectId)
435
-
436
- // Check if already exists (by ID in text)
437
- const existingPageId = await notionClient.findPageByProjectAndName(
438
- databaseId,
439
- projectId,
440
- idea.text
441
- )
442
-
443
- if (existingPageId) {
444
- const page = await notionClient.updatePage(existingPageId, properties)
445
- if (page) {
446
- return {
447
- success: true,
448
- action: 'updated',
449
- pageId: page.id,
450
- url: page.url,
451
- }
452
- }
453
- } else {
454
- const page = await notionClient.createPage(databaseId, properties)
455
- if (page) {
456
- return {
457
- success: true,
458
- action: 'created',
459
- pageId: page.id,
460
- url: page.url,
461
- }
462
- }
463
- }
464
-
465
- return { success: false, action: 'skipped', error: 'Failed to sync' }
466
- } catch (error) {
467
- return {
468
- success: false,
469
- action: 'skipped',
470
- error: (error as Error).message,
471
- }
472
- }
473
- }
474
-
475
- /**
476
- * Full sync - sync all data to Notion
477
- * Used for initial setup or manual resync
478
- */
479
- export async function fullSync(
480
- projectId: string,
481
- config: NotionIntegrationConfig,
482
- data: {
483
- shipped?: ShippedFeature[]
484
- ideas?: Idea[]
485
- }
486
- ): Promise<{
487
- shipped: { synced: number; failed: number }
488
- ideas: { synced: number; failed: number }
489
- }> {
490
- const results = {
491
- shipped: { synced: 0, failed: 0 },
492
- ideas: { synced: 0, failed: 0 },
493
- }
494
-
495
- // Sync shipped features
496
- if (data.shipped && config.databases.shipped) {
497
- for (const feature of data.shipped) {
498
- const result = await syncShippedFeature(projectId, feature, config)
499
- if (result.success) {
500
- results.shipped.synced++
501
- } else {
502
- results.shipped.failed++
503
- }
504
- }
505
- }
506
-
507
- // Sync ideas
508
- if (data.ideas && config.databases.ideas) {
509
- for (const idea of data.ideas) {
510
- const result = await syncIdea(projectId, idea, config)
511
- if (result.success) {
512
- results.ideas.synced++
513
- } else {
514
- results.ideas.failed++
515
- }
516
- }
517
- }
518
-
519
- return results
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
- }