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!
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>
)
}
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>
)
}
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)
}}
/>
)
}
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 }))
)
},
}
)
}
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>
}
/>
)
}
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>
)
}
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
}
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>
)
}
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>
)
}
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)};
}
`
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>
</>
)
}
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);
`
}
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}"
}
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)
}
})
)
}
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 })
})
)
}
}
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))
})
}
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")),
}
}
Top comments (0)