User profile results page

This commit is contained in:
Kalle 2022-08-11 23:21:15 +03:00
parent f38fafd493
commit 2a7d32b77f
12 changed files with 225 additions and 72 deletions

View File

@ -16,9 +16,9 @@ Calendar
- [x] E2E test adding and editing event
- [x] Add winners page
- [ ] E2E test add winners page
- [ ] Winners on the event info page
- [x] Winners on the event info page
- [ ] There should be a banner on the list page if you have past tournaments to report
- [ ] On the user page tab showing past results
- [x] On the user page tab showing past results
- [ ] Translations
## Other

View File

@ -3,13 +3,13 @@ export function Section({
children,
className,
}: {
title: string;
title?: string;
children: React.ReactNode;
className?: string;
}) {
return (
<section className="section">
<h2>{title}</h2>
{title && <h2>{title}</h2>}
<div className={className}>{children}</div>
</section>
);

View File

@ -285,6 +285,69 @@ export function findResultsByEventId(eventId: CalendarEvent["id"]) {
return result;
}
const findResultsByUserIdStm = sql.prepare(`
select
"CalendarEvent"."id" as "eventId",
"CalendarEvent"."name" as "eventName",
"CalendarEventResultTeam"."name" as "teamName",
"CalendarEventResultTeam"."placement",
"CalendarEvent"."participantCount",
(select max("startTime")
from "CalendarEventDate"
where "eventId" = "CalendarEvent"."id")
as "startTime"
from "CalendarEventResultPlayer"
join "CalendarEventResultTeam"
on "CalendarEventResultTeam"."id" = "CalendarEventResultPlayer"."teamId"
join "CalendarEvent"
on "CalendarEvent"."id" = "CalendarEventResultTeam"."eventId"
where "CalendarEventResultPlayer"."userId" = $userId
order by "startTime" desc
`);
const findMatesByResultTeamIdStm = sql.prepare(`
select
"CalendarEventResultPlayer"."name",
"User"."id",
"User"."discordName" as "discordName",
"User"."discordDiscriminator" as "discordDiscriminator",
"User"."discordId" as "discordId",
"User"."discordAvatar" as "discordAvatar"
from "CalendarEventResultPlayer"
join "User"
on "User"."id" = "CalendarEventResultPlayer"."userId"
where "teamId" = $teamId
and "userId" != $userId
`);
export function findResultsByUserId(userId: User["id"]) {
return (
findResultsByUserIdStm.all({ userId }) as Array<{
eventId: CalendarEvent["id"];
eventName: CalendarEvent["name"];
teamName: CalendarEventResultTeam["name"];
placement: CalendarEventResultTeam["placement"];
participantCount: CalendarEvent["participantCount"];
startTime: CalendarEventDate["startTime"];
}>
).map((row) => ({
...row,
mates: (
findMatesByResultTeamIdStm.all({
teamId: row.eventId,
userId,
}) as Array<{
name: CalendarEventResultPlayer["name"];
id: User["id"];
discordName: User["discordName"];
discordDiscriminator: User["discordDiscriminator"];
discordId: User["discordId"];
discordAvatar: User["discordAvatar"];
}>
).map(({ name, ...mate }) => name ?? mate),
}));
}
const findAllBetweenTwoTimestampsStm = sql.prepare(`
select
"CalendarEvent"."name",

View File

@ -26,7 +26,7 @@ import {
import styles from "~/styles/calendar-event.css";
import { databaseTimestampToDate } from "~/utils/dates";
import { notFoundIfFalsy } from "~/utils/remix";
import { discordFullName, makeTitle } from "~/utils/strings";
import { discordFullName, makeTitle, placementString } from "~/utils/strings";
import {
calendarEditPage,
calendarReportWinnersPage,
@ -171,10 +171,10 @@ function Results() {
<div className="event__results-participant-count">
{data.event.participantCount} teams participated
</div>
<table className="event__results-table">
<table>
<thead>
<tr>
<th>Placement</th>
<th>Placing</th>
<th>Name</th>
<th>Members</th>
</tr>
@ -182,7 +182,9 @@ function Results() {
<tbody>
{data.results.map((result, i) => (
<tr key={i}>
<td className="text-center">{result.placement}</td>
<td className="text-center">
{placementString(result.placement)}
</td>
<td>{result.teamName}</td>
<td>
<ul className="event__results-players">
@ -193,7 +195,7 @@ function Results() {
) : (
<Link
to={userPage(player.discordId)}
className="stack horizontal xs justify-center"
className="stack horizontal xs items-center"
>
<Avatar
discordAvatar={player.discordAvatar}

View File

@ -1,20 +1,23 @@
import type { LoaderFunction, MetaFunction } from "@remix-run/node";
import type { LinksFunction, LoaderArgs, MetaFunction } from "@remix-run/node";
import { json } from "@remix-run/node";
import { Outlet, useLoaderData } from "@remix-run/react";
import type { UseDataFunctionReturn } from "@remix-run/react/dist/components";
import { countries } from "countries-list";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { Main } from "~/components/Main";
import { SubNav, SubNavLink } from "~/components/SubNav";
import { db } from "~/db";
import type { CountsByUserId } from "~/db/models/badges.server";
import type { User } from "~/db/types";
import { useUser } from "~/modules/auth";
import { i18next } from "~/modules/i18n";
import { translatedCountry } from "~/utils/i18n.server";
import { notFoundIfFalsy } from "~/utils/remix";
import { makeTitle } from "~/utils/strings";
import { discordFullName } from "~/utils/strings";
import { discordFullName, makeTitle } from "~/utils/strings";
import styles from "~/styles/u.css";
export const links: LinksFunction = () => {
return [{ rel: "stylesheet", href: styles }];
};
export const meta: MetaFunction = ({ data }: { data: UserPageLoaderData }) => {
return {
@ -28,23 +31,9 @@ export const handle = {
export const userParamsSchema = z.object({ identifier: z.string() });
export type UserPageLoaderData = Pick<
User,
| "id"
| "discordName"
| "discordAvatar"
| "discordDiscriminator"
| "discordId"
| "youtubeId"
| "twitch"
| "twitter"
| "bio"
> & {
country?: { emoji: string; code: string; name: string };
badges: CountsByUserId;
};
export type UserPageLoaderData = UseDataFunctionReturn<typeof loader>;
export const loader: LoaderFunction = async ({ request, params }) => {
export const loader = async ({ request, params }: LoaderArgs) => {
const locale = await i18next.getLocale(request);
const { identifier } = userParamsSchema.parse(params);
const user = notFoundIfFalsy(db.users.findByIdentifier(identifier));
@ -53,7 +42,7 @@ export const loader: LoaderFunction = async ({ request, params }) => {
? countries[user.country as keyof typeof countries]
: undefined;
return json<UserPageLoaderData>({
return json({
id: user.id,
discordAvatar: user.discordAvatar,
discordDiscriminator: user.discordDiscriminator,
@ -76,11 +65,12 @@ export const loader: LoaderFunction = async ({ request, params }) => {
}
: undefined,
badges: db.badges.countsByUserId(user.id),
results: db.calendarEvents.findResultsByUserId(user.id),
});
};
export default function UserPageLayout() {
const data = useLoaderData<UserPageLoaderData>();
const data = useLoaderData<typeof loader>();
const user = useUser();
const { t } = useTranslation();
@ -92,11 +82,16 @@ export default function UserPageLayout() {
<SubNavLink to="" data-cy="profile-page-link">
{t("header.profile")}
</SubNavLink>
{isOwnPage ? (
{isOwnPage && (
<SubNavLink to="edit" data-cy="edit-page-link">
{t("actions.edit")}
</SubNavLink>
) : null}
)}
{data.results.length > 0 && (
<SubNavLink to="results" data-cy="results-page-link">
{t("results")}
</SubNavLink>
)}
</SubNav>
<Main>
<Outlet />

View File

@ -1,23 +1,17 @@
import type { LinksFunction } from "@remix-run/node";
import { useMatches } from "@remix-run/react";
import clsx from "clsx";
import * as React from "react";
import { useTranslation } from "react-i18next";
import invariant from "tiny-invariant";
import { Avatar } from "~/components/Avatar";
import styles from "~/styles/u.css";
import type { UserPageLoaderData } from "../u.$identifier";
import * as React from "react";
import type { Unpacked } from "~/utils/types";
import clsx from "clsx";
import { assertUnreachable } from "~/utils/types";
import { Badge } from "~/components/Badge";
import { TwitchIcon } from "~/components/icons/Twitch";
import { TwitterIcon } from "~/components/icons/Twitter";
import { YouTubeIcon } from "~/components/icons/YouTube";
import type { Unpacked } from "~/utils/types";
import { assertUnreachable } from "~/utils/types";
import { badgeExplanationText } from "../badges/$id";
import { Badge } from "~/components/Badge";
import { useTranslation } from "react-i18next";
export const links: LinksFunction = () => {
return [{ rel: "stylesheet", href: styles }];
};
import type { UserPageLoaderData } from "../u.$identifier";
export const handle = {
i18n: "badges",

View File

@ -0,0 +1,76 @@
import { Link, useMatches } from "@remix-run/react";
import { useTranslation } from "react-i18next";
import invariant from "tiny-invariant";
import { Avatar } from "~/components/Avatar";
import { Section } from "~/components/Section";
import { databaseTimestampToDate } from "~/utils/dates";
import { discordFullName, placementString } from "~/utils/strings";
import { userPage } from "~/utils/urls";
import type { UserPageLoaderData } from "../u.$identifier";
export default function UserResultsPage() {
const { i18n } = useTranslation();
const [, parentRoute] = useMatches();
invariant(parentRoute);
const data = parentRoute.data as UserPageLoaderData;
return (
<Section className="u__results-section">
<table>
<thead>
<tr>
<th>Placing</th>
<th>Team</th>
<th>Tournament</th>
<th>Date</th>
<th>Members</th>
</tr>
</thead>
<tbody>
{data.results.map((result) => (
<tr key={result.eventId}>
<td className="text-center">
{placementString(result.placement)}
</td>
<td>{result.teamName}</td>
<td>{result.eventName}</td>
<td>
{databaseTimestampToDate(result.startTime).toLocaleDateString(
i18n.language,
{
day: "numeric",
month: "numeric",
year: "numeric",
}
)}
</td>
<td>
<ul className="u__results-players">
{result.mates.map((player) => (
<li key={typeof player === "string" ? player : player.id}>
{typeof player === "string" ? (
player
) : (
<Link
to={userPage(player.discordId)}
className="stack horizontal xs items-center"
>
<Avatar
discordAvatar={player.discordAvatar}
discordId={player.discordId}
size="xxs"
/>{" "}
{discordFullName(player)}
</Link>
)}
</li>
))}
</ul>
</td>
</tr>
))}
</tbody>
</table>
</Section>
);
}

View File

@ -25,33 +25,10 @@
gap: var(--s-1);
}
.event__results-table {
width: 100%;
border-collapse: separate;
border-spacing: 0 var(--s-1-5);
font-size: var(--fonts-sm);
text-align: left;
}
.event__results-table > thead {
font-size: var(--fonts-xxs);
}
.event__results-table > thead > tr {
text-align: center;
}
.event__results-table > tbody > tr {
padding-block: var(--s-2);
}
.event__results-table > tbody > tr:nth-child(2n) {
background-color: var(--bg);
}
.event__results-players {
display: flex;
flex-wrap: wrap;
padding: 0;
gap: var(--s-3);
list-style: none;
}

View File

@ -263,6 +263,30 @@ select:focus {
outline: 2px solid var(--theme);
}
table {
width: 100%;
border-collapse: separate;
border-spacing: 0 var(--s-1-5);
font-size: var(--fonts-xs);
text-align: left;
}
table > thead {
font-size: var(--fonts-xxs);
}
table > tbody > tr:nth-child(2n) {
background-color: var(--bg);
}
table > thead > tr > th {
padding-inline: var(--s-1);
}
table > tbody > tr > td {
padding-inline: var(--s-1);
}
hr {
border-color: var(--theme-transparent);
}

View File

@ -129,3 +129,15 @@
font-size: var(--fonts-xxxs);
font-weight: var(--bold);
}
.u__results-section {
overflow-x: auto;
}
.u__results-players {
display: flex;
flex-wrap: wrap;
padding: 0;
gap: var(--s-3);
list-style: none;
}

View File

@ -9,3 +9,11 @@ export function discordFullName(
export function makeTitle(title: string | string[]) {
return `${Array.isArray(title) ? title.join(" | ") : title} | sendou.ink`;
}
export function placementString(placement: number) {
if (placement === 1) return "🥇";
if (placement === 2) return "🥈";
if (placement === 3) return "🥉";
return `${placement}th`;
}

View File

@ -18,5 +18,7 @@
"actions.save": "Save",
"actions.saving": "Saving...",
"actions.edit": "Edit"
"actions.edit": "Edit",
"results": "Results"
}