1
\$\begingroup\$

I want to implement this in a SQL database schema, but am doing it in JavaScript first to make sure I have the logic right. Basically, I would like to track "actions" a user performs. If they perform a certain amount of actions within a period (a day for now), then they hit the goal for that day. Then if they do this for multiple days in a row, they get a streak. If they skip a day, they lose the streak.

Do I have this implemented correctly? It appears to be working as expected, but I am not 100% sure this is how this type of thing should be implemented. Any suggestions on how to improve the implementation?

const { shiftTimeBy } = require('time-fast-forward')
const dayjs = require('dayjs')
const utc = require('dayjs/plugin/utc')
dayjs.extend(utc)

// 20 days ago
shiftTimeBy(-1 * 20 * 24 * 60 * 60 * 1000);

let startOfDay = dayjs.utc().startOf('day').toDate()
let startOfPreviousDay = dayjs.utc().startOf('day').subtract(1, 'day').toDate()

const actionTrackers = {}

// do action 22 times in a day, but daily goal is only 10
let i = 0
while (i < 22) {
  trackAction({ actionId: 'vote', userId: 'bar' })
  i++
}

// jump ahead to the next day
shiftTimeBy((1 * 24 * 60 * 60 * 1000) + 220);

startOfDay = dayjs.utc().startOf('day').toDate()
startOfPreviousDay = dayjs.utc().startOf('day').subtract(1, 'day').toDate()

// do action 22 times in a day again, but daily goal is only 10
i = 0
while (i < 22) {
  trackAction({ actionId: 'vote', userId: 'bar' })
  i++
}

// jump ahead 3 days
shiftTimeBy((3 * 24 * 60 * 60 * 1000) + 220);

startOfDay = dayjs.utc().startOf('day').toDate()
startOfPreviousDay = dayjs.utc().startOf('day').subtract(1, 'day').toDate()

// do action 22 times in a day again, but daily goal is only 10
i = 0
while (i < 22) {
  trackAction({ actionId: 'vote', userId: 'bar' })
  i++
}

function trackAction({ actionId, userId, dailyGoal = 10 }) {
  const actionUsersTracker = actionTrackers[actionId] = actionTrackers[actionId] ?? {}
  const actionUserTracker = actionUsersTracker[userId] = actionUsersTracker[userId] ?? {}

  if (!actionUserTracker.lastDate) {
    actionUserTracker.lastDate = new Date
    actionUserTracker.actionCount = 1
    actionUserTracker.streakCount = 0
  } else {
    if (actionUserTracker.lastDate < startOfDay) {
      if (actionUserTracker.lastDate < startOfPreviousDay) {
        actionUserTracker.streakCount = 0
      }

      actionUserTracker.lastDate = new Date
      actionUserTracker.actionCount = 1
    } else {
      // within same day
      actionUserTracker.actionCount++

      if (actionUserTracker.actionCount === dailyGoal) {
        actionUserTracker.streakCount++
      }
    }
  }

  console.log(actionUserTracker)
}

The time manipulation library is there to simulate a test where time passes and we perform the action at different points in time.

\$\endgroup\$

2 Answers 2

2
\$\begingroup\$

First of all, I would separate the code from the actual tracker and the testing code.
Your code would also benefit from avoiding the use of
"Magic Numbers".
I would also just save the date of action for every time a user is making an action.
You should note that I haven't tested the code, and wrote it to make an example of
how I would create the basic logic. It probably won't work without modifying it to suit your needs.

First, let's create some objects that may represent our DB and basic insert logic:

const usersDB = [
  {
    uid: "uid_1",
    username: "Chris",
    email: "[email protected]"
  }
];

const actionsDB = [
    {
        actionName: "clickButton",
        uid: "uid_1",
        counts: [
            {
                date: "1/1/2022",
                count: 7,
            },
            {
                date: "1/2/2022",
                count: 7,
            },
            {
                date: "1/3/2022",
                count: 7,
            },
        ]
    }
];

