diff --git a/packages/server/dataloader/LocalCache.ts b/packages/server/dataloader/LocalCache.ts deleted file mode 100644 index ab73c74efa0..00000000000 --- a/packages/server/dataloader/LocalCache.ts +++ /dev/null @@ -1,178 +0,0 @@ -import {DBType} from '../database/rethinkDriver' -import RedisCache, {CacheType} from './RedisCache' -import {Updater} from './RethinkDBCache' - -const resolvedPromise = Promise.resolve() - -type Thunk = () => void - -/** - * This cache is only used for the User table, {@see ProxiedCache}, {@see ../db} - */ -export default class LocalCache { - private cacheMap = {} as {[key: string]: {ts: number; promise: Promise}} - private hasReadDispatched = true - private hasWriteDispatched = true - private fetches = [] as { - table: keyof CacheType - id: string - resolve: (payload: any) => void - reject: (err: any) => void - }[] - private cacheHits = [] as Thunk[] - private writes = [] as { - table: T - id: string - resolve: (payload: any) => void - updater: Updater - }[] - private ttl: number - private redisCache = new RedisCache() - // private redisCache = new RedisCache(this.clearLocal) - constructor(ttl: number) { - this.ttl = ttl - setInterval(this.gc, this.ttl).unref() - } - - private gc = () => { - const oldestValidTS = Date.now() - this.ttl - for (const [key, entry] of Object.entries(this.cacheMap)) { - const {ts} = entry - if (ts < oldestValidTS) { - delete this.cacheMap[key] - } - } - } - private resolveCacheHits(cacheHits: Thunk[]) { - // cacheHits are possibly from the previous batch, hence the param - cacheHits.forEach((cacheHit) => { - cacheHit() - }) - } - private dispatchReadBatch = async () => { - this.hasReadDispatched = true - // grab a reference to them now because after await is called they may be overwritten when a new batch is created - const {fetches, cacheHits} = this - if (fetches.length === 0) { - this.resolveCacheHits(cacheHits) - return - } - try { - const values = await this.redisCache.read(fetches) - this.resolveCacheHits(cacheHits) - fetches.forEach((fetch, i) => { - const {resolve, reject} = fetch - const value = values[i] - const handle = value instanceof Error ? reject : resolve - handle(value) - }) - } catch (e) { - this.resolveCacheHits(cacheHits) - fetches.forEach((fetch) => { - const {table, id, reject} = fetch - const key = `${table}:${id}` - this.clearLocal(key) - reject(e) - }) - } - } - - private clearLocal(key: string) { - delete this.cacheMap[key] - return this - } - private primeLocal(key: string, doc: CacheType[keyof CacheType]) { - this.cacheMap[key] = { - ts: Date.now(), - promise: Promise.resolve(doc) - } - } - - private dispatchWriteBatch = async () => { - this.hasWriteDispatched = true - const {writes} = this - const results = await this.redisCache.write(writes as any) - writes.forEach(({resolve, table, id}, idx) => { - const key = `${table}:${id}` - const result = results[idx] - this.primeLocal(key, result!) - resolve(result) - }) - } - async clear(table: T, id: string) { - const key = `${table}:${id}` - this.clearLocal(key) - await this.redisCache.clear(key) - return this - } - - async prime(table: T, docs: CacheType[T][]) { - docs.forEach((doc: any) => { - const key = `${table}:${doc.id}` - this.primeLocal(key, doc) - }) - this.redisCache.prime(table, docs) - return this - } - async read(table: T, id: string) { - if (this.hasReadDispatched) { - this.hasReadDispatched = false - this.fetches = [] - this.cacheHits = [] - resolvedPromise.then(() => { - process.nextTick(this.dispatchReadBatch) - }) - } - const key = `${table}:${id}` - const cachedRecord = this.cacheMap[key] - if (cachedRecord) { - cachedRecord.ts = Date.now() - // there's marginal savings to be had by reusing promises instead of creating a new one for each cache hit - // not implemented for the sake of simplicity - return new Promise((resolve) => { - this.cacheHits.push(() => { - resolve(cachedRecord.promise) - }) - }) - } - const promise = new Promise((resolve, reject) => { - this.fetches.push({resolve, reject, table, id}) - }) - this.cacheMap[key] = {ts: Date.now(), promise} - return promise - } - - async readMany(table: T, ids: string[]) { - const loadPromises = [] as Promise[] - ids.forEach((id) => { - loadPromises.push(this.read(table, id).catch((error) => error)) - }) - return Promise.all(loadPromises) - } - async write

(table: P, id: string, updater: any) { - if (this.hasWriteDispatched) { - this.hasWriteDispatched = false - this.writes = [] as any[] - resolvedPromise.then(() => { - process.nextTick(this.dispatchWriteBatch) - }) - } - return new Promise((resolve) => { - this.writes.push({id, table, updater, resolve}) - }) - } - - async writeMany

(table: P, ids: string[], updater: Updater) { - return Promise.all(ids.map((id) => this.write(table, id, updater))) - } - - // currently doesn't support updater functions - async writeTable>(table: T, updater: P) { - Object.keys(this.cacheMap).forEach((key) => { - if (!key.startsWith(`${table}:`)) return - const doc = this.cacheMap[key] - Object.assign(doc as any, updater) - }) - await this.redisCache.writeTable(table, updater) - } -} diff --git a/packages/server/dataloader/RedisCache.ts b/packages/server/dataloader/RedisCache.ts deleted file mode 100644 index 89d9064d921..00000000000 --- a/packages/server/dataloader/RedisCache.ts +++ /dev/null @@ -1,164 +0,0 @@ -import Redis from 'ioredis' -import ms from 'ms' -import {Unpromise} from 'parabol-client/types/generics' -import {DBType} from '../database/rethinkDriver' -import RedisInstance from '../utils/RedisInstance' -import RethinkDBCache, {RWrite} from './RethinkDBCache' -import customRedisQueries from './customRedisQueries' -import hydrateRedisDoc from './hydrateRedisDoc' -export type RedisType = { - [P in keyof typeof customRedisQueries]: Unpromise>[0] -} - -export type CacheType = RedisType - -const TTL = ms('3h') - -const msetpx = (key: string, value: Record) => { - return ['set', key, JSON.stringify(value), 'PX', TTL] as string[] -} -// type ClearLocal = (key: string) => void - -export default class RedisCache { - rethinkDBCache = new RethinkDBCache() - redis?: Redis - // remote invalidation is stuck on upgrading to Redis v6 in prod - // invalidator = new Redis(process.env.REDIS_URL) - cachedTypes = new Set() - invalidatorClientId!: string - // constructor(clearLocal: ClearLocal) { - // this.initializeInvalidator(clearLocal) - // } - // private async initializeInvalidator(clearLocal: ClearLocal) { - // this.invalidator.subscribe('__redis__:invalidate') - // this.invalidator.on('message', (_channel, key) => { - // clearLocal(key) - // }) - // this.invalidatorClientId = await this.getRedis().client('id') - // } - // private trackInvalidations(fetches: {table: T}[]) { - // // This O(N) operation could be O(1), but that requires updating the prefixes as we cache more things - // // the risk of an invalid cache hit isn't worth it - // for (let i = 0; i < fetches.length; i++) { - // const {table} = fetches[i] - // if (!this.cachedTypes.has(table)) { - // this.cachedTypes.add(table) - // // whenever any key starts with the ${table} prefix gets set, send an invalidation message to the invalidator - // // noloop is used to exclude sending the message to the client that called set - // this.getRedis().client('TRACKING', 'ON', 'REDIRECT', this.invalidatorClientId, 'BCAST', 'NOLOOP', 'PREFIX', table) - // } - // } - // } - private getRedis() { - if (!this.redis) { - this.redis = new RedisInstance('redisCache') - } - return this.redis - } - read = async (fetches: {table: T; id: string}[]) => { - // this.trackInvalidations(fetches) - const fetchKeys = fetches.map(({table, id}) => `${table}:${id}`) - const cachedDocs = await this.getRedis().mget(...fetchKeys) - const missingKeysForRethinkDB = [] as {table: T; id: string}[] - const customQueriesByType = {} as {[type: string]: string[]} - const customQueries = [] as Promise[] - - for (let i = 0; i < cachedDocs.length; i++) { - const cachedDoc = cachedDocs[i] - if (cachedDoc === null) { - const fetch = fetches[i]! - const {table, id} = fetch - const customQuery = customRedisQueries[table as keyof typeof customRedisQueries] - if (!!customQuery) { - customQueriesByType[table] = customQueriesByType[table] || [] - customQueriesByType[table]!.push(id) - } else { - missingKeysForRethinkDB.push(fetch) - } - } - } - const customTypes = Object.keys(customQueriesByType) - if (missingKeysForRethinkDB.length + customTypes.length === 0) { - return cachedDocs.map((doc, idx) => hydrateRedisDoc(doc!, fetches[idx]!.table)) - } - - customTypes.forEach((type) => { - const customQuery = customRedisQueries[type as keyof typeof customRedisQueries] - const ids = customQueriesByType[type]! - customQueries.push(customQuery(ids)) - }) - const [docsByKey, ...customResults] = await Promise.all([ - missingKeysForRethinkDB.length === 0 - ? ({} as any) - : this.rethinkDBCache.read(missingKeysForRethinkDB as any), - ...customQueries - ]) - customResults.forEach((resultByTypeIdx, idx) => { - const type = customTypes[idx]! - const ids = customQueriesByType[type]! - ids.forEach((id, idx) => { - const key = `${type}:${id}` - docsByKey[key] = resultByTypeIdx[idx] - }) - }) - - const writes = [] as string[][] - Object.keys(docsByKey).forEach((key) => { - writes.push(msetpx(key, docsByKey[key]!)) - }) - // don't wait for redis to populate the local cache - this.getRedis().multi(writes).exec() - return fetchKeys.map((key, idx) => { - const cachedDoc = cachedDocs[idx] - return cachedDoc ? hydrateRedisDoc(cachedDoc, fetches[idx]!.table) : docsByKey[key]! - }) - } - - write = async (writes: RWrite[]) => { - const results = await this.rethinkDBCache.write(writes as any) - const redisWrites = [] as string[][] - results.forEach((result, idx) => { - // result will be null if the underlying document is not found - if (!result) return - const write = writes[idx]! - const {table, id} = write - const key = `${table}:${id}` - redisWrites.push(msetpx(key, result)) - }) - // awaiting redis isn't strictly required, can get speedboost by removing the wait - await this.getRedis().multi(redisWrites).exec() - return results - } - - clear = async (key: string) => { - return this.getRedis().del(key) - } - prime = async (table: T, docs: CacheType[T][]) => { - const writes = docs.map((doc: any) => { - return msetpx(`${table}:${doc.id}`, doc) - }) - await this.getRedis().multi(writes).exec() - } - writeTable = async (table: T, updater: Partial) => { - // inefficient to not update rethink & redis in parallel, but writeTable is uncommon - await this.rethinkDBCache.writeTable(table, updater) - return new Promise((resolve) => { - const stream = this.getRedis().scanStream({match: `${table}:*`, count: 100}) - stream.on('data', async (keys) => { - if (!keys?.length) return - stream.pause() - const userStrs = await this.getRedis().mget(...keys) - const writes = userStrs.map((userStr, idx) => { - const user = JSON.parse(userStr!) - Object.assign(user, updater) - return msetpx(keys[idx], user) - }) - await this.getRedis().multi(writes).exec() - stream.resume() - }) - stream.on('end', () => { - resolve() - }) - }) - } -} diff --git a/packages/server/dataloader/RethinkDBCache.ts b/packages/server/dataloader/RethinkDBCache.ts deleted file mode 100644 index b33a05a28fd..00000000000 --- a/packages/server/dataloader/RethinkDBCache.ts +++ /dev/null @@ -1,64 +0,0 @@ -import getRethink, {DBType} from '../database/rethinkDriver' -import {RDatum} from '../database/stricterR' -import {OptionalExceptFor} from '../utils/TypeUtil' - -export type Updater = - | (T extends {updatedAt: Date} ? OptionalExceptFor : Partial) - | ((doc: RDatum) => any) - -export interface RRead { - id: string - table: T -} -export interface RWrite extends RRead { - updater: Updater -} - -export default class RethinkDBCache { - read = async (fetches: RRead[]) => { - const r = await getRethink() - const idsByTable = {} as {[table: string]: string[]} - fetches.forEach((fetch) => { - const {table, id} = fetch - idsByTable[table] = idsByTable[table] || [] - idsByTable[table]!.push(id) - }) - const reqlObj = {} as {[table: string]: DBType[T][]} - Object.entries(idsByTable).forEach(([table, ids]) => { - reqlObj[table] = r - .table(table as any) - .getAll(r.args(ids)) - .coerceTo('array') as unknown as DBType[T][] - }) - - const dbDocsByTable = await r(reqlObj).run() - const docsByKey = {} as {[key: string]: DBType[T]} - Object.keys(dbDocsByTable).forEach((table) => { - const docs = dbDocsByTable[table] - docs?.forEach((doc) => { - const key = `${table}:${doc.id}` - docsByKey[key] = doc - }) - }) - return docsByKey - } - write = async (writes: RWrite[]) => { - const r = await getRethink() - const reqlParts = writes.map((update) => { - const {table, id, updater} = update - return ( - r - .table(table) - .get(id) - // "always" will return the document whether it has changed or not - .update(updater as any, {returnChanges: 'always'})('changes')(0)('new_val') - .default(null) - ) - }) - return r(reqlParts).run() as Promise<(DBType[T] | null)[]> - } - writeTable = async (table: T, updater: Partial) => { - const r = await getRethink() - return r.table(table).update(updater).run() - } -} diff --git a/packages/server/dataloader/customLoaderMakers.ts b/packages/server/dataloader/customLoaderMakers.ts index 20ce92a0f0c..30c05ca1d68 100644 --- a/packages/server/dataloader/customLoaderMakers.ts +++ b/packages/server/dataloader/customLoaderMakers.ts @@ -894,3 +894,45 @@ export const featureFlagByOwnerId = (parent: RootDataLoader) => { } ) } + +export const publicTemplatesByType = (parent: RootDataLoader) => { + const redis = getRedis() + return new NullableDataLoader( + async (meetingTypes) => { + return Promise.all( + meetingTypes.map(async (type) => { + const templateType = type === 'poker' ? 'poker' : 'retrospective' + const redisKey = `publicTemplates:${templateType}` + const cachedTemplatesStr = await redis.get(redisKey) + if (cachedTemplatesStr) { + const cachedTemplates = JSON.parse(cachedTemplatesStr) as MeetingTemplate[] + cachedTemplates.forEach( + (meetingTemplate) => meetingTemplate.createdAt === new Date(meetingTemplate.createdAt) + ) + return cachedTemplates + } + const freshTemplates = await getKysely() + .selectFrom('MeetingTemplate') + .selectAll() + .where('teamId', '=', 'aGhostTeam') + .where('isActive', '=', true) + .where('type', '=', templateType) + .where(({or, eb}) => + or([ + eb('hideStartingAt', 'is', null), + sql`make_date(2020 , extract(month from current_date)::integer, extract(day from current_date)::integer) between "hideEndingAt" and "hideStartingAt"`, + sql`make_date(2019 , extract(month from current_date)::integer, extract(day from current_date)::integer) between "hideEndingAt" and "hideStartingAt"` + ]) + ) + .orderBy('isFree') + .execute() + await redis.setex(redisKey, 60 * 60 * 24, JSON.stringify(freshTemplates)) + return freshTemplates + }) + ) + }, + { + ...parent.dataLoaderOptions + } + ) +} diff --git a/packages/server/db.ts b/packages/server/db.ts deleted file mode 100644 index f551a594110..00000000000 --- a/packages/server/db.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * This is a 3-stage cache - * Stage 1 is a query batcher and local cache (Node process), which stores parsed JSON. - * Stage 2 is a remote cache (Redis), which stores the document in serialized JSON - * Stage 3 is the DB itself (RethinkDB) - * Stage 1 runs a garbage collector every hour to remove docs that haven't been used in 1 hour - * Stage 2 uses Redis's built-in TTL to expire documents after 3 hours - * Stage 2 listens for invalidations coming from other nodes and invalidates Stage 1 when necessary (WIP, stuck on Redis v6) - * - * READS: - * Stage 1 caches the record inside a Promise. if not found, it queries Stage 2, caches the result, and returns - * Stage 2 gets the missing items & looks them up in redis. If not found, it queries Stage 3, caches the result, and returns - * Stage 3 sends a single batched query to RethinkDB containing all items not present in Stage 2 - * - * WRITES: - * Stage 1 passes the write request to Stage 2updates the cached value if found, calls Stage 2, then returns when Stage 2 finishes - * Stage 2 queries the current value from redis. If found, updates the value in redis and calls Stage 3 - * Stage 3 batches all updates and sends a single query to RethinkDB - * - * PROXIED CACHE: - * The LocalCache is essentially a DataLoader that doesn't expire after the request completes - * ProxiedCache maps the LocalCache to the DataLoader API so the dataloader worker can use it - */ - -import ms from 'ms' -import LocalCache from './dataloader/LocalCache' - -const db = new LocalCache(ms('1h')) -export default db diff --git a/packages/server/graphql/public/types/PokerMeetingSettings.ts b/packages/server/graphql/public/types/PokerMeetingSettings.ts index 145e1c939a4..ddefe03406a 100644 --- a/packages/server/graphql/public/types/PokerMeetingSettings.ts +++ b/packages/server/graphql/public/types/PokerMeetingSettings.ts @@ -1,7 +1,4 @@ -import db from '../../../db' -import {ORG_HOTNESS_FACTOR, TEAM_HOTNESS_FACTOR} from '../../../utils/getTemplateScore' import connectionFromTemplateArray from '../../queries/helpers/connectionFromTemplateArray' -import getScoredTemplates from '../../queries/helpers/getScoredTemplates' import resolveSelectedTemplate from '../../queries/helpers/resolveSelectedTemplate' import {PokerMeetingSettingsResolvers} from '../resolverTypes' @@ -13,8 +10,7 @@ const PokerMeetingSettings: PokerMeetingSettingsResolvers = { const templates = await dataLoader .get('meetingTemplatesByType') .load({teamId, meetingType: 'poker'}) - const scoredTemplates = await getScoredTemplates(templates, TEAM_HOTNESS_FACTOR) - return scoredTemplates + return templates }, organizationTemplates: async ({teamId}, {first, after}, {dataLoader}) => { @@ -25,17 +21,11 @@ const PokerMeetingSettings: PokerMeetingSettingsResolvers = { (template) => template.scope !== 'TEAM' && template.teamId !== teamId && template.type === 'poker' ) - const scoredTemplates = await getScoredTemplates(organizationTemplates, ORG_HOTNESS_FACTOR) - return connectionFromTemplateArray(scoredTemplates, first, after) + return connectionFromTemplateArray(organizationTemplates, first, after) }, - publicTemplates: async (_src, {first, after}) => { - const publicTemplates = await db.read('publicTemplates', 'poker') - publicTemplates.sort((a, b) => { - if (a.isFree && !b.isFree) return -1 - if (!a.isFree && b.isFree) return 1 - return 0 - }) + publicTemplates: async (_src, {first, after}, {dataLoader}) => { + const publicTemplates = await dataLoader.get('publicTemplatesByType').loadNonNull('poker') return connectionFromTemplateArray(publicTemplates, first, after) } } diff --git a/packages/server/graphql/public/types/RetrospectiveMeetingSettings.ts b/packages/server/graphql/public/types/RetrospectiveMeetingSettings.ts index 3203317c635..a704f321f85 100644 --- a/packages/server/graphql/public/types/RetrospectiveMeetingSettings.ts +++ b/packages/server/graphql/public/types/RetrospectiveMeetingSettings.ts @@ -1,8 +1,5 @@ -import db from '../../../db' import {MeetingTypeEnum} from '../../../postgres/types/Meeting' -import {ORG_HOTNESS_FACTOR, TEAM_HOTNESS_FACTOR} from '../../../utils/getTemplateScore' import connectionFromTemplateArray from '../../queries/helpers/connectionFromTemplateArray' -import getScoredTemplates from '../../queries/helpers/getScoredTemplates' import resolveSelectedTemplate from '../../queries/helpers/resolveSelectedTemplate' import {RetrospectiveMeetingSettingsResolvers} from '../resolverTypes' @@ -14,15 +11,14 @@ const RetrospectiveMeetingSettings: RetrospectiveMeetingSettingsResolvers = { reflectTemplates: ({teamId}, _args, {dataLoader}) => { return dataLoader.get('meetingTemplatesByType').load({teamId, meetingType: 'retrospective'}) }, - + // We should remove this since it's not longer used teamTemplates: async ({teamId}, _args, {dataLoader}) => { const templates = await dataLoader .get('meetingTemplatesByType') .load({teamId, meetingType: 'retrospective' as MeetingTypeEnum}) - const scoredTemplates = await getScoredTemplates(templates, TEAM_HOTNESS_FACTOR) - return scoredTemplates + return templates }, - + // We should remove this since it's not longer used organizationTemplates: async ({teamId}, {first, after}, {dataLoader}) => { const team = await dataLoader.get('teams').loadNonNull(teamId) const {orgId} = team @@ -31,17 +27,13 @@ const RetrospectiveMeetingSettings: RetrospectiveMeetingSettingsResolvers = { (template) => template.scope !== 'TEAM' && template.teamId !== teamId && template.type === 'retrospective' ) - const scoredTemplates = await getScoredTemplates(organizationTemplates, ORG_HOTNESS_FACTOR) - return connectionFromTemplateArray(scoredTemplates, first, after) + return connectionFromTemplateArray(organizationTemplates, first, after) }, - - publicTemplates: async (_src, {first, after}) => { - const publicTemplates = await db.read('publicTemplates', 'retrospective') - publicTemplates.sort((a, b) => { - if (a.isFree && !b.isFree) return -1 - if (!a.isFree && b.isFree) return 1 - return 0 - }) + // We should remove this since it's not longer used + publicTemplates: async (_src, {first, after}, {dataLoader}) => { + const publicTemplates = await dataLoader + .get('publicTemplatesByType') + .loadNonNull('retrospective') return connectionFromTemplateArray(publicTemplates, first, after) } } diff --git a/packages/server/graphql/queries/helpers/getScoredTemplates.ts b/packages/server/graphql/queries/helpers/getScoredTemplates.ts deleted file mode 100644 index f549c4cc6aa..00000000000 --- a/packages/server/graphql/queries/helpers/getScoredTemplates.ts +++ /dev/null @@ -1,23 +0,0 @@ -import db from '../../../db' -import getTemplateScore from '../../../utils/getTemplateScore' - -const getScoredTemplates = async ( - templates: T[], - newHotnessFactor: number -) => { - const sharedTemplateIds = templates.map(({id}) => id) - const sharedTemplateEndTimes = await db.readMany('endTimesByTemplateId', sharedTemplateIds) - const scoreByTemplateId = {} as {[templateId: string]: number} - templates.forEach((template, idx) => { - const {id: templateId, createdAt} = template - const endTimes = sharedTemplateEndTimes[idx]! - scoreByTemplateId[templateId] = getTemplateScore(createdAt, endTimes, newHotnessFactor) - }) - // mutative, but doesn't matter if we change the sort oder - templates.sort((a, b) => { - return scoreByTemplateId[a.id]! > scoreByTemplateId[b.id]! ? -1 : 1 - }) - return templates -} - -export default getScoredTemplates diff --git a/packages/server/utils/getTemplateScore.ts b/packages/server/utils/getTemplateScore.ts deleted file mode 100644 index 63dbd527890..00000000000 --- a/packages/server/utils/getTemplateScore.ts +++ /dev/null @@ -1,37 +0,0 @@ -/* - * On social sites (e.g. Reddit) hotness is a function of age & upvotes - * Instead of upvotes, our templates rely on # meetings run - * Unlike social sites, templates shouldn't age immediately after cretion - * They get a 1-month period to grow in popularity before age plays against them - * After 2 months, age stops helping the score & the score depends on usage - * Adapted from https://stats.areppim.com/glossaire/scurve_def.htm. - * Inverted the LN to invert the S shape - */ - -const SATURTION = 1 // max score, we want a score that is 0-1 -const MIDPOINT = 45 // after this many days, the score will be SATURATION / 2 -const GROWTH_INTERVAL = 30 // 80% of the decline happens in this many days - -// Default hotness factors for ranking templates -export const ORG_HOTNESS_FACTOR = 0.8 -export const TEAM_HOTNESS_FACTOR = 0.9 - -const getAgeScore = (age: number) => { - const ageDays = age / 1000 / 60 / 60 / 24 - return SATURTION / (1 + Math.exp((Math.log(81) / GROWTH_INTERVAL) * (ageDays - MIDPOINT))) -} - -// weightCreatedAt: 0-1, how important is age vs. the number of meetings run -const getTemplateScore = ( - templateCreatedAt: Date, - meetingsEndedAt: number[], - newHotnessFactor: number -) => { - const now = Date.now() - const templateAge = now - templateCreatedAt.getTime() - const templateScore = getAgeScore(templateAge) - const meetingAges = meetingsEndedAt.map((endedAt) => now - endedAt) - const meetingScore = meetingAges.reduce((sum, age) => sum + getAgeScore(age), 0) - return newHotnessFactor * templateScore + (1 - newHotnessFactor) * meetingScore -} -export default getTemplateScore