Repository: League-of-Foundry-Developers/foundry-vtt-types
Pull request #3375 (2025-06-10 16:07:38)

Update CalendarData and GameTime


Patch:
diff --git a/src/foundry/client/config.d.mts b/src/foundry/client/config.d.mts
index e04b4e411..fca238961 100644
--- a/src/foundry/client/config.d.mts
+++ b/src/foundry/client/config.d.mts
@@ -1446,13 +1446,7 @@ declare global {
     /**
      * Configuration for time tracking
      */
-    time: {
-      /** @defaultValue `0` */
-      turnTime: number;
-
-      /** @defaultValue `0` */
-      roundTime: number;
-    };
+    time: CONFIG.Time;
 
     /**
      * Configuration for the ActiveEffect embedded document type
@@ -3314,6 +3308,42 @@ declare global {
         mc: CONFIG.Combat.SoundPreset;
       }
     }
+
+    interface Time {
+      /** The Calendar configuration used for in-world timekeeping. */
+      worldCalendarConfig: foundry.data.CalendarData.CreateData;
+
+      /** The CalendarData subclass is used for in-world timekeeping. */
+      worldCalendarClass: typeof foundry.data.CalendarData;
+
+      /** The Calendar configuration used for IRL timekeeping. */
+      earthCalendarConfig: foundry.data.CalendarData.CreateData;
+
+      /** The CalendarData subclass is used for IRL timekeeping. */
+      earthCalendarClass: typeof foundry.data.CalendarData;
+
+      /**
+       * The number of seconds which automatically elapse at the end of a Combat turn.
+       * @defaultValue `0`
+       */
+      turnTime: number;
+
+      /**
+       * The number of seconds which automatically elapse at the end of a Combat round.
+       * @defaultValue `0`
+       */
+      roundTime: number;
+
+      /** Formatting functions used to display time data as strings. */
+      formatters: CONFIG.Time.formatters;
+    }
+
+    namespace Time {
+      interface formatters {
+        timestamp: typeof foundry.data.CalendarData.formatTimestamp;
+        ago: typeof foundry.data.CalendarData.formatAgo;
+      }
+    }
   }
 
   const CONFIG: CONFIG;
diff --git a/src/foundry/client/data/calendar.d.mts b/src/foundry/client/data/calendar.d.mts
index d1ce5e4e9..a458a5f33 100644
--- a/src/foundry/client/data/calendar.d.mts
+++ b/src/foundry/client/data/calendar.d.mts
@@ -1,5 +1,6 @@
 import type DataModel from "#common/abstract/data.d.mts";
 import type * as fields from "#common/data/fields.mjs";
