src/app/features/dashboard-widgets/birthday-dashboard-widget/birthday-dashboard/birthday-dashboard-index.service.ts
import { DatabaseIndexingService } from "#src/app/core/entity/database-indexing/database-indexing.service";
import { inject, Injectable } from "@angular/core";
import moment from "moment";
import { Entity } from "#src/app/core/entity/model/entity";
import { EntityRegistry } from "#src/app/core/entity/database-entity.decorator";
import { EntitySchemaService } from "#src/app/core/entity/schema/entity-schema.service";

export interface EntityPropertyMap {
  [key: string]: string | string[];
}

/**
 * One entity paired with the next occurrence of a single one of its configured
 * birthday properties. An entity with several matching birthday properties is
 * represented by several `EntityWithBirthday` entries (one per matching property),
 * mirroring the underlying index which emits one row per (entity, property) match.
 */
export interface EntityWithBirthday {
  entity: Entity;
  birthday: Date;
  newAge: number;
}

const INDEX_ID_PREFIX = "birthdayDashboard";
const VIEW_NAME = "birthday";

function toPropertyList(properties: string | string[]): string[] {
  return Array.isArray(properties) ? properties : [properties];
}

/**
 * Builds and identifies the single PouchDB/CouchDB view that indexes entities of all
 * configured types by the cyclic day-of-year of their date-of-birth propertie(s), so
 * that entities with an upcoming birthday can be queried efficiently without loading
 * all entities of any type.
 */
@Injectable({
  providedIn: "root",
})
export class BirthdayDashboardIndexService {
  private readonly dbIndexing = inject(DatabaseIndexingService);
  private readonly entityRegistry = inject(EntityRegistry);
  private readonly entitySchemaService = inject(EntitySchemaService);

  buildBirthdayIndex(entityConfig: EntityPropertyMap) {
    return this.dbIndexing.createIndex({
      _id: `_design/${this.getIndexId(entityConfig)}`,
      views: {
        [VIEW_NAME]: {
          map: this.buildMapFunction(entityConfig),
        },
      },
    });
  }

  /**
   * Query the birthday index for entities of any configured type whose (real calendar)
   * birthday falls within `threshold` days from today.
   *
   * The underlying range query is padded by one day on each side to account for the
   * index's reference-year-based cyclic-day-of-year approximation (e.g. around Feb 29
   * in leap years). That padding can also pull in rows that don't actually belong (e.g.
   * a birthday that was yesterday, or one day beyond the threshold), so the results are
   * re-filtered afterward using the exact, real calendar day difference
   */
  async queryBirthdayIndex(
    entityConfig: EntityPropertyMap,
    threshold: number,
  ): Promise<EntityWithBirthday[]> {
    const today = moment().startOf("day").toDate();
    const todayDayOfYear = this.getCyclicDayOfYear(today);

    const result = await this.dbIndexing.queryIndexRaw(
      `${this.getIndexId(entityConfig)}/${VIEW_NAME}`,
      {
        startkey: todayDayOfYear - 1,
        endkey: todayDayOfYear + threshold + 1,
        include_docs: true,
      },
    );

    return result.rows
      .map((row) => ({ ...row, value: parseDateOnly(row.value) }))
      .filter(
        (row) =>
          moment(getNextOccurrence(row.value, today)).diff(today, "days") <=
          threshold,
      )
      .map((row) => {
        const entityType = Entity.extractTypeFromId(row.doc._id);
        const entityConstructor = this.entityRegistry.get(entityType);
        const entity = new entityConstructor("");
        this.entitySchemaService.loadDataIntoEntity(entity, row.doc);
        const birthday = getNextOccurrence(row.value, today);
        return {
          entity,
          birthday,
          newAge: birthday.getFullYear() - row.value.getFullYear(),
        };
      });
  }

