Files
ignacionelson 6e47d76ba6 ProjectSend 2.0.0
Client file sharing, rebuilt from the ground up: a private area per
client, resumable uploads, folders, groups and categories, sharing with
expiry dates and download limits, comments, file versions, an activity
log, a REST API, and sixteen languages.

This repository begins here. ProjectSend 2 was developed privately, and
that development history is not published — the previous generation
remains available, with its own history, at projectsend/legacy.

Free software under the GNU General Public License v2, or (at your
option) any later version.
2026-08-14 01:38:12 -03:00

66 lines
3.3 KiB
TypeScript

import { intlLocale } from '@/lib/intl-locale';
/**
* Dates arrive from the server as ISO strings and are rendered in the
* viewer's own locale and timezone. Both have to be passed in explicitly:
* these stay pure so they can be unit-tested and called outside a React
* tree. Inside components use `useFormatDate()`, which reads both off the
* shared props once and hands back the same three functions already bound.
*
* The locale goes through intlLocale() first: the app's keys are catalogue
* file names (pt_BR), and Intl only accepts BCP 47 tags (pt-BR). It throws on
* the difference rather than degrading, which takes the whole page with it.
*
* The timezone is passed straight through. Unlike the locale it needs no
* translation — it is the IANA identifier the server resolved and validated
* (TimezoneRegistry), and `undefined` correctly means "the browser's own",
* which is the right answer before the props have arrived.
*
* **Instants and calendar dates are not the same thing**, which is why
* there are three functions and not two. A timestamp is a point in time and
* belongs in the reader's zone. A date like a file's expiry is a calendar
* day — the 12th is the 12th everywhere — and shifting it by an offset is
* always wrong: it used to render "11 Aug" for everyone west of Greenwich.
* Match the function to which kind of value you have, not to how much of it
* you want on screen.
*/
export interface DateFormatOptions {
locale: string;
/** An IANA identifier. Undefined falls back to the browser's own zone. */
timeZone?: string;
}
/** An instant, shown as a date: "12 Aug 2026". Tolerates null so optional columns can call it directly. */
export function formatDate(iso: string | null, { locale, timeZone }: DateFormatOptions): string {
return iso === null ? '' : new Date(iso).toLocaleDateString(intlLocale(locale), { dateStyle: 'medium', timeZone });
}
/** An instant with its time: "12 Aug 2026, 14:30" -- for logs, where ordering within a day matters. */
export function formatDateTime(iso: string | null, { locale, timeZone }: DateFormatOptions): string {
return iso === null ? '' : new Date(iso).toLocaleString(intlLocale(locale), { dateStyle: 'medium', timeStyle: 'short', timeZone });
}
/**
* A bare `YYYY-MM-DD` from the server, shown as itself: "12 Aug 2026".
*
* Deliberately ignores the viewer's timezone. `new Date('2026-08-12')` is
* parsed as UTC midnight, so formatting it in any negative offset moves it
* to the 11th; pinning the formatter to UTC as well cancels that out and
* the date survives the round trip. This is what the scattered
* `new Date(iso + 'T00:00:00')` workarounds were reaching for.
*/
export function formatCalendarDate(date: string | null, { locale }: DateFormatOptions): string {
return date === null ? '' : new Date(`${date}T00:00:00Z`).toLocaleDateString(intlLocale(locale), { dateStyle: 'medium', timeZone: 'UTC' });
}
/** A calendar date trimmed for a chart axis: "12 Aug". */
export function formatCalendarDateShort(date: string | null, { locale }: DateFormatOptions): string {
return date === null
? ''
: new Date(`${date}T00:00:00Z`).toLocaleDateString(intlLocale(locale), {
month: 'short',
day: 'numeric',
timeZone: 'UTC',
});
}