diff --git a/webapp/package-lock.json b/webapp/package-lock.json index c516722650..809c088d3d 100644 --- a/webapp/package-lock.json +++ b/webapp/package-lock.json @@ -20,6 +20,7 @@ "axios": "1.7.7", "clsx": "2.1.1", "d3": "5.16.0", + "date-fns": "4.1.0", "debug": "4.3.7", "draft-convert": "2.1.13", "draft-js": "0.11.7", @@ -72,6 +73,7 @@ "@testing-library/user-event": "14.5.2", "@total-typescript/ts-reset": "0.6.1", "@types/d3": "5.16.0", + "@types/date-fns": "2.6.0", "@types/debug": "4.1.12", "@types/draft-convert": "2.1.8", "@types/draft-js": "0.11.18", @@ -3732,6 +3734,16 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/date-fns": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@types/date-fns/-/date-fns-2.6.0.tgz", + "integrity": "sha512-9DSw2ZRzV0Tmpa6PHHJbMcZn79HHus+BBBohcOaDzkK/G3zMjDUDYjJIWBFLbkh+1+/IOS0A59BpQfdr37hASg==", + "deprecated": "This is a stub types definition for date-fns (https://github.com/date-fns/date-fns). date-fns provides its own type definitions, so you don't need @types/date-fns installed!", + "dev": true, + "dependencies": { + "date-fns": "*" + } + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -6148,6 +6160,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", diff --git a/webapp/package.json b/webapp/package.json index da78848498..117add1312 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -27,6 +27,7 @@ "clsx": "2.1.1", "d3": "5.16.0", "debug": "4.3.7", + "date-fns": "4.1.0", "draft-convert": "2.1.13", "draft-js": "0.11.7", "draftjs-to-html": "0.9.1", @@ -83,6 +84,7 @@ "@types/draft-js": "0.11.18", "@types/draftjs-to-html": "0.8.4", "@types/js-cookie": "3.0.6", + "@types/date-fns": "2.6.0", "@types/jsoneditor": "9.9.5", "@types/lodash": "4.17.9", "@types/node": "22.7.3", diff --git a/webapp/public/locales/en/main.json b/webapp/public/locales/en/main.json index f4d3c0eb93..aacacbb411 100644 --- a/webapp/public/locales/en/main.json +++ b/webapp/public/locales/en/main.json @@ -80,6 +80,7 @@ "global.time.weekly": "Weekly", "global.time.monthly": "Monthly", "global.time.annual": "Annual", + "global.time.weekShort": "W.", "global.update.success": "Update successful", "global.errorLogs": "Error logs", "global.error.emptyName": "Name cannot be empty", diff --git a/webapp/public/locales/fr/main.json b/webapp/public/locales/fr/main.json index 3062419fea..7a9ded3695 100644 --- a/webapp/public/locales/fr/main.json +++ b/webapp/public/locales/fr/main.json @@ -80,6 +80,7 @@ "global.time.weekly": "Hebdomadaire", "global.time.monthly": "Mensuel", "global.time.annual": "Annuel", + "global.time.weekShort": "S.", "global.update.success": "Mise à jour réussie", "global.errorLogs": "Logs d'erreurs", "global.error.emptyName": "Le nom ne peut pas être vide", diff --git a/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/index.tsx b/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/index.tsx index 317dc599ff..ad2a838019 100644 --- a/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/index.tsx @@ -53,14 +53,17 @@ import UsePromiseCond, { } from "../../../../../common/utils/UsePromiseCond"; import useStudySynthesis from "../../../../../../redux/hooks/useStudySynthesis"; import ButtonBack from "../../../../../common/ButtonBack"; -import moment from "moment"; import MatrixGrid from "../../../../../common/MatrixGrid/index.tsx"; -import { generateCustomColumns } from "../../../../../common/MatrixGrid/utils.ts"; +import { + generateCustomColumns, + generateDateTime, +} from "../../../../../common/MatrixGrid/utils.ts"; import { ColumnTypes } from "../../../../../common/MatrixGrid/types.ts"; import SplitView from "../../../../../common/SplitView/index.tsx"; import ResultFilters from "./ResultFilters.tsx"; import { toError } from "../../../../../../utils/fnUtils.ts"; import EmptyView from "../../../../../common/page/SimpleContent.tsx"; +import { getStudyMatrixIndex } from "../../../../../../services/api/matrix.ts"; function ResultDetails() { const { study } = useOutletContext<{ study: StudyMetadata }>(); @@ -151,6 +154,15 @@ function ResultDetails() { }, ); + const { data: dateTimeMetadata } = usePromise( + () => getStudyMatrixIndex(study.id, path), + { + deps: [study.id, path], + }, + ); + + const dateTime = dateTimeMetadata && generateDateTime(dateTimeMetadata); + const synthesisRes = usePromise( () => { if (outputId && selectedItem && isSynthesis) { @@ -164,47 +176,6 @@ function ResultDetails() { }, ); - // !NOTE: Workaround to display the date in the correct format, to be replaced by a proper solution. - const dateTimeFromIndex = useMemo(() => { - if (!matrixRes.data) { - return []; - } - - // Annual format has a static string - if (timestep === Timestep.Annual) { - return ["Annual"]; - } - - // Directly use API's week index (handles 53 weeks) as no formatting is required. - // !NOTE: Suboptimal: Assumes API consistency, lacks flexibility. - if (timestep === Timestep.Weekly) { - return matrixRes.data.index.map((weekNumber) => weekNumber.toString()); - } - - // Original date/time format mapping for moment parsing - const parseFormat = { - [Timestep.Hourly]: "MM/DD HH:mm", - [Timestep.Daily]: "MM/DD", - [Timestep.Monthly]: "MM", - }[timestep]; - - // Output formats for each timestep to match legacy UI requirements - const outputFormat = { - [Timestep.Hourly]: "DD MMM HH:mm I", - [Timestep.Daily]: "DD MMM I", - [Timestep.Monthly]: "MMM", - }[timestep]; - - const needsIndex = - timestep === Timestep.Hourly || timestep === Timestep.Daily; - - return matrixRes.data.index.map((dateTime, i) => - moment(dateTime, parseFormat).format( - outputFormat.replace("I", needsIndex ? ` - ${i + 1}` : ""), - ), - ); - }, [matrixRes.data, timestep]); - //////////////////////////////////////////////////////////////// // Event Handlers //////////////////////////////////////////////////////////////// @@ -320,7 +291,7 @@ function ResultDetails() { titles: matrix.columns, }), ]} - dateTime={dateTimeFromIndex} + dateTime={dateTime} isReaOnlyEnabled /> ) diff --git a/webapp/src/components/common/MatrixGrid/types.ts b/webapp/src/components/common/MatrixGrid/types.ts index f090a869e6..a996de9a63 100644 --- a/webapp/src/components/common/MatrixGrid/types.ts +++ b/webapp/src/components/common/MatrixGrid/types.ts @@ -22,6 +22,8 @@ import { // Enums //////////////////////////////////////////////////////////////// +// TODO update enums to be singular + export const ColumnTypes = { DateTime: "datetime", Number: "number", @@ -46,13 +48,37 @@ export const Aggregates = { Total: "total", } as const; +export const TimeFrequency = { + // TODO update old enum occurrences + ANNUAL: "annual", + MONTHLY: "monthly", + WEEKLY: "weekly", + DAILY: "daily", + HOURLY: "hourly", +} as const; + //////////////////////////////////////////////////////////////// // Types //////////////////////////////////////////////////////////////// // Derived types export type ColumnType = (typeof ColumnTypes)[keyof typeof ColumnTypes]; +// TODO add sufix Type export type Operation = (typeof Operations)[keyof typeof Operations]; +export type AggregateType = (typeof Aggregates)[keyof typeof Aggregates]; +export type TimeFrequencyType = + (typeof TimeFrequency)[keyof typeof TimeFrequency]; + +export type DateIncrementFunction = (date: Date, amount: number) => Date; +export type FormatFunction = (date: Date, firstWeekSize: number) => string; + +// !NOTE: This is temporary, date/time array should be generated by the API +export interface DateTimeMetadataDTO { + start_date: string; + steps: number; + first_week_size: number; + level: TimeFrequencyType; +} export interface TimeSeriesColumnOptions { count: number; @@ -75,7 +101,6 @@ export interface EnhancedGridColumn extends BaseGridColumn { editable: boolean; } -export type AggregateType = (typeof Aggregates)[keyof typeof Aggregates]; export type AggregateConfig = AggregateType[] | boolean | "stats" | "all"; export interface MatrixAggregates { @@ -111,8 +136,3 @@ export interface MatrixUpdateDTO { coordinates: number[][]; // Array of [col, row] pairs operation: MatrixUpdate; } - -export type DateIncrementStrategy = ( - date: moment.Moment, - step: number, -) => moment.Moment; diff --git a/webapp/src/components/common/MatrixGrid/useGridCellContent.ts b/webapp/src/components/common/MatrixGrid/useGridCellContent.ts index efd696f3d2..57c94c5fe4 100644 --- a/webapp/src/components/common/MatrixGrid/useGridCellContent.ts +++ b/webapp/src/components/common/MatrixGrid/useGridCellContent.ts @@ -20,7 +20,7 @@ import { ColumnTypes, MatrixAggregates, } from "./types"; -import { formatDateTime, formatNumber } from "./utils"; +import { formatNumber } from "./utils"; type CellContentGenerator = ( row: number, @@ -55,7 +55,7 @@ const cellContentGenerators: Record = { [ColumnTypes.DateTime]: (row, col, column, data, dateTime) => ({ kind: GridCellKind.Text, data: "", // Date/time columns are not editable - displayData: formatDateTime(dateTime?.[row] ?? ""), + displayData: dateTime?.[row] ?? "", readonly: !column.editable, allowOverlay: false, }), diff --git a/webapp/src/components/common/MatrixGrid/utils.ts b/webapp/src/components/common/MatrixGrid/utils.ts index 07589ac517..9a1d904fc6 100644 --- a/webapp/src/components/common/MatrixGrid/utils.ts +++ b/webapp/src/components/common/MatrixGrid/utils.ts @@ -12,9 +12,7 @@ * This file is part of the Antares project. */ -import moment from "moment"; import { - DateIncrementStrategy, EnhancedGridColumn, ColumnTypes, TimeSeriesColumnOptions, @@ -23,10 +21,28 @@ import { AggregateType, AggregateConfig, Aggregates, + DateIncrementFunction, + FormatFunction, + TimeFrequency, + TimeFrequencyType, + DateTimeMetadataDTO, } from "./types"; +import { + type FirstWeekContainsDate, + parseISO, + addHours, + addDays, + addWeeks, + addMonths, + addYears, + format, + startOfWeek, + Locale, +} from "date-fns"; +import { fr, enUS } from "date-fns/locale"; import { getCurrentLanguage } from "../../../utils/i18nUtils"; import { Theme } from "@glideapps/glide-data-grid"; -import { MatrixIndex } from "../../../common/types"; +import { t } from "i18next"; export const darkTheme: Theme = { accentColor: "rgba(255, 184, 0, 0.9)", @@ -85,26 +101,6 @@ export const aggregatesTheme: Partial = { headerFontStyle: "bold 11px", }; -const dateIncrementStrategies: Record< - MatrixIndex["level"], - DateIncrementStrategy -> = { - hourly: (date, step) => date.clone().add(step, "hours"), - daily: (date, step) => date.clone().add(step, "days"), - weekly: (date, step) => date.clone().add(step, "weeks"), - monthly: (date, step) => date.clone().add(step, "months"), - annual: (date, step) => date.clone().add(step, "years"), -}; - -const dateTimeFormatOptions: Intl.DateTimeFormatOptions = { - year: "numeric", - month: "short", - day: "numeric", - hour: "numeric", - minute: "numeric", - timeZone: "UTC", // Ensures consistent UTC-based time representation -}; - //////////////////////////////////////////////////////////////// // Functions //////////////////////////////////////////////////////////////// @@ -121,86 +117,89 @@ export function formatNumber(num: number | undefined): string { } const [integerPart, decimalPart] = num.toString().split("."); - // Format integer part with thousand separators - const formattedInteger = integerPart.replace(/\d(?=(\d{3})+$)/g, "$& "); - + const formattedInteger = integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, " "); // Return formatted number, preserving decimal part if it exists return decimalPart ? `${formattedInteger}.${decimalPart}` : formattedInteger; } +function getLocale(): Locale { + const lang = getCurrentLanguage(); + return lang && lang.startsWith("fr") ? fr : enUS; +} + /** - * Formats a date and time string using predefined locale and format options. - * - * This function takes a date/time string, creates a Date object from it, - * and then formats it according to the specified options. The formatting - * is done using the French locale as the primary choice, falling back to - * English if French is not available. - * - * Important: This function will always return the time in UTC, regardless - * of the system's local time zone. This behavior is controlled by the - * 'timeZone' option in dateTimeFormatOptions. - * - * @param dateTime - The date/time string to format. This should be an ISO 8601 string (e.g., "2024-01-01T00:00:00Z"). - * @returns The formatted date/time string in the format specified by dateTimeFormatOptions, always in UTC. + * Configuration object for different time frequencies * - * @example returns "1 janv. 2024, 00:00" (French locale) - * formatDateTime("2024-01-01T00:00:00Z") - * - * @example returns "Jan 1, 2024, 12:00 AM" (English locale) - * formatDateTime("2024-01-01T00:00:00Z") + * This object defines how to increment and format dates for various time frequencies. + * The WEEKLY frequency is of particular interest as it implements custom week starts + * and handles ISO week numbering. */ -export function formatDateTime(dateTime: string): string { - const date = moment.utc(dateTime); - const currentLocale = getCurrentLanguage(); - const locales = [currentLocale, "en-US"]; - - return date.toDate().toLocaleString(locales, dateTimeFormatOptions); -} +const TIME_FREQUENCY_CONFIG: Record< + TimeFrequencyType, + { + increment: DateIncrementFunction; + format: FormatFunction; + } +> = { + [TimeFrequency.ANNUAL]: { + increment: addYears, + format: () => t("global.time.annual"), + }, + [TimeFrequency.MONTHLY]: { + increment: addMonths, + format: (date: Date) => format(date, "MMM", { locale: getLocale() }), + }, + [TimeFrequency.WEEKLY]: { + increment: addWeeks, + format: (date: Date, firstWeekSize: number) => { + const weekStart = startOfWeek(date, { locale: getLocale() }); + + return format(weekStart, `'${t("global.time.weekShort")}' ww`, { + locale: getLocale(), + weekStartsOn: firstWeekSize === 1 ? 0 : 1, + firstWeekContainsDate: firstWeekSize as FirstWeekContainsDate, + }); + }, + }, + [TimeFrequency.DAILY]: { + increment: addDays, + format: (date: Date) => format(date, "EEE d", { locale: getLocale() }), + }, + [TimeFrequency.HOURLY]: { + increment: addHours, + format: (date: Date) => + format(date, "EEE d HH:mm", { locale: getLocale() }), + }, +}; /** - * Generates an array of date-time strings based on the provided time metadata. - * - * This function creates a series of date-time strings, starting from the given start date - * and incrementing based on the specified level (hourly, daily, weekly, monthly, or yearly). - * It uses the Moment.js library for date manipulation and the ISO 8601 format for date-time strings. + * Generates an array of formatted date/time strings based on the provided configuration * - * @param timeMetadata - The time metadata object. - * @param timeMetadata.start_date - The starting date-time in ISO 8601 format (e.g., "2023-01-01T00:00:00Z"). - * @param timeMetadata.steps - The number of date-time strings to generate. - * @param timeMetadata.level - The increment level for date-time generation. + * This function handles various time frequencies, with special attention to weekly formatting. + * For weekly frequency, it respects custom week starts while maintaining ISO week numbering. * - * @returns An array of ISO 8601 formatted date-time strings. - * - * @example - * const result = generateDateTime({ - * start_date: "2023-01-01T00:00:00Z", - * steps: 3, - * level: "daily" - * }); - * - * Returns: [ - * "2023-01-01T00:00:00.000Z", - * "2023-01-02T00:00:00.000Z", - * "2023-01-03T00:00:00.000Z" - * ] - * - * @see {@link MatrixIndex} for the structure of the timeMetadata object. - * @see {@link DateIncrementStrategy} for the date increment strategy type. + * @param config - Configuration object for date/time generation + * @param config.start_date - The starting date for generation + * @param config.steps - Number of increments to generate + * @param config.first_week_size - Defines the number of days for the first the week (from 1 to 7) + * @param config.level - The time frequency level (ANNUAL, MONTHLY, WEEKLY, DAILY, HOURLY) + * @returns An array of formatted date/time strings */ -export function generateDateTime({ - // eslint-disable-next-line camelcase +export const generateDateTime = ({ start_date, steps, + first_week_size, level, -}: MatrixIndex): string[] { - const startDate = moment.utc(start_date, "YYYY-MM-DD HH:mm:ss"); - const incrementStrategy = dateIncrementStrategies[level]; +}: DateTimeMetadataDTO): string[] => { + const { increment, format } = TIME_FREQUENCY_CONFIG[level]; + const initialDate = parseISO(start_date); - return Array.from({ length: steps }, (_, i) => - incrementStrategy(startDate, i).toISOString(), - ); -} + return Array.from({ length: steps }, (_, index) => { + const currentDate = increment(initialDate, index); + return format(currentDate, first_week_size); + }); +}; /** * Generates an array of EnhancedGridColumn objects representing time series data columns. @@ -292,6 +291,7 @@ export function generateDataColumns( return []; } +// TODO add docs + refactor export function getAggregateTypes( aggregateConfig: AggregateConfig, ): AggregateType[] {