Compare commits

...

1 Commits

Author SHA1 Message Date
8171db0985 Add inactive flag to commanders and update queries
- Add inactive BOOLEAN column to commanders with default FALSE
  (idempotent)
- Filter inactive in stats, views, and related queries
- Return and persist inactive flag in Commander data
- Add API to toggle inactive and frontend toggle control
- Show INACTIVE badge and allow deactivation in UI
- Rename Top Commanders to Top Played Commanders
- Bump frontend version to 2.1.9
2026-02-13 14:27:50 +01:00
9 changed files with 509 additions and 321 deletions

View File

@@ -41,6 +41,17 @@ CREATE TABLE IF NOT EXISTS games (
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Add inactive column for archiving commanders (idempotent)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'commanders' AND column_name = 'inactive'
) THEN
ALTER TABLE commanders ADD COLUMN inactive BOOLEAN NOT NULL DEFAULT FALSE;
END IF;
END $$;
-- Performance indexes for efficient queries
CREATE INDEX IF NOT EXISTS idx_commanders_user_id ON commanders(user_id);
CREATE INDEX IF NOT EXISTS idx_games_user_id ON games(user_id);
@@ -48,6 +59,7 @@ CREATE INDEX IF NOT EXISTS idx_games_commander_id ON games(commander_id);
CREATE INDEX IF NOT EXISTS idx_games_date ON games(date);
CREATE INDEX IF NOT EXISTS idx_games_user_commander ON games(user_id, commander_id);
CREATE INDEX IF NOT EXISTS idx_games_user_date ON games(user_id, date);
CREATE INDEX IF NOT EXISTS idx_commanders_user_inactive ON commanders(user_id, inactive);
-- Function to update updated_at timestamp
CREATE OR REPLACE FUNCTION update_timestamp()
@@ -83,18 +95,18 @@ CREATE VIEW user_stats AS
SELECT
u.id as user_id,
u.username,
(SELECT COUNT(DISTINCT id) FROM commanders WHERE user_id = u.id) as total_commanders,
(SELECT COUNT(*) FROM games WHERE user_id = u.id) as total_games,
(SELECT COUNT(*) FROM games WHERE user_id = u.id AND won = TRUE) as total_wins,
(SELECT COUNT(DISTINCT id) FROM commanders WHERE user_id = u.id AND inactive = FALSE) as total_commanders,
(SELECT COUNT(*) FROM games g JOIN commanders c ON g.commander_id = c.id WHERE g.user_id = u.id AND c.inactive = FALSE) as total_games,
(SELECT COUNT(*) FROM games g JOIN commanders c ON g.commander_id = c.id WHERE g.user_id = u.id AND g.won = TRUE AND c.inactive = FALSE) as total_wins,
ROUND(
CASE
WHEN (SELECT COUNT(*) FROM games WHERE user_id = u.id) > 0
THEN ((SELECT COUNT(*) FROM games WHERE user_id = u.id AND won = TRUE)::NUMERIC * 100.0 / (SELECT COUNT(*) FROM games WHERE user_id = u.id))
WHEN (SELECT COUNT(*) FROM games g JOIN commanders c ON g.commander_id = c.id WHERE g.user_id = u.id AND c.inactive = FALSE) > 0
THEN ((SELECT COUNT(*) FROM games g JOIN commanders c ON g.commander_id = c.id WHERE g.user_id = u.id AND g.won = TRUE AND c.inactive = FALSE)::NUMERIC * 100.0 / (SELECT COUNT(*) FROM games g JOIN commanders c ON g.commander_id = c.id WHERE g.user_id = u.id AND c.inactive = FALSE))
ELSE 0
END, 2
) as win_rate,
(SELECT AVG(rounds) FROM games WHERE user_id = u.id) as avg_rounds,
(SELECT MAX(date) FROM games WHERE user_id = u.id) as last_game_date
(SELECT AVG(g.rounds) FROM games g JOIN commanders c ON g.commander_id = c.id WHERE g.user_id = u.id AND c.inactive = FALSE) as avg_rounds,
(SELECT MAX(g.date) FROM games g JOIN commanders c ON g.commander_id = c.id WHERE g.user_id = u.id AND c.inactive = FALSE) as last_game_date
FROM users u
GROUP BY u.id, u.username;
@@ -105,6 +117,7 @@ SELECT
c.name,
c.colors,
c.user_id,
c.inactive,
(SELECT COUNT(*) FROM games WHERE commander_id = c.id) as total_games,
(SELECT COUNT(*) FROM games WHERE commander_id = c.id AND won = TRUE) as total_wins,
ROUND(
@@ -118,4 +131,5 @@ SELECT
(SELECT COUNT(*) FROM games WHERE commander_id = c.id AND starting_player_won = TRUE) as starting_player_wins,
(SELECT COUNT(*) FROM games WHERE commander_id = c.id AND sol_ring_turn_one_won = TRUE) as sol_ring_wins,
(SELECT MAX(date) FROM games WHERE commander_id = c.id) as last_played
FROM commanders c;
FROM commanders c
WHERE c.inactive = FALSE;

View File

@@ -16,7 +16,7 @@ export class CommanderRepository extends Repository {
`
INSERT INTO ${this.tableName} (name, colors, user_id)
VALUES ($1, $2, $3)
RETURNING id, name, colors, user_id, created_at, updated_at
RETURNING id, name, colors, user_id, inactive, created_at, updated_at
`,
[name, colors, userId]
)
@@ -63,6 +63,7 @@ export class CommanderRepository extends Repository {
c.name,
c.colors,
c.user_id,
c.inactive,
c.created_at,
c.updated_at,
(SELECT COUNT(*) FROM games WHERE commander_id = c.id) as total_games,
@@ -102,6 +103,7 @@ export class CommanderRepository extends Repository {
c.name,
c.colors,
c.user_id,
c.inactive,
c.created_at,
c.updated_at,
(SELECT COUNT(*) FROM games WHERE commander_id = c.id) as total_games,
@@ -133,6 +135,7 @@ export class CommanderRepository extends Repository {
c.name,
c.colors,
c.user_id,
c.inactive,
c.created_at,
c.updated_at,
(SELECT COUNT(*) FROM games WHERE commander_id = c.id) as total_games,
@@ -141,7 +144,7 @@ export class CommanderRepository extends Repository {
(SELECT ROUND(AVG(rounds)::NUMERIC, 2) FROM games WHERE commander_id = c.id) as avg_rounds,
(SELECT MAX(date) FROM games WHERE commander_id = c.id) as last_played
FROM ${this.tableName} c
WHERE c.user_id = $1 AND (SELECT COUNT(*) FROM games WHERE commander_id = c.id) >= 5
WHERE c.user_id = $1 AND c.inactive = FALSE AND (SELECT COUNT(*) FROM games WHERE commander_id = c.id) >= 5
ORDER BY win_rate DESC, c.name ASC
LIMIT $2
`
@@ -163,6 +166,7 @@ export class CommanderRepository extends Repository {
c.id,
c.name,
c.colors,
c.inactive,
(SELECT COUNT(*) FROM games WHERE commander_id = c.id) as total_games,
(SELECT COUNT(*) FROM games WHERE commander_id = c.id AND won = TRUE) as total_wins,
ROUND((SELECT COUNT(*) FROM games WHERE commander_id = c.id AND won = TRUE)::NUMERIC * 100.0 / NULLIF((SELECT COUNT(*) FROM games WHERE commander_id = c.id), 0), 2) as win_rate,
@@ -248,6 +252,24 @@ export class CommanderRepository extends Repository {
}
}
/**
* Toggle a commander's inactive status
*/
async toggleInactive(commanderId, userId, inactive) {
// Verify ownership
const existing = await this.findById(commanderId)
if (!existing || existing.user_id !== userId) {
throw new Error('Commander not found or access denied')
}
const result = await dbManager.query(
`UPDATE ${this.tableName} SET inactive = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 AND user_id = $3 RETURNING *`,
[inactive, commanderId, userId]
)
return result.rows[0]
}
/**
* Delete a commander
*/

View File

@@ -20,7 +20,9 @@ const createCommanderSchema = z.object({
colors: z
.array(
z.enum(['W', 'U', 'B', 'R', 'G'], {
errorMap: () => ({ message: 'Invalid color (must be W, U, B, R, or G)' })
errorMap: () => ({
message: 'Invalid color (must be W, U, B, R, or G)'
})
}),
{
errorMap: () => ({ message: 'Colors must be an array' })
@@ -47,7 +49,9 @@ const updateCommanderSchema = z.object({
colors: z
.array(
z.enum(['W', 'U', 'B', 'R', 'G'], {
errorMap: () => ({ message: 'Invalid color (must be W, U, B, R, or G)' })
errorMap: () => ({
message: 'Invalid color (must be W, U, B, R, or G)'
})
})
)
.min(1, 'Select at least one color')
@@ -64,15 +68,13 @@ const commanderQuerySchema = z.object({
.min(1, 'Search query cannot be empty')
.max(50, 'Search query limited to 50 characters')
.optional(),
limit: z
.coerce
limit: z.coerce
.number('Limit must be a number')
.int('Limit must be a whole number')
.min(1, 'Minimum 1 commander per page')
.max(50, 'Maximum 50 commanders per page')
.default(20),
offset: z
.coerce
offset: z.coerce
.number('Offset must be a number')
.int('Offset must be a whole number')
.min(0, 'Offset cannot be negative')
@@ -81,15 +83,15 @@ const commanderQuerySchema = z.object({
.enum(['created_at', 'updated_at', 'name', 'total_games'])
.default('created_at')
.optional(),
sortOrder: z
.enum(['ASC', 'DESC'])
.default('DESC')
.optional()
sortOrder: z.enum(['ASC', 'DESC']).default('DESC').optional()
})
const toggleInactiveSchema = z.object({
inactive: z.boolean()
})
const popularCommandersQuerySchema = z.object({
limit: z
.coerce
limit: z.coerce
.number('Limit must be a number')
.int('Limit must be a whole number')
.min(1, 'Minimum 1 commander')
@@ -104,6 +106,7 @@ function transformCommander(cmd) {
name: cmd.name,
colors: cmd.colors || [],
userId: cmd.user_id,
inactive: cmd.inactive || false,
totalGames: parseInt(cmd.total_games) || 0,
totalWins: parseInt(cmd.total_wins) || 0,
winRate: cmd.win_rate ? parseFloat(cmd.win_rate) : 0,
@@ -137,14 +140,26 @@ export default async function commanderRoutes(fastify, options) {
},
async (request, reply) => {
try {
const { q, limit, offset, sortBy, sortOrder } = commanderQuerySchema.parse(request.query)
const { q, limit, offset, sortBy, sortOrder } =
commanderQuerySchema.parse(request.query)
const userId = request.user.id
let commanders
if (q) {
commanders = await commanderRepo.searchCommandersByName(userId, q, limit, offset)
commanders = await commanderRepo.searchCommandersByName(
userId,
q,
limit,
offset
)
} else {
commanders = await commanderRepo.getCommandersByUserId(userId, limit, offset, sortBy, sortOrder)
commanders = await commanderRepo.getCommandersByUserId(
userId,
limit,
offset,
sortBy,
sortOrder
)
}
reply.send({
@@ -258,12 +273,15 @@ export default async function commanderRoutes(fastify, options) {
// LAYER 2: Business logic validation
// Check if user has reached max commander limit (100)
const commanderCount = await commanderRepo.countCommandersByUserId(userId)
const commanderCount =
await commanderRepo.countCommandersByUserId(userId)
if (commanderCount >= 100) {
return reply.code(409).send({
error: 'Conflict',
message: 'Commander limit reached',
details: ['You have reached the maximum of 100 commanders. Delete some to add more.']
details: [
'You have reached the maximum of 100 commanders. Delete some to add more.'
]
})
}
@@ -277,7 +295,9 @@ export default async function commanderRoutes(fastify, options) {
return reply.code(409).send({
error: 'Conflict',
message: 'Commander already exists',
details: [`You already have a commander named "${validatedData.name}"`]
details: [
`You already have a commander named "${validatedData.name}"`
]
})
}
@@ -354,7 +374,11 @@ export default async function commanderRoutes(fastify, options) {
updatePayload.colors = JSON.stringify(updatePayload.colors)
}
const updated = await commanderRepo.updateCommander(commanderId, userId, updatePayload)
const updated = await commanderRepo.updateCommander(
commanderId,
userId,
updatePayload
)
if (!updated) {
reply.code(400).send({
@@ -390,6 +414,74 @@ export default async function commanderRoutes(fastify, options) {
}
)
// Toggle commander inactive status
fastify.patch(
'/:id/inactive',
{
config: { rateLimit: { max: 30, timeWindow: '1 minute' } },
preHandler: [
async (request, reply) => {
try {
await request.jwtVerify()
} catch (err) {
reply.code(401).send({
error: 'Unauthorized',
message: 'Invalid or expired token'
})
}
}
]
},
async (request, reply) => {
try {
const { id } = request.params
const userId = request.user.id
const commanderId = parseInt(id)
if (isNaN(commanderId) || commanderId <= 0) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Invalid commander ID',
details: ['Commander ID must be a positive integer']
})
}
const { inactive } = toggleInactiveSchema.parse(request.body)
const updated = await commanderRepo.toggleInactive(
commanderId,
userId,
inactive
)
if (!updated) {
return reply.code(404).send({
error: 'Not Found',
message: 'Commander not found'
})
}
reply.send({
message: `Commander ${inactive ? 'deactivated' : 'reactivated'} successfully`,
commander: transformCommander(updated)
})
} catch (error) {
if (error instanceof z.ZodError) {
return reply.code(400).send({
error: 'Validation Error',
message: 'Invalid request body',
details: formatValidationErrors(error)
})
} else {
fastify.log.error('Toggle inactive error:', error)
reply.code(500).send({
error: 'Internal Server Error',
message: 'Failed to update commander status'
})
}
}
}
)
// Delete commander
fastify.delete(
'/:id',
@@ -518,7 +610,10 @@ export default async function commanderRoutes(fastify, options) {
try {
const { limit } = popularCommandersQuerySchema.parse(request.query)
const userId = request.user.id
const commanders = await commanderRepo.getPopularCommandersByUserId(userId, limit)
const commanders = await commanderRepo.getPopularCommandersByUserId(
userId,
limit
)
reply.send({
commanders: commanders.map(transformCommander),

View File

@@ -118,13 +118,14 @@ export default async function statsRoutes(fastify, options) {
const playerCountStats = await dbManager.all(
`
SELECT
player_count,
g.player_count,
COUNT(*) as total,
SUM(CASE WHEN won = TRUE THEN 1 ELSE 0 END) as wins
FROM games
WHERE user_id = $1
GROUP BY player_count
ORDER BY player_count
SUM(CASE WHEN g.won = TRUE THEN 1 ELSE 0 END) as wins
FROM games g
JOIN commanders c ON g.commander_id = c.id
WHERE g.user_id = $1 AND c.inactive = FALSE
GROUP BY g.player_count
ORDER BY g.player_count
`,
[userId]
)
@@ -139,7 +140,7 @@ export default async function statsRoutes(fastify, options) {
SUM(CASE WHEN g.won = TRUE THEN 1 ELSE 0 END) as wins
FROM games g
JOIN commanders c ON g.commander_id = c.id
WHERE g.user_id = $1
WHERE g.user_id = $1 AND c.inactive = FALSE
GROUP BY c.colors
`,
[userId]

View File

@@ -230,14 +230,17 @@
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
>
<template x-for="commander in commanders" :key="commander.id">
<div class="card hover:shadow-lg transition-shadow">
<div class="card hover:shadow-lg transition-shadow" :class="{ 'opacity-60': commander.inactive }">
<!-- Commander Header -->
<div class="flex justify-between items-start mb-4">
<div>
<div class="flex items-center gap-2">
<h3
class="text-lg font-semibold text-gray-900"
x-text="commander.name"
></h3>
<span x-show="commander.inactive" class="px-2 py-0.5 rounded text-xs font-bold bg-gray-200 text-gray-600">INACTIVE</span>
</div>
<div class="flex space-x-1 mt-1">
<template x-for="color in commander.colors" :key="color">
<div
@@ -477,6 +480,21 @@
</div>
</div>
<!-- Inactive Toggle -->
<div class="mb-6 flex items-center justify-between p-3 rounded-lg" :class="editingCommander.inactive ? 'bg-gray-100' : 'bg-gray-50'">
<div>
<p class="text-sm font-medium text-gray-700">Commander Status</p>
<p class="text-xs text-gray-500" x-text="editingCommander.inactive ? 'This commander is excluded from stats and dropdowns.' : 'This commander is active and included in stats.'"></p>
</div>
<button
type="button"
@click="toggleInactive(editingCommander)"
class="px-3 py-1.5 rounded text-sm font-medium transition-colors"
:class="editingCommander.inactive ? 'bg-green-100 text-green-700 hover:bg-green-200' : 'bg-gray-200 text-gray-600 hover:bg-gray-300'"
x-text="editingCommander.inactive ? 'Reactivate' : 'Deactivate'"
></button>
</div>
<!-- Form Actions -->
<div class="flex justify-end space-x-4">
<button

View File

@@ -256,7 +256,7 @@
<!-- Top Commanders -->
<div class="card">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-semibold">Top Commanders</h2>
<h2 class="text-xl font-semibold">Top Played Commanders</h2>
<a
href="/commanders.html"
class="text-edh-accent hover:text-edh-primary"

View File

@@ -36,9 +36,9 @@
<div class="mb-8 flex justify-between items-center">
<h2 class="text-2xl font-semibold text-gray-900">Recent Games</h2>
<div class="flex space-x-4">
<button @click="exportGames()" class="btn btn-secondary">
<!--<button @click="exportGames()" class="btn btn-secondary">
Export JSON
</button>
</button>-->
<button @click="showLogForm = !showLogForm" class="btn btn-primary">
<span x-show="!showLogForm">Log New Game</span>
<span x-show="showLogForm">Cancel</span>
@@ -79,10 +79,10 @@
required
>
<option value="">Select a commander...</option>
<template x-for="commander in commanders" :key="commander.id">
<template x-for="commander in commanders.filter(c => !c.inactive || (editingGame && editingGame.commanderId == c.id))" :key="commander.id">
<option
:value="commander.id"
x-text="commander.name"
x-text="commander.name + (commander.inactive ? ' (inactive)' : '')"
></option>
</template>
</select>
@@ -379,7 +379,9 @@
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<span x-text="pagination.isLoadingMore ? 'Loading...' : 'Load More Games'"></span>
<span
x-text="pagination.isLoadingMore ? 'Loading...' : 'Load More Games'"
></span>
</button>
</div>
</div>
@@ -397,7 +399,8 @@
<div class="p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-2">Delete Game</h3>
<p class="text-gray-600 mb-6">
Are you sure you want to delete this game record? This action cannot be undone.
Are you sure you want to delete this game record? This action cannot
be undone.
</p>
<div class="flex justify-end space-x-3">
<button
@@ -419,7 +422,8 @@
</div>
</div>
<!-- Scripts --> <script
<!-- Scripts -->
<script
defer
src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"
></script>

View File

@@ -230,6 +230,40 @@ function commanderManager() {
}
},
async toggleInactive(commander) {
const newInactive = !commander.inactive
commander.inactive = newInactive
try {
const token =
localStorage.getItem('edh-stats-token') ||
sessionStorage.getItem('edh-stats-token')
const response = await fetch(`/api/commanders/${commander.id}/inactive`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ inactive: newInactive })
})
if (response.ok) {
const data = await response.json()
const index = this.commanders.findIndex((c) => c.id === commander.id)
if (index !== -1) {
this.commanders.splice(index, 1, data.commander)
}
} else {
commander.inactive = !newInactive
this.serverError = 'Failed to update commander status'
}
} catch (error) {
commander.inactive = !newInactive
console.error('Toggle inactive error:', error)
this.serverError = 'Network error occurred'
}
},
async deleteCommander(commander) {
if (!confirm(`Are you sure you want to delete "${commander.name}"?`))
return

View File

@@ -1 +1 @@
2.1.8
2.1.9