From f193e17320bc12740fed9dafe5d7e9aad0e4f575 Mon Sep 17 00:00:00 2001 From: Bamco Date: Tue, 24 Oct 2023 19:42:18 +0200 Subject: [PATCH] assess life settings and reminder backend --- .../firestore/users/exercises/assess_life.ts | 260 +++++++++++++++++- .../users/exercises/wheel_of_life.ts | 2 +- apps/backend-functions/src/main.ts | 1 + .../src/pubsub/scheduled-task-runner.ts | 16 +- .../src/pubsub/user-exercises/assess_life.ts | 47 ++++ .../scheduled-task.interface.ts | 12 +- .../shared/scheduled-task/scheduled-task.ts | 8 +- .../assess-life/assess-life.component.ts | 50 +++- .../assess-life-settings.component.ts | 4 +- .../lib/assess-life/assess-life.service.ts | 87 +++--- .../entry/assess-life-entry.component.html | 2 +- .../entry/assess-life-entry.component.ts | 64 +---- .../lib/assess-life/pipes/interval.pipe.ts | 13 +- .../src/lib/assess-life/utils/date.utils.ts | 67 +++++ libs/model/src/lib/assess-life.ts | 15 +- libs/utils/src/lib/helpers.ts | 14 + libs/utils/src/lib/pipes/date-fns.pipe.ts | 7 + libs/utils/src/lib/pipes/smart-join.pipe.ts | 15 +- 18 files changed, 534 insertions(+), 150 deletions(-) create mode 100644 apps/backend-functions/src/pubsub/user-exercises/assess_life.ts create mode 100644 libs/exercises/src/lib/assess-life/utils/date.utils.ts diff --git a/apps/backend-functions/src/firestore/users/exercises/assess_life.ts b/apps/backend-functions/src/firestore/users/exercises/assess_life.ts index e4b7d4a72..9e0bc33fd 100644 --- a/apps/backend-functions/src/firestore/users/exercises/assess_life.ts +++ b/apps/backend-functions/src/firestore/users/exercises/assess_life.ts @@ -1,12 +1,266 @@ -import { onDocumentCreate } from '@strive/api/firebase' +import { arrayUnion, logger, onDocumentCreate, onDocumentUpdate } from '@strive/api/firebase' +import { AssessLifeEntry, AssessLifeInterval, AssessLifeSettings, Message, createAssessLifeEntry, createAssessLifeSettings, createDearFutureSelf, createMessage } from '@strive/model' +import { getDocumentSnap, toDate, unique } from '../../../shared/utils' +import { addMonths, addQuarters, addWeeks, addYears, differenceInDays, formatISO, isBefore, isEqual, startOfMonth, startOfQuarter, startOfWeek } from 'date-fns' +import { getNextDay, startOfAssessLifeYear } from '@strive/exercises/assess-life/utils/date.utils' +import { deleteScheduledTask, upsertScheduledTask } from 'apps/backend-functions/src/shared/scheduled-task/scheduled-task' +import { ScheduledTaskUserExerciseAssessLife, enumWorkerType } from 'apps/backend-functions/src/shared/scheduled-task/scheduled-task.interface' -export const assessLifeCreatedHandler = onDocumentCreate(`Users/{uid}/Exercises/AssessLife`, 'assessLifeCreatedHandler', +export const assessLifeSettingsCreatedHandler = onDocumentCreate(`Users/{uid}/Exercises/AssessLife`, 'assessLifeSettingsCreatedHandler', async (snapshot, context) => { + const { uid } = context.params as { uid: string } + const settings = createAssessLifeSettings(toDate({ ...snapshot.data(), id: snapshot.id })) + + // if the preferred day is never, then do not send reminders + if (settings.preferredDay === 'never') return + + return upsertReminder(uid, settings) +}) + +export const assessLifeSettingsChangeHandler = onDocumentUpdate(`Users/{uid}/Exercises/AssessLife`, 'assessLifeChangeHandler', +async (snapshot, context) => { + + logger.log('changed assess life settings') + + const { uid } = context.params as { uid: string } + const before = createAssessLifeSettings(toDate({ ...snapshot.before.data(), id: snapshot.id })) + const after = createAssessLifeSettings(toDate({ ...snapshot.after.data(), id: snapshot.id })) + + logger.log('before', before) + logger.log('after', after) + + const preferredDayChanged = before.preferredDay !== after.preferredDay + const preferredTimeChanged = before.preferredTime !== after.preferredTime + + if (preferredDayChanged && after.preferredDay === 'never') { + logger.log('delete scheduled task') + return deleteScheduledTask(`${uid}assesslife`) + } + + if (preferredDayChanged || preferredTimeChanged) { + logger.log('upsert reminder because of changed time') + return upsertReminder(uid, after) + } + + const beforeAvailableIntervals = getAvailableIntervals(before) + const afterAvailableIntervals = getAvailableIntervals(after) + + // check if all settings are never + if (beforeAvailableIntervals.length && !afterAvailableIntervals.length) { + logger.log('delete scheduled task because all intervals are never') + return deleteScheduledTask(`${uid}assesslife`) + } + + // if any intervals have been added or if any intervals have been removed, update the reminder + const addedIntervals = afterAvailableIntervals.filter(interval => !beforeAvailableIntervals.includes(interval)) + const removedIntervals = beforeAvailableIntervals.filter(interval => !afterAvailableIntervals.includes(interval)) + logger.log('added intervals so updating', addedIntervals) + logger.log('removed intervals so updating', removedIntervals) + if (addedIntervals.length || removedIntervals.length) { + return upsertReminder(uid, after) + } +}) + +export const assessLifeEntryCreatedHandler = onDocumentCreate(`Users/{uid}/Exercises/AssessLife/Entries`, 'assessLifeCreatedHandler', +async (snapshot, context) => { + + const { uid } = context.params + const entry = createAssessLifeEntry(toDate({ ...snapshot.data(), id: snapshot.id })) + + const promises = Promise.all([ + saveGratitude(uid, entry), + saveWheelOfLife(uid, entry), + saveDearFutureSelf(uid, entry), + saveImagine(uid, entry) + ]) + + return promises + + // TODO Sync goal priorities after submitting. And keep the goal priorities in the entry to see what the history was. + +}) + +// export const assessLifeEntryChangeHandler = onDocumentCreate(`Users/{uid}/Exercises/AssessLife/Entries`, 'assessLifeChangeHandler', +// async (snapshot, context) => { + +// const { uid } = context.params +// const entry = createAssessLifeEntry(toDate({ ...snapshot.data(), id: snapshot.id })) + + // should not save entry when updating probably. Would have to update but that's getting complicated + // saveGratitude(uid, entry) + // TO DO // Sync gratitude with gratitude journal // Sync wheel of life with wheel of life // Sync dear future self with dear future self // Send imagine.future and imagine.die as dear future self message -}) \ No newline at end of file +// }) + +async function saveGratitude(uid: string, entry: AssessLifeEntry) { + const items = entry.gratitude.entries.slice(0, 3) + if (!items.length) return + + const date = formatISO(new Date(), { representation: 'date' }) + const snap = await getDocumentSnap(`Users/${uid}/Exercises/DailyGratitude/Entries/${date}`) + if (!snap.exists) await snap.ref.set({ items }) +} + +async function saveWheelOfLife(uid: string, entry: AssessLifeEntry) { + if (Object.values(entry.wheelOfLife).every(val => val === '')) return + + const date = formatISO(new Date(), { representation: 'date' }) + const snap = await getDocumentSnap(`Users/${uid}/Exercises/WheelOfLife/Entries/${date}`) + if (!snap.exists) await snap.ref.set({ ...entry.wheelOfLife }) +} + +async function saveDearFutureSelf(uid: string, entry: AssessLifeEntry) { + if (Object.values(entry.dearFutureSelf).every(val => val === '')) return + + const deliveryDate = entry.interval === 'weekly' ? addWeeks(entry.createdAt, 1) + : entry.interval === 'monthly' ? addMonths(entry.createdAt, 1) + : entry.interval === 'quarterly' ? addQuarters(entry.createdAt, 1) + : addYears(entry.createdAt, 1) + + const message1 = createMessage({ + description: entry.dearFutureSelf.advice, + deliveryDate, + createdAt: entry.createdAt + }) + + const message2 = createMessage({ + description: entry.dearFutureSelf.predictions, + deliveryDate, + createdAt: entry.createdAt + }) + + const message3 = createMessage({ + description: entry.dearFutureSelf.anythingElse, + deliveryDate, + createdAt: entry.createdAt + }) + + addDearFutureSelfMessage(uid, message1) + addDearFutureSelfMessage(uid, message2) + addDearFutureSelfMessage(uid, message3) +} + +async function saveImagine(uid: string, entry: AssessLifeEntry) { + if (Object.values(entry.imagine).every(val => val === '')) return + + const deliveryDate = addYears(entry.createdAt, 5) + + const message1 = createMessage({ + description: entry.imagine.future, + deliveryDate, + createdAt: entry.createdAt + }) + + const message2 = createMessage({ + description: entry.imagine.die, + deliveryDate, + createdAt: entry.createdAt + }) + + addDearFutureSelfMessage(uid, message1) + addDearFutureSelfMessage(uid, message2) +} + +async function addDearFutureSelfMessage(uid: string, message: Message) { + const snap = await getDocumentSnap(`Users/${uid}/Exercises/DearFutureSelf`) + if (snap.exists) { + await snap.ref.update({ + messages: arrayUnion(message) + }) + } else { + const dfs = createDearFutureSelf({ id: 'DearFutureSelf', messages: [message] }) + await snap.ref.set(dfs) + } +} + +export function upsertReminder(uid: string, settings: AssessLifeSettings) { + const { performAt, performIntervals } = getNextReminder(settings) + + const id = `${uid}assesslife` + const task: ScheduledTaskUserExerciseAssessLife = { + worker: enumWorkerType.userExerciseAssessLife, + performAt, + options: { userId: uid, intervals: performIntervals }, + status: 'scheduled' + } + + return upsertScheduledTask(id, task) +} + +export function getNextReminder(settings: AssessLifeSettings) { + if (settings.preferredDay === 'never') throw new Error('Should not set reminders if preferred day is never') + + const intervals = getAvailableIntervals(settings) + const now = settings.createdAt + + const startOfNextInterval = { + weekly: (date: Date) => startOfWeek(addWeeks(date, 1)), + monthly: (date: Date) => startOfMonth(addMonths(date, 1)), + quarterly: (date: Date) => startOfQuarter(addQuarters(date, 1)), + yearly: (date: Date) => startOfAssessLifeYear(addYears(date, 1), 12, 24) + } + + const startOfNextNextInterval = { + weekly: (date: Date) => startOfWeek(addWeeks(date, 2)), + monthly: (date: Date) => startOfMonth(addMonths(date, 2)), + quarterly: (date: Date) => startOfQuarter(addQuarters(date, 2)), + yearly: (date: Date) => startOfAssessLifeYear(addYears(date, 2), 12, 24) + } + + const minDays = { + weekly: 3, // last three days + monthly: 21, // last three weeks + quarterly: 60, // last two months + yearly: 240 // last 8 months + } + + let performAt: Date | undefined = undefined + let performIntervals: AssessLifeInterval[] = [] + + for (const interval of intervals) { + const start = startOfNextInterval[interval](now) + const next = getNextDay(start, settings.preferredDay) + + // do not send reminder if the next reminder is too soon - and thus try to set next next reminder + const difference = differenceInDays(next, now) + if (difference > minDays[interval]) { + + if (!performAt || isBefore(next, performAt)) { + performAt = next + performIntervals = [interval] + } else if (isEqual(next, performAt)) { + performIntervals.push(interval) + } + + } else { + const startNextNext = startOfNextNextInterval[interval](now) + const nextNext = getNextDay(startNextNext, settings.preferredDay) + + if (!performAt || isBefore(nextNext, performAt)) { + performAt = nextNext + performIntervals = [interval] + } else if (isEqual(nextNext, performAt)) { + performIntervals.push(interval) + } + } + } + + if (!performAt) throw new Error('No performAt found') + + const [hours, minutes] = settings.preferredTime.split(':').map(str => parseInt(str)) + performAt.setHours(hours) + performAt.setMinutes(minutes) + + return { performAt, performIntervals } +} + +function getAvailableIntervals(settings: AssessLifeSettings) { + const availableIntervals = ['weekly', 'monthly', 'quarterly', 'yearly'] + return unique(Object.values(settings).filter(interval => availableIntervals.includes(interval))) as AssessLifeInterval[] +} diff --git a/apps/backend-functions/src/firestore/users/exercises/wheel_of_life.ts b/apps/backend-functions/src/firestore/users/exercises/wheel_of_life.ts index d15c24491..eb418928d 100644 --- a/apps/backend-functions/src/firestore/users/exercises/wheel_of_life.ts +++ b/apps/backend-functions/src/firestore/users/exercises/wheel_of_life.ts @@ -1,6 +1,6 @@ import { WheelOfLifeSettings } from '@strive/model' import { logger, onDocumentCreate, onDocumentDelete, onDocumentUpdate } from '@strive/api/firebase' -import { updateAggregation } from '../../../shared/scheduled-task/aggregation' +import { updateAggregation } from '../../../shared/aggregation/aggregation' import { deleteScheduledTask, upsertScheduledTask } from '../../../shared/scheduled-task/scheduled-task' import { enumWorkerType, ScheduledTaskUserExerciseWheelOfLife } from '../../../shared/scheduled-task/scheduled-task.interface' import { addMonths, addQuarters, addWeeks, addYears } from 'date-fns' diff --git a/apps/backend-functions/src/main.ts b/apps/backend-functions/src/main.ts index 6a6794406..b097e8528 100644 --- a/apps/backend-functions/src/main.ts +++ b/apps/backend-functions/src/main.ts @@ -6,6 +6,7 @@ export { scheduledFocusEmailRunner } from './pubsub/email/focus' // firestorage export { userSpectatorChangeHandler, userSpectatorCreatedHandler, userSpectatorDeleteHandler } from './firestore/users/user-spectators/user-spectator' export { affirmationsCreatedHandler, affirmationsChangeHandler, affirmationsDeleteHandler } from './firestore/users/exercises/affirmation' +export { assessLifeSettingsCreatedHandler, assessLifeSettingsChangeHandler, assessLifeEntryCreatedHandler } from './firestore/users/exercises/assess_life' export { dailyGratitudeCreatedHandler, dailyGratitudeChangedHandler, dailyGratitudeDeleteHandler } from './firestore/users/exercises/daily_gratitude' export { dearFutureSelfCreatedHandler, dearFutureSelfChangedHandler, dearFutureSelfDeleteHandler } from './firestore/users/exercises/dear_future_self' export { wheelOfLifeCreatedHandler, wheelOfLifeChangedHandler, wheelOfLifeDeleteHandler, wheelOfLifeEntryCreatedHandler } from './firestore/users/exercises/wheel_of_life' diff --git a/apps/backend-functions/src/pubsub/scheduled-task-runner.ts b/apps/backend-functions/src/pubsub/scheduled-task-runner.ts index 63aaf9c4c..f97a1ff01 100644 --- a/apps/backend-functions/src/pubsub/scheduled-task-runner.ts +++ b/apps/backend-functions/src/pubsub/scheduled-task-runner.ts @@ -9,6 +9,7 @@ import { ScheduledTaskGoalInviteLinkDeadline, ScheduledTaskMilestoneDeadline, ScheduledTaskUserExerciseAffirmations, + ScheduledTaskUserExerciseAssessLife, ScheduledTaskUserExerciseDailyGratitude, ScheduledTaskUserExerciseDearFutureSelfMessage, ScheduledTaskUserExerciseWheelOfLife @@ -20,8 +21,9 @@ import { sendAffirmationPushNotification, scheduleNextAffirmation } from './user import { scheduleNextReminder, sendWheelOfLifePushNotification } from './user-exercises/wheel_of_life' import { sendDearFutureSelfEmail, sendDearFutureSelfPushNotification } from './user-exercises/dear_future_self' -import { DearFutureSelf, Personal, Affirmations, WheelOfLifeSettings, createGoalSource } from '@strive/model' +import { DearFutureSelf, Personal, Affirmations, WheelOfLifeSettings, createGoalSource, AssessLifeSettings } from '@strive/model' import { AES, enc } from 'crypto-js' +import { scheduleNextAssessLifeReminder, sendAssessLifePuthNotification } from './user-exercises/assess_life' // https://fireship.io/lessons/cloud-functions-scheduled-time-trigger/ // crontab.guru to determine schedule value @@ -42,7 +44,8 @@ async () => { const reschedulingTasks = [ enumWorkerType.userExerciseAffirmation, enumWorkerType.userExerciseDailyGratitudeReminder, - enumWorkerType.userExerciseWheelOfLifeReminder + enumWorkerType.userExerciseWheelOfLifeReminder, + enumWorkerType.userExerciseAssessLife ] // Loop over documents and push job. @@ -84,7 +87,8 @@ const workers: IWorkers = { userExerciseAffirmation: (options) => userExerciseAffirmationsHandler(options), userExerciseDailyGratitudeReminder: (options) => userExerciseDailyGratitudeReminderHandler(options), userExerciseDearFutureSelfMessage: (options) => userExerciseDearFutureSelfMessageHandler(options), - userExerciseWheelOfLifeReminder: (options) => userExerciseWheelOfLifeReminderHandler(options) + userExerciseWheelOfLifeReminder: (options) => userExerciseWheelOfLifeReminderHandler(options), + userExerciseAssessLife: (options) => userExerciseAssessLifeHandler(options) } function deleteInviteLinkGoal(options: ScheduledTaskGoalInviteLinkDeadline['options']) { @@ -143,4 +147,10 @@ async function userExerciseWheelOfLifeReminderHandler(options: ScheduledTaskUser // reschedule task for next interval scheduleNextReminder(settings, options.userId) +} + +async function userExerciseAssessLifeHandler(options: ScheduledTaskUserExerciseAssessLife['options']) { + const settings = await getDocument(`Users/${options.userId}/Exercises/AssessLife`) + sendAssessLifePuthNotification(options) + scheduleNextAssessLifeReminder(settings, options.userId) } \ No newline at end of file diff --git a/apps/backend-functions/src/pubsub/user-exercises/assess_life.ts b/apps/backend-functions/src/pubsub/user-exercises/assess_life.ts new file mode 100644 index 000000000..7e85ba6af --- /dev/null +++ b/apps/backend-functions/src/pubsub/user-exercises/assess_life.ts @@ -0,0 +1,47 @@ +import { AssessLifeSettings, Personal, getInterval } from '@strive/model' +import type { Message } from 'firebase-admin/messaging' +import { getDocument } from '../../shared/utils' +import { admin } from '@strive/api/firebase' +import { ScheduledTaskUserExerciseAssessLife, enumWorkerType } from '../../shared/scheduled-task/scheduled-task.interface' +import { smartJoin } from '@strive/utils/helpers' +import { getNextReminder } from '../../firestore/users/exercises/assess_life' +import { upsertScheduledTask } from '../../shared/scheduled-task/scheduled-task' + +export async function sendAssessLifePuthNotification(options: ScheduledTaskUserExerciseAssessLife['options']) { + + const { userId, intervals } = options + + const converted = intervals.map(interval => getInterval(interval)) + const readable = smartJoin(converted, ', ', ' and ') + + const personal = await getDocument(`Users/${userId}/Personal/${userId}`) + const link = `goals?t=assesslife` + const messages: Message[] = personal.fcmTokens.map(token => ({ + token, + notification: { + title: `Time to Assess Life`, + body: `Take a moment to do the ${readable} assessment` + }, + data: { link }, + webpush: { + notification: { + icon: 'https://firebasestorage.googleapis.com/v0/b/strive-journal.appspot.com/o/FCMImages%2Ficon-72x72.png?alt=media&token=19250b44-1aef-4ea6-bbaf-d888150fe4a9', + }, + fcmOptions: { link } + } + })) + if (!messages.length) return + return admin.messaging().sendEach(messages) +} + +export async function scheduleNextAssessLifeReminder(settings: AssessLifeSettings, userId: string) { + const { performAt, performIntervals: intervals } = getNextReminder(settings) + + const task: ScheduledTaskUserExerciseAssessLife = { + worker: enumWorkerType.userExerciseAssessLife, + performAt, + options: { userId, intervals }, + status: 'scheduled' + } + return upsertScheduledTask(`${userId}assesslife`, task) +} \ No newline at end of file diff --git a/apps/backend-functions/src/shared/scheduled-task/scheduled-task.interface.ts b/apps/backend-functions/src/shared/scheduled-task/scheduled-task.interface.ts index 65b59a76e..dfed285a6 100644 --- a/apps/backend-functions/src/shared/scheduled-task/scheduled-task.interface.ts +++ b/apps/backend-functions/src/shared/scheduled-task/scheduled-task.interface.ts @@ -1,3 +1,5 @@ +import { AssessLifeInterval } from "@strive/model" + interface ScheduledTaskBase { worker: enumWorkerType performAt: FirebaseFirestore.FieldValue | string | Date @@ -18,7 +20,8 @@ export enum enumWorkerType { userExerciseAffirmation = 'userExerciseAffirmation', userExerciseDailyGratitudeReminder = 'userExerciseDailyGratitudeReminder', userExerciseDearFutureSelfMessage = 'userExerciseDearFutureSelfMessage', - userExerciseWheelOfLifeReminder = 'userExerciseWheelOfLifeReminder' + userExerciseWheelOfLifeReminder = 'userExerciseWheelOfLifeReminder', + userExerciseAssessLife = 'userExerciseAssessLife' } export interface ScheduledTaskGoalInviteLinkDeadline extends ScheduledTaskBase { @@ -64,4 +67,11 @@ export interface ScheduledTaskUserExerciseWheelOfLife extends ScheduledTaskBase options: { userId: string } +} + +export interface ScheduledTaskUserExerciseAssessLife extends ScheduledTaskBase { + options: { + userId: string, + intervals: AssessLifeInterval[] + } } \ No newline at end of file diff --git a/apps/backend-functions/src/shared/scheduled-task/scheduled-task.ts b/apps/backend-functions/src/shared/scheduled-task/scheduled-task.ts index 60ef319ae..e67c4a662 100644 --- a/apps/backend-functions/src/shared/scheduled-task/scheduled-task.ts +++ b/apps/backend-functions/src/shared/scheduled-task/scheduled-task.ts @@ -16,7 +16,7 @@ export async function upsertScheduledTask(id: string, scheduledTask: Partial) }) } -function updateScheduledTask(id: string, _performAt: string | FirebaseFirestore.FieldValue | Date) { - const performAt = getTimestamp(_performAt) - return db.doc(`ScheduledTasks/${id}`).update({ performAt }) +function updateScheduledTask(id: string, scheduledTask: Partial) { + const performAt = getTimestamp(scheduledTask.performAt) + return db.doc(`ScheduledTasks/${id}`).update({ performAt, options: scheduledTask.options }) } export function deleteScheduledTask(id: string) { diff --git a/apps/journal/src/app/pages/exercises/assess-life/assess-life.component.ts b/apps/journal/src/app/pages/exercises/assess-life/assess-life.component.ts index 6c1bb874f..1a502a83d 100644 --- a/apps/journal/src/app/pages/exercises/assess-life/assess-life.component.ts +++ b/apps/journal/src/app/pages/exercises/assess-life/assess-life.component.ts @@ -6,12 +6,13 @@ import { combineLatest, firstValueFrom, map, of, shareReplay, startWith, switchM import { ScreensizeService } from '@strive/utils/services/screensize.service' import { SeoService } from '@strive/utils/services/seo.service' -import { AssessLifeEntry, AssessLifeInterval, AssessLifeSettings } from '@strive/model' +import { AssessLifeEntry, AssessLifeInterval, AssessLifeSettings, createAssessLifeEntry } from '@strive/model' import { AuthService } from '@strive/auth/auth.service' import { AssessLifeEntryService, AssessLifeSettingsService } from '@strive/exercises/assess-life/assess-life.service' import { AssessLifeEntryComponent } from '@strive/exercises/assess-life/components/entry/assess-life-entry.component' -import { differenceInDays } from 'date-fns' +import { addMonths, addQuarters, addWeeks, addYears, differenceInDays, getMonth, getQuarter, getWeek, startOfDay, startOfMonth, startOfQuarter, startOfWeek } from 'date-fns' import { AuthModalComponent, enumAuthSegment } from '@strive/auth/components/auth-modal/auth-modal.page' +import { getAssessLifeId, getAssessLifeYear, startOfAssessLifeYear, } from '@strive/exercises/assess-life/utils/date.utils' function getActivatedQuestions(settings: AssessLifeSettings | undefined, interval: AssessLifeInterval) { if (!settings) return [] @@ -25,23 +26,39 @@ function getActivatedQuestions(settings: AssessLifeSettings | undefined, interva function getEntryStatus(entries: AssessLifeEntry[], settings: AssessLifeSettings | undefined, interval: AssessLifeInterval) { const questions = getActivatedQuestions(settings, interval) - const minDays: Record = { - weekly: 7, - monthly: 30, - quarterly: 90, - yearly: 365 - } if (questions.length === 0) return { disabled: true, message: 'No questions activated - change in settings' } if (entries.length === 0) return { disabled: false, message: `Ready for a new entry!`} + const today = startOfDay(new Date()) const lastEntry = entries[0] - const today = new Date() - const days = differenceInDays(today, lastEntry.createdAt) - if (days < minDays[interval]) return { disabled: true, message: `You can't add a new entry yet. You can add a new entry in ${minDays[interval] - days} day${minDays[interval] - days === 1 ? '' : 's'}`} + const getInterval = { + weekly: getWeek, + monthly: getMonth, + quarterly: getQuarter, + yearly: (date: Date) => getAssessLifeYear(date, 12, 24) + } + const intervalDeltaSinceLastEntry = getInterval[interval](today) - getInterval[interval](lastEntry.createdAt) + if (intervalDeltaSinceLastEntry > 0) return { disabled: false, message: `Ready for a new entry!`} + + const startOfInterval = { + weekly: startOfWeek, + monthly: startOfMonth, + quarterly: startOfQuarter, + yearly: (date: Date) => startOfAssessLifeYear(date, 12, 24) + } + + const addInterval = { + weekly: (date: Date) => addWeeks(date, 1), + monthly: (date: Date) => addMonths(date, 1), + quarterly: (date: Date) => addQuarters(date, 1), + yearly: (date: Date) => addYears(date, 1) + } - return { disabled: false, message: `Ready for a new entry!`} + const startOfNextInterval = startOfInterval[interval](addInterval[interval](lastEntry.createdAt)) + const daysLeft = differenceInDays(startOfNextInterval, today) + return { disabled: true, message: `You can't add a new entry yet. You can add a new entry in ${daysLeft} day${daysLeft === 1 ? '' : 's'}`} } @Component({ @@ -135,7 +152,7 @@ export class AssessLifeComponent { firstValueFrom(this.weekly$), firstValueFrom(this.monthly$), firstValueFrom(this.quarterly$), - firstValueFrom(this.yearly$) + firstValueFrom(this.yearly$), ]) const todos = [] @@ -144,9 +161,12 @@ export class AssessLifeComponent { if (!quarterly.disabled) todos.push('quarterly') if (!yearly.disabled) todos.push('yearly') + const id = getAssessLifeId(interval) + const entry = createAssessLifeEntry({ id, interval }) + const modal = await this.modalCtrl.create({ component: AssessLifeEntryComponent, - componentProps: { interval, previousEntry, todos } + componentProps: { entry, previousEntry, todos } }) modal.onDidDismiss().then(({ data: nextInterval }) => { if (nextInterval) this.addEntry(nextInterval) @@ -157,7 +177,7 @@ export class AssessLifeComponent { async openEntry(entry: AssessLifeEntry) { this.modalCtrl.create({ component: AssessLifeEntryComponent, - componentProps: { entry, interval: entry.interval } + componentProps: { entry } }).then(modal => modal.present()) } diff --git a/apps/journal/src/app/pages/exercises/assess-life/settings/assess-life-settings.component.ts b/apps/journal/src/app/pages/exercises/assess-life/settings/assess-life-settings.component.ts index 2cac9879b..9daf14b92 100644 --- a/apps/journal/src/app/pages/exercises/assess-life/settings/assess-life-settings.component.ts +++ b/apps/journal/src/app/pages/exercises/assess-life/settings/assess-life-settings.component.ts @@ -4,7 +4,7 @@ import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms' import { IonicModule, PopoverController } from '@ionic/angular' import { AuthService } from '@strive/auth/auth.service' import { AssessLifeSettingsService } from '@strive/exercises/assess-life/assess-life.service' -import { AssessLifeIntervalWithNever, createAssessLifeSettings, DayWithNever } from '@strive/model' +import { AssessLifeIntervalWithNever, createAssessLifeSettings, WeekdayWithNever } from '@strive/model' import { DatetimeComponent } from '@strive/ui/datetime/datetime.component' import { HeaderModule } from '@strive/ui/header/header.module' @@ -31,7 +31,7 @@ export class AssessLifeSettingsComponent implements OnInit { loading = signal(true) form = new FormGroup({ - preferredDay: new FormControl('sunday', { nonNullable: true }), + preferredDay: new FormControl('sunday', { nonNullable: true }), preferredTime: new FormControl('19:00', { nonNullable: true }), dearFutureSelf: new FormControl('yearly', { nonNullable: true }), environment: new FormControl('quarterly', { nonNullable: true }), diff --git a/libs/exercises/src/lib/assess-life/assess-life.service.ts b/libs/exercises/src/lib/assess-life/assess-life.service.ts index 580dc0592..2acfd9ae7 100644 --- a/libs/exercises/src/lib/assess-life/assess-life.service.ts +++ b/libs/exercises/src/lib/assess-life/assess-life.service.ts @@ -67,53 +67,68 @@ export class AssessLifeEntryService extends FireSubCollection { return createAssessLifeEntry(toDate({ ...snapshot.data(), id: snapshot.id })) } + async save(entry: AssessLifeEntry) { + if (!this.auth.uid) throw new Error('uid should be defined when saving wheel of life entries') + const encryptedEntry = await this.encrypt(entry) + return this.upsert(encryptedEntry, { params: { uid: this.auth.uid }}) + } + async decrypt(entries: AssessLifeEntry[]): Promise { const encryptionKey = await this.personalService.getEncryptionKey() - for (const entry of entries) { - entry.dearFutureSelf.advice = AES.decrypt(entry.dearFutureSelf.advice, encryptionKey).toString(enc.Utf8) - entry.dearFutureSelf.predictions = AES.decrypt(entry.dearFutureSelf.predictions, encryptionKey).toString(enc.Utf8) - entry.dearFutureSelf.anythingElse = AES.decrypt(entry.dearFutureSelf.anythingElse, encryptionKey).toString(enc.Utf8) - - entry.environment.past.entries = entry.environment.past.entries.map(v => AES.decrypt(v, encryptionKey).toString(enc.Utf8)) - entry.environment.future.entries = entry.environment.future.entries.map(v => AES.decrypt(v, encryptionKey).toString(enc.Utf8)) + entries = entries.map(entry => { + Object.keys(entry).forEach(key => { + const typedKey = key as keyof AssessLifeEntry + const excludedProperties = ['id', 'createdAt', 'updatedAt', 'interval'] + if (excludedProperties.includes(key)) return + entry[typedKey] = _decrypt(entry[typedKey], encryptionKey) + }) + return entry + }) - entry.explore.past.entries = entry.explore.past.entries.map(v => AES.decrypt(v, encryptionKey).toString(enc.Utf8)) - entry.explore.future.entries = entry.explore.future.entries.map(v => AES.decrypt(v, encryptionKey).toString(enc.Utf8)) + return entries + } - entry.forgive.entries = entry.forgive.entries.map(v => AES.decrypt(v, encryptionKey).toString(enc.Utf8)) + private async encrypt(entry: AssessLifeEntry): Promise { + const encryptionKey = await this.personalService.getEncryptionKey() - entry.gratitude.entries = entry.gratitude.entries.map(v => AES.decrypt(v, encryptionKey).toString(enc.Utf8)) + Object.keys(entry).forEach(key => { + const typedKey = key as keyof AssessLifeEntry + const excludedProperties = ['id', 'createdAt', 'updatedAt', 'interval'] + if (excludedProperties.includes(key)) return + entry[typedKey] = _encrypt(entry[typedKey], encryptionKey) + }) - entry.imagine.die = AES.decrypt(entry.imagine.die, encryptionKey).toString(enc.Utf8) - entry.imagine.future = AES.decrypt(entry.imagine.future, encryptionKey).toString(enc.Utf8) + return entry + } +} - entry.learn.past.entries = entry.learn.past.entries.map(v => AES.decrypt(v, encryptionKey).toString(enc.Utf8)) - entry.learn.future.entries = entry.learn.future.entries.map(v => AES.decrypt(v, encryptionKey).toString(enc.Utf8)) +function _decrypt(object: any, decryptKey: string) { + Object.keys(object).forEach(key => { + if (typeof object[key] === 'object') { + _decrypt(object[key], decryptKey) + } else if (Array.isArray(object[key])) { + object[key] = object[key].map((v: string) => +AES.decrypt(v.toString(), decryptKey).toString(enc.Utf8)) + } else if (typeof object[key] === 'string') { + object[key] = AES.decrypt(object[key], decryptKey).toString(enc.Utf8) + } + }) - entry.proud.entries = entry.proud.entries.map(v => AES.decrypt(v, encryptionKey).toString(enc.Utf8)) + return object +} - entry.timeManagement.past.entries = entry.timeManagement.past.entries.map(v => AES.decrypt(v, encryptionKey).toString(enc.Utf8)) - entry.timeManagement.futureMoreTime.entries = entry.timeManagement.futureMoreTime.entries.map(v => AES.decrypt(v, encryptionKey).toString(enc.Utf8)) - entry.timeManagement.futureLessTime.entries = entry.timeManagement.futureLessTime.entries.map(v => AES.decrypt(v, encryptionKey).toString(enc.Utf8)) +function _encrypt(object: any, encryptKey: string) { + const encrypt = (value: string) => value ? AES.encrypt(value, encryptKey).toString() : '' - entry.wheelOfLife.career = +AES.decrypt(entry.wheelOfLife.career.toString(), encryptionKey).toString(enc.Utf8) - entry.wheelOfLife.development = +AES.decrypt(entry.wheelOfLife.development.toString(), encryptionKey).toString(enc.Utf8) - entry.wheelOfLife.environment = +AES.decrypt(entry.wheelOfLife.environment.toString(), encryptionKey).toString(enc.Utf8) - entry.wheelOfLife.family = +AES.decrypt(entry.wheelOfLife.family.toString(), encryptionKey).toString(enc.Utf8) - entry.wheelOfLife.friends = +AES.decrypt(entry.wheelOfLife.friends.toString(), encryptionKey).toString(enc.Utf8) - entry.wheelOfLife.fun = +AES.decrypt(entry.wheelOfLife.fun.toString(), encryptionKey).toString(enc.Utf8) - entry.wheelOfLife.health = +AES.decrypt(entry.wheelOfLife.health.toString(), encryptionKey).toString(enc.Utf8) - entry.wheelOfLife.love = +AES.decrypt(entry.wheelOfLife.love.toString(), encryptionKey).toString(enc.Utf8) - entry.wheelOfLife.money = +AES.decrypt(entry.wheelOfLife.money.toString(), encryptionKey).toString(enc.Utf8) - entry.wheelOfLife.spirituality = +AES.decrypt(entry.wheelOfLife.spirituality.toString(), encryptionKey).toString(enc.Utf8) + Object.keys(object).forEach(key => { + if (typeof object[key] === 'object') { + _encrypt(object[key], encryptKey) + } else if (Array.isArray(object[key])) { + object[key] = object[key].map(encrypt) + } else if (typeof object[key] === 'string') { + object[key] = encrypt(object[key]) } + }) - return entries - } - - save(entry: AssessLifeEntry) { - if (!this.auth.uid) throw new Error('uid should be defined when saving wheel of life entries') - this.upsert(entry, { params: { uid: this.auth.uid }}) - } + return object } \ No newline at end of file diff --git a/libs/exercises/src/lib/assess-life/components/entry/assess-life-entry.component.html b/libs/exercises/src/lib/assess-life/components/entry/assess-life-entry.component.html index ad0db479e..3440d6f86 100644 --- a/libs/exercises/src/lib/assess-life/components/entry/assess-life-entry.component.html +++ b/libs/exercises/src/lib/assess-life/components/entry/assess-life-entry.component.html @@ -13,7 +13,7 @@ - + diff --git a/libs/exercises/src/lib/assess-life/components/entry/assess-life-entry.component.ts b/libs/exercises/src/lib/assess-life/components/entry/assess-life-entry.component.ts index 629ee66b8..170db6f37 100644 --- a/libs/exercises/src/lib/assess-life/components/entry/assess-life-entry.component.ts +++ b/libs/exercises/src/lib/assess-life/components/entry/assess-life-entry.component.ts @@ -7,13 +7,10 @@ import { BehaviorSubject, shareReplay, switchMap, tap } from 'rxjs' import { ModalDirective } from '@strive/utils/directives/modal.directive' import { AuthService } from '@strive/auth/auth.service' import { delay } from '@strive/utils/helpers' -import { AssessLifeEntry, AssessLifeInterval, AssessLifeSettings, createAssessLifeEntry } from '@strive/model' +import { AssessLifeEntry, AssessLifeInterval, AssessLifeSettings, createAssessLifeEntry, getInterval } from '@strive/model' import { AssessLifeForm } from '../../forms/assess-life.form' import { AssessLifeEntryService, AssessLifeSettingsService } from '../../assess-life.service' -import { PersonalService } from '@strive/user/personal.service' -import { AES } from 'crypto-js' -import { getInterval } from '../../pipes/interval.pipe' type Section = 'intro' | 'previousIntention' @@ -123,7 +120,7 @@ const allSteps: { ] @Component({ - selector: 'strive-assess-life-entry', + selector: '[entry] strive-assess-life-entry', templateUrl: './assess-life-entry.component.html', styleUrls: ['./assess-life-entry.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush @@ -146,8 +143,6 @@ export class AssessLifeEntryComponent extends ModalDirective implements OnInit { shareReplay({ bufferSize: 1, refCount: true }), tap(settings => { if (settings) { - if (!this.interval) throw new Error('No interval provided') - const activatedSteps = allSteps.filter(step => { if (step.setting && settings[step.setting] !== this.interval) return false if (step.section === 'previousIntention' && !this.previousEntry) return false @@ -164,17 +159,17 @@ export class AssessLifeEntryComponent extends ModalDirective implements OnInit { }) ) - @Input() interval?: AssessLifeInterval - @Input() entry?: AssessLifeEntry + @Input() entry = createAssessLifeEntry() @Input() previousEntry?: AssessLifeEntry @Input() todos: AssessLifeInterval[] = [] + get interval() { return this.entry.interval } + constructor( private alertCtrl: AlertController, private auth: AuthService, location: Location, modalCtrl: ModalController, - private personalService: PersonalService, private service: AssessLifeEntryService, private settingsService: AssessLifeSettingsService ) { @@ -182,14 +177,10 @@ export class AssessLifeEntryComponent extends ModalDirective implements OnInit { } ngOnInit() { - if (this.entry) { - this.form.patchValue(this.entry, { emitEvent: false }) - } + this.form.patchValue(this.entry, { emitEvent: false }) } async step(direction: 'next' | 'previous') { - if (!this.interval) return - this.stepping$.next(true) // input value is being added to the form const steps = this.steps() @@ -226,53 +217,12 @@ export class AssessLifeEntryComponent extends ModalDirective implements OnInit { async save() { if (!this.auth.uid) return - const key = await this.personalService.getEncryptionKey() - const entry = createAssessLifeEntry({ - interval: this.interval, ...this.entry, ...this.form.getRawValue() }) - entry.dearFutureSelf.advice = entry.dearFutureSelf.advice === '' ? '' : AES.encrypt(entry.dearFutureSelf.advice, key).toString() - entry.dearFutureSelf.predictions = entry.dearFutureSelf.predictions === '' ? '' : AES.encrypt(entry.dearFutureSelf.predictions, key).toString() - entry.dearFutureSelf.anythingElse = entry.dearFutureSelf.anythingElse === '' ? '' : AES.encrypt(entry.dearFutureSelf.anythingElse, key).toString() - - entry.environment.past.entries = entry.environment.past.entries.map(v => AES.encrypt(v, key).toString()) - entry.environment.future.entries = entry.environment.future.entries.map(v => AES.encrypt(v, key).toString()) - - entry.explore.past.entries = entry.explore.past.entries.map(v => AES.encrypt(v, key).toString()) - entry.explore.future.entries = entry.explore.future.entries.map(v => AES.encrypt(v, key).toString()) - - entry.forgive.entries = entry.forgive.entries.map(v => AES.encrypt(v, key).toString()) - - entry.gratitude.entries = entry.gratitude.entries.map(v => AES.encrypt(v, key).toString()) - - entry.imagine.future = entry.imagine.future === '' ? '' : AES.encrypt(entry.imagine.future, key).toString() - entry.imagine.die = entry.imagine.die === '' ? '' : AES.encrypt(entry.imagine.die, key).toString() - - entry.learn.past.entries = entry.learn.past.entries.map(v => AES.encrypt(v, key).toString()) - entry.learn.future.entries = entry.learn.future.entries.map(v => AES.encrypt(v, key).toString()) - - entry.timeManagement.past.entries = entry.timeManagement.past.entries.map(v => AES.encrypt(v, key).toString()) - entry.timeManagement.futureMoreTime.entries = entry.timeManagement.futureMoreTime.entries.map(v => AES.encrypt(v, key).toString()) - entry.timeManagement.futureLessTime.entries = entry.timeManagement.futureLessTime.entries.map(v => AES.encrypt(v, key).toString()) - - entry.proud.entries = entry.proud.entries.map(v => AES.encrypt(v, key).toString()) - - entry.wheelOfLife.career = entry.wheelOfLife.career === '' ? '' : AES.encrypt(entry.wheelOfLife.career.toString(), key).toString() - entry.wheelOfLife.development = entry.wheelOfLife.development === '' ? '' : AES.encrypt(entry.wheelOfLife.development.toString(), key).toString() - entry.wheelOfLife.environment = entry.wheelOfLife.environment === '' ? '' : AES.encrypt(entry.wheelOfLife.environment.toString(), key).toString() - entry.wheelOfLife.family = entry.wheelOfLife.family === '' ? '' : AES.encrypt(entry.wheelOfLife.family.toString(), key).toString() - entry.wheelOfLife.friends = entry.wheelOfLife.friends === '' ? '' : AES.encrypt(entry.wheelOfLife.friends.toString(), key).toString() - entry.wheelOfLife.fun = entry.wheelOfLife.fun === '' ? '' : AES.encrypt(entry.wheelOfLife.fun.toString(), key).toString() - entry.wheelOfLife.health = entry.wheelOfLife.health === '' ? '' : AES.encrypt(entry.wheelOfLife.health.toString(), key).toString() - entry.wheelOfLife.love = entry.wheelOfLife.love === '' ? '' : AES.encrypt(entry.wheelOfLife.love.toString(), key).toString() - entry.wheelOfLife.money = entry.wheelOfLife.money === '' ? '' : AES.encrypt(entry.wheelOfLife.money.toString(), key).toString() - entry.wheelOfLife.spirituality = entry.wheelOfLife.spirituality === '' ? '' : AES.encrypt(entry.wheelOfLife.spirituality.toString(), key).toString() - - const id = await this.service.upsert(entry, { params: { uid: this.auth.uid } }) - this.form.id.setValue(id) + await this.service.save(entry) this.form.markAsPristine() } diff --git a/libs/exercises/src/lib/assess-life/pipes/interval.pipe.ts b/libs/exercises/src/lib/assess-life/pipes/interval.pipe.ts index ad34daa97..5cd0ec359 100644 --- a/libs/exercises/src/lib/assess-life/pipes/interval.pipe.ts +++ b/libs/exercises/src/lib/assess-life/pipes/interval.pipe.ts @@ -1,19 +1,10 @@ import { Pipe, PipeTransform } from '@angular/core' - -export function getInterval(value: string): string { - switch (value) { - case 'weekly': return 'week' - case 'monthly': return 'month' - case 'quarterly': return 'quarter' - case 'yearly': return 'year' - default: return '' - } -} +import { AssessLifeInterval, getInterval } from '@strive/model' @Pipe({ name: 'interval', standalone: true }) export class AssessLifeIntervalPipe implements PipeTransform { - transform(value: string): string { + transform(value: AssessLifeInterval): string { return getInterval(value) } } \ No newline at end of file diff --git a/libs/exercises/src/lib/assess-life/utils/date.utils.ts b/libs/exercises/src/lib/assess-life/utils/date.utils.ts new file mode 100644 index 000000000..2393d2389 --- /dev/null +++ b/libs/exercises/src/lib/assess-life/utils/date.utils.ts @@ -0,0 +1,67 @@ +import { AssessLifeInterval, Weekday } from '@strive/model' +import { addDays, getMonth, getQuarter, getWeek, getYear, isBefore, nextDay, startOfDay, startOfMonth, startOfQuarter } from 'date-fns' + +const weekdayMapping: Record = { + 'sunday': 0, + 'monday': 1, + 'tuesday': 2, + 'wednesday': 3, + 'thursday': 4, + 'friday': 5, + 'saturday': 6 +} + +export function getNextDay(date: Date, weekday: Weekday) { + const today = startOfDay(date) + const yesterday = addDays(today, -1) // to return today if today is weekday + return nextDay(yesterday, weekdayMapping[weekday]) +} + +export function getAssessLifeYear(date: Date, startMonth: number, startDay: number) { + const year = getYear(date) + const yearStart = new Date(year, startMonth - 1, startDay) + + if (isBefore(date, yearStart)) { + yearStart.setFullYear(year - 1) + } + + return getYear(yearStart) +} + +export function startOfAssessLifeYear(date: Date, startMonth: number, startDay: number) { + const year = getYear(date) + const yearStart = new Date(year, startMonth - 1, startDay) + + if (isBefore(date, yearStart)) { + yearStart.setFullYear(year - 1); + } + + return yearStart +} + +export function getAssessLifeId(interval: AssessLifeInterval, date?: Date) { + const today = startOfDay(date ?? new Date()) + + const startMethods = { + 'weekly': getWeek, + 'monthly': startOfMonth, + 'quarterly': startOfQuarter, + 'yearly': (date: Date) => startOfAssessLifeYear(date, 12, 24) + } + + const start = startMethods[interval](today) + const year = getYear(start) + const quarter = getQuarter(start) + const month = getMonth(start) + const week = getWeek(start) + + if (interval === 'weekly') { + return `${year}-${quarter}-${month}-${week}` + } else if (interval === 'monthly') { + return `${year}-${quarter}-${month}` + } else if (interval === 'quarterly') { + return `${year}-${quarter}` + } else { + return `${year}` + } +} \ No newline at end of file diff --git a/libs/model/src/lib/assess-life.ts b/libs/model/src/lib/assess-life.ts index 740b84f10..e397701ac 100644 --- a/libs/model/src/lib/assess-life.ts +++ b/libs/model/src/lib/assess-life.ts @@ -1,9 +1,20 @@ import { ListEntries, createListEntries } from './form-utils' -export type DayWithNever = 'monday' | 'tuesday' | 'wednesday' | 'thursday' | 'friday' | 'saturday' | 'sunday' | 'never' +export type Weekday = 'monday' | 'tuesday' | 'wednesday' | 'thursday' | 'friday' | 'saturday' | 'sunday' +export type WeekdayWithNever = Weekday | 'never' export type AssessLifeInterval = 'weekly' | 'monthly' | 'quarterly' | 'yearly' export type AssessLifeIntervalWithNever = AssessLifeInterval | 'never' +export function getInterval(value: AssessLifeInterval): string { + switch (value) { + case 'weekly': return 'week' + case 'monthly': return 'month' + case 'quarterly': return 'quarter' + case 'yearly': return 'year' + default: return '' + } +} + export interface AssessLifeSettings { id?: string dearFutureSelf: AssessLifeIntervalWithNever @@ -13,7 +24,7 @@ export interface AssessLifeSettings { gratitude: AssessLifeIntervalWithNever imagine: AssessLifeIntervalWithNever learn: AssessLifeIntervalWithNever - preferredDay: DayWithNever, + preferredDay: WeekdayWithNever, preferredTime: string, prioritizeGoals: AssessLifeIntervalWithNever proud: AssessLifeIntervalWithNever diff --git a/libs/utils/src/lib/helpers.ts b/libs/utils/src/lib/helpers.ts index a7ee672a4..a9e0dbae0 100644 --- a/libs/utils/src/lib/helpers.ts +++ b/libs/utils/src/lib/helpers.ts @@ -18,6 +18,20 @@ export function unique(array: T[]) { return Array.from(new Set(array)) } +/** + * Example with (['A', 'B', 'C'], ', ', ' & ') + * output : "A, B & C" + * @param str + * @param joinWith + * @param endWith + * @returns + */ +export function smartJoin(str: string[], joinWith = ', ', endWith = ', ') { + const duplicate = [...str] + const last = duplicate.pop() + return `${duplicate.join(joinWith)}${duplicate.length ? endWith : ''}${last || ''}` +} + export function isValidHttpUrl(_url: string) { /** diff --git a/libs/utils/src/lib/pipes/date-fns.pipe.ts b/libs/utils/src/lib/pipes/date-fns.pipe.ts index 5554b3447..ef8d79544 100644 --- a/libs/utils/src/lib/pipes/date-fns.pipe.ts +++ b/libs/utils/src/lib/pipes/date-fns.pipe.ts @@ -20,4 +20,11 @@ export class IsFuturePipe implements PipeTransform { transform(date: Date) { return isFuture(date) } +} + +@Pipe({ name: 'getMonthName', standalone: true }) +export class GetMonthNamePipe implements PipeTransform { + transform(month: number) { + return new Date(0, month).toLocaleString('default', { month: 'long' }) + } } \ No newline at end of file diff --git a/libs/utils/src/lib/pipes/smart-join.pipe.ts b/libs/utils/src/lib/pipes/smart-join.pipe.ts index 404a563b7..0d3fa0e27 100644 --- a/libs/utils/src/lib/pipes/smart-join.pipe.ts +++ b/libs/utils/src/lib/pipes/smart-join.pipe.ts @@ -1,18 +1,5 @@ import { Pipe, PipeTransform } from '@angular/core' - -/** - * Example with (['A', 'B', 'C'], ', ', ' & ') - * output : "A, B & C" - * @param str - * @param joinWith - * @param endWith - * @returns - */ -export function smartJoin(str: string[], joinWith = ', ', endWith = ', ') { - const duplicate = [...str] - const last = duplicate.pop() - return `${duplicate.join(joinWith)}${duplicate.length ? endWith : ''}${last || ''}` -} +import { smartJoin } from '../helpers' @Pipe({ name: 'smartJoin', standalone: true }) export class SmartJoinPipe implements PipeTransform {