@umituz/web-dashboard 3.2.2 → 4.0.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.
Files changed (125) hide show
  1. package/README.md +202 -14
  2. package/package.json +21 -29
  3. package/src/domains/analytics/components/AnalyticsCard.tsx +23 -21
  4. package/src/domains/analytics/components/AnalyticsChart.tsx +345 -149
  5. package/src/domains/analytics/components/AnalyticsLayout.tsx +93 -112
  6. package/src/domains/analytics/components/MetricCard.tsx +42 -64
  7. package/src/domains/analytics/hooks/index.ts +7 -1
  8. package/src/domains/analytics/hooks/useAnalytics.ts +144 -139
  9. package/src/domains/analytics/index.ts +11 -0
  10. package/src/domains/analytics/services/AnalyticsEngineService.ts +103 -55
  11. package/src/domains/analytics/services/PerformanceService.ts +117 -69
  12. package/src/domains/analytics/types/analytics.ts +20 -6
  13. package/src/domains/analytics/types/index.ts +3 -0
  14. package/src/domains/analytics/utils/analytics.test.ts +183 -0
  15. package/src/domains/analytics/utils/analytics.ts +26 -9
  16. package/src/domains/analytics/utils/i18nKeys.ts +40 -0
  17. package/src/domains/analytics/utils/index.ts +2 -0
  18. package/src/domains/auth/components/AuthLayout.tsx +63 -54
  19. package/src/domains/auth/components/ForgotPasswordForm.tsx +96 -98
  20. package/src/domains/auth/components/LoginForm.tsx +167 -226
  21. package/src/domains/auth/components/RegisterForm.tsx +202 -272
  22. package/src/domains/auth/components/ResetPasswordForm.tsx +105 -151
  23. package/src/domains/auth/components/shared/AuthErrorBanner.tsx +35 -0
  24. package/src/domains/auth/components/shared/EmailInput.tsx +91 -0
  25. package/src/domains/auth/components/shared/PasswordInput.tsx +119 -0
  26. package/src/domains/auth/components/shared/PasswordStrengthIndicator.tsx +75 -0
  27. package/src/domains/auth/components/shared/SocialLoginButtons.tsx +58 -0
  28. package/src/domains/auth/components/shared/SocialProviderIcons.tsx +48 -0
  29. package/src/domains/auth/components/shared/index.ts +20 -0
  30. package/src/domains/auth/hooks/index.ts +1 -0
  31. package/src/domains/auth/hooks/useAuth.ts +151 -232
  32. package/src/domains/auth/index.ts +8 -4
  33. package/src/domains/auth/types/auth.ts +26 -5
  34. package/src/domains/auth/types/index.ts +1 -0
  35. package/src/domains/auth/utils/auth.test.ts +182 -0
  36. package/src/domains/auth/utils/auth.ts +71 -130
  37. package/src/domains/auth/utils/i18nKeys.ts +127 -0
  38. package/src/domains/auth/utils/index.ts +7 -1
  39. package/src/domains/auth/utils/secureToken.ts +33 -0
  40. package/src/domains/auth/utils/validationKeys.ts +23 -0
  41. package/src/domains/calendar/hooks/useCalendar.ts +145 -151
  42. package/src/domains/calendar/index.ts +0 -1
  43. package/src/domains/calendar/services/CalendarService.ts +122 -179
  44. package/src/domains/calendar/utils/contentItemMapper.test.ts +108 -0
  45. package/src/domains/calendar/utils/contentItemMapper.ts +99 -0
  46. package/src/domains/calendar/utils/index.ts +33 -30
  47. package/src/domains/layouts/components/BrandLogo.tsx +12 -9
  48. package/src/domains/layouts/components/DashboardHeader.tsx +78 -80
  49. package/src/domains/layouts/components/DashboardLayout.tsx +45 -21
  50. package/src/domains/layouts/components/DashboardSidebar.tsx +48 -51
  51. package/src/domains/layouts/types/layout.ts +7 -1
  52. package/src/domains/layouts/types/user.ts +3 -1
  53. package/src/domains/modules/build.test.tsx +97 -0
  54. package/src/domains/modules/build.tsx +71 -0
  55. package/src/domains/modules/index.ts +2 -0
  56. package/src/domains/modules/types.ts +29 -0
  57. package/src/domains/onboarding/components/AppFocusStep.tsx +99 -68
  58. package/src/domains/onboarding/components/OnboardingWizard.tsx +109 -183
  59. package/src/domains/onboarding/components/PlanStep.tsx +156 -123
  60. package/src/domains/onboarding/components/PlatformsStep.tsx +74 -54
  61. package/src/domains/onboarding/components/StepNavigation.tsx +108 -0
  62. package/src/domains/onboarding/components/StepProgress.tsx +62 -0
  63. package/src/domains/onboarding/components/UserTypeStep.tsx +59 -80
  64. package/src/domains/onboarding/components/index.ts +4 -0
  65. package/src/domains/onboarding/hooks/useOnboarding.ts +76 -66
  66. package/src/domains/onboarding/index.ts +1 -0
  67. package/src/domains/onboarding/types/onboarding.ts +57 -17
  68. package/src/domains/onboarding/utils/i18nKeys.ts +72 -0
  69. package/src/domains/onboarding/utils/index.ts +2 -0
  70. package/src/domains/permissions/PermissionsContext.tsx +103 -0
  71. package/src/domains/permissions/hasPermission.test.ts +50 -0
  72. package/src/domains/permissions/hasPermission.ts +38 -0
  73. package/src/domains/permissions/index.ts +19 -0
  74. package/src/domains/permissions/types.ts +35 -0
  75. package/src/domains/permissions/utils/i18nKeys.ts +11 -0
  76. package/src/domains/session/DemoLogin.tsx +50 -0
  77. package/src/domains/session/OrgSwitcher.tsx +110 -0
  78. package/src/domains/session/SessionContext.tsx +150 -0
  79. package/src/domains/session/inMemoryAuthAdapter.ts +53 -0
  80. package/src/domains/session/index.ts +18 -0
  81. package/src/domains/session/types.ts +47 -0
  82. package/src/domains/session/utils/i18nKeys.ts +12 -0
  83. package/src/domains/settings/components/AuditLogList.tsx +59 -0
  84. package/src/domains/settings/components/PermissionMatrixScreen.tsx +80 -0
  85. package/src/domains/settings/components/SettingsLayout.tsx +75 -60
  86. package/src/domains/settings/components/SettingsSection.tsx +35 -22
  87. package/src/domains/settings/components/UserManagementTable.tsx +87 -0
  88. package/src/domains/settings/components/index.ts +10 -0
  89. package/src/domains/settings/hooks/useSettings.ts +77 -52
  90. package/src/domains/settings/types/settings.ts +2 -0
  91. package/src/domains/settings/utils/i18nKeys.ts +18 -0
  92. package/src/domains/settings/utils/settings.ts +11 -4
  93. package/src/i18n/I18nContext.tsx +33 -0
  94. package/src/i18n/defaultT.test.ts +43 -0
  95. package/src/i18n/defaultT.ts +34 -0
  96. package/src/i18n/index.ts +7 -0
  97. package/src/i18n/types.ts +13 -0
  98. package/src/index.ts +1 -7
  99. package/src/primitives/Button.tsx +30 -0
  100. package/src/primitives/Checkbox.tsx +40 -0
  101. package/src/primitives/Input.tsx +19 -0
  102. package/src/primitives/Skeleton.tsx +26 -0
  103. package/src/primitives/cn.ts +15 -0
  104. package/src/primitives/index.ts +23 -0
  105. package/src/styles/dashboard.css +3045 -0
  106. package/src/styles/tokens.contract.test.ts +66 -0
  107. package/src/styles/tokens.css +124 -0
  108. package/src/domains/billing/components/BillingLayout.tsx +0 -63
  109. package/src/domains/billing/components/BillingPage.tsx +0 -49
  110. package/src/domains/billing/components/BillingPortal.tsx +0 -198
  111. package/src/domains/billing/components/InvoiceCard.tsx +0 -143
  112. package/src/domains/billing/components/PaymentMethodsList.tsx +0 -116
  113. package/src/domains/billing/components/PlanComparison.tsx +0 -199
  114. package/src/domains/billing/components/UsageCard.tsx +0 -103
  115. package/src/domains/billing/components/index.ts +0 -13
  116. package/src/domains/billing/constants/billing.ts +0 -69
  117. package/src/domains/billing/hooks/index.ts +0 -7
  118. package/src/domains/billing/hooks/useBilling.ts +0 -385
  119. package/src/domains/billing/index.ts +0 -73
  120. package/src/domains/billing/types/billing.ts +0 -342
  121. package/src/domains/billing/types/index.ts +0 -30
  122. package/src/domains/billing/utils/billing.ts +0 -360
  123. package/src/domains/billing/utils/index.ts +0 -31
  124. package/src/domains/billing/utils/polar.ts +0 -63
  125. package/src/domains/calendar/components/index.ts +0 -7
