@jmruthers/pace-core 0.5.183 → 0.5.185

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 (307) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +60 -1
  3. package/core-usage-manifest.json +312 -0
  4. package/dist/{DataTable-QAB34V6K.js → DataTable-IX2NBUTP.js} +6 -6
  5. package/dist/{DataTable-Bz8ffqyA.d.ts → DataTable-Z9NLVJh0.d.ts} +1 -1
  6. package/dist/{index-Bl--n7-T.d.ts → PublicPageProvider-BABf6JCh.d.ts} +21 -10
  7. package/dist/{UnifiedAuthProvider-7F6T4B6K.js → UnifiedAuthProvider-A4BCQRJY.js} +4 -2
  8. package/dist/{UnifiedAuthProvider-F86d7dSi.d.ts → UnifiedAuthProvider-BG0AL5eE.d.ts} +2 -1
  9. package/dist/{api-ROMBCNKU.js → api-BMFCXVQX.js} +2 -2
  10. package/dist/{chunk-RA3JUFMW.js → chunk-445GEP27.js} +154 -4
  11. package/dist/{chunk-RA3JUFMW.js.map → chunk-445GEP27.js.map} +1 -1
  12. package/dist/{chunk-CSOFYHAG.js → chunk-AISXLWGZ.js} +374 -60
  13. package/dist/chunk-AISXLWGZ.js.map +1 -0
  14. package/dist/{chunk-FUEYYMX5.js → chunk-FXFJRTKI.js} +24 -3
  15. package/dist/chunk-FXFJRTKI.js.map +1 -0
  16. package/dist/{chunk-QETLRQI6.js → chunk-HC67NW5K.js} +380 -360
  17. package/dist/chunk-HC67NW5K.js.map +1 -0
  18. package/dist/chunk-HESYZWZW.js +388 -0
  19. package/dist/chunk-HESYZWZW.js.map +1 -0
  20. package/dist/{chunk-QUVSNGIP.js → chunk-HGPQUCBC.js} +34 -9
  21. package/dist/{chunk-QUVSNGIP.js.map → chunk-HGPQUCBC.js.map} +1 -1
  22. package/dist/{chunk-UHNYIBXL.js → chunk-IXSNYUCT.js} +1 -1
  23. package/dist/chunk-IXSNYUCT.js.map +1 -0
  24. package/dist/{chunk-MI7HBHN3.js → chunk-MX3EIJGQ.js} +4 -3
  25. package/dist/{chunk-MI7HBHN3.js.map → chunk-MX3EIJGQ.js.map} +1 -1
  26. package/dist/{chunk-PWAHJW4G.js → chunk-OKI34GZD.js} +86 -33
  27. package/dist/chunk-OKI34GZD.js.map +1 -0
  28. package/dist/{chunk-W22JP75J.js → chunk-STTZQK2I.js} +3 -3
  29. package/dist/chunk-THRPYOFK.js +215 -0
  30. package/dist/chunk-THRPYOFK.js.map +1 -0
  31. package/dist/{chunk-M7W4CP3M.js → chunk-U6WNSFX5.js} +2 -1
  32. package/dist/chunk-U6WNSFX5.js.map +1 -0
  33. package/dist/{chunk-QCDXODCA.js → chunk-XAUHJD3L.js} +2 -2
  34. package/dist/components.d.ts +182 -6
  35. package/dist/components.js +157 -11
  36. package/dist/components.js.map +1 -1
  37. package/dist/eslint-rules/pace-core-compliance.cjs +406 -0
  38. package/dist/{file-reference-D06mEEWW.d.ts → file-reference-BjR39ktt.d.ts} +7 -1
  39. package/dist/hooks.d.ts +7 -14
  40. package/dist/hooks.js +10 -22
  41. package/dist/hooks.js.map +1 -1
  42. package/dist/index.d.ts +11 -11
  43. package/dist/index.js +79 -16
  44. package/dist/index.js.map +1 -1
  45. package/dist/providers.d.ts +1 -1
  46. package/dist/providers.js +3 -1
  47. package/dist/rbac/index.d.ts +205 -14
  48. package/dist/rbac/index.js +28 -6
  49. package/dist/timezone-_pgH8qrY.d.ts +530 -0
  50. package/dist/{types-_x1f4QBF.d.ts → types-DUyCRSTj.d.ts} +1 -1
  51. package/dist/types.d.ts +1 -1
  52. package/dist/types.js +1 -1
  53. package/dist/{usePublicRouteParams-JJczomYq.d.ts → usePublicRouteParams-CvnC3d-e.d.ts} +113 -2
  54. package/dist/utils.d.ts +109 -151
  55. package/dist/utils.js +128 -138
  56. package/dist/utils.js.map +1 -1
  57. package/docs/api/README.md +60 -1
  58. package/docs/api/classes/ColumnFactory.md +1 -1
  59. package/docs/api/classes/ErrorBoundary.md +1 -1
  60. package/docs/api/classes/InvalidScopeError.md +1 -1
  61. package/docs/api/classes/Logger.md +178 -0
  62. package/docs/api/classes/MissingUserContextError.md +1 -1
  63. package/docs/api/classes/OrganisationContextRequiredError.md +1 -1
  64. package/docs/api/classes/PermissionDeniedError.md +1 -1
  65. package/docs/api/classes/RBACAuditManager.md +2 -2
  66. package/docs/api/classes/RBACCache.md +1 -1
  67. package/docs/api/classes/RBACEngine.md +2 -2
  68. package/docs/api/classes/RBACError.md +1 -1
  69. package/docs/api/classes/RBACNotInitializedError.md +1 -1
  70. package/docs/api/classes/SecureSupabaseClient.md +5 -5
  71. package/docs/api/classes/StorageUtils.md +1 -1
  72. package/docs/api/enums/FileCategory.md +1 -1
  73. package/docs/api/enums/LogLevel.md +54 -0
  74. package/docs/api/enums/RBACErrorCode.md +1 -1
  75. package/docs/api/enums/RPCFunction.md +1 -1
  76. package/docs/api/interfaces/AggregateConfig.md +1 -1
  77. package/docs/api/interfaces/BadgeProps.md +1 -1
  78. package/docs/api/interfaces/ButtonProps.md +1 -1
  79. package/docs/api/interfaces/CalendarProps.md +18 -2
  80. package/docs/api/interfaces/CardProps.md +1 -1
  81. package/docs/api/interfaces/ColorPalette.md +1 -1
  82. package/docs/api/interfaces/ColorShade.md +1 -1
  83. package/docs/api/interfaces/ComplianceResult.md +30 -0
  84. package/docs/api/interfaces/DataAccessRecord.md +1 -1
  85. package/docs/api/interfaces/DataRecord.md +1 -1
  86. package/docs/api/interfaces/DataTableAction.md +1 -1
  87. package/docs/api/interfaces/DataTableColumn.md +1 -1
  88. package/docs/api/interfaces/DataTableProps.md +1 -1
  89. package/docs/api/interfaces/DataTableToolbarButton.md +1 -1
  90. package/docs/api/interfaces/DatabaseComplianceResult.md +85 -0
  91. package/docs/api/interfaces/DatabaseIssue.md +41 -0
  92. package/docs/api/interfaces/EmptyStateConfig.md +1 -1
  93. package/docs/api/interfaces/EnhancedNavigationMenuProps.md +1 -1
  94. package/docs/api/interfaces/EventAppRoleData.md +6 -6
  95. package/docs/api/interfaces/ExportColumn.md +1 -1
  96. package/docs/api/interfaces/ExportOptions.md +1 -1
  97. package/docs/api/interfaces/FileDisplayProps.md +1 -1
  98. package/docs/api/interfaces/FileMetadata.md +1 -1
  99. package/docs/api/interfaces/FileReference.md +1 -1
  100. package/docs/api/interfaces/FileSizeLimits.md +1 -1
  101. package/docs/api/interfaces/FileUploadOptions.md +24 -8
  102. package/docs/api/interfaces/FileUploadProps.md +24 -13
  103. package/docs/api/interfaces/FooterProps.md +1 -1
  104. package/docs/api/interfaces/FormFieldProps.md +1 -1
  105. package/docs/api/interfaces/FormProps.md +1 -1
  106. package/docs/api/interfaces/GrantEventAppRoleParams.md +9 -9
  107. package/docs/api/interfaces/InactivityWarningModalProps.md +1 -1
  108. package/docs/api/interfaces/InputProps.md +1 -1
  109. package/docs/api/interfaces/LabelProps.md +1 -1
  110. package/docs/api/interfaces/LoggerConfig.md +62 -0
  111. package/docs/api/interfaces/LoginFormProps.md +1 -1
  112. package/docs/api/interfaces/NavigationAccessRecord.md +1 -1
  113. package/docs/api/interfaces/NavigationContextType.md +1 -1
  114. package/docs/api/interfaces/NavigationGuardProps.md +1 -1
  115. package/docs/api/interfaces/NavigationItem.md +1 -1
  116. package/docs/api/interfaces/NavigationMenuProps.md +1 -1
  117. package/docs/api/interfaces/NavigationProviderProps.md +1 -1
  118. package/docs/api/interfaces/Organisation.md +1 -1
  119. package/docs/api/interfaces/OrganisationContextType.md +1 -1
  120. package/docs/api/interfaces/OrganisationMembership.md +1 -1
  121. package/docs/api/interfaces/OrganisationProviderProps.md +1 -1
  122. package/docs/api/interfaces/OrganisationSecurityError.md +1 -1
  123. package/docs/api/interfaces/PaceAppLayoutProps.md +36 -23
  124. package/docs/api/interfaces/PaceLoginPageProps.md +1 -1
  125. package/docs/api/interfaces/PageAccessRecord.md +1 -1
  126. package/docs/api/interfaces/PagePermissionContextType.md +1 -1
  127. package/docs/api/interfaces/PagePermissionGuardProps.md +11 -11
  128. package/docs/api/interfaces/PagePermissionProviderProps.md +1 -1
  129. package/docs/api/interfaces/PaletteData.md +1 -1
  130. package/docs/api/interfaces/PermissionEnforcerProps.md +1 -1
  131. package/docs/api/interfaces/ProgressProps.md +1 -1
  132. package/docs/api/interfaces/ProtectedRouteProps.md +1 -1
  133. package/docs/api/interfaces/PublicPageFooterProps.md +1 -1
  134. package/docs/api/interfaces/PublicPageHeaderProps.md +1 -1
  135. package/docs/api/interfaces/PublicPageLayoutProps.md +1 -1
  136. package/docs/api/interfaces/QuickFix.md +52 -0
  137. package/docs/api/interfaces/RBACAccessValidateParams.md +1 -1
  138. package/docs/api/interfaces/RBACAccessValidateResult.md +1 -1
  139. package/docs/api/interfaces/RBACAuditLogParams.md +1 -1
  140. package/docs/api/interfaces/RBACAuditLogResult.md +1 -1
  141. package/docs/api/interfaces/RBACConfig.md +4 -4
  142. package/docs/api/interfaces/RBACContext.md +1 -1
  143. package/docs/api/interfaces/RBACLogger.md +1 -1
  144. package/docs/api/interfaces/RBACPageAccessCheckParams.md +1 -1
  145. package/docs/api/interfaces/RBACPermissionCheckParams.md +1 -1
  146. package/docs/api/interfaces/RBACPermissionCheckResult.md +1 -1
  147. package/docs/api/interfaces/RBACPermissionsGetParams.md +1 -1
  148. package/docs/api/interfaces/RBACPermissionsGetResult.md +1 -1
  149. package/docs/api/interfaces/RBACResult.md +1 -1
  150. package/docs/api/interfaces/RBACRoleGrantParams.md +1 -1
  151. package/docs/api/interfaces/RBACRoleGrantResult.md +1 -1
  152. package/docs/api/interfaces/RBACRoleRevokeParams.md +1 -1
  153. package/docs/api/interfaces/RBACRoleRevokeResult.md +1 -1
  154. package/docs/api/interfaces/RBACRoleValidateParams.md +1 -1
  155. package/docs/api/interfaces/RBACRoleValidateResult.md +1 -1
  156. package/docs/api/interfaces/RBACRolesListParams.md +1 -1
  157. package/docs/api/interfaces/RBACRolesListResult.md +1 -1
  158. package/docs/api/interfaces/RBACSessionTrackParams.md +1 -1
  159. package/docs/api/interfaces/RBACSessionTrackResult.md +1 -1
  160. package/docs/api/interfaces/ResourcePermissions.md +1 -1
  161. package/docs/api/interfaces/RevokeEventAppRoleParams.md +7 -7
  162. package/docs/api/interfaces/RoleBasedRouterContextType.md +1 -1
  163. package/docs/api/interfaces/RoleBasedRouterProps.md +1 -1
  164. package/docs/api/interfaces/RoleManagementResult.md +5 -5
  165. package/docs/api/interfaces/RouteAccessRecord.md +1 -1
  166. package/docs/api/interfaces/RouteConfig.md +1 -1
  167. package/docs/api/interfaces/RuntimeComplianceResult.md +55 -0
  168. package/docs/api/interfaces/SecureDataContextType.md +1 -1
  169. package/docs/api/interfaces/SecureDataProviderProps.md +1 -1
  170. package/docs/api/interfaces/SessionRestorationLoaderProps.md +1 -1
  171. package/docs/api/interfaces/SetupIssue.md +41 -0
  172. package/docs/api/interfaces/StorageConfig.md +1 -1
  173. package/docs/api/interfaces/StorageFileInfo.md +1 -1
  174. package/docs/api/interfaces/StorageFileMetadata.md +1 -1
  175. package/docs/api/interfaces/StorageListOptions.md +1 -1
  176. package/docs/api/interfaces/StorageListResult.md +1 -1
  177. package/docs/api/interfaces/StorageUploadOptions.md +1 -1
  178. package/docs/api/interfaces/StorageUploadResult.md +1 -1
  179. package/docs/api/interfaces/StorageUrlOptions.md +1 -1
  180. package/docs/api/interfaces/StyleImport.md +1 -1
  181. package/docs/api/interfaces/SwitchProps.md +1 -1
  182. package/docs/api/interfaces/TabsContentProps.md +1 -1
  183. package/docs/api/interfaces/TabsListProps.md +1 -1
  184. package/docs/api/interfaces/TabsProps.md +1 -1
  185. package/docs/api/interfaces/TabsTriggerProps.md +1 -1
  186. package/docs/api/interfaces/TextareaProps.md +1 -1
  187. package/docs/api/interfaces/ToastActionElement.md +1 -1
  188. package/docs/api/interfaces/ToastProps.md +1 -1
  189. package/docs/api/interfaces/UnifiedAuthContextType.md +1 -1
  190. package/docs/api/interfaces/UnifiedAuthProviderProps.md +1 -1
  191. package/docs/api/interfaces/UseFormDialogOptions.md +62 -0
  192. package/docs/api/interfaces/UseFormDialogReturn.md +117 -0
  193. package/docs/api/interfaces/UseInactivityTrackerOptions.md +1 -1
  194. package/docs/api/interfaces/UseInactivityTrackerReturn.md +1 -1
  195. package/docs/api/interfaces/UsePublicEventLogoOptions.md +2 -2
  196. package/docs/api/interfaces/UsePublicEventLogoReturn.md +1 -1
  197. package/docs/api/interfaces/UsePublicEventOptions.md +1 -1
  198. package/docs/api/interfaces/UsePublicEventReturn.md +1 -1
  199. package/docs/api/interfaces/UsePublicFileDisplayOptions.md +2 -2
  200. package/docs/api/interfaces/UsePublicFileDisplayReturn.md +1 -1
  201. package/docs/api/interfaces/UsePublicRouteParamsReturn.md +1 -1
  202. package/docs/api/interfaces/UseResolvedScopeOptions.md +2 -2
  203. package/docs/api/interfaces/UseResolvedScopeReturn.md +1 -1
  204. package/docs/api/interfaces/UseResourcePermissionsOptions.md +1 -1
  205. package/docs/api/interfaces/UserEventAccess.md +1 -1
  206. package/docs/api/interfaces/UserMenuProps.md +1 -1
  207. package/docs/api/interfaces/UserProfile.md +1 -1
  208. package/docs/api/modules.md +738 -42
  209. package/docs/api-reference/hooks.md +111 -0
  210. package/docs/api-reference/rpc-functions.md +1 -1
  211. package/docs/api-reference/utilities.md +184 -0
  212. package/docs/getting-started/installation-guide.md +75 -16
  213. package/docs/getting-started/quick-start.md +61 -11
  214. package/docs/implementation-guides/authentication.md +88 -12
  215. package/docs/implementation-guides/file-reference-system.md +2 -1
  216. package/docs/implementation-guides/file-upload-storage.md +21 -0
  217. package/docs/rbac/README.md +1 -0
  218. package/docs/rbac/compliance/compliance-guide.md +544 -0
  219. package/docs/rbac/getting-started.md +158 -33
  220. package/docs/standards/pace-core-compliance.md +432 -0
  221. package/eslint-config-pace-core.cjs +93 -0
  222. package/package.json +15 -3
  223. package/scripts/analyze-bundle.js +232 -0
  224. package/scripts/build-css.js +56 -0
  225. package/scripts/build-docs-incremental.js +1015 -0
  226. package/scripts/check-pace-core-compliance.cjs +2353 -0
  227. package/scripts/generate-docs.js +157 -0
  228. package/scripts/setup-build-cache.js +73 -0
  229. package/scripts/utils/command-runner.js +131 -0
  230. package/scripts/utils/env.js +33 -0
  231. package/scripts/utils/index.js +10 -0
  232. package/scripts/utils/logger.js +88 -0
  233. package/scripts/utils/path-helpers.js +37 -0
  234. package/scripts/validate-formats.js +133 -0
  235. package/scripts/validate-master.js +155 -0
  236. package/scripts/validate-pre-publish.js +140 -0
  237. package/scripts/validate-theme.js +142 -0
  238. package/src/components/Calendar/Calendar.tsx +8 -1
  239. package/src/components/Card/Card.tsx +47 -8
  240. package/src/components/DatePickerWithTimezone/DatePickerWithTimezone.test.tsx +314 -0
  241. package/src/components/DatePickerWithTimezone/DatePickerWithTimezone.tsx +126 -0
  242. package/src/components/DatePickerWithTimezone/README.md +135 -0
  243. package/src/components/DatePickerWithTimezone/index.ts +10 -0
  244. package/src/components/DateTimeField/DateTimeField.test.tsx +358 -0
  245. package/src/components/DateTimeField/DateTimeField.tsx +232 -0
  246. package/src/components/DateTimeField/README.md +148 -0
  247. package/src/components/DateTimeField/index.ts +10 -0
  248. package/src/components/FileUpload/FileUpload.tsx +3 -0
  249. package/src/components/Header/Header.test.tsx +47 -18
  250. package/src/components/Header/Header.tsx +24 -6
  251. package/src/components/PaceAppLayout/PaceAppLayout.tsx +29 -20
  252. package/src/components/PaceAppLayout/README.md +9 -0
  253. package/src/components/PaceLoginPage/PaceLoginPage.tsx +1 -1
  254. package/src/components/ProtectedRoute/ProtectedRoute.test.tsx +37 -8
  255. package/src/components/ProtectedRoute/ProtectedRoute.tsx +12 -4
  256. package/src/components/index.ts +8 -0
  257. package/src/eslint-rules/pace-core-compliance.cjs +406 -0
  258. package/src/eslint-rules/pace-core-compliance.js +640 -0
  259. package/src/hooks/__tests__/useFormDialog.test.ts +478 -0
  260. package/src/hooks/index.ts +2 -0
  261. package/src/hooks/useFileReference.test.ts +1 -0
  262. package/src/hooks/useFormDialog.ts +147 -0
  263. package/src/index.ts +27 -0
  264. package/src/providers/services/OrganisationServiceProvider.tsx +6 -5
  265. package/src/providers/services/UnifiedAuthProvider.tsx +24 -3
  266. package/src/rbac/__tests__/scenarios.user-role.test.tsx +3 -0
  267. package/src/rbac/compliance/database-validator.ts +165 -0
  268. package/src/rbac/compliance/index.ts +38 -0
  269. package/src/rbac/compliance/quick-fix-suggestions.ts +209 -0
  270. package/src/rbac/compliance/runtime-compliance.ts +77 -0
  271. package/src/rbac/compliance/setup-validator.ts +131 -0
  272. package/src/rbac/components/PagePermissionGuard.tsx +8 -64
  273. package/src/rbac/components/__tests__/PagePermissionGuard.test.tsx +35 -21
  274. package/src/rbac/docs/event-based-apps.md +285 -0
  275. package/src/rbac/errors.ts +11 -0
  276. package/src/rbac/hooks/useRoleManagement.ts +292 -12
  277. package/src/rbac/index.ts +30 -0
  278. package/src/services/OrganisationService.ts +4 -0
  279. package/src/types/file-reference.ts +6 -0
  280. package/src/utils/__tests__/timezone.test.ts +345 -0
  281. package/src/utils/file-reference/__tests__/file-reference.test.ts +2 -0
  282. package/src/utils/file-reference/index.ts +1 -0
  283. package/src/utils/formatting/formatDateTimeTimezone.test.ts +167 -0
  284. package/src/utils/formatting/formatting.ts +179 -0
  285. package/src/utils/index.ts +27 -1
  286. package/src/utils/location/index.ts +16 -0
  287. package/src/utils/location/location.test.ts +286 -0
  288. package/src/utils/location/location.ts +175 -0
  289. package/src/utils/timezone/index.ts +17 -0
  290. package/src/utils/timezone/timezone.test.ts +349 -0
  291. package/src/utils/timezone/timezone.ts +281 -0
  292. package/dist/chunk-CSOFYHAG.js.map +0 -1
  293. package/dist/chunk-FUEYYMX5.js.map +0 -1
  294. package/dist/chunk-HKIT6O7W.js +0 -198
  295. package/dist/chunk-HKIT6O7W.js.map +0 -1
  296. package/dist/chunk-KUEN3HFB.js +0 -94
  297. package/dist/chunk-KUEN3HFB.js.map +0 -1
  298. package/dist/chunk-M7W4CP3M.js.map +0 -1
  299. package/dist/chunk-PWAHJW4G.js.map +0 -1
  300. package/dist/chunk-QETLRQI6.js.map +0 -1
  301. package/dist/chunk-UHNYIBXL.js.map +0 -1
  302. package/dist/formatting-5wETwiGF.d.ts +0 -162
  303. /package/dist/{DataTable-QAB34V6K.js.map → DataTable-IX2NBUTP.js.map} +0 -0
  304. /package/dist/{UnifiedAuthProvider-7F6T4B6K.js.map → UnifiedAuthProvider-A4BCQRJY.js.map} +0 -0
  305. /package/dist/{api-ROMBCNKU.js.map → api-BMFCXVQX.js.map} +0 -0
  306. /package/dist/{chunk-W22JP75J.js.map → chunk-STTZQK2I.js.map} +0 -0
  307. /package/dist/{chunk-QCDXODCA.js.map → chunk-XAUHJD3L.js.map} +0 -0
