progress-row/base.js

/**
 * @module ProgressRow
 */
import { getMethodCard } from './rows/method-card.js'
import {
  getPlaylistCards,
  processPlaylistItem,
} from './rows/playlist-card.js'
import { globalConfig } from '../config.js'
import { getContentCardMap, processContentItem } from './rows/content-card.js'
import { fetchByRailContentIds } from '../sanity.js'
import { addContextToContent } from '../contentAggregator.js'
import { fetchPlaylist } from '../content-org/playlists.js'
import { TabResponseType } from '../../contentMetaData.js'
import { GET, PUT } from '../../infrastructure/http/HttpClient.ts'
import { postProcessBadge } from "../../contentTypeConfig.js";
import { db } from '../sync/index'

export const USER_PIN_PROGRESS_KEY = 'user_pin_progress_row'
const CACHE_EXPIRY_MS = 5 * 60 * 1000

/**
 * Retrieves user's pinned data by brand, from localStorage or BE call.
 * @param brand
 * @returns {Promise<any|*|{id, type}>}
 */
async function getUserPinnedItem(brand) {
  const key = getUserPinProgressKey()

  const pinnedProgress = await getStoredPinnedData(key)
  const cachedData = pinnedProgress[brand]

  if (isCacheValid(cachedData)) {
    delete cachedData.cachedAt // is for internal use
    return (cachedData.id && cachedData.type)
      ? cachedData
      : null
  }

  const url = `/api/user-management-system/v1/progress/pin?brand=${brand}`
  try {
    const response = await GET(url)
    if (response === "" || (response && !response.error)) { // "" is 204 case
      return await setUserBrandPinnedItem(brand, response)
    }
    return response
  } catch (error) {
      return null
  }
}

/**
 * Pins a specific progress row for a user, scoped by brand.
 *
 * @param {string} brand - The brand context for the pin action.
 * @param {number|string} id - The ID of the progress item to pin.
 * @param {string} progressType - The type of progress (e.g., 'content', 'playlist').
 * @returns {Promise<Object>} - A promise resolving to the response from the pin API.
 *
 * @example
 * pinProgressRow('drumeo', 12345, 'content')
 *   .then(response => console.log(response))
 *   .catch(error => console.error(error));
 */
export async function pinProgressRow(brand, id, progressType) {
  const url = `/api/user-management-system/v1/progress/pin?brand=${brand}&id=${id}&progressType=${progressType}`
  const response = await PUT(url, null)

  if (response && !response.error) {
    return await setUserBrandPinnedItem(brand, {
      id,
      type: progressType,
    })
  }
  return response
}

/**
 * Unpins the current pinned progress row for a user, scoped by brand.
 *
 * @param {string} brand - The brand context for the unpin action.
 * @returns {Promise<Object>} - A promise resolving to the response from the unpin API.
 *
 * @example
 * unpinProgressRow('drumeo', 123456)
 *   .then(response => console.log(response))
 *   .catch(error => console.error(error));
 */
export async function unpinProgressRow(brand) {
  const url = `/api/user-management-system/v1/progress/unpin?brand=${brand}`
  const response = await PUT(url, null)
  if (response && !response.error) {
    await setUserBrandPinnedItem(brand, null)
  }
  return response
}

/**
 * Gets the localStorage key for user pinned progress, scoped by user ID
 */
export function getUserPinProgressKey(id) {
  const userId = id || globalConfig.sessionConfig?.userId || globalConfig.railcontentConfig?.userId
  return USER_PIN_PROGRESS_KEY + `_${userId}`
}

export async function setUserPinnedProgressRow(userId, pinnedData) {
  const key = getUserPinProgressKey(userId)

  pinnedData.map(item => ({...item, cachedAt: Date.now()}))
  await globalConfig.localStorage.setItem(key, JSON.stringify(pinnedData))
}

async function setUserBrandPinnedItem(brand, pinnedData) {
  if (!brand) return

  const key = getUserPinProgressKey()
  let pinnedProgress = await getStoredPinnedData(key)

  const processed = pinnedData && typeof pinnedData === 'object'
    ? pinnedData
    : null

  pinnedProgress[brand] = setPinnedData(processed)
  await globalConfig.localStorage.setItem(key, JSON.stringify(pinnedProgress))
  return processed
}

async function getStoredPinnedData(key) {
  const pinnedProgressRaw = await globalConfig.localStorage.getItem(key)
  const pinnedProgress = pinnedProgressRaw ? JSON.parse(pinnedProgressRaw) : {}
  return pinnedProgress || {}
}

function setPinnedData(pinnedData) {
  const now = Date.now()
  return {
    ...pinnedData,
    cachedAt: now
  }
}

function isCacheValid(cachedData) {
  return cachedData?.cachedAt && (Date.now() - cachedData.cachedAt) < CACHE_EXPIRY_MS
}

