DEV Community

Cover image for Developing a Scoreboard Feature for Full-Stack Applications
Rodion Chachura
Rodion Chachura

Posted on • Originally published at radzion.com

Developing a Scoreboard Feature for Full-Stack Applications

🐙 GitHub | 🎮 Demo

Implementing a Real-Time User Scoreboard: A Deep Dive into Full-Stack Development

In this article, we'll explore the full-stack implementation of an engaging feature: a scoreboard showcasing the most productive users. This post will be particularly relevant if you're tasked with implementing near real-time data reporting or are simply intrigued by the development of such scoreboards. While Increaser's codebase resides in a private repository, you can access all reusable components and utilities at RadzionKit. Let's dive in!

Scoreboard

Enhancing Community Engagement: Building a Scoreboard in a Next.js SSG Application

The goal of introducing this scoreboard is to foster a communal atmosphere in the app, signaling to new users that it is both active and flourishing. While the total work hours may not be the ideal measure of productivity, it provides a reliable foundation to start from. Our front-end is built as a Static Site Generated (SSG) Next.js application, featuring a dedicated community page for the scoreboard. This page primarily consists of two components: ManageProfile and Scoreboard.

import { Page } from "layout/Page"
import { FixedWidthContent } from "components/reusable/fixed-width-content"
import { PageTitle } from "ui/PageTitle"
import { VStack } from "@increaser/ui/layout/Stack"
import { UserStateOnly } from "user/state/UserStateOnly"
import { ClientOnly } from "ui/ClientOnly"
import { ManageProfile } from "./ManageProfile"
import { Scoreboard } from "./CurrentMonthUsers"

export const CommunityPage: Page = () => {
  return (
    <FixedWidthContent>
      <ClientOnly>
        <PageTitle documentTitle={`👋 Community`} title="Community" />
      </ClientOnly>
      <UserStateOnly>
        <VStack style={{ width: "fit-content" }} gap={40}>
          <ManageProfile />
          <Scoreboard />
        </VStack>
      </UserStateOnly>
    </FixedWidthContent>
  )
}
Enter fullscreen mode Exit fullscreen mode

By default, every user is anonymous on our platform. While the scoreboard includes these anonymous users, their names and countries remain hidden. The ManageProfile component is specifically designed to enable users to make their profiles public and edit details like their name and country.

import { HStack, VStack } from "@increaser/ui/layout/Stack"
import { Text } from "@increaser/ui/text"
import { ManagePrivacy } from "../ManagePrivacy"
import { Panel } from "@increaser/ui/panel/Panel"
import { useAssertUserState } from "user/state/UserStateContext"
import { useState } from "react"
import { IconButton } from "@increaser/ui/buttons/IconButton"
import { EditIcon } from "@increaser/ui/icons/EditIcon"
import { PublicProfileForm } from "../PublicProfileForm"
import { ScoreboardDisplayName } from "../ScoreboardDisplayName"
import { SeparatedByLine } from "@increaser/ui/layout/SeparatedByLine"
import { CountryCode } from "@increaser/utils/countries"
import { LabeledValue } from "@increaser/ui/text/LabeledValue"

export const ManageProfile = () => {
  const { isAnonymous, name, country } = useAssertUserState()

  const [isEditing, setIsEditing] = useState(false)

  return (
    <Panel kind="secondary">
      <VStack gap={20}>
        <SeparatedByLine gap={20}>
          <HStack
            fullWidth
            justifyContent="space-between"
            alignItems="center"
            gap={16}
            wrap="wrap"
          >
            <Text size={18} weight="bold">
              Your profile
            </Text>
            <ManagePrivacy />
          </HStack>
          {isEditing ? (
            <PublicProfileForm onCancel={() => setIsEditing(false)} />
          ) : (
            <HStack
              alignItems="center"
              gap={8}
              fullWidth
              justifyContent="space-between"
            >
              <LabeledValue name="Name">
                <HStack alignItems="center" gap={8}>
                  {isAnonymous ? (
                    <ScoreboardDisplayName />
                  ) : (
                    <ScoreboardDisplayName
                      name={name ?? undefined}
                      country={(country as CountryCode) ?? undefined}
                    />
                  )}
                </HStack>
              </LabeledValue>
              <IconButton
                style={{
                  opacity: isAnonymous ? 0 : 1,
                  pointerEvents: isAnonymous ? "none" : undefined,
                }}
                title="Edit profile"
                kind="secondary"
                icon={<EditIcon />}
                onClick={() => setIsEditing(true)}
              />
            </HStack>
          )}
        </SeparatedByLine>
      </VStack>
    </Panel>
  )
}
Enter fullscreen mode Exit fullscreen mode