@@ -2,6 +2,9 @@
2
2
  * Utility functions for formatting data in the application
3
3
  */
4
4
 
5
+ import { parseISO, isValid } from 'date-fns';
6
+ import { formatInTimeZone, getTimezoneAbbreviation } from '../timezone';
7
+
5
8
  /**
6
9
  * Format a date as a readable string in "dd mmm yyyy" format (e.g., "15 Jun 2024")
7
10
  */
@@ -168,3 +171,179 @@ export function formatFileSize(bytes: number): string {
168
171
 
169
172
  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
170
173
  }
174
+
175
+ /**
176
+ * Options for formatting date/time with timezone
177
+ */
178
+ export interface DateTimeFormatOptions {
179
+ /**
180
+ * Include timezone abbreviation (default: true)
181
+ */
182
+ includeTimezone?: boolean;
183
+ /**
184
+ * Custom format string (default: 'MMM dd, yyyy HH:mm')
185
+ */
186
+ format?: string;
187
+ }
188
+
189
+ /**
190
+ * Format a UTC date for display in a specific timezone
191
+ *
192
+ * @param utcDate - UTC date (ISO string, Date object, or undefined)
193
+ * @param timezone - IANA timezone string (e.g., 'America/New_York')
194
+ * @param options - Formatting options
195
+ * @returns Formatted date string or empty string if invalid
196
+ *
197
+ * @example
198
+ * ```ts
199
+ * formatDateTimeForDisplay('2024-01-15T10:00:00Z', 'America/New_York');
200
+ * // "Jan 15, 2024 05:00 (EST)"
201
+ *
202
+ * formatDateTimeForDisplay('2024-01-15T10:00:00Z', 'America/New_York', { includeTimezone: false });
203
+ * // "Jan 15, 2024 05:00"
204
+ * ```
205
+ */
206
+ export function formatDateTimeForDisplay(
207
+ utcDate: string | Date | undefined,
208
+ timezone: string | undefined,
209
+ options: DateTimeFormatOptions = {}
210
+ ): string {
211
+ if (!utcDate) {
212
+ return '';
213
+ }
214
+
215
+ if (!timezone) {
216
+ return '';
217
+ }
218
+
219
+ try {
220
+ const { includeTimezone = true, format: formatStr = 'MMM dd, yyyy HH:mm' } = options;
221
+
222
+ let dateObj: Date;
223
+ if (typeof utcDate === 'string') {
224
+ dateObj = parseISO(utcDate);
225
+ } else {
226
+ dateObj = utcDate;
227
+ }
228
+
229
+ if (!isValid(dateObj)) {
230
+ return '';
231
+ }
232
+
233
+ const formatted = formatInTimeZone(dateObj, timezone, formatStr);
234
+
235
+ if (includeTimezone) {
236
+ const tzAbbr = getTimezoneAbbreviation(dateObj, timezone);
237
+ return `${formatted} (${tzAbbr})`;
238
+ }
239
+
240
+ return formatted;
241
+ } catch {
242
+ return '';
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Format a UTC date for display (date only, no time)
248
+ *
249
+ * @param utcDate - UTC date (ISO string, Date object, or undefined)
250
+ * @returns Formatted date string or empty string if invalid
251
+ *
252
+ * @example
253
+ * ```ts
254
+ * formatDateOnlyForDisplay('2024-01-15T10:00:00Z');
255
+ * // "15 January 2024"
256
+ * ```
257
+ */
258
+ export function formatDateOnlyForDisplay(utcDate: string | Date | undefined): string {
259
+ if (!utcDate) {
260
+ return '';
261
+ }
262
+
263
+ try {
264
+ let dateObj: Date;
265
+ if (typeof utcDate === 'string') {
266
+ dateObj = parseISO(utcDate);
267
+ } else {
268
+ dateObj = utcDate;
269
+ }
270
+
271
+ if (!isValid(dateObj)) {
272
+ return '';
273
+ }
274
+
275
+ // Use 'en-GB' locale for "dd mmm yyyy" format
276
+ return dateObj.toLocaleDateString('en-GB', {
277
+ year: 'numeric',
278
+ month: 'long',
279
+ day: 'numeric'
280
+ });
281
+ } catch {
282
+ return '';
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Format a UTC date for table display (compact format with timezone)
288
+ *
289
+ * @param utcDate - UTC date (ISO string, Date object, or undefined)
290
+ * @param timezone - IANA timezone string
291
+ * @returns Formatted date string or empty string if invalid
292
+ *
293
+ * @example
294
+ * ```ts
295
+ * formatDateTimeForTable('2024-01-15T10:00:00Z', 'America/New_York');
296
+ * // "Jan 15, 2024 05:00 (EST)"
297
+ * ```
298
+ */
299
+ export function formatDateTimeForTable(
300
+ utcDate: string | Date | undefined,
301
+ timezone: string | undefined
302
+ ): string {
303
+ return formatDateTimeForDisplay(utcDate, timezone, {
304
+ includeTimezone: true,
305
+ format: 'MMM dd, yyyy HH:mm'
306
+ });
307
+ }
308
+
309
+ /**
310
+ * Format a UTC date for map display (compact format)
311
+ *
312
+ * @param utcDate - UTC date (ISO string, Date object, or undefined)
313
+ * @param timezone - IANA timezone string
314
+ * @returns Formatted date string or empty string if invalid
315
+ *
316
+ * @example
317
+ * ```ts
318
+ * formatDateTimeForMap('2024-01-15T10:00:00Z', 'America/New_York');
319
+ * // "Jan 15, 05:00 EST"
320
+ * ```
321
+ */
322
+ export function formatDateTimeForMap(
323
+ utcDate: string | Date | undefined,
324
+ timezone: string | undefined
325
+ ): string {
326
+ if (!utcDate || !timezone) {
327
+ return '';
328
+ }
329
+
330
+ try {
331
+ let dateObj: Date;
332
+ if (typeof utcDate === 'string') {
333
+ dateObj = parseISO(utcDate);
334
+ } else {
335
+ dateObj = utcDate;
336
+ }
337
+
338
+ if (!isValid(dateObj)) {
339
+ return '';
340
+ }
341
+
342
+ const formatted = formatInTimeZone(dateObj, timezone, 'MMM dd, HH:mm');
343
+ const tzAbbr = getTimezoneAbbreviation(dateObj, timezone);
344
+
345
+ return `${formatted} ${tzAbbr}`;
346
+ } catch {
347
+ return '';
348
+ }
349
+ }
@@ -120,8 +120,13 @@ export {
120
120
  formatNumber,
121
121
  formatPercent,
122
122
  formatCompactNumber,
123
- formatFileSize
123
+ formatFileSize,
124
+ formatDateTimeForDisplay,
125
+ formatDateOnlyForDisplay,
126
+ formatDateTimeForTable,
127
+ formatDateTimeForMap
124
128
  } from './formatting/formatting';
129
+ export type { DateTimeFormatOptions } from './formatting/formatting';
125
130
 
126
131
  // Organisation context utilities
127
132
  export {
@@ -130,3 +135,24 @@ export {
130
135
  getOrganisationContext,
131
136
  isOrganisationContextAvailable
132
137
  } from './context/organisationContext';
138
+
139
+ // Timezone utilities
140
+ export {
141
+ formatInTimeZone,
142
+ getTimezoneAbbreviation,
143
+ formatTimeInTimeZone,
144
+ getUserTimeZone,
145
+ toZonedTime,
146
+ fromZonedTime,
147
+ roundToNearestMinutes,
148
+ getTimeZoneDifference
149
+ } from './timezone';
150
+
151
+ // Location utilities
152
+ export {
153
+ formatCoordinates,
154
+ hasValidCoordinates,
155
+ areCoordinatesEqual,
156
+ getGoogleMapsUrl
157
+ } from './location';
158
+ export type { Coordinates } from './location';
@@ -0,0 +1,16 @@
1
+ /**
2
+ * @file Location Utilities Exports
3
+ * @package @jmruthers/pace-core
4
+ * @module Utils/Location
5
+ * @since 0.1.0
6
+ */
7
+
8
+ export {
9
+ formatCoordinates,
10
+ hasValidCoordinates,
11
+ areCoordinatesEqual,
12
+ getGoogleMapsUrl
13
+ } from './location';
14
+
15
+ export type { Coordinates } from './location';
16
+
@@ -0,0 +1,286 @@
1
+ /**
2
+ * @file Location Utilities Tests
3
+ * @package @jmruthers/pace-core
4
+ * @module Utils/Location/__tests__
5
+ * @since 0.1.0
6
+ *
7
+ * Comprehensive tests for location utility functions.
8
+ * Tests cover all major functionality, edge cases, and error handling.
9
+ */
10
+
11
+ import { describe, it, expect } from 'vitest';
12
+ import {
13
+ formatCoordinates,
14
+ hasValidCoordinates,
15
+ areCoordinatesEqual,
16
+ getGoogleMapsUrl,
17
+ type Coordinates
18
+ } from './location';
19
+
20
+ describe('Location Utilities', () => {
21
+ describe('formatCoordinates', () => {
22
+ it('formats valid coordinates with 6 decimal places', () => {
23
+ const coords: Coordinates = { lat: -37.8136, lng: 144.9631 };
24
+ const result = formatCoordinates(coords);
25
+ expect(result).toBe('-37.813600, 144.963100');
26
+ });
27
+
28
+ it('formats coordinates with fewer decimal places', () => {
29
+ const coords: Coordinates = { lat: 0, lng: 0 };
30
+ const result = formatCoordinates(coords);
31
+ expect(result).toBe('0.000000, 0.000000');
32
+ });
33
+
34
+ it('formats coordinates with many decimal places', () => {
35
+ const coords: Coordinates = { lat: -37.813612345, lng: 144.963198765 };
36
+ const result = formatCoordinates(coords);
37
+ expect(result).toBe('-37.813612, 144.963199');
38
+ });
39
+
40
+ it('returns "N/A" for undefined', () => {
41
+ const result = formatCoordinates(undefined);
42
+ expect(result).toBe('N/A');
43
+ });
44
+
45
+ it('returns "N/A" for null', () => {
46
+ // @ts-expect-error - Testing null input
47
+ const result = formatCoordinates(null);
48
+ expect(result).toBe('N/A');
49
+ });
50
+
51
+ it('returns "N/A" for missing lat', () => {
52
+ // @ts-expect-error - Testing invalid input
53
+ const result = formatCoordinates({ lng: 144.9631 });
54
+ expect(result).toBe('N/A');
55
+ });
56
+
57
+ it('returns "N/A" for missing lng', () => {
58
+ // @ts-expect-error - Testing invalid input
59
+ const result = formatCoordinates({ lat: -37.8136 });
60
+ expect(result).toBe('N/A');
61
+ });
62
+
63
+ it('returns "N/A" for NaN values', () => {
64
+ const coords: Coordinates = { lat: NaN, lng: 144.9631 };
65
+ const result = formatCoordinates(coords);
66
+ expect(result).toBe('N/A');
67
+ });
68
+
69
+ it('returns "N/A" for Infinity values', () => {
70
+ const coords: Coordinates = { lat: Infinity, lng: 144.9631 };
71
+ const result = formatCoordinates(coords);
72
+ expect(result).toBe('N/A');
73
+ });
74
+
75
+ it('returns "N/A" for non-number lat', () => {
76
+ // @ts-expect-error - Testing invalid input
77
+ const result = formatCoordinates({ lat: 'invalid', lng: 144.9631 });
78
+ expect(result).toBe('N/A');
79
+ });
80
+
81
+ it('returns "N/A" for non-number lng', () => {
82
+ // @ts-expect-error - Testing invalid input
83
+ const result = formatCoordinates({ lat: -37.8136, lng: 'invalid' });
84
+ expect(result).toBe('N/A');
85
+ });
86
+ });
87
+
88
+ describe('hasValidCoordinates', () => {
89
+ it('returns true for valid coordinates', () => {
90
+ const coords: Coordinates = { lat: -37.8136, lng: 144.9631 };
91
+ expect(hasValidCoordinates(coords)).toBe(true);
92
+ });
93
+
94
+ it('returns true for coordinates at boundaries', () => {
95
+ expect(hasValidCoordinates({ lat: -90, lng: -180 })).toBe(true);
96
+ expect(hasValidCoordinates({ lat: 90, lng: 180 })).toBe(true);
97
+ expect(hasValidCoordinates({ lat: 0, lng: 0 })).toBe(true);
98
+ });
99
+
100
+ it('returns false for lat out of range (too low)', () => {
101
+ expect(hasValidCoordinates({ lat: -91, lng: 0 })).toBe(false);
102
+ });
103
+
104
+ it('returns false for lat out of range (too high)', () => {
105
+ expect(hasValidCoordinates({ lat: 91, lng: 0 })).toBe(false);
106
+ });
107
+
108
+ it('returns false for lng out of range (too low)', () => {
109
+ expect(hasValidCoordinates({ lat: 0, lng: -181 })).toBe(false);
110
+ });
111
+
112
+ it('returns false for lng out of range (too high)', () => {
113
+ expect(hasValidCoordinates({ lat: 0, lng: 181 })).toBe(false);
114
+ });
115
+
116
+ it('returns false for undefined', () => {
117
+ expect(hasValidCoordinates(undefined)).toBe(false);
118
+ });
119
+
120
+ it('returns false for null', () => {
121
+ // @ts-expect-error - Testing null input
122
+ expect(hasValidCoordinates(null)).toBe(false);
123
+ });
124
+
125
+ it('returns false for missing lat', () => {
126
+ expect(hasValidCoordinates({ lng: 144.9631 })).toBe(false);
127
+ });
128
+
129
+ it('returns false for missing lng', () => {
130
+ expect(hasValidCoordinates({ lat: -37.8136 })).toBe(false);
131
+ });
132
+
133
+ it('returns false for NaN lat', () => {
134
+ expect(hasValidCoordinates({ lat: NaN, lng: 144.9631 })).toBe(false);
135
+ });
136
+
137
+ it('returns false for NaN lng', () => {
138
+ expect(hasValidCoordinates({ lat: -37.8136, lng: NaN })).toBe(false);
139
+ });
140
+
141
+ it('returns false for Infinity lat', () => {
142
+ expect(hasValidCoordinates({ lat: Infinity, lng: 144.9631 })).toBe(false);
143
+ });
144
+
145
+ it('returns false for Infinity lng', () => {
146
+ expect(hasValidCoordinates({ lat: -37.8136, lng: Infinity })).toBe(false);
147
+ });
148
+
149
+ it('returns false for non-number lat', () => {
150
+ // @ts-expect-error - Testing invalid input
151
+ expect(hasValidCoordinates({ lat: 'invalid', lng: 144.9631 })).toBe(false);
152
+ });
153
+
154
+ it('returns false for non-number lng', () => {
155
+ // @ts-expect-error - Testing invalid input
156
+ expect(hasValidCoordinates({ lat: -37.8136, lng: 'invalid' })).toBe(false);
157
+ });
158
+ });
159
+
160
+ describe('areCoordinatesEqual', () => {
161
+ it('returns true for identical coordinates', () => {
162
+ const coords1: Coordinates = { lat: -37.8136, lng: 144.9631 };
163
+ const coords2: Coordinates = { lat: -37.8136, lng: 144.9631 };
164
+ expect(areCoordinatesEqual(coords1, coords2)).toBe(true);
165
+ });
166
+
167
+ it('returns true for coordinates within default tolerance', () => {
168
+ const coords1: Coordinates = { lat: -37.8136, lng: 144.9631 };
169
+ const coords2: Coordinates = { lat: -37.8137, lng: 144.9632 };
170
+ expect(areCoordinatesEqual(coords1, coords2)).toBe(true);
171
+ });
172
+
173
+ it('returns false for coordinates outside default tolerance', () => {
174
+ const coords1: Coordinates = { lat: -37.8136, lng: 144.9631 };
175
+ const coords2: Coordinates = { lat: -37.8150, lng: 144.9650 };
176
+ expect(areCoordinatesEqual(coords1, coords2)).toBe(false);
177
+ });
178
+
179
+ it('returns true for coordinates within custom tolerance', () => {
180
+ const coords1: Coordinates = { lat: -37.8136, lng: 144.9631 };
181
+ const coords2: Coordinates = { lat: -37.8137, lng: 144.9632 };
182
+ expect(areCoordinatesEqual(coords1, coords2, 0.001)).toBe(true);
183
+ });
184
+
185
+ it('returns false for coordinates outside custom tolerance', () => {
186
+ const coords1: Coordinates = { lat: -37.8136, lng: 144.9631 };
187
+ const coords2: Coordinates = { lat: -37.8137, lng: 144.9632 };
188
+ expect(areCoordinatesEqual(coords1, coords2, 0.00001)).toBe(false);
189
+ });
190
+
191
+ it('returns true when both are null', () => {
192
+ expect(areCoordinatesEqual(null, null)).toBe(true);
193
+ });
194
+
195
+ it('returns true when both are undefined', () => {
196
+ expect(areCoordinatesEqual(undefined, undefined)).toBe(true);
197
+ });
198
+
199
+ it('returns false when one is null and other is valid', () => {
200
+ const coords: Coordinates = { lat: -37.8136, lng: 144.9631 };
201
+ expect(areCoordinatesEqual(null, coords)).toBe(false);
202
+ expect(areCoordinatesEqual(coords, null)).toBe(false);
203
+ });
204
+
205
+ it('returns false when one is undefined and other is valid', () => {
206
+ const coords: Coordinates = { lat: -37.8136, lng: 144.9631 };
207
+ expect(areCoordinatesEqual(undefined, coords)).toBe(false);
208
+ expect(areCoordinatesEqual(coords, undefined)).toBe(false);
209
+ });
210
+
211
+ it('returns false for invalid coordinates', () => {
212
+ const coords1: Coordinates = { lat: -37.8136, lng: 144.9631 };
213
+ // @ts-expect-error - Testing invalid input
214
+ const coords2 = { lat: 91, lng: 0 };
215
+ expect(areCoordinatesEqual(coords1, coords2)).toBe(false);
216
+ });
217
+
218
+ it('handles edge case: exactly at tolerance boundary', () => {
219
+ const coords1: Coordinates = { lat: -37.8136, lng: 144.9631 };
220
+ const coords2: Coordinates = { lat: -37.8136 + 0.0001, lng: 144.9631 };
221
+ expect(areCoordinatesEqual(coords1, coords2, 0.0001)).toBe(true);
222
+ });
223
+ });
224
+
225
+ describe('getGoogleMapsUrl', () => {
226
+ it('generates URL for valid coordinates', () => {
227
+ const coords: Coordinates = { lat: -37.8136, lng: 144.9631 };
228
+ const result = getGoogleMapsUrl(coords);
229
+ expect(result).toBe('https://www.google.com/maps/search/?api=1&query=-37.8136,144.9631');
230
+ });
231
+
232
+ it('generates URL with negative coordinates', () => {
233
+ const coords: Coordinates = { lat: -90, lng: -180 };
234
+ const result = getGoogleMapsUrl(coords);
235
+ expect(result).toBe('https://www.google.com/maps/search/?api=1&query=-90,-180');
236
+ });
237
+
238
+ it('generates URL with positive coordinates', () => {
239
+ const coords: Coordinates = { lat: 90, lng: 180 };
240
+ const result = getGoogleMapsUrl(coords);
241
+ expect(result).toBe('https://www.google.com/maps/search/?api=1&query=90,180');
242
+ });
243
+
244
+ it('returns empty string for undefined', () => {
245
+ const result = getGoogleMapsUrl(undefined);
246
+ expect(result).toBe('');
247
+ });
248
+
249
+ it('returns empty string for null', () => {
250
+ // @ts-expect-error - Testing null input
251
+ const result = getGoogleMapsUrl(null);
252
+ expect(result).toBe('');
253
+ });
254
+
255
+ it('returns empty string for invalid coordinates', () => {
256
+ // @ts-expect-error - Testing invalid input
257
+ const result = getGoogleMapsUrl({ lat: 91, lng: 0 });
258
+ expect(result).toBe('');
259
+ });
260
+
261
+ it('returns empty string for missing lat', () => {
262
+ // @ts-expect-error - Testing invalid input
263
+ const result = getGoogleMapsUrl({ lng: 144.9631 });
264
+ expect(result).toBe('');
265
+ });
266
+
267
+ it('returns empty string for missing lng', () => {
268
+ // @ts-expect-error - Testing invalid input
269
+ const result = getGoogleMapsUrl({ lat: -37.8136 });
270
+ expect(result).toBe('');
271
+ });
272
+
273
+ it('returns empty string for NaN values', () => {
274
+ const coords: Coordinates = { lat: NaN, lng: 144.9631 };
275
+ const result = getGoogleMapsUrl(coords);
276
+ expect(result).toBe('');
277
+ });
278
+
279
+ it('returns empty string for Infinity values', () => {
280
+ const coords: Coordinates = { lat: Infinity, lng: 144.9631 };
281
+ const result = getGoogleMapsUrl(coords);
282
+ expect(result).toBe('');
283
+ });
284
+ });
285
+ });
286
+
@@ -0,0 +1,175 @@
1
+ /**
2
+ * @file Location Utilities
3
+ * @package @jmruthers/pace-core
4
+ * @module Utils/Location
5
+ * @since 0.1.0
6
+ *
7
+ * Utility functions for working with geographic coordinates.
8
+ * Provides functions for formatting, validating, comparing, and generating URLs for coordinates.
9
+ *
10
+ * Features:
11
+ * - Format coordinates for display
12
+ * - Validate coordinate objects
13
+ * - Compare coordinates with tolerance
14
+ * - Generate Google Maps URLs
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * import { formatCoordinates, hasValidCoordinates, areCoordinatesEqual, getGoogleMapsUrl } from '@jmruthers/pace-core/utils/location';
19
+ *
20
+ * const coords = { lat: -37.8136, lng: 144.9631 };
21
+ *
22
+ * // Format for display
23
+ * formatCoordinates(coords); // "-37.813600, 144.963100"
24
+ *
25
+ * // Validate
26
+ * hasValidCoordinates(coords); // true
27
+ *
28
+ * // Compare
29
+ * areCoordinatesEqual(coords, { lat: -37.8137, lng: 144.9632 }); // true (within tolerance)
30
+ *
31
+ * // Generate Google Maps URL
32
+ * getGoogleMapsUrl(coords); // "https://www.google.com/maps/search/?api=1&query=-37.8136,144.9631"
33
+ * ```
34
+ */
35
+
36
+ /**
37
+ * Coordinate interface for latitude and longitude
38
+ */
39
+ export interface Coordinates {
40
+ lat: number;
41
+ lng: number;
42
+ }
43
+
44
+ /**
45
+ * Format coordinates as a string with 6 decimal places
46
+ *
47
+ * @param coords - Coordinate object with lat and lng
48
+ * @returns Formatted string "lat, lng" or "N/A" if invalid
49
+ *
50
+ * @example
51
+ * ```ts
52
+ * formatCoordinates({ lat: -37.8136, lng: 144.9631 });
53
+ * // "-37.813600, 144.963100"
54
+ *
55
+ * formatCoordinates(undefined);
56
+ * // "N/A"
57
+ * ```
58
+ */
59
+ export function formatCoordinates(coords?: Coordinates): string {
60
+ if (!coords || typeof coords.lat !== 'number' || typeof coords.lng !== 'number') {
61
+ return 'N/A';
62
+ }
63
+
64
+ if (!isFinite(coords.lat) || !isFinite(coords.lng)) {
65
+ return 'N/A';
66
+ }
67
+
68
+ return `${coords.lat.toFixed(6)}, ${coords.lng.toFixed(6)}`;
69
+ }
70
+
71
+ /**
72
+ * Check if coordinates are valid
73
+ *
74
+ * @param coords - Coordinate object to validate
75
+ * @returns true if coordinates are valid, false otherwise
76
+ *
77
+ * @example
78
+ * ```ts
79
+ * hasValidCoordinates({ lat: -37.8136, lng: 144.9631 }); // true
80
+ * hasValidCoordinates({ lat: 91, lng: 0 }); // false (lat out of range)
81
+ * hasValidCoordinates(undefined); // false
82
+ * ```
83
+ */
84
+ export function hasValidCoordinates(coords?: { lat?: number; lng?: number }): boolean {
85
+ if (!coords) {
86
+ return false;
87
+ }
88
+
89
+ const { lat, lng } = coords;
90
+
91
+ if (typeof lat !== 'number' || typeof lng !== 'number') {
92
+ return false;
93
+ }
94
+
95
+ if (!isFinite(lat) || !isFinite(lng)) {
96
+ return false;
97
+ }
98
+
99
+ // Latitude must be between -90 and 90
100
+ if (lat < -90 || lat > 90) {
101
+ return false;
102
+ }
103
+
104
+ // Longitude must be between -180 and 180
105
+ if (lng < -180 || lng > 180) {
106
+ return false;
107
+ }
108
+
109
+ return true;
110
+ }
111
+
112
+ /**
113
+ * Check if two coordinates are equal within a tolerance
114
+ *
115
+ * @param coords1 - First coordinate object
116
+ * @param coords2 - Second coordinate object
117
+ * @param tolerance - Tolerance in degrees (default: 0.0001° ≈ 11 meters)
118
+ * @returns true if coordinates are within tolerance, false otherwise
119
+ *
120
+ * @example
121
+ * ```ts
122
+ * const coords1 = { lat: -37.8136, lng: 144.9631 };
123
+ * const coords2 = { lat: -37.8137, lng: 144.9632 };
124
+ * areCoordinatesEqual(coords1, coords2); // true (within default tolerance)
125
+ * areCoordinatesEqual(coords1, coords2, 0.00001); // false (stricter tolerance)
126
+ * ```
127
+ */
128
+ export function areCoordinatesEqual(
129
+ coords1: Coordinates | null | undefined,
130
+ coords2: Coordinates | null | undefined,
131
+ tolerance: number = 0.0001
132
+ ): boolean {
133
+ // Both null/undefined are considered equal
134
+ if (!coords1 && !coords2) {
135
+ return true;
136
+ }
137
+
138
+ // One null/undefined and one not are not equal
139
+ if (!coords1 || !coords2) {
140
+ return false;
141
+ }
142
+
143
+ // Validate both coordinates
144
+ if (!hasValidCoordinates(coords1) || !hasValidCoordinates(coords2)) {
145
+ return false;
146
+ }
147
+
148
+ // Check if within tolerance (with small epsilon for floating point precision)
149
+ const epsilon = 1e-10;
150
+ const latDiff = Math.abs(coords1.lat - coords2.lat);
151
+ const lngDiff = Math.abs(coords1.lng - coords2.lng);
152
+
153
+ return latDiff <= tolerance + epsilon && lngDiff <= tolerance + epsilon;
154
+ }
155
+
156
+ /**
157
+ * Generate a Google Maps search URL for coordinates
158
+ *
159
+ * @param coords - Coordinate object with lat and lng
160
+ * @returns Google Maps search URL or empty string if invalid
161
+ *
162
+ * @example
163
+ * ```ts
164
+ * getGoogleMapsUrl({ lat: -37.8136, lng: 144.9631 });
165
+ * // "https://www.google.com/maps/search/?api=1&query=-37.8136,144.9631"
166
+ * ```
167
+ */
168
+ export function getGoogleMapsUrl(coords?: Coordinates): string {
169
+ if (!coords || !hasValidCoordinates(coords)) {
170
+ return '';
171
+ }
172
+
173
+ return `https://www.google.com/maps/search/?api=1&query=${coords.lat},${coords.lng}`;
174
+ }
175
+