  /**
   * Deterministic id for the design doc indexing the given entities/properties config.
   * Different widget configs (e.g. different dashboard instances) get their own design doc
   * so that their map functions don't overwrite each other.
   */
  private getIndexId(entityConfig: EntityPropertyMap): string {
    const configPart = Object.keys(entityConfig)
      .sort()
      .map((entityType) => {
        const properties = toPropertyList(entityConfig[entityType])
          .slice()
          .sort();
        return `${entityType}(${properties.join(",")})`;
      })
      .join("_");
    return `${INDEX_ID_PREFIX}_${configPart}`;
  }

  /**
   * Day-of-year (1-365) for the given date's month/day, computed against a fixed non-leap
   * reference year so that the same month/day always maps to the same cyclic key,
   * independent of the actual (birth) year and independent of leap years.
   */
  private getCyclicDayOfYear(date: Date): number {
    return (
      Math.floor(
        (Date.UTC(2001, date.getMonth(), date.getDate()) -
          Date.UTC(2001, 0, 1)) /
          86400000,
      ) + 1
    );
  }

  /**
   * Build the map function (as a string, to be run by PouchDB/CouchDB) that indexes
   * documents of every configured entity type by the cyclic day-of-year of any of their
   * configured date-of-birth properties.
   *
   * The emitted key is the cyclic day-of-year (see {@link getCyclicDayOfYear}), duplicated at +365
   * so that range queries spanning a year boundary (e.g. "next 32 days" in late December) don't need
   * to wrap around manually. The emitted value is the raw property value (the date of birth).
   *
   * Only entities that are not `inactive` are indexed.
   */
  private buildMapFunction(entityConfig: EntityPropertyMap): string {
    const typeBlocks = Object.keys(entityConfig)
      .sort()
      .map((entityType) => {
        const properties = toPropertyList(entityConfig[entityType])
          .slice()
          .sort();
        const propertyEmits = properties
          .map(
            (property) => `
          if (doc.${property}) {
            var raw = doc.${property};
            var month, day;
            if (typeof raw === "string" && raw.length >= 10) {
              month = parseInt(raw.substring(5, 7), 10);
              day = parseInt(raw.substring(8, 10), 10);
            } else {
              var parsed = new Date(raw);
              month = parsed.getMonth() + 1;
              day = parsed.getDate();
            }
            var dayOfYear = Math.floor((Date.UTC(2001, month - 1, day) - Date.UTC(2001, 0, 1)) / 86400000) + 1;
            emit(dayOfYear, raw);
            emit(dayOfYear + 365, raw);
          }`,
          )
          .join("\n");

        return `
      if (doc._id.startsWith("${entityType}:")) {
        ${propertyEmits}
      }`;
      })
      .join("\n");

    return `(doc) => {
    if (doc.inactive) return;
    ${typeBlocks}
  }`;
  }
}

/**
 * Parse a stored date-of-birth value into a Date at local midnight.
 *
 * `moment()` reads an ISO date string (`"YYYY-MM-DD"`) in local time, whereas
 * `new Date("2020-01-15")` reads it as UTC and would then land on the previous day via the
 * local `getFullYear()`/`getMonth()`/`getDate()` accessors used downstream. `moment.ISO_8601`
 * also covers any legacy value still stored as a full ISO timestamp, normalizing it to the
 * local calendar day.
 */
export function parseDateOnly(value: string): Date {
  return moment(value, moment.ISO_8601).startOf("day").toDate();
}

/**
 * Real calendar date of the next occurrence (this year, or next year if it has already
 * passed) of the given date's month/day, relative to `today`.
 *
 * Uses the native `Date` constructor (not `moment`) on purpose: its month/day overflow
 * folds a Feb 29 birthday onto March 1 in non-leap years, matching how the index's
 * reference-year day-of-year keys treat it. `moment([y, m, d])` would instead be invalid.
 */
export function getNextOccurrence(date: Date, today: Date): Date {
  const next = new Date(today.getFullYear(), date.getMonth(), date.getDate());
  if (today.getTime() > next.getTime()) {
    next.setFullYear(next.getFullYear() + 1);
  }
  return next;
}

results matching ""

    No results matching ""