Developing a Scoreboard Feature for Full-Stack Applications
Published: 2024-01-08 03:40:12

{% embed https://youtu.be/9OWKI7e6-3w %}

🐙 [GitHub](https://github.com/radzionc/radzionkit)  |  🎮 [Demo](https://increaser.org)

### 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](https://cdn-images-1.medium.com/proxy/1*PHaC4Drw9JvNkqWsWdBKwA.jpeg)

### 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`.

```tsx
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>
  )
}
```

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.

```tsx
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>
  )
}
```

### 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.

```tsx
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)
      }}
    />
  )
}
```

The interaction with our API is encapsulated within the `useApi` hook. For insights on implementing a TypeScript backend effortlessly, refer to [my blog post](https://radzion.com/blog/api). 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.

```tsx
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 }))
        )
      },
    }
  )
}
```

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.

```tsx
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>
      }
    />
  )
}
```

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](https://radzion.com/blog/combo).

```tsx
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>
  )
}
```

### 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.

```tsx
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
}
```

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.

```tsx
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>
  )
}
```

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.

```tsx
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>
  )
}
```

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.

```tsx
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)};
  }
`
```

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.

```tsx
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>
    </>
  )
}
```

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.

```tsx
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);
  `
}
```

### 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.

```tsx
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}"
}
```

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.

```tsx
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)
      }
    })
  )
}
```

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.

```tsx
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 })
      })
    )
  }
}
```

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.

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

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.

```tsx
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")),
  }
}
```