+import type { AnyObject } from "#utils";
 
 declare namespace CalendarData {
   interface Schema extends fields.DataSchema {
@@ -54,8 +55,8 @@ declare namespace CalendarData {
       /** The year which is considered year 0 in the calendar. */
       yearZero: fields.NumberField<{ required: true; nullable: false; integer: true }>;
 
-      /** The day of the week that the first day of year zero falls on. */
-      firstWeekday: fields.NumberField<{ required: true; nullable: false; min: 1; integer: true }>;
+      /** The index of days.values that is the first weekday at time=0 */
+      firstWeekday: fields.NumberField<{ required: true; nullable: false; min: 0; integer: true }>;
 
       /** A definition of how leap years work within a calendar. */
       leapYear: fields.SchemaField<ConfigLeapYear.Schema, { required: true; nullable: true; initial: null }>;
@@ -110,18 +111,6 @@ declare namespace CalendarData {
 
       /** The number of days in the month during a leap year. If not defined the value of days is used. */
       leapDays: fields.NumberField<{ required: false; nullable: true }>;
-
-      /** The amount to offset day numbers for this month. */
-      dayOffset: fields.NumberField<{ required: true; nullable: true; min: 0; integer: true }>;
-
-      /** If this month is an intercalary month. */
-      intercalary: fields.BooleanField<{ initial: false }>;
-
-      /**
-       * The day of the week this month should always start on.
-       * If the value is null the month will start on the next weekday after the previous month.
-       */
-      startingWeekday: fields.NumberField<{ required: false; nullable: true }>;
     }
 
     interface InitializedData extends fields.SchemaField.InitializedData<Schema> {}
@@ -135,6 +124,9 @@ declare namespace CalendarData {
       /** The configuration of the days of the week. */
       values: fields.ArrayField<fields.SchemaField<ConfigDay.Schema>, { required: true; nullable: false }>;
 
+      /** The number of days in a year. */
+      daysPerYear: fields.NumberField<{ required: true; nullable: false; positive: true }>;
+
       /** The number of hours in a day. */
       hoursPerDay: fields.NumberField<{ required: true; nullable: false; positive: true }>;
 
@@ -161,9 +153,6 @@ declare namespace CalendarData {
 
       /** The ordinal position of this weekday in the week. */
       ordinal: fields.NumberField<{ required: true; nullable: false; min: 1; integer: true }>;
-
-      /** Is this weekday considered a rest day (weekend)? */
-      isRestDay: fields.BooleanField<{ required: false; nullable: false; initial: false }>;
     }
 
     interface InitializedData extends fields.SchemaField.InitializedData<Schema> {}
@@ -193,28 +182,157 @@ declare namespace CalendarData {
       abbreviation: fields.StringField;
 
       /** The ordinal month in which the season starts. */
-      startMonth: fields.NumberField<{ required: true; nullable: true; min: 0; integer: true }>;
+      monthStart: fields.NumberField<{ required: true; nullable: true; min: 0; integer: true }>;
 
       /** The day of the month on which the season starts. */
-      startDay: fields.NumberField<{ required: true; nullable: true; min: 0; integer: true }>;
+      dayStart: fields.NumberField<{ required: true; nullable: true; min: 0; integer: true }>;
 
       /** The ordinal month in which the season ends. */
-      endMonth: fields.NumberField<{ required: true; nullable: true; min: 0; integer: true }>;
+      monthEnd: fields.NumberField<{ required: true; nullable: true; min: 0; integer: true }>;
 
       /** The day of the month on which the season ends. */
-      endDay: fields.NumberField<{ required: true; nullable: true; min: 0; integer: true }>;
+      dayEnd: fields.NumberField<{ required: true; nullable: true; min: 0; integer: true }>;
     }
 
     interface InitializedData extends fields.SchemaField.InitializedData<Schema> {}
 
     interface CreateData extends fields.SchemaField.CreateData<Schema> {}
   }
+
+  /**
+   * A decomposition of the integer world time in seconds into component parts.
+   * Each component expresses the number of that temporal unit since the time=0 epoch.
+   */
+  interface TimeComponents {
+    /** The number of years completed since zero. */
+    year: number;
+
+    /** The number of days completed within the year. */
+    day: number;
+
+    /**
+     * The number of hours completed within the year.
+     * @remarks This should say "completed within the _day_"
+     */
+    hour: number;
+
+    /** The number of minutes completed within the hour. */
+    minute: number;
+
+    /** The number of seconds completed within the minute. */
+    second: number;
+
+    /** The month, an index of the months.values array. */
+    month: number;
+
+    /** The day of the month, starting from zero. */
+    dayOfMonth: number;
+
+    /** The weekday, an index of the days.values array. */
+    dayOfWeek: number;
+
+    /** The season, an index of the seasons.values array. */
+    season: number;
+
+    /** Is it a leap year? */
+    leapYear: boolean;
+  }
+
+  /** @returns The returned string format */
+  type TimeFormatter = (
+    /** The configured calendar */
+    calendar: CalendarData<TimeComponents>,
+
+    /** Time components to format */
+    components: TimeComponents,
+
+    /** Additional formatting options */
+    options: AnyObject,
+  ) => string;
+
+  type FormatterName = keyof typeof CONFIG.time.formatters;
+  type Formatter = FormatterName | TimeFormatter;
+  type FormatterOptions<Formatter extends CalendarData.Formatter> = Parameters<
+    Formatter extends FormatterName ? (typeof CONFIG.time.formatters)[Formatter] : Formatter
+  >[1];
+
+  interface FormatAgoOptions {
+    /** @defaultValue `false` */
+    short?: boolean | undefined;
+
+    maxTerms?: number | undefined;
+  }
 }
 
 /**
  * Game Time Calendar configuration data model.
  */
-declare class CalendarData extends DataModel<CalendarData.Schema> {}
+declare class CalendarData<Components extends CalendarData.TimeComponents> extends DataModel<CalendarData.Schema> {
+  /**
+   * Expand a world time integer into an object containing the relevant time components.
+   * @param components - An amount of time expressed as components
+   * @returns The cumulative time in seconds
+   */
+  componentsToTime(components: Partial<Components>): number;
+
+  /**
+   * Modify some start time by adding a number of seconds or components to it. The delta components may be negative.
+   * @param startTime - The initial time
+   * @param deltaTime - Differential components to add
+   * @returns The resulting time
+   */
+  add(startTime: number | Components, deltaTime: number | Components): Components;
+
+  /**
+   * Compute the difference between some new time and some other time.
+   * @param endTime   - A time to difference relative to the start time.
+   * @param startTime - The starting time. If not provided the current world time is used.
+   * @returns The time difference expressed as components
+   */
+  difference(endTime: number | Components, startTime?: number | Components): Components;
+
+  /**
+   * Format a time using one of several supported display formats.
+   * @param time      - The time components to format, by default the current world time.
+   * @param formatter - The formatter function applied to the time. If a string is provided, it must be a function configured in {@linkcode CONFIG.time.formatters}. (default: `"timestamp"`)
+   * @param options   - Options passed to the formatter function (default: `{}`)
+   * @returns The formatted date and time string
+   * @remarks Throws an error if `formatter` is a string and the formatter by that name is not found
+   */
+  format<Formatter extends CalendarData.Formatter>(
+    time?: number | Components,
+    formatter?: Formatter,
+    options?: CalendarData.FormatterOptions<Formatter>,
+  ): string;
+
+  /**
+   * Test whether a year is a leap year.
+   * @param year - The year to test
+   * @returns Is it a leap year?
+   */
+  isLeapYear(year: number): boolean;
+
+  /**
+   * Expand a world time integer into an object containing the relevant time components.
+   * @param time - A time in seconds (default: `0`)
+   * @returns The time expressed as components
+   */
+  timeToComponents(time: number): Components;
+
+  /** Format time components as a YYYY-MM-DD HH:MM:SS timestamp. */
+  static formatTimestamp(
+    calendar: CalendarData<CalendarData.TimeComponents>,
+    components: CalendarData.TimeComponents,
+    _options?: AnyObject,
+  ): string;
+
+  /** Format time components as "{years}, {days}, {hours}, {minutes}, {seconds} ago". */
+  static formatAgo(
+    calendar: CalendarData<CalendarData.TimeComponents>,
+    components: CalendarData.TimeComponents,
+    options?: CalendarData.FormatAgoOptions,
+  ): string;
+}
 
 export const SIMPLIFIED_GREGORIAN_CALENDAR_CONFIG: CalendarData.CreateData;
 
diff --git a/src/foundry/client/helpers/time.d.mts b/src/foundry/client/helpers/time.d.mts
index 18fa4274c..5483abe73 100644
--- a/src/foundry/client/helpers/time.d.mts
+++ b/src/foundry/client/helpers/time.d.mts
@@ -1,9 +1,73 @@
+import type CalendarData from "#client/data/calendar.mjs";
+import type { AnyObject } from "#utils";
+
 /**
  * A singleton class at which keeps the official Server and World time stamps.
  * Uses a basic implementation of https://www.geeksforgeeks.org/cristians-algorithm/ for synchronization.
  * @see {@link foundry.Game#time}
  */
-declare class GameTime {}
+declare class GameTime {
+  constructor();
+
+  /**
+   * The amount of time to delay before re-syncing the official server time.
+   * @defaultValue `1000 * 60 * 5`
+   */
+  static SYNC_INTERVAL_MS: number;
+
+  /** The calendar instance for in-world timekeeping. */
+  get calendar(): CalendarData<CalendarData.TimeComponents>;
+
+  /** The "Earth" calendar instance for IRL timekeeping. */
+  get earthCalendar(): CalendarData<CalendarData.TimeComponents>;
+
+  /** The current server time based on the last synchronization point and the approximated one-way latency. */
+  get serverTime(): number;
+
+  /** The current World time expressed in seconds. */
+  get worldTime(): number;
+
+  /** The current World time expressed as components. */
+  get components(): CalendarData.TimeComponents;
+
+  /** The average one-way latency between client and server in milliseconds. */
+  get averageLatency(): number;
+
+  /**
+   * Initialize a calendar configuration.
+   * This is called once automatically upon construction, but can be called manually if `CONFIG.time` changes.
+   */
+  initializeCalendar(): void;
+
+  /**
+   * Advance or rewind the world time according to a delta amount expressed either in seconds or as components.
+   * @param delta   - The number of seconds to advance (or rewind if negative) by
+   * @param options - Additional options passed to `game.settings.set`
+   * @returns The new game time
+   */
+  advance(delta: CalendarData.TimeComponents | number, options?: AnyObject): Promise<number>;
+
+  /**
+   * Directly set the world time to a certain value expressed either in seconds or as components.
+   * @param time    - The desired world time
+   * @param options - Additional options passed to `game.settings.set`
+   * @returns The new game time
+   */
+  set(time: CalendarData.TimeComponents | number, options?: AnyObject): Promise<number>;
+
+  /** Synchronize the local client game time with the official time kept by the server */
+  sync(): Promise<GameTime>;
+
+  /**
+   * Handle follow-up actions when the official World time is changed
+   * @param worldTime - The new canonical World time.
+   * @param options   - Options passed from the requesting client where the change was made
+   * @param userId    - The ID of the User who advanced the time
+   */
+  onUpdateWorldTime(worldTime: number, options: AnyObject, userId: string): void;
+
+  #GameTime: true;
+}
 
 declare namespace GameTime {}
 


Test patch:
diff --git a/tests/foundry/client/data/calendar.mjs.test-d.ts b/tests/foundry/client/data/calendar.mjs.test-d.ts
index 60fa490ae..904702225 100644
--- a/tests/foundry/client/data/calendar.mjs.test-d.ts
+++ b/tests/foundry/client/data/calendar.mjs.test-d.ts
@@ -3,8 +3,8 @@ const _gregorian = {
   name: "Simplified Gregorian",
   description: "The Gregorian calendar with some simplifications regarding leap years or seasonal timing.",
   years: {
-    yearZero: 1,
-    firstWeekday: 1,
+    yearZero: 0,
+    firstWeekday: 0,
     leapYear: {
       leapStart: 8,
       leapInterval: 4,
@@ -13,7 +13,7 @@ const _gregorian = {
   months: {
     values: [
       { name: "January", abbreviation: "Jan", ordinal: 1, days: 31 },
-      { name: "February", abbreviation: "Feb", ordinal: 2, days: 29, leapDays: 28 },
+      { name: "February", abbreviation: "Feb", ordinal: 2, days: 28, leapDays: 29 },
       { name: "March", abbreviation: "Mar", ordinal: 3, days: 31 },
       { name: "April", abbreviation: "Apr", ordinal: 4, days: 30 },
       { name: "May", abbreviation: "May", ordinal: 5, days: 31 },
@@ -33,22 +33,21 @@ const _gregorian = {
       { name: "Wednesday", abbreviation: "Wed", ordinal: 3 },
       { name: "Thursday", abbreviation: "Thu", ordinal: 4 },
       { name: "Friday", abbreviation: "Fri", ordinal: 5 },
-      { name: "Saturday", abbreviation: "Sat", ordinal: 6, isRestDay: true },
-      { name: "Sunday", abbreviation: "Sun", ordinal: 7, isRestDay: true },
+      // As of 13.345 `isRestDay` still included despite not being part of data model
+      { name: "Saturday", abbreviation: "Sat", ordinal: 6 /*, isRestDay: true*/ },
+      { name: "Sunday", abbreviation: "Sun", ordinal: 7 /*, isRestDay: true*/ },
     ],
-    // 13.337 bug leaving this property out
-    // daysPerYear: 365,
+    daysPerYear: 365,
     hoursPerDay: 24,
     minutesPerHour: 60,
     secondsPerMinute: 60,
   },
   seasons: {
     values: [
-      // 13.337 version has misnamed & missing properties
-      { name: "Spring", /** ordinal: 1, */ startMonth: 3, endMonth: 5 },
-      { name: "Summer", /** ordinal: 2, */ startMonth: 6, endMonth: 8 },
-      { name: "Fall", /** ordinal: 3, */ startMonth: 9, endMonth: 11 },
-      { name: "Winter", /** ordinal: 4, */ startMonth: 12, endMonth: 2 },
+      { name: "Spring", monthStart: 3, monthEnd: 5 },
+      { name: "Summer", monthStart: 6, monthEnd: 8 },
+      { name: "Fall", monthStart: 9, monthEnd: 11 },
+      { name: "Winter", monthStart: 12, monthEnd: 2 },
     ],
   },
 } satisfies foundry.data.CalendarData.CreateData;
diff --git a/tests/foundry/client/helpers/time.test-d.ts b/tests/foundry/client/helpers/time.test-d.ts
index 095b07782..a3b4d9a3d 100644
--- a/tests/foundry/client/helpers/time.test-d.ts
+++ b/tests/foundry/client/helpers/time.test-d.ts
@@ -1,18 +1,15 @@
 import { expectTypeOf } from "vitest";
 
-new GameTime(undefined);
-new GameTime(null);
+const GameTime = foundry.helpers.GameTime;
 
 if (game) {
-  const gameTime = new GameTime(game.socket);
+  const gameTime = new GameTime();
 
   expectTypeOf(gameTime.serverTime).toEqualTypeOf<number>();
   expectTypeOf(gameTime.worldTime).toEqualTypeOf<number>();
   expectTypeOf(gameTime.advance(100)).toEqualTypeOf<Promise<number>>();
-  expectTypeOf(gameTime.sync(game.socket)).toEqualTypeOf<Promise<GameTime>>();
-  expectTypeOf(gameTime.sync(undefined)).toEqualTypeOf<Promise<GameTime>>();
-  expectTypeOf(gameTime.sync(null)).toEqualTypeOf<Promise<GameTime>>();
-  expectTypeOf(gameTime.onUpdateWorldTime(100)).toEqualTypeOf<void>();
+  expectTypeOf(gameTime.sync()).toEqualTypeOf<Promise<GameTime>>();
+  expectTypeOf(gameTime.onUpdateWorldTime(100, {}, game.userId!)).toEqualTypeOf<void>();
 
   expectTypeOf(GameTime.SYNC_INTERVAL_MS).toEqualTypeOf<number>();
 }