const insertAction = (uid, actionName, date) => {
  const userActionRecord = actionsDB.find(action => action.actionName === actionName && action.uid === uid);
  const userDailyCount = userActionRecord.counts.find(dailyCount => dailyCount.date === date);
  if (userDailyCount) {
      userDailyCount.count += 1;
  } else {
      userActionRecord.counts.push({date: date, count: 1})
  }
}

This way, we can populate the object with whichever data we want to test our logic.
Now, let's create the streak counting function and the tracking of making an action function:

const DAILY_GOAL_COUNT = 10;
const STREAK_COUNT = 7;
const ONE_DAY = <object that represents a day in your context>

const getStreaks = (daysWithGoals) => {
    if (daysWithGoals < STREAK_COUNT) {
        return;
    }
    
    streaks = [[daysWithGoals[0].date]];
    currStreakIndex = 0;
    
    for (let i = 1; i < daysWithGoals.Length; i++) {
        if (daysWithGoals[i].date - daysWithGoals[i - 1] === ONE_DAY) {
            streaks[currStreakIndex].push(daysWithGoals[i]);
        } else {
            streaks.push([daysWithGoals[i].date])
            currStreakIndex++;
        }
    }
    
    return streaks
             .filter(streak => streak.Length >= STREAK_COUNT)
             .map((streak) => {
                 return {start: streak[0], end: streak[streak.Length - 1]}
             })
}

const makeActionAndTrack = (uid, actionName) => {
    insertAction(uid, actionName, Date());

    const daysWithGoals = actionsDB
                            .filter(action => action.uid === uid && action.actionName === actionName)
                            .map(action => action.counts.filter(dailyCount => dailyCount.count >= DAILY_GOAL_COUNT))
    const streaks = getStreaks(daysWithGoals);
    if (streaks) {
        streaks.forEach(streak => console.log(streak.start, streak.end))
    }
}

This way we made our functions more atomic (probably could be improved a lot by separating to even smaller functions, and maybe creating a util to help with the dates like you did :) ).
I would also separate the insert and the tracking (in my example I just
wanted to make an example of a trigger to the tracking function - an
insertion of an action, but it could be a scheduled event or any event).
and so we'd be able to implement @kemicofa ghost answer without modifying
the code too much.

\$\endgroup\$
1
  • \$\begingroup\$ The only problem I can see with this is, it stores records for data which is no longer relevant (streaks in the past). \$\endgroup\$ Commented Jul 5, 2022 at 23:44
1
\$\begingroup\$

I most likely would not run this script on every action a user can do especially when you're only interested to see if they achieved their goal "after a day". It seems redundant and inefficient.

What happens if one day the user can "undo" an action, but they've already achieved their goal? You would have to include that logic in your current script.

Also, tracking events are usually side effects and don't need an immediate response.

What I would do, is create a (micro) service that handles tracking events or if I had a lot (like A LOT) of users use a streaming tool like Kafka / Pubsub / etc where the events could be consumed by your tracking service.

Your tracking service would be responsible to record (or delete etc) various events within the database throughout the day. THEN I would trigger a CRON (aka Cloud Function) process once a day that would loop through all the events of that day and make decisions on whether or not a person has achieved a specific goal and give them whatever "reward" they were meant to have.

\$\endgroup\$
3
  • \$\begingroup\$ Interesting, do you have recommendations on tools to use for each (cron, queue, etc.) :) I am using Next.js so hoping for something simple haha. \$\endgroup\$ Commented Jul 5, 2022 at 23:46
  • 1
    \$\begingroup\$ @Lance if it's not too late, then you should switch to Remix.run, so much more fun to use. Crons are not tied to a specific framework or anything. It can be just a simple backend javascript library that you trigger with a system event. That being said, this package seems like it would be simple enough to use for crons: npmjs.com/package/node-cron \$\endgroup\$
    – kockburn
    Commented Jul 6, 2022 at 8:54
  • 1
    \$\begingroup\$ Oh it's too late to switch frameworks :) I think I might try "github actions post to http endpoint" for cron. \$\endgroup\$ Commented Jul 6, 2022 at 10:00

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.