/**
 * Fetches and combines recent user progress rows and playlists, excluding certain types and parents.
 *
 * @param {Object} [options={}] - Options for fetching progress rows.
 * @param {string|null} [options.brand=null] - The brand context for progress data.
 * @param {number} [options.limit=8] - Maximum number of progress rows to return.
 * @returns {Promise<Object>} - A promise that resolves to an object containing progress rows formatted for UI.
 *
 * @example
 * getProgressRows({ brand: 'drumeo', limit: 10 })
 *   .then(data => console.log(data))
 *   .catch(error => console.error(error));
 */
export async function getProgressRows({ brand = 'drumeo', limit = 8 } = {}, options = {}) {
  // since this MCS method abstracts db, provide pull abstractions instead of making MPF/MA do it on their own
  if (options.pull) {
    await db.contentProgress.pull()
  }
  // otherwise check for fresh data from server by default
  else {
    db.contentProgress.pull()
  }

  const userPinnedItem = await getUserPinnedItem(brand)

  const [contentCardMap, playlistCards, methodCard] = await getCards(brand, limit, userPinnedItem)

  const pinnedCard = await popPinnedItem(userPinnedItem, contentCardMap, playlistCards, methodCard)

  let allResultsLength = playlistCards.length + contentCardMap.size
  if (methodCard) {
    allResultsLength += 1
  }

  const results = sortCards(pinnedCard, contentCardMap, playlistCards, methodCard, limit)
  return {
    type: TabResponseType.PROGRESS_ROWS,
    displayBrowseAll: allResultsLength > limit,
    data: results,
  }
}

async function getCards(brand, limit, userPinnedItem) {
  return Promise.all([
    getContentCardMap(brand, limit, userPinnedItem).catch(e => {
      console.error('getContentCardMap failed:', e)
      return new Map()
    }),
    getPlaylistCards(brand, limit).catch(e => {
      console.error('getPlaylistCards failed:', e)
      return []
    }),
    getMethodCard(brand).catch(e => {
      console.error('getMethodCard failed:', e)
      return null
    }),
  ])
}

/**
 * Pop the userPinnedItem from cards and return it.
 * If userPinnedItem is not found, generate the pinned card from scratch.
 *
 **/
async function popPinnedItem(userPinnedItem, contentCardMap, playlistCards, methodCard) {
  if (!userPinnedItem) return null
  const pinnedId = parseInt(userPinnedItem.id)
  const progressType = userPinnedItem.progressType ?? userPinnedItem.type

  let item = null
  if (progressType === 'content') {
    if (contentCardMap.has(pinnedId)) {
      item = contentCardMap.get(pinnedId)
      contentCardMap.delete(pinnedId)
    } else {
      // we use fetchByRailContentIds so that we don't have the _type restriction in the query
      let data = await fetchByRailContentIds([pinnedId], 'progress-tracker')
      data = postProcessBadge(data)
      item = await processContentItem(
        await addContextToContent(() => data[0] ?? null, {
          addNextLesson: true,
          addNavigateTo: true,
          addProgressStatus: true,
          addProgressPercentage: true,
          addProgressTimestamp: true,
        })
      )
    }
  } else if (progressType === 'playlist') {
    const pinnedPlaylist = playlistCards.find((p) => p.playlist.id === pinnedId)
    if (pinnedPlaylist) {
      item = pinnedPlaylist
    } else {
      const playlist = await fetchPlaylist(pinnedId)
      item = processPlaylistItem({
        id: pinnedId,
        playlist: playlist,
        type: 'playlist',
        progressTimestamp: new Date().getTime(),
      })
    }
  } else if (progressType === 'method') {
    // simply get method card and return
    item = methodCard
  }
  return item
}

/**
 * Order cards by progress timestamp, move pinned card to the front,
 * remove any duplicate cards showing the same content twice,
 * slice the result based on the provided limit.
 **/
function sortCards(pinnedCard, contentCardMap, playlistCards, methodCard, limit) {
  let combined = []
  if (pinnedCard) {
    pinnedCard.pinned = true
    combined.push(pinnedCard)
  }

  const progressList = Array.from(contentCardMap.values())

  combined = [...combined, ...progressList, ...playlistCards]

  // welcome card state will only show if pinned
  if (methodCard && methodCard.type !== 'method') {
    combined.push(methodCard)
  }

  return mergeAndSortItems(combined, limit)
}

function mergeAndSortItems(items, limit) {
  const seen = new Set()
  const deduped = []

  for (const item of items) {
    const key = `${item.id}-${item.progressType}`
    if (!seen.has(key)) {
      seen.add(key)
      deduped.push(item)
    }
  }

  return deduped
    .filter((item) => typeof item.progressTimestamp === 'number' && item.progressTimestamp >= 0)
    .sort((a, b) => {
      if (a.pinned && !b.pinned) return -1
      if (!a.pinned && b.pinned) return 1
      return b.progressTimestamp - a.progressTimestamp
    })
    .slice(0, limit)
}