app-event-formatted-date.ts
1 import { 2 type Optional, 3 isSome, 4 isNothing, 5 } from '@jet/environment/types/optional'; 6 import type { LocalizationWrapper } from '@jet-app/app-store/foundation/wrappers/localization'; 7 import type { 8 AppEventFormattedDate, 9 AppEventBadgeKind, 10 } from '@jet-app/app-store/api/models'; 11 import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph'; 12 import { formattedDatesWithKind } from '@jet-app/app-store/common/app-promotions/app-event'; 13 14 /** 15 * Partial type of {@linkcode AppEventFormattedDate} with just the properties 16 * that are actually used 17 */ 18 export type RequiredAppEventFormattedDate = Pick< 19 AppEventFormattedDate, 20 'displayText' | 'displayFromDate' | 'countdownToDate' | 'countdownStringKey' 21 >; 22 23 /** 24 * Represents a client-side serialization of an {@linkcode RequiredAppEventFormattedDate} 25 * 26 * This is needed because our client-side code will receive the event object with `Date` properties 27 * serialized as ISO 8601-formatted strings, while the server-side code will receive the original 28 * `Date` values. We need to normalize this to make sure we have consistent logic in both environments 29 */ 30 type SerializedAppEventFormattedDate = Pick< 31 RequiredAppEventFormattedDate, 32 'displayText' | 'countdownStringKey' 33 > & { 34 readonly displayFromDate?: string; 35 readonly countdownToDate?: string; 36 }; 37 38 function deserializeDate(value: Optional<Date | string>): Date | undefined { 39 if (isNothing(value)) { 40 return undefined; 41 } 42 43 return typeof value === 'string' ? new Date(value) : value; 44 } 45 46 /** 47 * Turn {@linkcode date} in either the client- or server-side format into the 48 * server-side format by parsing the ISO 8601 string values into `Date` instances 49 */ 50 function deserializeDateProperties( 51 date: SerializedAppEventFormattedDate | RequiredAppEventFormattedDate, 52 ): RequiredAppEventFormattedDate { 53 const { countdownToDate, displayFromDate, ...rest } = date; 54 55 return { 56 // Normalize properties that might have been serialized as `string` to `Date` 57 countdownToDate: deserializeDate(countdownToDate), 58 displayFromDate: deserializeDate(displayFromDate), 59 60 // Use all of the other properties with their existing values 61 ...rest, 62 }; 63 } 64 65 /** 66 * A {@linkcode RequiredAppEventFormattedDate} with a definitely-defined `.displayFromDate` property 67 */ 68 type AppEventFormattedDateWithDisplayFromDate = 69 RequiredAppEventFormattedDate & { 70 readonly displayFromDate: Date; 71 }; 72 73 function hasDisplayRequirement( 74 date: RequiredAppEventFormattedDate, 75 ): date is AppEventFormattedDateWithDisplayFromDate { 76 return isSome(date.displayFromDate); 77 } 78 79 export function chooseAppEventDate( 80 dates: (SerializedAppEventFormattedDate | RequiredAppEventFormattedDate)[], 81 ): Optional<RequiredAppEventFormattedDate> { 82 const nowTime = Date.now(); 83 84 // We might be passed `dates` in the expected format (server-side) or with their `Date` 85 // properties serialized as strings (client-side); we need to normalize them all to the 86 // same format 87 const normalizedDates = dates.map((date) => 88 deserializeDateProperties(date), 89 ); 90 91 // A `dates` member might not have a `.displayFromDate`; if that's the case, we will 92 // use that as a fallback if all other options are in the future 93 const fallback = normalizedDates.find( 94 (date) => !hasDisplayRequirement(date), 95 ); 96 97 // Find all of the `dates` members with a `.displayFromDate` in the past 98 const optionsWithPastDisplayFromDates = normalizedDates 99 // Ensure all `date` objects have a display requirement 100 .filter((date) => hasDisplayRequirement(date)) 101 // Filter out any `date` objects with a display requirement in the future 102 .filter((date) => { 103 const dateTime = date.displayFromDate.getTime(); 104 const timeDifference = nowTime - dateTime; 105 106 return timeDifference > 0; 107 }); 108 109 // If there are none, use the fallback 110 if (optionsWithPastDisplayFromDates.length === 0) { 111 return fallback; 112 } 113 114 // Otherwise, find the `date` object with the most recent `.displayFromDate` 115 return optionsWithPastDisplayFromDates.reduce((acc, next) => { 116 const accTime = acc.displayFromDate.getTime(); 117 const nextTime = next.displayFromDate.getTime(); 118 119 // Which time is closer to "now"? 120 const accTimeDiff = nowTime - accTime; 121 const nextTimeDiff = nowTime - nextTime; 122 123 return accTimeDiff > nextTimeDiff ? next : acc; 124 }); 125 } 126 127 /** 128 * Partial type of {@linkcode LocalizationWrapper} with just the methods that 129 * are actually called 130 * 131 * This partial type simplifies testing by reducing the surface area of the function's 132 * dependencies 133 */ 134 type RequiredLocalization = Pick<LocalizationWrapper, 'string'>; 135 136 function msToMinutes(ms: number): number { 137 return ms / (1_000 * 60); 138 } 139 140 export function renderDate( 141 localization: RequiredLocalization, 142 date: RequiredAppEventFormattedDate, 143 ): Optional<string> { 144 if (typeof date.countdownStringKey === 'string' && date.countdownToDate) { 145 const nowTime = Date.now(); 146 const translationString = localization.string(date.countdownStringKey); 147 148 const countdownToDateTime = date.countdownToDate.getTime(); 149 const diffTime = countdownToDateTime - nowTime; 150 151 const count = Math.floor(msToMinutes(diffTime)); 152 153 return translationString.replace('@@count@@', count.toString()); 154 } 155 156 if (typeof date.displayText === 'string') { 157 return date.displayText; 158 } 159 160 return undefined; 161 } 162 163 /** 164 * Helper function to compute formatted dates for app events. 165 * Handles date conversion and error handling. 166 * 167 * @param objectGraph - objectGraph from Jet 168 * @param badgeKind - The badge kind from the app event 169 * @param startDate - The start date (string or Date) 170 * @param endDate - The optional end date (string or Date) 171 * @returns Array of formatted dates or undefined if an error occurs 172 */ 173 export function computeAppEventFormattedDates( 174 objectGraph: AppStoreObjectGraph, 175 badgeKind: AppEventBadgeKind, 176 startDate: string | Date, 177 endDate?: string | Date | null, 178 ): RequiredAppEventFormattedDate[] | undefined { 179 // Use deserializeDate function to convert dates 180 const startDateObj = deserializeDate(startDate); 181 const endDateObj = deserializeDate(endDate); 182 183 // Validate that we have a valid start date 184 if (!startDateObj || isNaN(startDateObj.getTime())) { 185 return undefined; 186 } 187 188 return formattedDatesWithKind( 189 objectGraph, 190 badgeKind, 191 startDateObj, 192 endDateObj, 193 ); 194 }