/ src / jet / utils / app-event-formatted-date.ts
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  }