@tuturuuu/utils 0.0.2 → 0.6.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 (186) hide show
  1. package/CHANGELOG.md +305 -0
  2. package/biome.json +5 -0
  3. package/jsr.json +8 -8
  4. package/package.json +63 -32
  5. package/src/__tests__/ai-temp-auth.test.ts +309 -0
  6. package/src/__tests__/api-proxy-guard.test.ts +1451 -0
  7. package/src/__tests__/app-url.test.ts +270 -0
  8. package/src/__tests__/avatar-url.test.ts +97 -0
  9. package/src/__tests__/color-helper.test.ts +179 -0
  10. package/src/__tests__/constants.test.ts +351 -0
  11. package/src/__tests__/crypto.test.ts +107 -0
  12. package/src/__tests__/date-helper.test.ts +408 -0
  13. package/src/__tests__/fixtures/task-description-full-featured.json +456 -0
  14. package/src/__tests__/format.test.ts +317 -0
  15. package/src/__tests__/html-sanitizer.test.ts +360 -0
  16. package/src/__tests__/interest-calculator.test.ts +336 -0
  17. package/src/__tests__/interest-detector.test.ts +222 -0
  18. package/src/__tests__/label-colors.test.ts +241 -0
  19. package/src/__tests__/name-helper.test.ts +158 -0
  20. package/src/__tests__/node-diff.test.ts +576 -0
  21. package/src/__tests__/notification-service.test.ts +210 -0
  22. package/src/__tests__/onboarding-helper.test.ts +331 -0
  23. package/src/__tests__/path-helper.test.ts +152 -0
  24. package/src/__tests__/permissions.test.tsx +81 -0
  25. package/src/__tests__/request-emoji-limit.test.ts +172 -0
  26. package/src/__tests__/search-helper.test.ts +51 -0
  27. package/src/__tests__/storage-display-name.test.ts +37 -0
  28. package/src/__tests__/storage-path.test.ts +238 -0
  29. package/src/__tests__/tag-utils.test.ts +205 -0
  30. package/src/__tests__/task-description-yjs-state.test.ts +581 -0
  31. package/src/__tests__/task-helper-board-api-routing.test.ts +94 -0
  32. package/src/__tests__/task-helper-create-task.test.ts +129 -0
  33. package/src/__tests__/task-helpers.test.ts +464 -0
  34. package/src/__tests__/task-overrides.test.ts +305 -0
  35. package/src/__tests__/task-reorder-cache.test.ts +74 -0
  36. package/src/__tests__/task-sort-keys.test.ts +36 -0
  37. package/src/__tests__/task-transformers.test.ts +62 -0
  38. package/src/__tests__/text-helper.test.ts +776 -0
  39. package/src/__tests__/time-helper.test.ts +70 -0
  40. package/src/__tests__/time-tracker-period.test.ts +55 -0
  41. package/src/__tests__/timezone.test.ts +117 -0
  42. package/src/__tests__/upstash-rest.test.ts +77 -0
  43. package/src/__tests__/uuid-helper.test.ts +133 -0
  44. package/src/__tests__/workspace-helper.test.ts +859 -0
  45. package/src/__tests__/workspace-limits.test.ts +255 -0
  46. package/src/__tests__/yjs-helper.test.ts +581 -0
  47. package/src/abuse-protection/__tests__/backend-rate-limit.test.ts +113 -0
  48. package/src/abuse-protection/__tests__/edge.test.ts +136 -0
  49. package/src/abuse-protection/__tests__/index.test.ts +562 -0
  50. package/src/abuse-protection/__tests__/reputation.test.ts +192 -0
  51. package/src/abuse-protection/backend-rate-limit.ts +44 -0
  52. package/src/abuse-protection/constants.ts +117 -0
  53. package/src/abuse-protection/edge.ts +223 -0
  54. package/src/abuse-protection/index.ts +1545 -0
  55. package/src/abuse-protection/reputation.ts +587 -0
  56. package/src/abuse-protection/types.ts +97 -0
  57. package/src/abuse-protection/user-agent.ts +124 -0
  58. package/src/abuse-protection/user-suspension.ts +231 -0
  59. package/src/ai-temp-auth.ts +315 -0
  60. package/src/api-proxy-guard.ts +965 -0
  61. package/src/app-url.ts +96 -0
  62. package/src/avatar-url.ts +64 -0
  63. package/src/break-duration.ts +84 -0
  64. package/src/calendar-auth-token.test.ts +37 -0
  65. package/src/calendar-auth-token.ts +19 -0
  66. package/src/calendar-sync-coordination.md +197 -0
  67. package/src/calendar-utils.test.ts +169 -0
  68. package/src/calendar-utils.ts +91 -0
  69. package/src/color-helper.ts +110 -0
  70. package/src/common/nextjs.tsx +99 -0
  71. package/src/common/scan.tsx +15 -0
  72. package/src/configs/reports.ts +160 -0
  73. package/src/constants.ts +85 -0
  74. package/src/crypto.ts +21 -0
  75. package/src/currencies.ts +97 -0
  76. package/src/date-helper.ts +313 -0
  77. package/src/editor/convert-to-task.ts +264 -0
  78. package/src/editor/index.ts +5 -0
  79. package/src/email/__tests__/client.test.ts +141 -0
  80. package/src/email/__tests__/validation.test.ts +46 -0
  81. package/src/email/client.ts +92 -0
  82. package/src/email/server.ts +128 -0
  83. package/src/email/validation.ts +11 -0
  84. package/src/encryption/__tests__/calendar-events.test.ts +411 -0
  85. package/src/encryption/__tests__/configuration.test.ts +114 -0
  86. package/src/encryption/__tests__/field-encryption.test.ts +232 -0
  87. package/src/encryption/__tests__/key-generation.test.ts +30 -0
  88. package/src/encryption/__tests__/performance-edge-cases.test.ts +187 -0
  89. package/src/encryption/__tests__/test-helpers.ts +22 -0
  90. package/src/encryption/__tests__/workspace-key-encryption.test.ts +129 -0
  91. package/src/encryption/encryption-service.ts +343 -0
  92. package/src/encryption/index.ts +25 -0
  93. package/src/encryption/types.ts +57 -0
  94. package/src/exchange-rates.ts +49 -0
  95. package/src/feature-flags/__tests__/feature-flags.test.ts +302 -0
  96. package/src/feature-flags/core.ts +322 -0
  97. package/src/feature-flags/data.ts +16 -0
  98. package/src/feature-flags/default.ts +18 -0
  99. package/src/feature-flags/index.ts +7 -0
  100. package/src/feature-flags/requestable-features.ts +79 -0
  101. package/src/feature-flags/types.ts +4 -0
  102. package/src/fetcher.ts +2 -0
  103. package/src/finance/index.ts +4 -0
  104. package/src/finance/interest-calculator.ts +456 -0
  105. package/src/finance/interest-detector.ts +141 -0
  106. package/src/finance/transform-invoice-results.ts +219 -0
  107. package/src/finance/wallet-permissions.test.ts +169 -0
  108. package/src/finance/wallet-permissions.ts +82 -0
  109. package/src/format.ts +122 -3
  110. package/src/generated/platform-build-metadata.ts +11 -0
  111. package/src/hooks/use-platform.ts +64 -0
  112. package/src/html-sanitizer.ts +155 -0
  113. package/src/internal-domains.ts +497 -0
  114. package/src/keyboard-preset.ts +109 -0
  115. package/src/label-colors.ts +213 -0
  116. package/src/launchable-apps.test.ts +126 -0
  117. package/src/launchable-apps.ts +490 -0
  118. package/src/name-helper.ts +269 -0
  119. package/src/next-config.test.ts +234 -0
  120. package/src/next-config.ts +203 -0
  121. package/src/node-diff.ts +375 -0
  122. package/src/notification-service.ts +379 -0
  123. package/src/nova/scores/__tests__/calculate.test.ts +254 -0
  124. package/src/nova/scores/calculate.ts +132 -0
  125. package/src/nova/submissions/check-permission.ts +132 -0
  126. package/src/onboarding-helper.ts +213 -0
  127. package/src/path-helper.ts +93 -0
  128. package/src/permissions.tsx +1170 -0
  129. package/src/plan-helpers.test.ts +188 -0
  130. package/src/plan-helpers.ts +80 -0
  131. package/src/platform-release.test.ts +74 -0
  132. package/src/platform-release.ts +155 -0
  133. package/src/portless.ts +124 -0
  134. package/src/priority-styles.ts +42 -0
  135. package/src/request-emoji-limit.ts +335 -0
  136. package/src/search-helper.ts +18 -0
  137. package/src/search.test.ts +89 -0
  138. package/src/search.ts +355 -0
  139. package/src/storage-display-name.ts +30 -0
  140. package/src/storage-path.ts +147 -0
  141. package/src/tag-utils.ts +159 -0
  142. package/src/task/reorder.ts +245 -0
  143. package/src/task/transformers.ts +149 -0
  144. package/src/task-date-timezone.ts +133 -0
  145. package/src/task-description-content.ts +240 -0
  146. package/src/task-helper/board.ts +193 -0
  147. package/src/task-helper/bulk-actions.ts +564 -0
  148. package/src/task-helper/personal-external-staging.ts +21 -0
  149. package/src/task-helper/recycle-bin.ts +202 -0
  150. package/src/task-helper/relationships.ts +346 -0
  151. package/src/task-helper/shared.ts +109 -0
  152. package/src/task-helper/sort-keys.ts +337 -0
  153. package/src/task-helper/task-hooks-basic.ts +342 -0
  154. package/src/task-helper/task-hooks-move.ts +264 -0
  155. package/src/task-helper/task-operations.ts +278 -0
  156. package/src/task-helper.ts +12 -0
  157. package/src/task-helpers.ts +241 -0
  158. package/src/task-list-status.ts +62 -0
  159. package/src/task-overrides.ts +82 -0
  160. package/src/task-snapshot.ts +374 -0
  161. package/src/text-diff.ts +81 -0
  162. package/src/text-helper.ts +537 -0
  163. package/src/time-helper.ts +63 -0
  164. package/src/time-tracker-period.ts +73 -0
  165. package/src/timeblock-helper.ts +418 -0
  166. package/src/timezone.ts +190 -0
  167. package/src/timezones.json +1271 -0
  168. package/src/upstash-rest.ts +56 -0
  169. package/src/user-helper.ts +296 -0
  170. package/src/uuid-helper.ts +11 -0
  171. package/src/workspace-handle.ts +10 -0
  172. package/src/workspace-helper.ts +1408 -0
  173. package/src/workspace-limits.ts +68 -0
  174. package/src/yjs-helper.ts +217 -0
  175. package/src/yjs-task-description.ts +81 -0
  176. package/tsconfig.json +3 -5
  177. package/tsconfig.typecheck.json +33 -0
  178. package/vitest.config.ts +36 -0
  179. package/dist/index.d.ts +0 -8
  180. package/dist/index.js +0 -2
  181. package/dist/index.js.map +0 -1
  182. package/dist/index.mjs +0 -2
  183. package/dist/index.mjs.map +0 -1
  184. package/eslint.config.mjs +0 -20
  185. package/rollup.config.js +0 -41
  186. package/src/index.ts +0 -1