@@ -1,7 +1,11 @@
1
1
  /**
2
2
  * Calendar Service
3
3
  *
4
- * Firebase-based calendar service for managing content items
4
+ * Camp-agnostic calendar service for managing content items.
5
+ * Persistence is injected via the `ICalendarDatabase` seam — the
6
+ * package ships no Firebase (or any other) backend. Use
7
+ * `createInMemoryCalendarDatabase()` for demos and tests, or adapt
8
+ * your own backend to the interface.
5
9
  */
6
10
 
7
11
  import type {
@@ -16,7 +20,7 @@ import type {
16
20
  * Database interface for calendar operations
17
21
  * Implementations can provide different backends
18
22
  */
19
- interface ICalendarDatabase {
23
+ export interface ICalendarDatabase {
20
24
  getItems(userId: string): Promise<ContentItem[]>;
21
25
  getItemById(id: string): Promise<ContentItem | null>;
22
26
  createItem(userId: string, item: CreateContentItemParams): Promise<ContentItem>;
@@ -25,125 +29,120 @@ interface ICalendarDatabase {
25
29
  }
26
30
 
27
31
  /**
28
- * Firebase implementation of calendar database
32
+ * Resolve scheduled_at to ISO string regardless of source type
29
33
  */
30
- class FirebaseCalendarDatabase implements ICalendarDatabase {
31
- async getItems(userId: string): Promise<ContentItem[]> {
32
- // Lazy load Firebase
33
- const { collection, query, where, getDocs } = await import('firebase/firestore');
34
- const { getFirebaseDB } = await import('@umituz/web-firebase');
35
- const db = getFirebaseDB();
36
-
37
- const calendarQuery = query(
38
- collection(db as any, 'calendar_items'),
39
- where("user_id", "==", userId)
40
- );
41
-
42
- const postsQuery = query(
43
- collection(db as any, "posts"),
44
- where("userId", "==", userId)
45
- );
46
-
47
- const [calendarSnap, postsSnap] = await Promise.all([
48
- getDocs(calendarQuery),
49
- getDocs(postsQuery)
50
- ]);
51
-
52
- const calendarItems = calendarSnap.docs.map((doc: { id: string; data: () => Record<string, unknown> }) => ({
53
- id: doc.id,
54
- ...doc.data()
55
- } as ContentItem));
56
-
57
- const postItems = postsSnap.docs.map((doc: { id: string; data: () => Record<string, unknown> }) => {
58
- const data = doc.data();
59
- return {
60
- id: doc.id,
61
- title: data.title || 'Untitled Post',
62
- description: data.content || '',
63
- scheduled_at: data.scheduledAt
64
- ? (typeof data.scheduledAt === 'string' ? data.scheduledAt : (data.scheduledAt as { toDate: () => Date })?.toDate?.().toISOString())
65
- : new Date().toISOString(),
66
- platforms: data.platform ? [data.platform] : [],
67
- app_name: data.appName || 'My App',
68
- status: data.status || 'draft',
69
- type: 'post'
70
- } as ContentItem;
71
- });
72
-
73
- return [...calendarItems, ...postItems].sort((a, b) =>
74
- new Date(b.scheduled_at).getTime() - new Date(a.scheduled_at).getTime()
75
- );
34
+ const resolveScheduledAt = (value: string | Date | undefined): string => {
35
+ if (value === undefined) {
36
+ throw new Error('scheduled_at is required to create or update a content item');
76
37
  }
38
+ if (typeof value === 'string') return value;
39
+ return value.toISOString();
40
+ };
77
41
 
78
- async getItemById(id: string): Promise<ContentItem | null> {
79
- const { doc, getDoc } = await import('firebase/firestore');
80
- const { getFirebaseDB } = await import('@umituz/web-firebase');
81
- const db = getFirebaseDB();
82
-
83
- const docRef = doc(db as any, 'calendar_items', id);
84
- const snap = await getDoc(docRef);
42
+ /**
43
+ * In-memory calendar database — zero-dependency default for demos
44
+ * and tests. Data lives per-instance for the lifetime of the page.
45
+ */
46
+ export const createInMemoryCalendarDatabase = (): ICalendarDatabase => {
47
+ const items = new Map<string, ContentItem>();
48
+
49
+ const findByUser = (userId: string): ContentItem[] =>
50
+ [...items.values()].filter((item) => item.user_id === userId);
51
+
52
+ return {
53
+ async getItems(userId: string): Promise<ContentItem[]> {
54
+ return findByUser(userId).sort(
55
+ (a, b) =>
56
+ new Date(b.scheduled_at).getTime() - new Date(a.scheduled_at).getTime(),
57
+ );
58
+ },
59
+
60
+ async getItemById(id: string): Promise<ContentItem | null> {
61
+ return items.get(id) ?? null;
62
+ },
63
+
64
+ async createItem(
65
+ userId: string,
66
+ item: CreateContentItemParams,
67
+ ): Promise<ContentItem> {
68
+ const created: ContentItem = {
69
+ id: `item-${items.size + 1}-${userId}`,
70
+ title: item.title ?? '',
71
+ description: item.description ?? '',
72
+ scheduled_at: resolveScheduledAt(item.scheduled_at),
73
+ platforms: item.platforms ?? [],
74
+ app_name: item.app_name ?? '',
75
+ status: item.status ?? 'draft',
76
+ type: item.type ?? 'post',
77
+ user_id: userId,
78
+ created_at: new Date().toISOString(),
79
+ };
80
+ items.set(created.id, created);
81
+ return created;
82
+ },
83
+
84
+ async updateItem(id: string, updates: UpdateContentItemParams): Promise<void> {
85
+ const current = items.get(id);
86
+ if (!current) {
87
+ throw new Error(`Calendar item not found: ${id}`);
88
+ }
89
+ items.set(id, {
90
+ ...current,
91
+ ...updates,
92
+ scheduled_at:
93
+ updates.scheduled_at !== undefined
94
+ ? resolveScheduledAt(updates.scheduled_at)
95
+ : current.scheduled_at,
96
+ });
97
+ },
98
+
99
+ async deleteItem(id: string): Promise<void> {
100
+ items.delete(id);
101
+ },
102
+ };
103
+ };
85
104
 
86
- if (!snap.exists()) {
87
- return null;
105
+ /**
106
+ * Apply a CalendarFilter to a list of items.
107
+ * Single source of truth for filtering, shared between hook and service.
108
+ */
109
+ const applyCalendarFilter = (items: ContentItem[], filter?: CalendarFilter): ContentItem[] => {
110
+ if (!filter) return items;
111
+
112
+ return items.filter((item) => {
113
+ if (filter.search) {
114
+ const title = item.title?.toLowerCase() ?? '';
115
+ const query = filter.search.toLowerCase();
116
+ if (!title.includes(query)) return false;
88
117
  }
89
118
 
90
- return {
91
- id: snap.id,
92
- ...snap.data()
93
- } as ContentItem;
94
- }
95
-
96
- async createItem(userId: string, item: CreateContentItemParams): Promise<ContentItem> {
97
- const { collection, addDoc, serverTimestamp } = await import('firebase/firestore');
98
- const { getFirebaseDB } = await import('@umituz/web-firebase');
99
- const db = getFirebaseDB();
100
-
101
- const docRef = await addDoc(collection(db as any, 'calendar_items'), {
102
- ...item,
103
- scheduled_at: typeof item.scheduled_at === 'string' ? item.scheduled_at : item.scheduled_at.toISOString(),
104
- user_id: userId,
105
- created_at: serverTimestamp(),
106
- updated_at: serverTimestamp()
107
- });
108
-
109
- return {
110
- id: docRef.id,
111
- ...item,
112
- scheduled_at: typeof item.scheduled_at === 'string' ? item.scheduled_at : item.scheduled_at.toISOString(),
113
- user_id: userId,
114
- created_at: new Date().toISOString()
115
- } as ContentItem;
116
- }
117
-
118
- async updateItem(id: string, updates: UpdateContentItemParams): Promise<void> {
119
- const { doc, updateDoc, serverTimestamp } = await import('firebase/firestore');
120
- const { getFirebaseDB } = await import('@umituz/web-firebase');
121
- const db = getFirebaseDB();
122
-
123
- const docRef = doc(db as any, 'calendar_items', id);
119
+ if (filter.platforms && filter.platforms.length > 0) {
120
+ const itemPlatforms = item.platforms ?? [];
121
+ if (!itemPlatforms.some((p) => filter.platforms?.includes(p))) {
122
+ return false;
123
+ }
124
+ }
124
125
 
125
- const updateData: Record<string, unknown> = { ...updates };
126
- if (updates.scheduled_at) {
127
- updateData.scheduled_at = typeof updates.scheduled_at === 'string'
128
- ? updates.scheduled_at
129
- : updates.scheduled_at.toISOString();
126
+ if (filter.types && filter.types.length > 0) {
127
+ if (!item.type || !filter.types.includes(item.type)) {
128
+ return false;
129
+ }
130
130
  }
131
131
 
132
- await updateDoc(docRef, {
133
- ...updateData,
134
- updated_at: serverTimestamp()
135
- });
136
- }
132
+ if (filter.status && item.status !== filter.status) {
133
+ return false;
134
+ }
137
135
 
138
- async deleteItem(id: string): Promise<void> {
139
- const { doc, deleteDoc } = await import('firebase/firestore');
140
- const { getFirebaseDB } = await import('@umituz/web-firebase');
141
- const db = getFirebaseDB();
136
+ if (filter.dateRange) {
137
+ const itemDate = new Date(item.scheduled_at);
138
+ if (itemDate < filter.dateRange.start || itemDate > filter.dateRange.end) {
139
+ return false;
140
+ }
141
+ }
142
142
 
143
- const docRef = doc(db as any, 'calendar_items', id);
144
- await deleteDoc(docRef);
145
- }
146
- }
143
+ return true;
144
+ });
145
+ };
147
146
 
148
147
  /**
149
148
  * Calendar Service Implementation
@@ -152,19 +151,10 @@ class FirebaseCalendarDatabase implements ICalendarDatabase {
152
151
  * Uses database interface for backend abstraction
153
152
  */
154
153
  export class CalendarService implements ICalendarService {
155
- private static instance: CalendarService;
156
154
  private database: ICalendarDatabase;
157
155
 
158
- private constructor() {
159
- // Use Firebase implementation by default
160
- this.database = new FirebaseCalendarDatabase();
161
- }
162
-
163
- public static getInstance(): CalendarService {
164
- if (!CalendarService.instance) {
165
- CalendarService.instance = new CalendarService();
166
- }
167
- return CalendarService.instance;
156
+ constructor(database: ICalendarDatabase = createInMemoryCalendarDatabase()) {
157
+ this.database = database;
168
158
  }
169
159
 
170
160
  /**
@@ -175,90 +165,43 @@ export class CalendarService implements ICalendarService {
175
165
  }
176
166
 
177
167
  /**
178
- * Get all content items for a user
168
+ * Get all content items for a user, with optional filter
179
169
  */
180
170
  async getContentItems(userId: string, filter?: CalendarFilter): Promise<ContentItem[]> {
181
171
  const items = await this.database.getItems(userId);
182
-
183
- if (!filter) return items;
184
-
185
- // Apply filters
186
- return items.filter(item => {
187
- // Search filter
188
- if (filter.search && !item.title.toLowerCase().includes(filter.search.toLowerCase())) {
189
- return false;
190
- }
191
-
192
- // Platform filter
193
- if (filter.platforms && filter.platforms.length > 0) {
194
- if (!item.platforms.some(p => filter.platforms?.includes(p))) {
195
- return false;
196
- }
197
- }
198
-
199
- // Type filter
200
- if (filter.types && filter.types.length > 0) {
201
- if (!item.type || !filter.types.includes(item.type)) {
202
- return false;
203
- }
204
- }
205
-
206
- // Status filter
207
- if (filter.status && item.status !== filter.status) {
208
- return false;
209
- }
210
-
211
- // Date range filter
212
- if (filter.dateRange) {
213
- const itemDate = new Date(item.scheduled_at);
214
- if (itemDate < filter.dateRange.start || itemDate > filter.dateRange.end) {
215
- return false;
216
- }
217
- }
218
-
219
- return true;
220
- });
172
+ return applyCalendarFilter(items, filter);
221
173
  }
222
174
 
223
- /**
224
- * Get a single content item by ID
225
- */
226
175
  async getContentItemById(id: string): Promise<ContentItem | null> {
227
176
  return this.database.getItemById(id);
228
177
  }
229
178
 
230
- /**
231
- * Create a new content item
232
- */
233
179
  async createContentItem(userId: string, item: CreateContentItemParams): Promise<ContentItem> {
234
180
  return this.database.createItem(userId, item);
235
181
  }
236
182
 
237
- /**
238
- * Update an existing content item
239
- */
240
183
  async updateContentItem(id: string, updates: UpdateContentItemParams): Promise<void> {
241
184
  await this.database.updateItem(id, updates);
242
185
  }
243
186
 
244
- /**
245
- * Delete a content item
246
- */
247
187
  async deleteContentItem(id: string): Promise<void> {
248
188
  await this.database.deleteItem(id);
249
189
  }
250
190
 
251
- /**
252
- * Move content item to a new date
253
- */
254
191
  async moveContentItem(id: string, newDate: Date): Promise<void> {
255
192
  await this.updateContentItem(id, {
256
- scheduled_at: newDate
193
+ scheduled_at: newDate,
257
194
  });
258
195
  }
259
196
  }
260
197
 
261
198
  /**
262
- * Singleton instance
199
+ * Default singleton instance (in-memory — swap the backend via
200
+ * `setDatabase` or construct your own `CalendarService`).
201
+ */
202
+ export const calendarService = new CalendarService();
203
+
204
+ /**
205
+ * Exported for testing/extension.
263
206
  */
264
- export const calendarService = CalendarService.getInstance();
207
+ export { applyCalendarFilter };
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Tests for the calendar Firestore → ContentItem mappers.
3
+ */
4
+
5
+ import { describe, it, expect } from "vitest";
6
+ import { mapCalendarDocument, mapPostDocument } from "./contentItemMapper";
7
+ import type { FirestoreDocument } from "./contentItemMapper";
8
+
9
+ const makeDoc = (data: Record<string, unknown>): FirestoreDocument => ({
10
+ id: "doc-1",
11
+ data: () => data as never,
12
+ });
13
+
14
+ describe("mapCalendarDocument", () => {
15
+ it("maps a fully populated document", () => {
16
+ const item = mapCalendarDocument(
17
+ makeDoc({
18
+ title: "Launch post",
19
+ description: "Hello world",
20
+ scheduled_at: "2026-09-01T10:00:00.000Z",
21
+ platforms: ["twitter", "linkedin"],
22
+ app_name: "MyApp",
23
+ status: "scheduled",
24
+ type: "post",
25
+ }),
26
+ );
27
+
28
+ expect(item).toEqual({
29
+ id: "doc-1",
30
+ title: "Launch post",
31
+ description: "Hello world",
32
+ scheduled_at: "2026-09-01T10:00:00.000Z",
33
+ platforms: ["twitter", "linkedin"],
34
+ app_name: "MyApp",
35
+ status: "scheduled",
36
+ type: "post",
37
+ });
38
+ });
39
+
40
+ it("falls back to empty strings, never to package-side copy", () => {
41
+ const item = mapCalendarDocument(makeDoc({}));
42
+
43
+ expect(item.title).toBe("");
44
+ expect(item.description).toBe("");
45
+ expect(item.app_name).toBe("");
46
+ expect(item.platforms).toEqual([]);
47
+ });
48
+
49
+ it("normalizes unknown status/type to draft/post", () => {
50
+ const item = mapCalendarDocument(
51
+ makeDoc({ status: "weird", type: "unsupported" }),
52
+ );
53
+ expect(item.status).toBe("draft");
54
+ expect(item.type).toBe("post");
55
+ });
56
+
57
+ it("converts Firestore timestamps via toDate()", () => {
58
+ const item = mapCalendarDocument(
59
+ makeDoc({
60
+ scheduled_at: { toDate: () => new Date("2026-09-01T00:00:00.000Z") },
61
+ }),
62
+ );
63
+ expect(item.scheduled_at).toBe("2026-09-01T00:00:00.000Z");
64
+ });
65
+
66
+ it("maps a missing scheduled_at to the epoch, not 'now'", () => {
67
+ const item = mapCalendarDocument(makeDoc({}));
68
+ expect(item.scheduled_at).toBe(new Date(0).toISOString());
69
+ });
70
+
71
+ it("prefers camelCase appName as fallback", () => {
72
+ const item = mapCalendarDocument(makeDoc({ appName: "CamelApp" }));
73
+ expect(item.app_name).toBe("CamelApp");
74
+ });
75
+ });
76
+
77
+ describe("mapPostDocument", () => {
78
+ it("maps a posts-collection document", () => {
79
+ const item = mapPostDocument(
80
+ makeDoc({
81
+ title: "Post title",
82
+ content: "Body text",
83
+ scheduledAt: "2026-10-01T08:00:00.000Z",
84
+ platform: "instagram",
85
+ appName: "MyApp",
86
+ status: "published",
87
+ }),
88
+ );
89
+
90
+ expect(item).toEqual({
91
+ id: "doc-1",
92
+ title: "Post title",
93
+ description: "Body text",
94
+ scheduled_at: "2026-10-01T08:00:00.000Z",
95
+ platforms: ["instagram"],
96
+ app_name: "MyApp",
97
+ status: "published",
98
+ type: "post",
99
+ });
100
+ });
101
+
102
+ it("falls back to empty strings for missing display fields", () => {
103
+ const item = mapPostDocument(makeDoc({}));
104
+ expect(item.title).toBe("");
105
+ expect(item.description).toBe("");
106
+ expect(item.platforms).toEqual([]);
107
+ });
108
+ });
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Content Item Mapper
3
+ *
4
+ * Type-safe transformation between wire-format documents and domain ContentItem objects.
5
+ */
6
+
7
+ import type { ContentItem, ContentStatus, ContentType } from '../types/calendar.types';
8
+
9
+ /**
10
+ * Raw wire document shape for calendar_items records
11
+ */
12
+ interface CalendarItemDocument {
13
+ title?: string;
14
+ description?: string;
15
+ scheduled_at?: string | { toDate: () => Date };
16
+ platforms?: string[];
17
+ app_name?: string;
18
+ status?: string;
19
+ type?: string;
20
+ content?: string;
21
+ platform?: string;
22
+ appName?: string;
23
+ scheduledAt?: string | { toDate: () => Date };
24
+ userId?: string;
25
+ user_id?: string;
26
+ created_at?: string | { toDate: () => Date };
27
+ updated_at?: string | { toDate: () => Date };
28
+ }
29
+
30
+ /**
31
+ * Firestore document with id
32
+ */
33
+ export interface FirestoreDocument<T = Record<string, unknown>> {
34
+ id: string;
35
+ data: () => T;
36
+ }
37
+
38
+ /**
39
+ * Convert Firebase timestamp value to ISO string.
40
+ * A missing value maps to the epoch rather than "now" — missing data
41
+ * must not masquerade as freshly-scheduled content in date-sorted views.
42
+ */
43
+ const toIsoString = (value: string | { toDate: () => Date } | undefined): string => {
44
+ if (!value) return new Date(0).toISOString();
45
+ if (typeof value === 'string') return value;
46
+ return value.toDate().toISOString();
47
+ };
48
+
49
+ /**
50
+ * Normalize status string to ContentStatus union
51
+ */
52
+ const normalizeStatus = (status: string | undefined): ContentStatus => {
53
+ const valid: ContentStatus[] = ['draft', 'scheduled', 'published', 'failed'];
54
+ return valid.includes(status as ContentStatus) ? (status as ContentStatus) : 'draft';
55
+ };
56
+
57
+ /**
58
+ * Normalize type string to ContentType union
59
+ */
60
+ const normalizeType = (type: string | undefined): ContentType => {
61
+ const valid: ContentType[] = ['post', 'story', 'reel', 'tweet', 'article'];
62
+ return valid.includes(type as ContentType) ? (type as ContentType) : 'post';
63
+ };
64
+
65
+ /**
66
+ * Map calendar_items document to ContentItem
67
+ */
68
+ export const mapCalendarDocument = (doc: FirestoreDocument<CalendarItemDocument>): ContentItem => {
69
+ const data = doc.data();
70
+ return {
71
+ id: doc.id,
72
+ title: data.title ?? '',
73
+ description: data.description ?? data.content ?? '',
74
+ scheduled_at: toIsoString(data.scheduled_at),
75
+ platforms: Array.isArray(data.platforms) ? data.platforms : [],
76
+ app_name: data.app_name ?? data.appName ?? '',
77
+ status: normalizeStatus(data.status),
78
+ type: normalizeType(data.type),
79
+ };
80
+ };
81
+
82
+ /**
83
+ * Map posts document to ContentItem.
84
+ * Missing display fields fall back to empty strings so the consuming
85
+ * UI can render its own (localized) placeholder — no package-side copy.
86
+ */
87
+ export const mapPostDocument = (doc: FirestoreDocument<CalendarItemDocument>): ContentItem => {
88
+ const data = doc.data();
89
+ return {
90
+ id: doc.id,
91
+ title: data.title ?? '',
92
+ description: data.content ?? '',
93
+ scheduled_at: toIsoString(data.scheduledAt),
94
+ platforms: data.platform ? [data.platform] : [],
95
+ app_name: data.appName ?? '',
96
+ status: normalizeStatus(data.status),
97
+ type: 'post',
98
+ };
99
+ };
@@ -4,36 +4,20 @@
4
4
 
5
5
  import type { CalendarConfig } from '../types/calendar.types';
6
6
 
7
- /**
8
- * Default calendar configuration
9
- */
10
- export const DEFAULT_CALENDAR_CONFIG: CalendarConfig = {
11
- defaultView: 'month',
12
- showWeekends: true,
13
- startOfWeek: 0, // Sunday
14
- hourRange: {
15
- start: 0,
16
- end: 23,
17
- },
18
- enableDragDrop: true,
19
- showPlatformFilters: true,
20
- platforms: ['instagram', 'facebook', 'twitter', 'linkedin', 'tiktok'],
21
- slotDuration: 30, // 30 minutes
22
- };
7
+ export { mapCalendarDocument, mapPostDocument } from './contentItemMapper';
8
+ export type { FirestoreDocument } from './contentItemMapper';
23
9
 
24
10
  /**
25
11
  * Get week dates for a given date
26
12
  */
27
13
  export function getWeekDates(date: Date, startOfWeek: number = 0): Date[] {
28
- const week = [];
14
+ const week: Date[] = [];
29
15
  const current = new Date(date);
30
16
 
31
- // Find the first day of the week
32
17
  const day = current.getDay();
33
18
  const diff = (day < startOfWeek ? 7 : 0) + day - startOfWeek;
34
19
  current.setDate(current.getDate() - diff);
35
20
 
36
- // Get 7 days
37
21
  for (let i = 0; i < 7; i++) {
38
22
  week.push(new Date(current));
39
23
  current.setDate(current.getDate() + 1);
@@ -68,17 +52,22 @@ export function isDateInRange(date: Date, range: { start: Date; end: Date }): bo
68
52
  }
69
53
 
70
54
  /**
71
- * Format date for display
55
+ * Format date for display.
56
+ * The locale is taken as a parameter so the consumer can decide.
72
57
  */
73
- export function formatDate(date: Date, format: 'short' | 'long' | 'time' = 'short'): string {
58
+ export function formatDate(
59
+ date: Date,
60
+ format: 'short' | 'long' | 'time' = 'short',
61
+ locale: string = 'en-US',
62
+ ): string {
74
63
  if (format === 'time') {
75
- return date.toLocaleTimeString('en-US', {
64
+ return date.toLocaleTimeString(locale, {
76
65
  hour: '2-digit',
77
66
  minute: '2-digit',
78
67
  });
79
68
  }
80
69
 
81
- return date.toLocaleDateString('en-US', {
70
+ return date.toLocaleDateString(locale, {
82
71
  month: format === 'long' ? 'long' : 'short',
83
72
  day: 'numeric',
84
73
  year: 'numeric',
@@ -99,25 +88,32 @@ export function isSameDay(date1: Date, date2: Date): boolean {
99
88
  /**
100
89
  * Get date range for a view
101
90
  */
102
- export function getViewRange(view: 'month' | 'week', date: Date): { start: Date; end: Date } {
91
+ export function getViewRange(
92
+ view: 'month' | 'week',
93
+ date: Date,
94
+ ): { start: Date; end: Date } {
103
95
  if (view === 'month') {
104
96
  const start = new Date(date.getFullYear(), date.getMonth(), 1);
105
97
  const end = new Date(date.getFullYear(), date.getMonth() + 1, 0);
106
98
  return { start, end };
107
99
  }
108
100
 
109
- // Week view
110
101
  const week = getWeekDates(date);
111
- return {
112
- start: new Date(week[0].setHours(0, 0, 0, 0)),
113
- end: new Date(week[6].setHours(23, 59, 59, 999)),
114
- };
102
+ const firstDay = new Date(week[0]);
103
+ firstDay.setHours(0, 0, 0, 0);
104
+ const lastDay = new Date(week[6]);
105
+ lastDay.setHours(23, 59, 59, 999);
106
+ return { start: firstDay, end: lastDay };
115
107
  }
116
108
 
117
109
  /**
118
110
  * Generate time slots
119
111
  */
120
- export function generateTimeSlots(startHour: number, endHour: number, slotDuration: number): string[] {
112
+ export function generateTimeSlots(
113
+ startHour: number,
114
+ endHour: number,
115
+ slotDuration: number,
116
+ ): string[] {
121
117
  const slots: string[] = [];
122
118
 
123
119
  for (let hour = startHour; hour < endHour; hour++) {
@@ -129,3 +125,10 @@ export function generateTimeSlots(startHour: number, endHour: number, slotDurati
129
125
 
130
126
  return slots;
131
127
  }
128
+
129
+ /**
130
+ * Re-export the default calendar config from the central config module
131
+ * so consumers using only the calendar domain still get sensible defaults.
132
+ */
133
+ export { DEFAULT_CALENDAR_CONFIG } from '../../../domain/config/CalendarConfig';
134
+ export type { CalendarConfig };