Building User-Friendly Profile Management in React: From Privacy Settings to Public Profiles

At the beginning of the ManageProfile component, a title is displayed, followed by the ManagePrivacy component. This component features an intuitive radio input, allowing users to select between a public and an anonymous profile.

import { useAssertUserState } from "user/state/UserStateContext"
import { RadioInput } from "@increaser/ui/inputs/RadioInput"
import { capitalizeFirstLetter } from "@increaser/utils/capitalizeFirstLetter"
import { useUpdateUserProfileMutation } from "community/hooks/useUpdateUserProfileMutation"

const privacyOptions = ["public", "anonymous"] as const
type PrivacyOption = (typeof privacyOptions)[number]

export const ManagePrivacy = () => {
  const { isAnonymous } = useAssertUserState()
  const { mutate: updateUser } = useUpdateUserProfileMutation()

  return (
    <RadioInput<PrivacyOption>
      renderOption={capitalizeFirstLetter}
      options={privacyOptions}
      value={isAnonymous ? "anonymous" : "public"}
      onChange={(value) => {
        const fields = { isAnonymous: value === "anonymous" }
        updateUser(fields)
      }}
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

The interaction with our API is encapsulated within the useApi hook. For insights on implementing a TypeScript backend effortlessly, refer to my blog post. Additionally, we perform an optimistic update to the user state, enhancing the app's responsiveness. Upon successful operations, we invalidate the scoreboard queries, triggering a re-fetch to ensure the scoreboard reflects the updated user profile.

import { scoreboardPeriods } from "@increaser/entities/PerformanceScoreboard"
import { useInvalidateQueries } from "@increaser/ui/query/hooks/useInvalidateQueries"
import { User } from "@sentry/nextjs"
import { useApi } from "api/hooks/useApi"
import { getApiQueryKey } from "api/hooks/useApiQuery"
import { useMutation } from "react-query"
import { useUserState } from "user/state/UserStateContext"

export const useUpdateUserProfileMutation = () => {
  const invalidate = useInvalidateQueries()
  const api = useApi()
  const { updateState } = useUserState()

  return useMutation(
    (fields: Partial<Pick<User, "name" | "country" | "isAnonymous">>) => {
      updateState(fields)
      return api.call("updateUser", fields)
    },
    {
      onSuccess: () => {
        invalidate(
          ...scoreboardPeriods.map((id) => getApiQueryKey("scoreboard", { id }))
        )
      },
    }
  )
}
Enter fullscreen mode Exit fullscreen mode

Below, we present a preview of the user's appearance in the scoreboard. For an anonymous user, this includes a gray flag and the label "Anonymous." Conversely, for a public profile, an edit button is displayed, which activates the PublicProfileForm component.

import { Form } from "@increaser/ui/form/components/Form"
import { SameWidthChildrenRow } from "@increaser/ui/Layout/SameWidthChildrenRow"
import { Button } from "@increaser/ui/buttons/Button"
import { TextInput } from "@increaser/ui/inputs/TextInput"
import { Controller, useForm } from "react-hook-form"
import { useAssertUserState } from "user/state/UserStateContext"
import { CountryCode } from "@increaser/utils/countries"
import { CountryInput } from "@increaser/ui/inputs/CountryInput"
import { useUpdateUserMutation } from "user/mutations/useUpdateUserMutation"

interface PublicProfileFormProps {
  onCancel: () => void
}

interface PublicProfileFormShape {
  name: string | null
  country: CountryCode | null
}

export const PublicProfileForm = ({ onCancel }: PublicProfileFormProps) => {
  const { name, country } = useAssertUserState()

  const { mutate: updateUser } = useUpdateUserMutation()

  const { register, handleSubmit, control } = useForm<PublicProfileFormShape>({
    defaultValues: {
      name,
      country,
    },
  })

  return (
    <Form
      onSubmit={handleSubmit((fields) => {
        const newFields = {
          name: fields.name ?? undefined,
          country: fields.country ?? undefined,
        }
        updateUser(newFields)
        onCancel()
      })}
      content={
        <>
          <TextInput label="Name" {...register("name", { required: true })} />
          <Controller
            control={control}
            name="country"
            render={({ field: { value, onChange } }) => (
              <CountryInput
                label="Your country"
                value={value}
                onChange={onChange}
              />
            )}
          />
        </>
      }
      actions={
        <SameWidthChildrenRow gap={8}>
          <Button size="l" type="button" kind="secondary" onClick={onCancel}>
            Cancel
          </Button>
          <Button size="l" kind="reversed">
            Update
          </Button>
        </SameWidthChildrenRow>
      }
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

In this form, we extract the user's name and country from the state and assign them as default values to the useForm hook from the react-hook-form library. Upon form submission, the useUpdateUserMutation hook updates the user's profile. The user's name field utilizes a standard TextInput component available in RadzionKit. For selecting a country, we employ a custom-made combobox that allows users to choose from a list of all countries. For details on creating such an advanced input, explore this blog post.

import { QueryDependant } from "@increaser/ui/query/components/QueryDependant"
import { Spinner } from "@increaser/ui/loaders/Spinner"
import { Text } from "@increaser/ui/text"
import { LastScoreboardUpdate } from "./LastScoreboardUpdate"
import { ScoreboardTable } from "./ScoreboardTable"
import { Panel } from "@increaser/ui/panel/Panel"
import { HStack, VStack } from "@increaser/ui/layout/Stack"
import {
  ScoreboardPeriod,
  scoreboardPeriodInDays,
} from "@increaser/entities/PerformanceScoreboard"
import { useApiQuery } from "api/hooks/useApiQuery"

export const Scoreboard = () => {
  const scoreboardPeriod: ScoreboardPeriod = "week"
  const query = useApiQuery("scoreboard", { id: scoreboardPeriod })

  return (
    <Panel kind="secondary">
      <VStack gap={24}>
        <Text size={18} weight="semibold" color="regular">
          Last {scoreboardPeriodInDays[scoreboardPeriod]} Days Top Performers
        </Text>
        <QueryDependant
          {...query}
          success={(value) => (
            <VStack gap={24}>
              <ScoreboardTable
                myPosition={value.myPosition}
                users={value.users}
              />
              <HStack fullWidth justifyContent="end">
                <LastScoreboardUpdate value={value.syncedAt} />
              </HStack>
            </VStack>
          )}
          error={() => <Text>Something went wrong</Text>}
          loading={() => <Spinner />}
        />
      </VStack>
    </Panel>
  )
}
Enter fullscreen mode Exit fullscreen mode

Designing an Efficient Scoreboard UI: Techniques for Real-Time Data Display in React

In the Scoreboard component, we leverage the useApiQuery hook to fetch the user leaderboard for the past week from the API. The QueryDependant component acts as a wrapper, displaying different components based on the query's state. During data loading, a spinner is shown. In case of a query failure, an error message is displayed. Upon a successful query, the ScoreboardTable component is rendered.

import { ReactNode } from "react"

type QueryStatus = "idle" | "error" | "loading" | "success"

export interface QueryDependantProps<T> {
  status: QueryStatus
  data: T | undefined
  error: () => ReactNode
  loading: () => ReactNode
  success: (data: T) => ReactNode
}

export function QueryDependant<T>({
  status,
  data,
  error,
  loading,
  success,
}: QueryDependantProps<T>) {
  if (status === "error") {
    return <>{error()}</>
  }

  if (status === "loading") {
    return <>{loading()}</>
  }

  if (data) {
    return <>{success(data)}</>
  }

  return null
}
Enter fullscreen mode Exit fullscreen mode

Updating the scoreboard in real-time presents significant expense and maintenance challenges. As a more practical solution, we update the scoreboard every 10 minutes. The time of the last update is displayed using the LastScoreboardUpdate component. To ensure the time display remains current, the useRhythmicRerender hook is employed to forcefully re-render the component every minute.

import { Text } from "@increaser/ui/text"
import { useRhythmicRerender } from "@increaser/ui/hooks/useRhythmicRerender"
import { MS_IN_MIN } from "@increaser/utils/time"
import { formatDuration } from "@increaser/utils/time/formatDuration"

interface LastScoreboardUpdateProps {
  value: number
}

export const LastScoreboardUpdate = ({ value }: LastScoreboardUpdateProps) => {
  const now = useRhythmicRerender(1000)
  const duration = now - value

  return (
    <Text color="supporting" as="span" size={14} weight="regular">
      Updated{" "}
      {duration < MS_IN_MIN
        ? "just now"
        : `${formatDuration(duration, "ms")} ago`}
    </Text>
  )
}
Enter fullscreen mode Exit fullscreen mode

For displaying the user list, the ScoreboardTable component is employed, combining a flexbox element for list organization with CSS Grid for arranging content within each header and user item row. On smaller screens, the column that displays the average block duration is omitted, as it is deemed a less crucial metric.

import { Text } from "@increaser/ui/text"
import { formatDuration } from "@increaser/utils/time/formatDuration"
import styled from "styled-components"
import { getColor } from "@increaser/ui/theme/getters"
import { ScoreboardDisplayName } from "./ScoreboardDisplayName"
import { SeparatedByLine } from "@increaser/ui/layout/SeparatedByLine"
import { VStack } from "@increaser/ui/layout/Stack"
import { ScoreboardCountryFlag } from "./ScoreboardCountryFlag"
import { CountryCode } from "@increaser/utils/countries"
import { useIsScreenWidthLessThan } from "@increaser/ui/hooks/useIsScreenWidthLessThan"
import { absoluteOutline } from "@increaser/ui/css/absoluteOutline"
import { UserPerformanceRecord } from "@increaser/entities/PerformanceScoreboard"
import { order } from "@increaser/utils/array/order"

const Row = styled.div`
  display: grid;
  gap: 16px;
  align-items: center;
  align-content: center;
  position: relative;
`

const Outline = styled.div`
  ${absoluteOutline(10, 8)};
  background: transparent;
  border-radius: 8px;
  border: 2px solid ${getColor("primary")};
`

const Identity = styled.div`
  display: grid;
  align-items: center;
  grid-template-columns: 28px auto 1fr;
  gap: 8px;
`

const ColumnName = styled(Text)`
  color: ${getColor("textSupporting")};
`

interface ScoreboardProps {
  users: Omit<UserPerformanceRecord, "id">[]
  myPosition?: number
}

export const ScoreboardTable = ({ users, myPosition }: ScoreboardProps) => {
  const shouldHideAvgBlock = useIsScreenWidthLessThan(400)

  const rowStyle = shouldHideAvgBlock
    ? { gridTemplateColumns: "1fr 80px" }
    : { gridTemplateColumns: "1fr 80px 80px" }

  return (
    <SeparatedByLine gap={16}>
      <Row style={rowStyle}>
        <ColumnName as="div">
          <Identity>
            <Text>#</Text>
            <ScoreboardCountryFlag />
            <Text>Name</Text>
          </Identity>
        </ColumnName>
        {!shouldHideAvgBlock && <ColumnName>Avg. block</ColumnName>}
        <ColumnName style={{ textAlign: "end" }}>Daily avg.</ColumnName>
      </Row>
      <VStack gap={16}>
        {order(users, (u) => u.dailyAvgInMinutes, "desc").map(
          ({ dailyAvgInMinutes, profile, avgBlockInMinutes }, index) => (
            <Row style={rowStyle} key={index}>
              {myPosition === index && <Outline />}
              <Identity>
                <Text weight="semibold">{index + 1}.</Text>
                <ScoreboardDisplayName
                  name={profile?.name || "Anonymous"}
                  country={profile?.country as CountryCode}
                />
              </Identity>

              {!shouldHideAvgBlock && (
                <Text color="supporting" weight="semibold">
                  {formatDuration(avgBlockInMinutes, "min")}
                </Text>
              )}

              <Text weight="semibold" style={{ textAlign: "end" }}>
                {formatDuration(dailyAvgInMinutes, "min")}
              </Text>
            </Row>
          )
        )}
      </VStack>
    </SeparatedByLine>
  )
}
Enter fullscreen mode Exit fullscreen mode

The content is wrapped with the SeparatedByLine component from RadzionKit, a flexbox element that applies a bottom border and padding to all child elements, excluding the last one. This component is especially advantageous for delineating content with a line, as demonstrated in our scoreboard where it separates the header from the user list.

import styled from "styled-components"

import { VStack } from "./Stack"
import { getColor } from "../theme/getters"
import { toSizeUnit } from "../css/toSizeUnit"

export const SeparatedByLine = styled(VStack)`
  > *:not(:last-child) {
    border-bottom: 1px solid ${getColor("mistExtra")};
    padding-bottom: ${({ gap = 0 }) => toSizeUnit(gap)};
  }
`
Enter fullscreen mode Exit fullscreen mode

Firstly, a header is rendered within a CSS Grid Row component, followed by a list of users in a flexbox VStack component. We utilize the order utility from RadzionKit to sort users by their daily average. Subsequently, we iterate over the users, displaying their data. The ScoreboardDisplayName component presents the user's name along with their country flag. Additionally, the formatDuration utility from RadzionKit formats the duration of the user's average block and daily average.

import { Text } from "@increaser/ui/text"
import { ScoreboardCountryFlag } from "./ScoreboardCountryFlag"
import { CountryCode } from "@increaser/utils/countries"

interface ScoreboardDisplayNameProps {
  name?: string
  country?: CountryCode
}

export const ScoreboardDisplayName = ({
  name,
  country,
}: ScoreboardDisplayNameProps) => {
  return (
    <>
      <ScoreboardCountryFlag code={country} />
      <Text cropped weight="semibold" color={name ? "regular" : "shy"}>
        {name || "Anonymous"}
      </Text>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

To emphasize the current user, we compare the myPosition prop with the current index. When these values match, the Outline component is rendered. This component utilizes the absoluteOutline CSS utility, which creates an absolutely positioned element. The size of this element matches the parent element's dimensions, with additional specified offsets.

import { css } from "styled-components"
import { toSizeUnit } from "./toSizeUnit"

export const absoluteOutline = (
  horizontalOffset: number | string,
  verticalOffset: number | string
) => {
  return css`
    pointer-events: none;
    position: absolute;
    left: -${toSizeUnit(horizontalOffset)};
    top: -${toSizeUnit(verticalOffset)};
    width: calc(100% + ${toSizeUnit(horizontalOffset)} * 2);
    height: calc(100% + ${toSizeUnit(verticalOffset)} * 2);
  `
}
Enter fullscreen mode Exit fullscreen mode

Optimizing Server-Side Scoreboard Management with AWS Lambda and DynamoDB

Having addressed the front-end components, let's shift our focus to the server-side implementation. To refresh the scoreboard every 10 minutes, we employ an AWS Lambda function, which is triggered by a CloudWatch cron job. This setup is configured using Terraform.

resource "aws_cloudwatch_event_rule" "cron" {
  name = "tf-${var.name}"
  schedule_expression = "rate(10 minutes)"
}

resource "aws_cloudwatch_event_target" "cron" {
  rule = "${aws_cloudwatch_event_rule.cron.name}"
  target_id = "tf-${var.name}"
  arn = "${aws_lambda_function.service.arn}"
}
Enter fullscreen mode Exit fullscreen mode

Each time the Lambda function is triggered, it executes the syncScoreboards function. This function retrieves all users from the database who have non-empty sets and were updated within the timeframe corresponding to the scoreboard period. While the front-end displays only the scoreboard for the last 7 days, later we could add scoreboards for longer periods, such as 30 or 90 days. For this reason, we utilize the scoreboardPeriods array, which currently contains only the week period.

import { User } from "@increaser/entities/User"
import { tableName } from "@increaser/db/tableName"

import { MIN_IN_HOUR, MS_IN_MIN } from "@increaser/utils/time"
import {
  PerformanceScoreboard,
  UserPerformanceRecord,
  scoreboardPeriodInDays,
  scoreboardPeriods,
} from "@increaser/entities/PerformanceScoreboard"
import { getBlocks } from "@increaser/entities-utils/block"
import { getSetsDuration } from "@increaser/entities-utils/set/getSetsDuration"
import {
  doesScoreboardExist,
  putScoreboard,
  updateScoreboard,
} from "@increaser/db/scoreboard"
import { omit } from "@increaser/utils/record/omit"
import { order } from "@increaser/utils/array/order"
import { convertDuration } from "@increaser/utils/time/convertDuration"
import { getUserProfile } from "@increaser/entities-utils/scoreboard/getUserProfile"
import { totalScan } from "@increaser/dynamodb/totalScan"

type UserInfo = Pick<
  User,
  "id" | "name" | "country" | "sets" | "timeZone" | "isAnonymous"
>

export const syncScoreboards = async () => {
  const users = await totalScan<UserInfo>({
    TableName: tableName.users,
    FilterExpression: "size(#sets) > :size AND #updatedAt > :updatedAt",
    ExpressionAttributeNames: {
      "#id": "id",
      "#sets": "sets",
      "#name": "name",
      "#timeZone": "timeZone",
      "#country": "country",
      "#isAnonymous": "isAnonymous",
      "#updatedAt": "updatedAt",
    },
    ExpressionAttributeValues: {
      ":size": 0,
      ":updatedAt":
        Date.now() -
        convertDuration(
          Math.max(...Object.values(scoreboardPeriodInDays)),
          "d",
          "ms"
        ),
    },
    ProjectionExpression: "#id,#sets,#name,#timeZone,#country,#isAnonymous",
  })

  const records: UserPerformanceRecord[] = []
  await Promise.all(
    scoreboardPeriods.map(async (period) => {
      users.forEach((user) => {
        const { sets, id } = user
        const days = scoreboardPeriodInDays[period]
        const setsShouldStartForm =
          Date.now() - convertDuration(days, "d", "ms")
        const scoreboardSets = sets.filter(
          (set) => set.start > setsShouldStartForm
        )
        const total = getSetsDuration(scoreboardSets)
        const totalInMinutes = Math.round(total / MS_IN_MIN)

        const dailyAvgInMinutes = Math.round(totalInMinutes / days)
        if (dailyAvgInMinutes < 2 * MIN_IN_HOUR) return

        const blocks = getBlocks(scoreboardSets)

        const avgBlockInMinutes = totalInMinutes / blocks.length
        const userRecord: UserPerformanceRecord = {
          id,
          dailyAvgInMinutes,
          avgBlockInMinutes,
          profile: getUserProfile(user),
        }

        records.push(userRecord)
      })

      const content: PerformanceScoreboard = {
        id: period,
        syncedAt: Date.now(),
        users: order(records, (r) => r.dailyAvgInMinutes, "desc"),
      }

      if (await doesScoreboardExist(content.id)) {
        await updateScoreboard(content.id, omit(content, "id"))
      } else {
        await putScoreboard(content)
      }
    })
  )
}
Enter fullscreen mode Exit fullscreen mode

Scoreboards are stored in a DynamoDB table, with the primary key set to the scoreboard period. To expedite scoreboard queries, we duplicate user profile data directly in the scoreboard record. This strategy allows us to bypass querying the user table for every user in the list. However, this approach necessitates updating the scoreboard record each time a user profile is modified. Although this could be accomplished by monitoring user item updates via DynamoDB streams, we opt for a more cost-effective solution. We'll incorporate this update logic within the endpoint responsible for user profile updates, as it's the sole avenue through which users can modify their profiles.

import { assertUserId } from "../../auth/assertUserId"
import * as usersDb from "@increaser/db/user"
import { ApiResolver } from "../../resolvers/ApiResolver"
import {
  scoreboardPeriods,
  scoreboardSensitiveUserFields,
} from "@increaser/entities/PerformanceScoreboard"
import { intersection } from "@increaser/utils/array/intersection"
import { getScoreboard, updateScoreboard } from "@increaser/db/scoreboard"
import { findBy } from "@increaser/utils/array/findBy"
import { getUserProfile } from "@increaser/entities-utils/scoreboard/getUserProfile"
import { userReadonlyFields } from "@increaser/entities/User"

export const updateUser: ApiResolver<"updateUser"> = async ({
  input,
  context,
}) => {
  const hasReadonlyField =
    intersection(Object.keys(input), [...userReadonlyFields]).length > 0
  if (hasReadonlyField) {
    throw new Error("You cannot update readonly fields")
  }

  const userId = assertUserId(context)

  await usersDb.updateUser(userId, input)

  if (intersection(Object.keys(input), scoreboardSensitiveUserFields).length) {
    const userFields = await usersDb.getUser(
      userId,
      scoreboardSensitiveUserFields
    )
    await Promise.all(
      scoreboardPeriods.map(async (period) => {
        const scoreboard = await getScoreboard(period)
        const isInScoreboard = findBy(scoreboard.users, "id", userId)
        if (!isInScoreboard) return

        const users = scoreboard.users.map((user) => {
          if (user.id !== userId) return user
          return {
            ...user,
            profile: getUserProfile(userFields),
          }
        })
        await updateScoreboard(period, { users })
      })
    )
  }
}
Enter fullscreen mode Exit fullscreen mode

This process involves iterating over each key in the input object and verifying if it's present in the scoreboardSensitiveUserFields array. This check is performed using the intersection utility from RadzionKit. Should there be a profile change, we also iterate over every scoreboard to update the user profile data accordingly.

export const intersection = <T,>(...arrays: T[][]): T[] => {
  return arrays.reduce((acc, arr) => {
    return acc.filter((v) => arr.includes(v))
  })
}
Enter fullscreen mode Exit fullscreen mode

The query for the scoreboard is straightforward. It involves taking the scoreboard period as an input and returning the corresponding scoreboard record. Additionally, we calculate user's position in the scoreboard, which is then displayed on the front-end.

import { assertUserId } from "../../auth/assertUserId"
import { getScoreboard } from "@increaser/db/scoreboard"
import { omit } from "@increaser/utils/record/omit"
import { ApiResolver } from "../../resolvers/ApiResolver"

export const scoreboard: ApiResolver<"scoreboard"> = async ({
  input: { id },
  context,
}) => {
  const userId = assertUserId(context)

  const scoreboard = await getScoreboard(id)
  const myPosition = scoreboard.users.findIndex((item) => item.id === userId)

  return {
    ...scoreboard,
    myPosition,
    users: scoreboard.users.map((user) => omit(user, "id")),
  }
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)