baublet/w8mngr

View on GitHub
api/dataServices/foodLog/stats.ts

Summary

Maintainability
C
1 day
Test Coverage
import addDays from "date-fns/addDays";
import addMonths from "date-fns/addMonths";
import format from "date-fns/format";
import subMonths from "date-fns/subMonths";
import groupBy from "lodash.groupby";

import {
  dayStringFromDate,
  dayStringToDate,
  objectEntries,
} from "../../../shared";
import { Maybe } from "../../../shared/types";
import { Context } from "../../createContext";
import { FoodLogDataPoint } from "../../generated";
import { globalInMemoryCache } from "../../helpers";
import { FoodLogEntity, foodLogDataService } from ".";

export async function stats(
  context: Context,
  { userId, ...args }: { from?: Maybe<Date>; to?: Maybe<Date>; userId: string }
) {
  const { from, to } = getDateRangeWithDefault(args);
  const cacheKey = `food-log-stats-${userId}-${from}-${to}`;
  return globalInMemoryCache.getOrSet({
    key: cacheKey,
    expiry: Date.now() + 1000 * 60 * 2,
    fn: async () => {
      const foodLogsInRange = await foodLogDataService.findBy(context, (q) =>
        q
          .where("userId", "=", userId)
          .andWhere("day", ">=", from)
          .andWhere("day", "<=", to)
      );
      const foodLogsGroupedByDay: Record<string, FoodLogEntity[]> =
        groupBy<any>(foodLogsInRange, (log) => log.day);

      fillDays(foodLogsGroupedByDay, () => []);

      const days = objectEntries(foodLogsGroupedByDay);

      const averageDailyCaloriesMap: Record<string, number> = {};
      const averageDailyCarbsMap: Record<string, number> = {};
      const averageDailyFatMap: Record<string, number> = {};
      const averageDailyProteinMap: Record<string, number> = {};

      for (const [day, foodLogs] of days) {
        for (const foodLog of foodLogs) {
          maybeAddToTotal(averageDailyCaloriesMap, day, foodLog.calories);
          maybeAddToTotal(averageDailyCarbsMap, day, foodLog.carbs);
          maybeAddToTotal(averageDailyFatMap, day, foodLog.fat);
          maybeAddToTotal(averageDailyProteinMap, day, foodLog.protein);
        }
      }

      const averageDailyCalories = getFlattenedAverage(averageDailyCaloriesMap);
      const averageDailyCarbs = getFlattenedAverage(averageDailyCarbsMap);
      const averageDailyFat = getFlattenedAverage(averageDailyFatMap);
      const averageDailyProtein = getFlattenedAverage(averageDailyProteinMap);

      const visualizationData: FoodLogDataPoint[] = days.map(
        ([day, foodLogs]) => {
          return {
            day,
            ...foodLogs.reduce(
              (data, foodLog) => {
                data.calories = maybeAdd(data.calories, foodLog.calories);
                data.carbs = maybeAdd(data.carbs, foodLog.carbs);
                data.fat = maybeAdd(data.fat, foodLog.fat);
                data.protein = maybeAdd(data.protein, foodLog.protein);
                return data;
              },
              {
                calories: undefined,
                carbs: undefined,
                fat: undefined,
                protein: undefined,
              } as Omit<FoodLogDataPoint, "day">
            ),
            dayLabel: format(dayStringToDate(day), "PP"),
          };
        }
      );

      return {
        summary: {
          averageDailyCalories,
          averageDailyCarbs,
          averageDailyFat,
          averageDailyProtein,
          totalFoodsLogged: foodLogsInRange.length,
        },
        visualizationData: visualizationData.map((data, i) => ({
          day: data.day,
          dayLabel: format(dayStringToDate(data.day), "PP"),
          calories: data.calories,
          fat: data.fat,
          carbs: data.carbs,
          protein: data.protein,
        })),
      };
    },
  });
}

function orZero(value: any) {
  if (value === Infinity) {
    return 0;
  }
  if (isNaN(value)) {
    return 0;
  }
  if (typeof value === "string") {
    return parseInt(value, 10);
  }
  if (typeof value === "number") {
    return value;
  }
  return 0;
}

function maybeAdd(originalValue: undefined | number, value: any) {
  if (!originalValue) {
    return orZero(value);
  }
  return originalValue + orZero(value);
}

function maybeAddToTotal<T extends Record<any, any>>(
  subject: T,
  key: keyof T,
  value: any
) {
  const valueToAdd = orZero(parseInt(value, 10));
  const anySubject: any = subject;
  if (anySubject[key] === undefined) {
    anySubject[key] = valueToAdd;
  } else {
    anySubject[key] += valueToAdd;
  }
}

function getDateRangeWithDefault(args?: {
  from?: Maybe<Date>;
  to?: Maybe<Date>;
}) {
  let from = args?.from;
  let to = args?.to;

  if (!from && !to) {
    from = subMonths(new Date(), 3);
    to = new Date();
  } else if (!from && to) {
    from = subMonths(to, 3);
  } else if (from && !to) {
    to = addMonths(from, 3);
  }

  return {
    from: dayStringFromDate(from as Date),
    to: dayStringFromDate(to as Date),
  };
}

function getFlattenedAverage(set: Record<string, number>): number {
  const entries = objectEntries(set);
  let total = 0;
  let totalValues = 0;

  for (const [, value] of entries) {
    total += value;
    totalValues += 1;
  }

  if (totalValues === 0) {
    return 0;
  }

  const flattenedAverage = total / totalValues;

  if (isNaN(flattenedAverage)) {
    return 0;
  }

  if (flattenedAverage === Infinity) {
    return 0;
  }

  return flattenedAverage;
}

function fillDays<T>(record: Record<string, T>, fillEmptyDay: () => T) {
  const days = Object.keys(record).sort();
  const firstDay = days[0];
  const lastDay = days[days.length - 1];

  const firstDayDate = dayStringToDate(firstDay);

  let currentDay = firstDayDate;
  let currentDayString = firstDay;

  while (currentDayString !== lastDay) {
    currentDay = addDays(currentDay, 1);
    currentDayString = dayStringFromDate(currentDay);
    if (!record[currentDayString]) {
      record[currentDayString] = fillEmptyDay();
    }
  }
}