package/src/app-url.ts ADDED
@@ -0,0 +1,96 @@
1
+ import { type AppName, getAppDomainByUrl } from './internal-domains';
2
+
3
+ type AppUrlCandidate = string | null | undefined;
4
+
5
+ interface ResolveAppUrlOptions {
6
+ candidates: readonly AppUrlCandidate[];
7
+ fallback: string;
8
+ }
9
+
10
+ interface ResolveInternalAppUrlOptions extends ResolveAppUrlOptions {
11
+ appName: AppName;
12
+ }
13
+
14
+ function trimTrailingSlashes(value: string) {
15
+ let end = value.length;
16
+
17
+ while (end > 0 && value.charCodeAt(end - 1) === 47) {
18
+ end -= 1;
19
+ }
20
+
21
+ return end === value.length ? value : value.slice(0, end);
22
+ }
23
+
24
+ function isWildcardListenerHostname(hostname: string) {
25
+ return hostname === '0.0.0.0' || hostname === '::' || hostname === '[::]';
26
+ }
27
+
28
+ function normalizeHttpUrl(value: AppUrlCandidate) {
29
+ const trimmed = value?.trim();
30
+
31
+ if (!trimmed) {
32
+ return null;
33
+ }
34
+
35
+ try {
36
+ const url = new URL(trimmed);
37
+
38
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
39
+ return null;
40
+ }
41
+
42
+ if (isWildcardListenerHostname(url.hostname)) {
43
+ return null;
44
+ }
45
+
46
+ return trimTrailingSlashes(trimmed);
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ export function resolveAppUrl({ candidates, fallback }: ResolveAppUrlOptions) {
53
+ for (const candidate of candidates) {
54
+ const resolvedUrl = normalizeHttpUrl(candidate);
55
+
56
+ if (resolvedUrl) {
57
+ return resolvedUrl;
58
+ }
59
+ }
60
+
61
+ return normalizeHttpUrl(fallback) ?? fallback;
62
+ }
63
+
64
+ function getRegisteredAppNameForUrl(value: string) {
65
+ return getAppDomainByUrl(value)?.name ?? null;
66
+ }
67
+
68
+ export function resolveInternalAppUrl({
69
+ appName,
70
+ candidates,
71
+ fallback,
72
+ }: ResolveInternalAppUrlOptions) {
73
+ for (const candidate of candidates) {
74
+ const resolvedUrl = normalizeHttpUrl(candidate);
75
+
76
+ if (!resolvedUrl) {
77
+ continue;
78
+ }
79
+
80
+ const registeredAppName = getRegisteredAppNameForUrl(resolvedUrl);
81
+
82
+ if (registeredAppName && registeredAppName !== appName) {
83
+ continue;
84
+ }
85
+
86
+ const registeredAppUrl = getAppDomainByUrl(resolvedUrl);
87
+
88
+ if (registeredAppUrl?.kind === 'internal') {
89
+ return trimTrailingSlashes(registeredAppUrl.canonicalUrl);
90
+ }
91
+
92
+ return resolvedUrl;
93
+ }
94
+
95
+ return normalizeHttpUrl(fallback) ?? fallback;
96
+ }
@@ -0,0 +1,64 @@
1
+ const UUID_PATTERN =
2
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
3
+ const SUPABASE_PUBLIC_AVATAR_PATH = '/storage/v1/object/public/avatars/';
4
+ const SUPABASE_MALFORMED_PUBLIC_AVATAR_PATH =
5
+ '/storage/v1/object/v1/public/avatars/';
6
+
7
+ function getSupabasePublicAvatarObjectPath(pathname: string) {
8
+ if (pathname.startsWith(SUPABASE_PUBLIC_AVATAR_PATH)) {
9
+ return pathname.slice(SUPABASE_PUBLIC_AVATAR_PATH.length);
10
+ }
11
+
12
+ if (pathname.startsWith(SUPABASE_MALFORMED_PUBLIC_AVATAR_PATH)) {
13
+ return pathname.slice(SUPABASE_MALFORMED_PUBLIC_AVATAR_PATH.length);
14
+ }
15
+
16
+ return null;
17
+ }
18
+
19
+ function normalizeSupabasePublicAvatarUrl(src: string) {
20
+ try {
21
+ const sourceUrl = new URL(src);
22
+ const avatarObjectPath = getSupabasePublicAvatarObjectPath(
23
+ sourceUrl.pathname
24
+ );
25
+
26
+ if (
27
+ !sourceUrl.hostname.endsWith('.supabase.co') ||
28
+ avatarObjectPath === null
29
+ ) {
30
+ return src;
31
+ }
32
+
33
+ sourceUrl.pathname = `${SUPABASE_PUBLIC_AVATAR_PATH}${avatarObjectPath}`;
34
+
35
+ return sourceUrl.toString();
36
+ } catch {
37
+ return src;
38
+ }
39
+ }
40
+
41
+ export function normalizeAvatarImageSrc(
42
+ value: string | null | undefined
43
+ ): string | undefined {
44
+ const src = value?.trim();
45
+
46
+ if (!src || src.startsWith('//') || UUID_PATTERN.test(src)) {
47
+ return undefined;
48
+ }
49
+
50
+ if (/^https?:\/\//iu.test(src)) {
51
+ return normalizeSupabasePublicAvatarUrl(src);
52
+ }
53
+
54
+ if (
55
+ src.startsWith('/') ||
56
+ src.startsWith('blob:') ||
57
+ /^data:image\//iu.test(src) ||
58
+ src.startsWith('avatars/')
59
+ ) {
60
+ return src;
61
+ }
62
+
63
+ return undefined;
64
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Break Duration Utilities
3
+ *
4
+ * Handles validation and formatting for break durations.
5
+ * Breaks are constrained to multiples of 15 minutes.
6
+ */
7
+
8
+ /** The interval in minutes that break durations must be multiples of */
9
+ export const BREAK_INTERVAL = 15;
10
+
11
+ /** Minimum break duration in minutes */
12
+ export const MIN_BREAK_MINUTES = 15;
13
+
14
+ /** Maximum break duration in minutes (2 hours) */
15
+ export const MAX_BREAK_MINUTES = 120;
16
+
17
+ /** Available break duration options in minutes */
18
+ export const BREAK_OPTIONS = [15, 30, 45, 60, 75, 90, 105, 120] as const;
19
+
20
+ /** Type for valid break duration values */
21
+ export type BreakDuration = (typeof BREAK_OPTIONS)[number];
22
+
23
+ /**
24
+ * Check if a duration value is a valid break duration
25
+ * (positive, multiple of 15, within bounds)
26
+ */
27
+ export function isValidBreakDuration(minutes: number): boolean {
28
+ return (
29
+ minutes > 0 &&
30
+ minutes % BREAK_INTERVAL === 0 &&
31
+ minutes >= MIN_BREAK_MINUTES &&
32
+ minutes <= MAX_BREAK_MINUTES
33
+ );
34
+ }
35
+
36
+ /**
37
+ * Round a duration to the nearest valid break interval
38
+ */
39
+ export function roundToBreakInterval(minutes: number): number {
40
+ const rounded = Math.round(minutes / BREAK_INTERVAL) * BREAK_INTERVAL;
41
+ return Math.max(MIN_BREAK_MINUTES, Math.min(MAX_BREAK_MINUTES, rounded));
42
+ }
43
+
44
+ /**
45
+ * Format a break duration for display
46
+ * @param minutes Duration in minutes
47
+ * @returns Formatted string (e.g., "15 min", "1h", "1h 30m")
48
+ */
49
+ export function formatBreakDuration(minutes: number): string {
50
+ if (minutes < 60) {
51
+ return `${minutes} min`;
52
+ }
53
+ const hours = Math.floor(minutes / 60);
54
+ const mins = minutes % 60;
55
+ if (mins === 0) {
56
+ return `${hours}h`;
57
+ }
58
+ return `${hours}h ${mins}m`;
59
+ }
60
+
61
+ /**
62
+ * Get break options as label-value pairs for use in select components
63
+ */
64
+ export function getBreakOptionLabels(): Array<{
65
+ value: number;
66
+ label: string;
67
+ }> {
68
+ return BREAK_OPTIONS.map((mins) => ({
69
+ value: mins,
70
+ label: formatBreakDuration(mins),
71
+ }));
72
+ }
73
+
74
+ /**
75
+ * Parse a break duration from input, ensuring it's valid
76
+ * Returns null if invalid
77
+ */
78
+ export function parseBreakDuration(value: unknown): number | null {
79
+ const num = typeof value === 'string' ? parseInt(value, 10) : Number(value);
80
+ if (Number.isNaN(num) || !isValidBreakDuration(num)) {
81
+ return null;
82
+ }
83
+ return num;
84
+ }
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import {
3
+ fetchUserWorkspaceCalendarGoogleTokenForClient,
4
+ WORKSPACE_CALENDAR_GOOGLE_TOKEN_CLIENT_SELECT,
5
+ } from './calendar-auth-token';
6
+
7
+ describe('calendar-auth-token', () => {
8
+ it('does not request OAuth secret columns', () => {
9
+ expect(WORKSPACE_CALENDAR_GOOGLE_TOKEN_CLIENT_SELECT).not.toContain(
10
+ 'access_token'
11
+ );
12
+ expect(WORKSPACE_CALENDAR_GOOGLE_TOKEN_CLIENT_SELECT).not.toContain(
13
+ 'refresh_token'
14
+ );
15
+ });
16
+
17
+ it('scopes token reads to workspace and authenticated user', async () => {
18
+ const maybeSingle = vi.fn().mockResolvedValue({ data: null, error: null });
19
+ const eqUser = vi.fn().mockReturnValue({ maybeSingle });
20
+ const eqWorkspace = vi.fn().mockReturnValue({ eq: eqUser });
21
+ const select = vi.fn().mockReturnValue({ eq: eqWorkspace });
22
+ const from = vi.fn().mockReturnValue({ select });
23
+
24
+ await fetchUserWorkspaceCalendarGoogleTokenForClient({ from } as never, {
25
+ wsId: 'ws-1',
26
+ userId: 'user-1',
27
+ });
28
+
29
+ expect(from).toHaveBeenCalledWith('calendar_auth_tokens');
30
+ expect(select).toHaveBeenCalledWith(
31
+ WORKSPACE_CALENDAR_GOOGLE_TOKEN_CLIENT_SELECT
32
+ );
33
+ expect(eqWorkspace).toHaveBeenCalledWith('ws_id', 'ws-1');
34
+ expect(eqUser).toHaveBeenCalledWith('user_id', 'user-1');
35
+ expect(maybeSingle).toHaveBeenCalledOnce();
36
+ });
37
+ });
@@ -0,0 +1,19 @@
1
+ import type { TypedSupabaseClient } from '@tuturuuu/supabase/types';
2
+ import type { WorkspaceCalendarGoogleTokenClient } from '@tuturuuu/types/db';
3
+
4
+ export const WORKSPACE_CALENDAR_GOOGLE_TOKEN_CLIENT_SELECT =
5
+ 'id, ws_id, user_id, provider, account_email, account_name, is_active, expires_at, created_at' as const;
6
+
7
+ export async function fetchUserWorkspaceCalendarGoogleTokenForClient(
8
+ supabase: TypedSupabaseClient,
9
+ params: { wsId: string; userId: string }
10
+ ): Promise<WorkspaceCalendarGoogleTokenClient | null> {
11
+ const { data } = await supabase
12
+ .from('calendar_auth_tokens')
13
+ .select(WORKSPACE_CALENDAR_GOOGLE_TOKEN_CLIENT_SELECT)
14
+ .eq('ws_id', params.wsId)
15
+ .eq('user_id', params.userId)
16
+ .maybeSingle();
17
+
18
+ return data;
19
+ }
@@ -0,0 +1,197 @@
1
+ # Calendar Sync Coordination
2
+
3
+ This module provides utilities to coordinate calendar sync operations between active sync (user-initiated) and background sync (automated) to prevent conflicts and ensure data consistency.
4
+
5
+ ## Overview
6
+
7
+ The calendar sync coordination system uses a `lastUpsert` timestamp to enforce a 30-second cooldown period between sync operations for each workspace. Additionally, it includes a 4-week range check to ensure syncs only operate within a reasonable time window from the current week. This prevents:
8
+
9
+ - Race conditions between active and background syncs
10
+ - Excessive API calls to Google Calendar
11
+ - Data inconsistencies from concurrent updates
12
+ - Syncs on date ranges too far in the past or future
13
+
14
+ ## Database Schema
15
+
16
+ The system uses a `workspace_calendar_sync_coordination` table:
17
+
18
+ ```sql
19
+ CREATE TABLE workspace_calendar_sync_coordination (
20
+ ws_id UUID PRIMARY KEY REFERENCES workspaces(id) ON DELETE CASCADE,
21
+ last_upsert TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
22
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
23
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
24
+ );
25
+ ```
26
+
27
+ ## Functions
28
+
29
+ ### `canProceedWithSync(wsId: string, supabase?: any): Promise<boolean>`
30
+
31
+ Checks if a sync operation can proceed for the given workspace.
32
+
33
+ **Parameters:**
34
+
35
+ - `wsId`: The workspace ID
36
+ - `supabase`: Optional Supabase client (will create one if not provided)
37
+
38
+ **Returns:**
39
+
40
+ - `true` if sync can proceed (30+ seconds since last upsert)
41
+ - `false` if sync is blocked (less than 30 seconds since last upsert)
42
+
43
+ **Behavior:**
44
+
45
+ - Creates a coordination record if one doesn't exist for the workspace
46
+ - Allows sync for new workspaces (no existing record)
47
+ - Gracefully handles errors by allowing sync to proceed
48
+
49
+ ### `isWithin4WeeksFromCurrentWeek(startDate: Date, endDate: Date): boolean`
50
+
51
+ Checks if a date range is within 4 weeks from the current week.
52
+
53
+ **Parameters:**
54
+
55
+ - `startDate`: Start date to check
56
+ - `endDate`: End date to check
57
+
58
+ **Returns:**
59
+
60
+ - `true` if the date range overlaps with 4 weeks from current week
61
+ - `false` if the date range is outside the 4-week window
62
+
63
+ **Behavior:**
64
+
65
+ - Uses the current week as the reference point (not the current view)
66
+ - Checks if the date range overlaps with the 4-week period
67
+ - Useful for preventing syncs on distant past or future dates
68
+
69
+ ### `updateLastUpsert(wsId: string, supabase?: any): Promise<void>`
70
+
71
+ Updates the `last_upsert` timestamp for a workspace after a successful sync operation.
72
+
73
+ **Parameters:**
74
+
75
+ - `wsId`: The workspace ID
76
+ - `supabase`: Optional Supabase client (will create one if not provided)
77
+
78
+ **Behavior:**
79
+
80
+ - Upserts the timestamp using the workspace ID as the conflict target
81
+ - Logs success or error messages
82
+ - Gracefully handles errors
83
+
84
+ ### `FOUR_WEEKS_FROM_CURRENT_WEEK`
85
+
86
+ Constant representing 4 weeks (28 days) from the current week.
87
+
88
+ ## Usage
89
+
90
+ ### Active Sync (use-calendar-sync.tsx)
91
+
92
+ ```typescript
93
+ import {
94
+ canProceedWithSync,
95
+ isWithin4WeeksFromCurrentWeek,
96
+ updateLastUpsert,
97
+ } from '@tuturuuu/utils/calendar-sync-coordination';
98
+
99
+ const syncToTuturuuu = async () => {
100
+ // Check if current view is within 4 weeks from current week
101
+ if (dates.length > 0) {
102
+ const startDate = dates[0];
103
+ const endDate = dates[dates.length - 1];
104
+
105
+ if (startDate && endDate) {
106
+ const isWithinRange = isWithin4WeeksFromCurrentWeek(startDate, endDate);
107
+
108
+ if (!isWithinRange) {
109
+ console.log(
110
+ 'Sync blocked: Current view is outside 4 weeks from current week'
111
+ );
112
+ return;
113
+ }
114
+ }
115
+ }
116
+
117
+ // Check if we can proceed with sync
118
+ const canProceed = await canProceedWithSync(wsId);
119
+ if (!canProceed) {
120
+ console.log('Sync blocked due to 30-second cooldown');
121
+ return;
122
+ }
123
+
124
+ // Perform sync operations...
125
+
126
+ // Update timestamp after successful upsert
127
+ await updateLastUpsert(wsId);
128
+ };
129
+ ```
130
+
131
+ ### Background Sync (google-calendar-background-sync.ts)
132
+
133
+ ```typescript
134
+ import {
135
+ FOUR_WEEKS_FROM_CURRENT_WEEK,
136
+ canProceedWithSync,
137
+ updateLastUpsert,
138
+ } from '@tuturuuu/utils/calendar-sync-coordination';
139
+
140
+ const syncGoogleCalendarEvents = async (supabase: any) => {
141
+ for (const token of tokens) {
142
+ // Check if we can proceed with sync for this workspace
143
+ const canProceed = await canProceedWithSync(token.ws_id, supabase);
144
+ if (!canProceed) {
145
+ console.log(
146
+ `Skipping background sync for wsId ${token.ws_id} due to 30-second cooldown`
147
+ );
148
+ continue;
149
+ }
150
+
151
+ // Perform sync operations with 4-week range
152
+ const timeMin = new Date();
153
+ const timeMax = new Date();
154
+ timeMax.setDate(timeMax.getDate() + FOUR_WEEKS_FROM_CURRENT_WEEK);
155
+
156
+ // ... sync logic ...
157
+
158
+ // Update timestamp after successful upsert
159
+ await updateLastUpsert(token.ws_id, supabase);
160
+ }
161
+ };
162
+ ```
163
+
164
+ ## Error Handling
165
+
166
+ All functions are designed to be fault-tolerant:
167
+
168
+ - If the coordination table doesn't exist, sync operations are allowed to proceed
169
+ - If database operations fail, sync operations are allowed to proceed
170
+ - All errors are logged but don't block sync operations
171
+ - This ensures the system degrades gracefully if the coordination mechanism fails
172
+
173
+ ## Migration
174
+
175
+ To set up the coordination table, run the migration:
176
+
177
+ ```sql
178
+ -- Create table to coordinate calendar sync operations
179
+ CREATE TABLE workspace_calendar_sync_coordination (
180
+ ws_id UUID PRIMARY KEY REFERENCES workspaces(id) ON DELETE CASCADE,
181
+ last_upsert TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
182
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
183
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
184
+ );
185
+
186
+ -- Add RLS policies and triggers as needed
187
+ ```
188
+
189
+ ## Benefits
190
+
191
+ 1. **Prevents Conflicts**: Ensures only one sync operation per workspace every 30 seconds
192
+ 2. **Reduces API Load**: Prevents excessive calls to Google Calendar API
193
+ 3. **Maintains Consistency**: Avoids race conditions that could corrupt data
194
+ 4. **Graceful Degradation**: System continues to work even if coordination fails
195
+ 5. **Workspace Isolation**: Each workspace has its own coordination record
196
+ 6. **Reasonable Time Window**: Prevents syncs on distant past or future dates
197
+ 7. **Current Week Reference**: Uses current week as reference point for 4-week window
@@ -0,0 +1,169 @@
1
+ import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
2
+ import { describe, expect, it } from 'vitest';
3
+ import {
4
+ convertGoogleAllDayEvent,
5
+ createAllDayEvent,
6
+ isAllDayEvent,
7
+ } from './calendar-utils';
8
+
9
+ describe('calendar-utils', () => {
10
+ describe('isAllDayEvent', () => {
11
+ it('should detect all-day events with date-only format', () => {
12
+ const event: Pick<CalendarEvent, 'start_at' | 'end_at'> = {
13
+ start_at: '2024-01-01T00:00:00.000Z',
14
+ end_at: '2024-01-02T00:00:00.000Z',
15
+ };
16
+
17
+ expect(isAllDayEvent(event)).toBe(true);
18
+ });
19
+
20
+ it('should detect all-day events spanning multiple days', () => {
21
+ const event: Pick<CalendarEvent, 'start_at' | 'end_at'> = {
22
+ start_at: '2024-01-01T00:00:00.000Z',
23
+ end_at: '2024-01-03T00:00:00.000Z', // 48 hours
24
+ };
25
+
26
+ expect(isAllDayEvent(event)).toBe(true);
27
+ });
28
+
29
+ it('should reject timed events', () => {
30
+ const event: Pick<CalendarEvent, 'start_at' | 'end_at'> = {
31
+ start_at: '2024-01-01T10:00:00.000Z',
32
+ end_at: '2024-01-01T11:00:00.000Z',
33
+ };
34
+
35
+ expect(isAllDayEvent(event)).toBe(false);
36
+ });
37
+
38
+ it('should reject events not starting at midnight', () => {
39
+ const event: Pick<CalendarEvent, 'start_at' | 'end_at'> = {
40
+ start_at: '2024-01-01T01:00:00.000Z',
41
+ end_at: '2024-01-02T00:00:00.000Z',
42
+ };
43
+
44
+ expect(isAllDayEvent(event)).toBe(false);
45
+ });
46
+
47
+ it('should reject events not ending at midnight', () => {
48
+ const event: Pick<CalendarEvent, 'start_at' | 'end_at'> = {
49
+ start_at: '2024-01-01T00:00:00.000Z',
50
+ end_at: '2024-01-02T01:00:00.000Z',
51
+ };
52
+
53
+ expect(isAllDayEvent(event)).toBe(false);
54
+ });
55
+
56
+ it('should reject events with non-24-hour duration', () => {
57
+ const event: Pick<CalendarEvent, 'start_at' | 'end_at'> = {
58
+ start_at: '2024-01-01T00:00:00.000Z',
59
+ end_at: '2024-01-01T12:00:00.000Z', // 12 hours
60
+ };
61
+
62
+ expect(isAllDayEvent(event)).toBe(false);
63
+ });
64
+
65
+ it('should reject events with 0 duration', () => {
66
+ const event: Pick<CalendarEvent, 'start_at' | 'end_at'> = {
67
+ start_at: '2024-01-01T00:00:00.000Z',
68
+ end_at: '2024-01-01T00:00:00.000Z', // Same time
69
+ };
70
+
71
+ expect(isAllDayEvent(event)).toBe(false);
72
+ });
73
+
74
+ it('should handle events with seconds and milliseconds', () => {
75
+ const event: Pick<CalendarEvent, 'start_at' | 'end_at'> = {
76
+ start_at: '2024-01-01T00:00:00.123Z',
77
+ end_at: '2024-01-02T00:00:00.456Z',
78
+ };
79
+
80
+ expect(isAllDayEvent(event)).toBe(false);
81
+ });
82
+ });
83
+
84
+ describe('convertGoogleAllDayEvent', () => {
85
+ it('should convert Google date-only format to user timezone midnight', () => {
86
+ const result = convertGoogleAllDayEvent(
87
+ '2024-01-01',
88
+ '2024-01-02',
89
+ 'America/New_York'
90
+ );
91
+
92
+ // Should convert to timezone midnight
93
+ expect(result.start_at).toMatch(/2024-01-01T05:00:00\.000Z/); // UTC equivalent of midnight EST
94
+ expect(result.end_at).toMatch(/2024-01-02T05:00:00\.000Z/);
95
+ });
96
+
97
+ it('should handle missing dates by using current time', () => {
98
+ const result = convertGoogleAllDayEvent(
99
+ undefined,
100
+ undefined,
101
+ 'America/New_York'
102
+ );
103
+
104
+ expect(result.start_at).toBeDefined();
105
+ expect(result.end_at).toBeDefined();
106
+ expect(
107
+ new Date(result.end_at).getTime() - new Date(result.start_at).getTime()
108
+ ).toBe(60 * 60 * 1000); // 1 hour
109
+ });
110
+
111
+ it('should pass through existing dateTime format unchanged', () => {
112
+ const result = convertGoogleAllDayEvent(
113
+ '2024-01-01T10:00:00Z',
114
+ '2024-01-01T11:00:00Z',
115
+ 'America/New_York'
116
+ );
117
+
118
+ expect(result.start_at).toBe('2024-01-01T10:00:00Z');
119
+ expect(result.end_at).toBe('2024-01-01T11:00:00Z');
120
+ });
121
+
122
+ it('should handle mixed date formats', () => {
123
+ const result = convertGoogleAllDayEvent(
124
+ '2024-01-01',
125
+ '2024-01-01T11:00:00Z',
126
+ 'America/New_York'
127
+ );
128
+
129
+ // Should pass through as-is since not both are date-only
130
+ expect(result.start_at).toBe('2024-01-01');
131
+ expect(result.end_at).toBe('2024-01-01T11:00:00Z');
132
+ });
133
+ });
134
+
135
+ describe('createAllDayEvent', () => {
136
+ it('should create all-day event in user timezone', () => {
137
+ const date = new Date('2024-01-01T10:00:00Z');
138
+ const result = createAllDayEvent(date, 'America/New_York');
139
+
140
+ expect(result.start_at).toMatch(/2024-01-01T05:00:00\.000Z/); // UTC equivalent of midnight EST
141
+ expect(result.end_at).toMatch(/2024-01-02T05:00:00\.000Z/);
142
+ });
143
+
144
+ it('should create multi-day all-day event', () => {
145
+ const date = new Date('2024-01-01T10:00:00Z');
146
+ const result = createAllDayEvent(date, 'America/New_York', 3);
147
+
148
+ expect(result.start_at).toMatch(/2024-01-01T05:00:00\.000Z/);
149
+ expect(result.end_at).toMatch(/2024-01-04T05:00:00\.000Z/); // 3 days later
150
+ });
151
+
152
+ it('should handle undefined timezone', () => {
153
+ const date = new Date('2024-01-01T10:00:00Z');
154
+ const result = createAllDayEvent(date, undefined);
155
+
156
+ // When timezone is undefined, the function should use the system's local timezone.
157
+ // To make this test robust regardless of the environment, we compute the expected UTC start/end.
158
+ const localDate = new Date(date);
159
+ localDate.setHours(0, 0, 0, 0);
160
+ const expectedStart = localDate.toISOString();
161
+ const expectedEnd = new Date(
162
+ localDate.getTime() + 24 * 60 * 60 * 1000
163
+ ).toISOString();
164
+
165
+ expect(result.start_at).toBe(expectedStart);
166
+ expect(result.end_at).toBe(expectedEnd);
167
+ });
168
+ });
169
+ });