datetime.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331
  1. import { useEffect, useSyncExternalStore } from 'react';
  2. import { Platform } from 'react-native';
  3. import * as RNLocalize from 'react-native-localize';
  4. import type { TFunction } from 'i18next';
  5. const isWeb = Platform.OS === 'web';
  6. // Must match the SSR fallback in apps/next/stubs/react-native-localize.js.
  7. const SSR_DATE_LOCALE = 'en-US';
  8. /**
  9. * Per-locale date layout. Plain table instead of `Intl` so web, iOS and
  10. * Android format identically.
  11. */
  12. export type DateFormatSpec = {
  13. order: 'DMY' | 'MDY' | 'YMD';
  14. /** Separator for the numeric form ("05.10.2026", "10/05/2026"). */
  15. sep: string;
  16. /** Appended to the day in the named form ("5. Okt. 2026"). */
  17. dayMark: string;
  18. /** First day of week, 0 = Sunday. */
  19. weekStart: 0 | 1;
  20. };
  21. const DEFAULT_DATE_FORMAT: DateFormatSpec = { order: 'DMY', sep: '.', dayMark: '', weekStart: 1 };
  22. /** Full tag beats language. Keys are lowercase. */
  23. const DATE_FORMAT_BY_TAG: Record<string, Partial<DateFormatSpec>> = {
  24. 'en-us': { order: 'MDY', sep: '/', weekStart: 0 },
  25. 'en-ca': { order: 'YMD', sep: '-', weekStart: 0 },
  26. 'en-ph': { order: 'MDY', sep: '/', weekStart: 0 },
  27. 'fr-ca': { order: 'YMD', sep: '-', weekStart: 0 },
  28. 'pt-br': { sep: '/', weekStart: 0 },
  29. };
  30. const DATE_FORMAT_BY_LANG: Record<string, Partial<DateFormatSpec>> = {
  31. en: { sep: '/' },
  32. ru: { sep: '.' },
  33. uk: { sep: '.' },
  34. be: { sep: '.' },
  35. pl: { sep: '.' },
  36. cs: { sep: '.' },
  37. tr: { sep: '.' },
  38. de: { sep: '.', dayMark: '.' },
  39. fr: { sep: '/' },
  40. es: { sep: '/' },
  41. it: { sep: '/' },
  42. pt: { sep: '/' },
  43. el: { sep: '/' },
  44. ar: { sep: '/', weekStart: 0 },
  45. he: { sep: '.', weekStart: 0 },
  46. nl: { sep: '-' },
  47. sv: { order: 'YMD', sep: '-' },
  48. lt: { order: 'YMD', sep: '-' },
  49. hu: { order: 'YMD', sep: '.' },
  50. zh: { order: 'YMD', sep: '/', weekStart: 0 },
  51. ja: { order: 'YMD', sep: '/', weekStart: 0 },
  52. ko: { order: 'YMD', sep: '.', weekStart: 0 },
  53. };
  54. export function getDateFormatSpec(tag: string): DateFormatSpec {
  55. const lower = tag.toLowerCase().replace('_', '-');
  56. const lang = lower.split('-')[0] ?? '';
  57. return {
  58. ...DEFAULT_DATE_FORMAT,
  59. ...DATE_FORMAT_BY_LANG[lang],
  60. ...DATE_FORMAT_BY_TAG[lower],
  61. };
  62. }
  63. const MONTH_SHORT = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
  64. const MONTH_LONG = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
  65. const WEEKDAY_SHORT = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
  66. /** Month name inside a date ("5 октября"); `monthIndex` is 0-11. */
  67. export function getMonthName(t: TFunction, monthIndex: number, length: 'short' | 'long' = 'short'): string {
  68. const fallback = (length === 'long' ? MONTH_LONG : MONTH_SHORT)[monthIndex] ?? '';
  69. return t(`date_month_${length}_${monthIndex + 1}`, { defaultValue: fallback });
  70. }
  71. /** Standalone month name for pickers and headers ("Октябрь"); `monthIndex` is 0-11. */
  72. export function getMonthTitle(t: TFunction, monthIndex: number): string {
  73. return t(`date_month_name_${monthIndex + 1}`, { defaultValue: MONTH_LONG[monthIndex] ?? '' });
  74. }
  75. /** Two-letter weekday for calendar headers; `day` is 0 = Sunday … 6 = Saturday. */
  76. export function getWeekdayShort(t: TFunction, day: number): string {
  77. return t(`date_weekday_short_${day}`, { defaultValue: WEEKDAY_SHORT[day] ?? '' });
  78. }
  79. function readDeviceLocaleTag(): string {
  80. if (isWeb) {
  81. if (typeof navigator === 'undefined') return SSR_DATE_LOCALE;
  82. return navigator.language || (navigator as { userLanguage?: string }).userLanguage || SSR_DATE_LOCALE;
  83. }
  84. return RNLocalize.getLocales()?.[0]?.languageTag || SSR_DATE_LOCALE;
  85. }
  86. let currentDateLocaleTag = isWeb ? SSR_DATE_LOCALE : readDeviceLocaleTag();
  87. const dateLocaleListeners = new Set<() => void>();
  88. function subscribeDateLocale(cb: () => void) {
  89. dateLocaleListeners.add(cb);
  90. return () => { dateLocaleListeners.delete(cb); };
  91. }
  92. function getDateLocaleSnapshot() {
  93. return currentDateLocaleTag;
  94. }
  95. function getDateLocaleServerSnapshot() {
  96. return SSR_DATE_LOCALE;
  97. }
  98. export function hydrateDateLocale() {
  99. const next = readDeviceLocaleTag();
  100. if (next === currentDateLocaleTag) return;
  101. currentDateLocaleTag = next;
  102. dateLocaleListeners.forEach((listener) => listener());
  103. }
  104. /**
  105. * SSR-stable device locale. First client render matches the server (`en-US`
  106. * on web); after mount it updates to `navigator.language` and re-renders
  107. * subscribers so date order (M D, Y vs D M Y) does not hydrate-mismatch.
  108. */
  109. export function useDateLocaleTag(): string {
  110. const tag = useSyncExternalStore(
  111. subscribeDateLocale,
  112. getDateLocaleSnapshot,
  113. getDateLocaleServerSnapshot,
  114. );
  115. useEffect(() => {
  116. hydrateDateLocale();
  117. }, []);
  118. return tag;
  119. }
  120. function resolveLocaleTag(explicit?: string): string {
  121. if (explicit) return explicit;
  122. return currentDateLocaleTag;
  123. }
  124. function isSameCalendarDay(a: Date, b: Date): boolean {
  125. return a.getFullYear() === b.getFullYear()
  126. && a.getMonth() === b.getMonth()
  127. && a.getDate() === b.getDate();
  128. }
  129. function isSameClockTime(a: Date, b: Date): boolean {
  130. return a.getHours() === b.getHours() && a.getMinutes() === b.getMinutes();
  131. }
  132. export type FormatDateOptions = {
  133. /** Prefix "Today" / "Tomorrow" for near future dates. */
  134. inFuture?: boolean;
  135. /** Relative "Now" / "5m" / "3h" / "2d" / "4w" for recent dates. */
  136. inPast?: boolean;
  137. /** BCP 47 tag for date order; defaults to the device locale. */
  138. locale?: string;
  139. month?: 'numeric' | '2-digit' | 'short' | 'long';
  140. showTime?: boolean;
  141. showDate?: boolean;
  142. /** 12-hour clock with AM/PM. */
  143. hour12?: boolean;
  144. /** Accepted for API compatibility; not used. */
  145. timeZone?: string;
  146. yearPolicy?: 'auto' | 'always' | 'never';
  147. };
  148. export const formatDate = (
  149. input: Date | string | number,
  150. t: TFunction,
  151. {
  152. inFuture = false,
  153. inPast = false,
  154. locale, // 'en-US' (not used for universality)
  155. month = 'short', // 'numeric' | '2-digit' | 'short' | 'long' | ...
  156. showTime = false,
  157. showDate = true,
  158. hour12, // true/false | undefined (keeps locale default behavior)
  159. timeZone, // e.g. 'UTC' (not used for universality)
  160. yearPolicy = 'auto', // 'auto' | 'always' | 'never'
  161. } = {} as FormatDateOptions
  162. ): string => {
  163. const date = input instanceof Date ? input : new Date(input);
  164. const localeTag = resolveLocaleTag(locale);
  165. if (Number.isNaN(date.getTime())) return '';
  166. const nowYear = new Date().getFullYear();
  167. let rel = '';
  168. if (inFuture && showDate) {
  169. const today = new Date(); today.setHours(0, 0, 0, 0);
  170. const tomorrow = new Date(today); tomorrow.setDate(today.getDate() + 1);
  171. const dDay = new Date(date.getFullYear(), date.getMonth(), date.getDate());
  172. rel = dDay.getTime() === today.getTime() ? t('Today ') : dDay.getTime() === tomorrow.getTime() ? t('Tomorrow ') : '';
  173. }
  174. if (inPast) {
  175. const now = new Date();
  176. const diffMs = date.getTime() - now.getTime();
  177. const sec = Math.round(diffMs / 1000);
  178. const absSec = Math.abs(sec);
  179. if (absSec < 60)
  180. return t('Now');
  181. // Universal relative time formatting (same everywhere)
  182. const min = Math.round(sec / 60);
  183. if (Math.abs(min) < 60) {
  184. return `${Math.abs(min)}m`;
  185. }
  186. const hrs = Math.round(sec / 3600);
  187. if (Math.abs(hrs) < 24) {
  188. return `${Math.abs(hrs)}h`;
  189. }
  190. const days = Math.round(sec / (3600 * 24));
  191. if (Math.abs(days) < 31) {
  192. return `${Math.abs(days)}d`;
  193. }
  194. const weeks = Math.round(sec / (3600 * 24 * 7));
  195. if (Math.abs(weeks) < 24) {
  196. return `${Math.abs(weeks)}w`;
  197. }
  198. }
  199. return formatDateUniversal(date, t, {
  200. showDate,
  201. showTime,
  202. month,
  203. yearPolicy,
  204. nowYear,
  205. hour12,
  206. rel,
  207. localeTag
  208. });
  209. };
  210. /**
  211. * Numeric: "05.10.2026" / "10/05/2026" / "2026/10/05", year dropped → "05.10".
  212. * Named: "5 окт. 2026" / "Oct 5, 2026" / "2026 Oct 5", year dropped → "5 окт.".
  213. */
  214. function formatDatePart(date: Date, t: TFunction, spec: DateFormatSpec, month: FormatDateOptions['month'], withYear: boolean): string {
  215. const monthIndex = date.getMonth();
  216. const year = String(date.getFullYear());
  217. if (month === 'numeric' || month === '2-digit') {
  218. const pad = month === '2-digit';
  219. const d = pad ? String(date.getDate()).padStart(2, '0') : String(date.getDate());
  220. const m = pad ? String(monthIndex + 1).padStart(2, '0') : String(monthIndex + 1);
  221. const ordered = spec.order === 'MDY' ? [m, d, year]
  222. : spec.order === 'YMD' ? [year, m, d]
  223. : [d, m, year];
  224. return ordered.filter(part => withYear || part !== year).join(spec.sep);
  225. }
  226. const name = getMonthName(t, monthIndex, month === 'long' ? 'long' : 'short');
  227. const day = String(date.getDate()) + spec.dayMark;
  228. if (spec.order === 'MDY')
  229. return withYear ? `${name} ${day}, ${year}` : `${name} ${day}`;
  230. if (spec.order === 'YMD')
  231. return withYear ? `${year} ${name} ${day}` : `${name} ${day}`;
  232. return withYear ? `${day} ${name} ${year}` : `${day} ${name}`;
  233. }
  234. // Universal date formatting function (same result everywhere)
  235. function formatDateUniversal(date: Date, t: TFunction, {
  236. showDate,
  237. showTime,
  238. month,
  239. yearPolicy,
  240. nowYear,
  241. hour12,
  242. rel,
  243. localeTag,
  244. }: {
  245. showDate: boolean;
  246. showTime: boolean;
  247. month: FormatDateOptions['month'];
  248. yearPolicy: FormatDateOptions['yearPolicy'];
  249. nowYear: number;
  250. hour12?: boolean;
  251. rel: string;
  252. localeTag: string;
  253. }) {
  254. const parts: string[] = [];
  255. // --- DATE ---
  256. if (showDate && !rel) {
  257. const needYear = (yearPolicy === 'always') || (yearPolicy === 'auto' && date.getFullYear() !== nowYear);
  258. parts.push(formatDatePart(date, t, getDateFormatSpec(localeTag), month, needYear));
  259. }
  260. // --- TIME ---
  261. if (showTime) {
  262. let hours = date.getHours();
  263. const minutes = date.getMinutes();
  264. if (hour12) {
  265. const period = hours >= 12 ? 'PM' : 'AM';
  266. hours = hours % 12 || 12;
  267. parts.push(`${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')} ${period}`);
  268. } else {
  269. parts.push(`${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`);
  270. }
  271. }
  272. return (rel || '') + parts.join(' ');
  273. }
  274. /**
  275. * "start - end" for two Unix timestamps (seconds), collapsing a shared day or time.
  276. * Pass `locale` from `useDateLocaleTag()` in anything rendered on the server: the
  277. * module-level tag switches to the device locale as soon as any component hydrates,
  278. * so a later-hydrating caller without it renders a different date order than SSR.
  279. */
  280. export const formatDateInterval = (dateStart: number, dateEnd: number, t: TFunction, locale: string = currentDateLocaleTag): string => {
  281. const start = new Date(dateStart * 1000);
  282. const end = new Date(dateEnd * 1000);
  283. const isSingleDate = isSameCalendarDay(start, end);
  284. const isSingleTime = isSameClockTime(start, end);
  285. if (isSingleDate)
  286. if (isSingleTime)
  287. return formatDate(start, t, { inFuture: true, showTime: true, month: '2-digit', locale });
  288. else
  289. return formatDate(start, t, { inFuture: true, showTime: true, month: '2-digit', locale }) + ' - ' + formatDate(end, t, { inFuture: true, showTime: true, showDate: false, month: '2-digit', locale });
  290. else
  291. return formatDate(start, t, { inFuture: true, showTime: true, month: '2-digit', locale }) + ' - ' + formatDate(end, t, { inFuture: true, showTime: true, showDate: true, month: '2-digit', locale });
  292. };