fix: many session page improvements

This commit is contained in:
zk
2026-05-08 19:59:42 +00:00
parent 1f071b3dbc
commit ba707dd060
5 changed files with 372 additions and 148 deletions
@@ -43,7 +43,7 @@ export default function SpecificSessionPage({ reqUser, game }: UGPT) {
);
}
return <SessionPage {...{ data, game, reqUser }} />;
return <SessionPage key={data.session.sessionID} {...{ data, game, reqUser }} />;
}
function SessionPage({ data, game }: { data: SessionReturns } & UGPT) {
@@ -132,128 +132,137 @@ function SessionPage({ data, game }: { data: SessionReturns } & UGPT) {
`/sessions/${session.sessionID}/adjacent`,
);
const sessionIndex = sessionData.index;
const centerContent = (
<div className="d-flex flex-column gap-2 align-items-center justify-content-center text-center w-100 py-4 px-3">
<span
className="text-muted"
style={{ fontSize: "1.1rem", letterSpacing: "0.06em", textTransform: "uppercase" }}
>
Session #{sessionIndex}
</span>
<EditableText
as="h1"
authorised={isAuthorised || false}
className="enable-rfs my-0"
initialText={session.name || ""}
onSubmit={(name) => {
const newSession: SessionReturns = {
...sessionData,
session: { ...sessionData.session, name },
};
setSessionData(newSession);
updateSession(newSession);
}}
placeholderText={session.name || "Untitled Session"}
/>
<EditableText
authorised={isAuthorised || false}
className="fs-5 text-muted"
initialText={session.desc || ""}
onSubmit={(desc) => {
const newSession: SessionReturns = {
...sessionData,
session: { ...sessionData.session, desc },
};
setSessionData(newSession);
updateSession(newSession);
}}
placeholderText={session.desc || "No Description..."}
/>
{session.highlight && (
<Badge bg="warning" style={{ lineHeight: "15px" }}>
Highlight!
</Badge>
)}
{user.id === loggedInUser?.id && (
<div>
{highlight ? (
<Button
onClick={async () => {
setHighlight(false);
session.highlight = false;
await APIFetchV1(
`/sessions/${session.sessionID}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ highlight: false }),
},
true,
true,
);
}}
size="sm"
variant="outline-danger"
>
<Icon type="star" /> Un-Highlight
</Button>
) : (
<Button
onClick={async () => {
setHighlight(true);
session.highlight = true;
await APIFetchV1(
`/sessions/${session.sessionID}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ highlight: true }),
},
true,
true,
);
}}
size="sm"
variant="outline-warning"
>
<Icon regular type="star" /> Highlight
</Button>
)}
</div>
)}
</div>
);
return (
<Row className="justify-content-center">
<Col xs={12}>
<SessionNavRow
adjacent={adjacentData ?? null}
currentName={session.name}
game={game}
username={user.username}
/>
</Col>
<Col className="text-center" xs={12}>
<div className="d-flex flex-column gap-2 flex-wrap justify-content-center align-items-center">
<EditableText
as="h1"
authorised={isAuthorised || false}
className="enable-rfs"
initialText={session.name || ""}
onSubmit={(name) => {
const newSession: SessionReturns = {
...sessionData,
session: {
...sessionData.session,
name,
},
};
{/* Desktop: tape layout */}
<div className="d-none d-md-block">
<SessionNavRow
adjacent={adjacentData ?? null}
currentSession={session}
sessionIndex={sessionIndex}
username={user.username}
>
{centerContent}
</SessionNavRow>
</div>
setSessionData(newSession);
updateSession(newSession);
}}
placeholderText={session.name || "Untitled Session"}
{/* Mobile: stacked layout */}
<div className="d-md-none">
<MobileSessionNav
adjacent={adjacentData ?? null}
currentSession={session}
sessionIndex={sessionIndex}
username={user.username}
/>
<EditableText
authorised={isAuthorised || false}
className="fs-5"
initialText={session.desc || ""}
onSubmit={(desc) => {
const newSession: SessionReturns = {
...sessionData,
session: {
...sessionData.session,
desc,
},
};
setSessionData(newSession);
updateSession(newSession);
}}
placeholderText={session.desc || "No Description..."}
{centerContent}
<MobileSessionNav
adjacent={adjacentData ?? null}
bottom
currentSession={session}
sessionIndex={sessionIndex}
username={user.username}
/>
</div>
{session.highlight && (
<Badge
bg="warning"
className="ms-2"
style={{
lineHeight: "15px",
}}
>
Highlight!
</Badge>
)}
<Divider className="mt-8 mb-4" />
{user.id === loggedInUser?.id && (
<>
{highlight ? (
<Button
onClick={async () => {
setHighlight(false);
session.highlight = false;
await APIFetchV1(
`/sessions/${session.sessionID}`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
highlight: false,
}),
},
true,
true,
);
}}
variant="outline-danger"
>
<Icon type="star" /> Un-Highlight Session
</Button>
) : (
<Button
onClick={async () => {
setHighlight(true);
session.highlight = true;
await APIFetchV1(
`/sessions/${session.sessionID}`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
highlight: true,
}),
},
true,
true,
);
}}
variant="outline-warning"
>
<Icon regular type="star" /> Highlight Session
</Button>
)}
<Divider />
</>
)}
<Divider className="mt-4 mb-4" />
</Col>
<SessionOverview
reqUser={data.user}
@@ -273,43 +282,234 @@ function SessionPage({ data, game }: { data: SessionReturns } & UGPT) {
);
}
function SessionNavRow({
function formatSessionTimeDiff(fromSession: SessionDocument, toSession: SessionDocument): string {
const diffMs = toSession.timeStarted - fromSession.timeStarted;
const absDiff = Math.abs(diffMs);
const direction = diffMs < 0 ? "before" : "after";
const minutes = Math.round(absDiff / 60_000);
const hours = Math.round(absDiff / 3_600_000);
const days = Math.round(absDiff / 86_400_000);
const weeks = Math.round(absDiff / (7 * 86_400_000));
const months = Math.round(absDiff / (30 * 86_400_000));
let amount: string;
if (minutes < 60) {
amount = `${minutes}m`;
} else if (hours < 24) {
amount = `${hours}h`;
} else if (days < 14) {
amount = `${days}d`;
} else if (weeks < 8) {
amount = `${weeks}w`;
} else {
amount = `${months}mo`;
}
return `${amount} ${direction}`;
}
function MobileSessionNav({
adjacent,
currentName,
game,
bottom,
currentSession,
sessionIndex,
username,
}: {
adjacent: SessionAdjacentReturns | null;
currentName: string;
game: string;
bottom?: boolean;
currentSession: SessionDocument;
sessionIndex: number;
username: string;
}) {
const sessionUrl = (s: SessionDocument) =>
`/u/${username}/games/${s.game}/sessions/${s.sessionID}`;
const prev = adjacent?.prev ?? null;
const next = adjacent?.next ?? null;
return (
<div className="d-flex align-items-center justify-content-between mb-4">
<div style={{ minWidth: "140px" }}>
<div className="d-flex gap-2 mb-3">
{!bottom && prev ? (
<LinkButton
className="flex-fill d-flex align-items-center gap-2 text-start py-2 px-3"
to={sessionUrl(prev)}
variant="outline-secondary"
>
<span style={{ fontSize: "1.1rem" }}>&laquo;</span>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: "0.7rem", letterSpacing: "0.05em", textTransform: "uppercase", opacity: 0.6 }}>
&laquo; #{sessionIndex - 1} &middot; {formatSessionTimeDiff(currentSession, prev)}
</div>
<div className="text-truncate" style={{ fontWeight: 600 }}>
{prev.name}
</div>
</div>
</LinkButton>
) : !bottom ? (
<div className="flex-fill py-2 px-3 border border-secondary rounded d-flex align-items-center gap-2" style={{ opacity: 0.25 }}>
<span style={{ fontSize: "1.1rem" }}>&laquo;</span>
<div style={{ fontSize: "0.85rem" }}>No earlier session</div>
</div>
) : null}
{bottom && next ? (
<LinkButton
className="flex-fill d-flex align-items-center justify-content-end gap-2 text-end py-2 px-3"
to={sessionUrl(next)}
variant="outline-secondary"
>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: "0.7rem", letterSpacing: "0.05em", textTransform: "uppercase", opacity: 0.6 }}>
#{sessionIndex + 1} &middot; {formatSessionTimeDiff(currentSession, next)} &raquo;
</div>
<div className="text-truncate" style={{ fontWeight: 600 }}>
{next.name}
</div>
</div>
<span style={{ fontSize: "1.1rem" }}>&raquo;</span>
</LinkButton>
) : bottom ? (
<div className="flex-fill py-2 px-3 border border-secondary rounded d-flex align-items-center justify-content-end gap-2" style={{ opacity: 0.25 }}>
<div style={{ fontSize: "0.85rem" }}>No later session</div>
<span style={{ fontSize: "1.1rem" }}>&raquo;</span>
</div>
) : null}
</div>
);
}
function SessionNavRow({
adjacent,
children,
currentSession,
sessionIndex,
username,
}: {
adjacent: SessionAdjacentReturns | null;
children: React.ReactNode;
currentSession: SessionDocument;
sessionIndex: number;
username: string;
}) {
const sessionUrl = (s: SessionDocument) =>
`/u/${username}/games/${s.game}/sessions/${s.sessionID}`;
const borderColor = "var(--bs-secondary)";
const sideStyle = {
border: `1px solid ${borderColor}`,
background: "rgba(255,255,255,0.02)",
};
return (
<div className="d-flex align-items-stretch" style={{ minHeight: "80px" }}>
{/* Prev */}
<div style={{ flex: "2 1 0", minWidth: 0 }}>
{adjacent?.prev ? (
<LinkButton to={sessionUrl(adjacent.prev)} variant="outline-secondary">
&laquo; Prev
<LinkButton
className="w-100 h-100 d-flex flex-column align-items-start justify-content-center px-3 py-3 text-start"
style={{ borderRadius: "6px 0 0 6px", ...sideStyle }}
to={sessionUrl(adjacent.prev)}
variant="outline-secondary"
>
<span
style={{
fontSize: "0.7rem",
letterSpacing: "0.06em",
textTransform: "uppercase",
opacity: 0.6,
}}
>
&laquo; #{sessionIndex - 1} &middot;{" "}
{formatSessionTimeDiff(currentSession, adjacent.prev)}
</span>
<span
className="text-truncate w-100 mt-1"
style={{ fontSize: "1rem", fontWeight: 600 }}
>
{adjacent.prev.name}
</span>
</LinkButton>
) : (
<Button disabled variant="outline-secondary">
&laquo; Prev
</Button>
<div
className="w-100 h-100 d-flex flex-column align-items-start justify-content-center px-3 py-3"
style={{ borderRadius: "6px 0 0 6px", ...sideStyle, opacity: 0.25 }}
>
<span
style={{
fontSize: "0.7rem",
letterSpacing: "0.06em",
textTransform: "uppercase",
}}
>
&laquo; Prev
</span>
<span className="mt-1" style={{ fontSize: "1rem", fontWeight: 600 }}>
(This is your first session!)
</span>
</div>
)}
</div>
<span className="text-muted text-truncate px-2">{currentName}</span>
<div style={{ minWidth: "140px" }} className="text-end">
{/* Current session — children */}
<div
className="d-flex align-items-center justify-content-center"
style={{
flex: "3 1 0",
minWidth: 0,
borderTop: `1px solid ${borderColor}`,
borderBottom: `1px solid ${borderColor}`,
background: "rgba(255,255,255,0.015)",
}}
>
{children}
</div>
{/* Next */}
<div style={{ flex: "2 1 0", minWidth: 0 }}>
{adjacent?.next ? (
<LinkButton to={sessionUrl(adjacent.next)} variant="outline-secondary">
Next &raquo;
<LinkButton
className="w-100 h-100 d-flex flex-column align-items-end justify-content-center px-3 py-3 text-end"
style={{ borderRadius: "0 6px 6px 0", ...sideStyle }}
to={sessionUrl(adjacent.next)}
variant="outline-secondary"
>
<span
style={{
fontSize: "0.7rem",
letterSpacing: "0.06em",
textTransform: "uppercase",
opacity: 0.6,
}}
>
{formatSessionTimeDiff(currentSession, adjacent.next)} &middot;
#{sessionIndex + 1} &raquo;
</span>
<span
className="text-truncate w-100 mt-1"
style={{ fontSize: "1rem", fontWeight: 600 }}
>
{adjacent.next.name}
</span>
</LinkButton>
) : (
<Button disabled variant="outline-secondary">
Next &raquo;
</Button>
<div
className="w-100 h-100 d-flex flex-column align-items-end justify-content-center px-3 py-3"
style={{ borderRadius: "0 6px 6px 0", ...sideStyle, opacity: 0.25 }}
>
<span
style={{
fontSize: "0.7rem",
letterSpacing: "0.06em",
textTransform: "uppercase",
}}
>
Next &raquo;
</span>
<span className="mt-1" style={{ fontSize: "1rem", fontWeight: 600 }}>
(This is your most recent session!)
</span>
</div>
)}
</div>
</div>
@@ -95,6 +95,7 @@ export interface SessionReturns<GPT extends V3Game = V3Game> {
songs: SongDocument<GameGroup>[];
charts: ChartDocument<GPT>[];
user: UserDocument;
index: number;
}
export interface SessionAdjacentReturns {
@@ -125,12 +125,9 @@ describe("CreatePBDoc", () => {
} as ScoreData<"iidx-sp">,
});
const pb = (await CreatePBDoc(
"iidx-sp",
userId,
Testing511SPA,
log,
)) as PBScoreDocumentNoRank<"iidx-sp"> | undefined;
const pb = (await CreatePBDoc("iidx-sp", userId, Testing511SPA, log)) as
| PBScoreDocumentNoRank<"iidx-sp">
| undefined;
expect(pb).toBeDefined();
expect(pb!.scoreData.score).toBe(1926);
@@ -171,12 +168,9 @@ describe("CreatePBDoc", () => {
} as ScoreData<"iidx-sp">,
});
const pb = (await CreatePBDoc(
"iidx-sp",
userId,
Testing511SPA,
log,
)) as PBScoreDocumentNoRank<"iidx-sp"> | undefined;
const pb = (await CreatePBDoc("iidx-sp", userId, Testing511SPA, log)) as
| PBScoreDocumentNoRank<"iidx-sp">
| undefined;
expect(pb).toBeDefined();
expect(pb!.scoreData.score).toBe(2040);
@@ -4,7 +4,7 @@ import { GetSessionFolderRaises } from "#lib/folders/get-session-folder-raises";
import { withSession, withSessionOwner } from "#lib/router/middleware";
import { success } from "#lib/router/typed-router";
import { API_V1_ROUTER } from "#server/router/api/v1/router";
import { GetAdjacentSessions, GetSessionData } from "#utils/queries/sessions";
import { GetAdjacentSessions, GetSessionData, GetSessionIndex } from "#utils/queries/sessions";
import { GetUserWithID } from "#utils/user";
import { ExpectedErr } from "bliss";
@@ -15,10 +15,14 @@ import { ExpectedErr } from "bliss";
*/
API_V1_ROUTER.add("GET /sessions/:sessionID", withSession, async ({ ctx }) => {
const { sessionDoc: session } = ctx;
const sessionData = await GetSessionData(session);
const [sessionData, index] = await Promise.all([
GetSessionData(session),
GetSessionIndex(session),
]);
return success(`Successfully returned session ${session.name}.`, {
charts: sessionData.charts,
index,
scoreInfo: sessionData.scoreInfo,
scores: sessionData.scores,
session,
@@ -97,6 +97,31 @@ export async function GetAdjacentSessions(
};
}
/**
* Returns the 1-based chronological index of this session among all sessions
* for the same user and game, using (time_ended, id) as the sort key.
*/
export async function GetSessionIndex(session: SessionDocument): Promise<number> {
const timeEnded = UnixMillisecondsToISO8601(session.timeEnded);
const row = await DB.selectFrom("session")
.select(DB.fn.countAll<number>().as("count"))
.where("session.user_id", "=", session.userID)
.where("session.game", "=", session.game)
.where((eb) =>
eb.or([
eb("session.time_ended", "<", timeEnded),
eb.and([
eb("session.time_ended", "=", timeEnded),
eb("session.id", "<=", session.sessionID),
]),
]),
)
.executeTakeFirstOrThrow();
return Number(row.count);
}
export async function GetSessionData(session: SessionDocument): Promise<{
charts: Array<ChartDocument>;
scoreInfo: Array<SessionScoreInfo>;