fix: add pagination to quest list to stay within Discord component limits
All checks were successful
Deploy to Production / test (push) Successful in 45s

The available quests view was exceeding Discord's 40-component container
limit when many quests existed, causing an API error. Paginate both
active and available quest views at 7 quests per page with prev/next
navigation buttons.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
syntaxbullet
2026-03-28 14:20:53 +01:00
parent 0f871026eb
commit e56e133a69
2 changed files with 72 additions and 23 deletions

View File

@@ -16,16 +16,24 @@ export const quests = createCommand({
const response = await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const userId = interaction.user.id;
let currentView: 'active' | 'available' = 'active';
let currentPage = 0;
const updateView = async (viewType: 'active' | 'available', page: number = 0) => {
currentView = viewType;
currentPage = page;
const updateView = async (viewType: 'active' | 'available') => {
const userQuests = await questService.getUserQuests(userId);
const availableQuests = await questService.getAvailableQuests(userId);
const containers = viewType === 'active'
? getQuestListComponents(userQuests)
: getAvailableQuestsComponents(availableQuests);
const activeQuests = userQuests.filter(entry => entry.completedAt === null);
const totalItems = viewType === 'active' ? activeQuests.length : availableQuests.length;
const actionRows = getQuestActionRows(viewType);
const containers = viewType === 'active'
? getQuestListComponents(userQuests, page)
: getAvailableQuestsComponents(availableQuests, page);
const actionRows = getQuestActionRows(viewType, totalItems, page);
await interaction.editReply({
content: null,
@@ -50,10 +58,16 @@ export const quests = createCommand({
try {
if (i.customId === "quest_view_active") {
await i.deferUpdate();
await updateView('active');
await updateView('active', 0);
} else if (i.customId === "quest_view_available") {
await i.deferUpdate();
await updateView('available');
await updateView('available', 0);
} else if (i.customId === "quest_page_prev") {
await i.deferUpdate();
await updateView(currentView, Math.max(0, currentPage - 1));
} else if (i.customId === "quest_page_next") {
await i.deferUpdate();
await updateView(currentView, currentPage + 1);
} else if (i.customId.startsWith("quest_accept:")) {
const questIdStr = i.customId.split(":")[1];
if (!questIdStr) return;
@@ -65,7 +79,8 @@ export const quests = createCommand({
flags: MessageFlags.Ephemeral
});
await updateView('active');
// Stay on current view/page but refresh (accepted quest disappears from available)
await updateView(currentView, currentPage);
}
} catch (error) {
console.error("Quest interaction error:", error);

View File

@@ -43,6 +43,9 @@ const COLORS = {
COMPLETED: 0xf1c40f // Gold - completed
};
// Max quests per page (2 header + 1 page indicator + 7×5 components = 38, Discord max is 40 per container)
const QUESTS_PER_PAGE = 7;
/**
* Formats quest rewards object into a human-readable string
*/
@@ -70,15 +73,22 @@ function renderProgressBar(current: number, total: number, size: number = 10): s
/**
* Creates Components v2 containers for the quest list (active quests only)
*/
export function getQuestListComponents(userQuests: QuestEntry[]): ContainerBuilder[] {
export function getQuestListComponents(userQuests: QuestEntry[], page: number = 0): ContainerBuilder[] {
// Filter to only show in-progress quests (not completed)
const activeQuests = userQuests.filter(entry => entry.completedAt === null);
const totalPages = Math.max(1, Math.ceil(activeQuests.length / QUESTS_PER_PAGE));
const safePage = Math.min(page, totalPages - 1);
const pageQuests = activeQuests.slice(safePage * QUESTS_PER_PAGE, (safePage + 1) * QUESTS_PER_PAGE);
const container = new ContainerBuilder()
.setAccentColor(COLORS.ACTIVE)
.addTextDisplayComponents(
new TextDisplayBuilder().setContent("# 📜 Quest Log"),
new TextDisplayBuilder().setContent("-# Your active quests")
new TextDisplayBuilder().setContent(
totalPages > 1
? `-# Your active quests — Page ${safePage + 1}/${totalPages}`
: "-# Your active quests"
)
);
if (activeQuests.length === 0) {
@@ -89,7 +99,7 @@ export function getQuestListComponents(userQuests: QuestEntry[]): ContainerBuild
return [container];
}
activeQuests.forEach((entry) => {
pageQuests.forEach((entry) => {
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
const rewards = entry.quest.rewards as { xp?: number, balance?: number };
@@ -113,12 +123,20 @@ export function getQuestListComponents(userQuests: QuestEntry[]): ContainerBuild
/**
* Creates Components v2 containers for available quests with inline accept buttons
*/
export function getAvailableQuestsComponents(availableQuests: AvailableQuest[]): ContainerBuilder[] {
export function getAvailableQuestsComponents(availableQuests: AvailableQuest[], page: number = 0): ContainerBuilder[] {
const totalPages = Math.max(1, Math.ceil(availableQuests.length / QUESTS_PER_PAGE));
const safePage = Math.min(page, totalPages - 1);
const pageQuests = availableQuests.slice(safePage * QUESTS_PER_PAGE, (safePage + 1) * QUESTS_PER_PAGE);
const container = new ContainerBuilder()
.setAccentColor(COLORS.AVAILABLE)
.addTextDisplayComponents(
new TextDisplayBuilder().setContent("# 🗺️ Available Quests"),
new TextDisplayBuilder().setContent("-# Quests you can accept")
new TextDisplayBuilder().setContent(
totalPages > 1
? `-# Quests you can accept — Page ${safePage + 1}/${totalPages}`
: "-# Quests you can accept"
)
);
if (availableQuests.length === 0) {
@@ -129,10 +147,7 @@ export function getAvailableQuestsComponents(availableQuests: AvailableQuest[]):
return [container];
}
// Limit to 10 quests (5 action rows max with 2 added for navigation)
const questsToShow = availableQuests.slice(0, 10);
questsToShow.forEach((quest) => {
pageQuests.forEach((quest) => {
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
const rewards = quest.rewards as { xp?: number, balance?: number };
@@ -163,11 +178,30 @@ export function getAvailableQuestsComponents(availableQuests: AvailableQuest[]):
}
/**
* Returns action rows for navigation only
* Returns action rows for navigation and pagination
*/
export function getQuestActionRows(viewType: 'active' | 'available'): ActionRowBuilder<ButtonBuilder>[] {
// Navigation row
const navRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
export function getQuestActionRows(viewType: 'active' | 'available', totalItems: number, page: number): ActionRowBuilder<ButtonBuilder>[] {
const totalPages = Math.max(1, Math.ceil(totalItems / QUESTS_PER_PAGE));
const rows: ActionRowBuilder<ButtonBuilder>[] = [];
// Pagination row (only if more than one page)
if (totalPages > 1) {
rows.push(new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId("quest_page_prev")
.setLabel("◀ Prev")
.setStyle(ButtonStyle.Secondary)
.setDisabled(page <= 0),
new ButtonBuilder()
.setCustomId("quest_page_next")
.setLabel("Next ▶")
.setStyle(ButtonStyle.Secondary)
.setDisabled(page >= totalPages - 1)
));
}
// Tab navigation row
rows.push(new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId("quest_view_active")
.setLabel("📜 Active")
@@ -178,9 +212,9 @@ export function getQuestActionRows(viewType: 'active' | 'available'): ActionRowB
.setLabel("🗺️ Available")
.setStyle(viewType === 'available' ? ButtonStyle.Primary : ButtonStyle.Secondary)
.setDisabled(viewType === 'available')
);
));
return [navRow];
return rows;
}
/**