Army category optional. add Trophy Reset

This commit is contained in:
Hymmel 2025-10-15 13:33:56 +02:00
parent df76a1f0f2
commit b466e585a1
3 changed files with 166 additions and 36 deletions

View file

@ -39,6 +39,16 @@ model Base {
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
userId String userId String
defenses Defense[] defenses Defense[]
trophyResets TrophyReset[]
}
model TrophyReset {
id String @id @default(cuid())
trophiesAtStart Int
trophiesLost Int
createdAt DateTime @default(now())
base Base @relation(fields: [baseId], references: [id])
baseId String
} }
model Defense { model Defense {
@ -49,6 +59,6 @@ model Defense {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
base Base @relation(fields: [baseId], references: [id]) base Base @relation(fields: [baseId], references: [id])
baseId String baseId String
armyCategory ArmyCategory @relation(fields: [armyCategoryId], references: [id]) armyCategory ArmyCategory? @relation(fields: [armyCategoryId], references: [id])
armyCategoryId String armyCategoryId String?
} }

View file

@ -480,6 +480,41 @@ app.delete('/bases/:baseId', requireAuth, async (req, res) => {
} }
}); });
app.post('/bases/:baseId/trophy-resets', requireAuth, async (req, res) => {
try {
const { baseId } = req.params;
const { trophiesAtStart, trophiesLost } = req.body || {};
const parsedTrophiesAtStart = Number(trophiesAtStart);
const parsedTrophiesLost = Number(trophiesLost);
if (!Number.isFinite(parsedTrophiesAtStart) || parsedTrophiesAtStart < 0) {
return res.status(400).json({ error: 'Trophies at start must be a positive number' });
}
if (!Number.isFinite(parsedTrophiesLost)) {
return res.status(400).json({ error: 'Trophies lost must be a number' });
}
const base = await prisma.base.findFirst({ where: { id: baseId, userId: req.user.id } });
if (!base) {
return res.status(404).json({ error: 'Base not found' });
}
await prisma.trophyReset.create({
data: {
baseId: base.id,
trophiesAtStart: parsedTrophiesAtStart,
trophiesLost: parsedTrophiesLost,
},
});
return res.status(201).json({ message: 'Trophy reset logged' });
} catch (error) {
console.error(error);
return res.status(500).json({ error: 'Internal server error' });
}
});
app.post('/bases/:baseId/defenses', requireAuth, async (req, res) => { app.post('/bases/:baseId/defenses', requireAuth, async (req, res) => {
try { try {
const { baseId } = req.params; const { baseId } = req.params;
@ -489,9 +524,6 @@ app.post('/bases/:baseId/defenses', requireAuth, async (req, res) => {
const parsedPercent = Number(percent); const parsedPercent = Number(percent);
const parsedTrophies = Number(trophies ?? 0); const parsedTrophies = Number(trophies ?? 0);
if (!armyCategoryId) {
return res.status(400).json({ error: 'Army category is required' });
}
if (!Number.isFinite(parsedStars) || parsedStars < 0 || parsedStars > 3) { if (!Number.isFinite(parsedStars) || parsedStars < 0 || parsedStars > 3) {
return res.status(400).json({ error: 'Stars must be between 0 and 3' }); return res.status(400).json({ error: 'Stars must be between 0 and 3' });
} }
@ -502,22 +534,25 @@ app.post('/bases/:baseId/defenses', requireAuth, async (req, res) => {
return res.status(400).json({ error: 'Trophies must be between -200 and 200' }); return res.status(400).json({ error: 'Trophies must be between -200 and 200' });
} }
const [base, category] = await Promise.all([ const base = await prisma.base.findFirst({ where: { id: baseId, userId: req.user.id } });
prisma.base.findFirst({ where: { id: baseId, userId: req.user.id } }),
prisma.armyCategory.findFirst({ where: { id: armyCategoryId, userId: req.user.id } }),
]);
if (!base) { if (!base) {
return res.status(404).json({ error: 'Base not found' }); return res.status(404).json({ error: 'Base not found' });
} }
let category = null;
if (armyCategoryId) {
category = await prisma.armyCategory.findFirst({
where: { id: armyCategoryId, userId: req.user.id },
});
if (!category) { if (!category) {
return res.status(404).json({ error: 'Army category not found' }); return res.status(404).json({ error: 'Army category not found' });
} }
}
await prisma.defense.create({ await prisma.defense.create({
data: { data: {
baseId: base.id, baseId: base.id,
armyCategoryId: category.id, armyCategoryId: category ? category.id : null,
stars: parsedStars, stars: parsedStars,
percent: parsedPercent, percent: parsedPercent,
trophies: parsedTrophies, trophies: parsedTrophies,
@ -539,9 +574,6 @@ app.put('/defenses/:defenseId', requireAuth, async (req, res) => {
if (!baseId) { if (!baseId) {
return res.status(400).json({ error: 'Base is required' }); return res.status(400).json({ error: 'Base is required' });
} }
if (!armyCategoryId) {
return res.status(400).json({ error: 'Army category is required' });
}
const parsedStars = Number(stars); const parsedStars = Number(stars);
const parsedPercent = Number(percent); const parsedPercent = Number(percent);
@ -557,29 +589,34 @@ app.put('/defenses/:defenseId', requireAuth, async (req, res) => {
return res.status(400).json({ error: 'Trophies must be between -200 and 200' }); return res.status(400).json({ error: 'Trophies must be between -200 and 200' });
} }
const [defense, base, category] = await Promise.all([ const defense = await prisma.defense.findFirst({
prisma.defense.findFirst({
where: { id: defenseId, base: { userId: req.user.id } }, where: { id: defenseId, base: { userId: req.user.id } },
}), });
prisma.base.findFirst({ where: { id: baseId, userId: req.user.id } }),
prisma.armyCategory.findFirst({ where: { id: armyCategoryId, userId: req.user.id } }),
]);
if (!defense) { if (!defense) {
return res.status(404).json({ error: 'Defense not found' }); return res.status(404).json({ error: 'Defense not found' });
} }
const base = await prisma.base.findFirst({ where: { id: baseId, userId: req.user.id } });
if (!base) { if (!base) {
return res.status(404).json({ error: 'Base not found' }); return res.status(404).json({ error: 'Base not found' });
} }
let category = null;
if (armyCategoryId) {
category = await prisma.armyCategory.findFirst({
where: { id: armyCategoryId, userId: req.user.id },
});
if (!category) { if (!category) {
return res.status(404).json({ error: 'Army category not found' }); return res.status(404).json({ error: 'Army category not found' });
} }
}
await prisma.defense.update({ await prisma.defense.update({
where: { id: defense.id }, where: { id: defense.id },
data: { data: {
baseId: base.id, baseId: base.id,
armyCategoryId: category.id, armyCategoryId: armyCategoryId ? category.id : null,
stars: parsedStars, stars: parsedStars,
percent: parsedPercent, percent: parsedPercent,
trophies: parsedTrophies, trophies: parsedTrophies,
@ -628,10 +665,16 @@ app.get('/defenses', requireAuth, async (req, res) => {
const categoryLookup = new Map( const categoryLookup = new Map(
user.armyCategories.map((category) => [category.id, category.name]) user.armyCategories.map((category) => [category.id, category.name])
); );
categoryLookup.set(null, '(No category)');
const defenses = []; const defenses = [];
const baseBuckets = new Map(); const baseBuckets = new Map();
const categoryBuckets = new Map(); const categoryBuckets = new Map();
categoryBuckets.set(null, {
name: '(No category)',
items: [],
bases: new Map(),
});
user.bases.forEach((base) => { user.bases.forEach((base) => {
baseBuckets.set(base.id, { baseBuckets.set(base.id, {
@ -813,6 +856,9 @@ app.get('/profiles/:username', requireAuth, async (req, res) => {
}, },
}, },
}, },
trophyResets: {
orderBy: { createdAt: 'desc' },
},
}, },
}); });
@ -824,7 +870,13 @@ app.get('/profiles/:username', requireAuth, async (req, res) => {
trophies: defense.trophies, trophies: defense.trophies,
createdAt: defense.createdAt, createdAt: defense.createdAt,
armyCategoryId: defense.armyCategoryId, armyCategoryId: defense.armyCategoryId,
armyCategoryName: defense.armyCategory?.name || 'Unknown Army', armyCategoryName: defense.armyCategory?.name || '(No category)',
}));
const trophyResets = base.trophyResets.map((reset) => ({
id: reset.id,
trophiesAtStart: reset.trophiesAtStart,
trophiesLost: reset.trophiesLost,
createdAt: reset.createdAt,
})); }));
return { return {
id: base.id, id: base.id,
@ -836,6 +888,7 @@ app.get('/profiles/:username', requireAuth, async (req, res) => {
createdAt: base.createdAt, createdAt: base.createdAt,
summary: summarizeDefenses(defenses), summary: summarizeDefenses(defenses),
defenses, defenses,
trophyResets,
}; };
}); });

View file

@ -35,6 +35,7 @@ const API = {
deleteCategory: (categoryId: string) => `${API_BASE}/army-categories/${categoryId}`, deleteCategory: (categoryId: string) => `${API_BASE}/army-categories/${categoryId}`,
updateBase: (baseId: string) => `${API_BASE}/bases/${baseId}`, updateBase: (baseId: string) => `${API_BASE}/bases/${baseId}`,
deleteBase: (baseId: string) => `${API_BASE}/bases/${baseId}`, deleteBase: (baseId: string) => `${API_BASE}/bases/${baseId}`,
addTrophyReset: (baseId: string) => `${API_BASE}/bases/${baseId}/trophy-resets`,
updateDefense: (defenseId: string) => `${API_BASE}/defenses/${defenseId}`, updateDefense: (defenseId: string) => `${API_BASE}/defenses/${defenseId}`,
deleteDefense: (defenseId: string) => `${API_BASE}/defenses/${defenseId}`, deleteDefense: (defenseId: string) => `${API_BASE}/defenses/${defenseId}`,
profiles: `${API_BASE}/profiles`, profiles: `${API_BASE}/profiles`,
@ -104,6 +105,13 @@ type ProfileDefense = {
armyCategoryName: string; armyCategoryName: string;
}; };
type TrophyReset = {
id: string;
trophiesAtStart: number;
trophiesLost: number;
createdAt: string;
};
type ProfileBase = { type ProfileBase = {
id: string; id: string;
title: string; title: string;
@ -114,6 +122,7 @@ type ProfileBase = {
createdAt: string; createdAt: string;
summary: Summary; summary: Summary;
defenses: ProfileDefense[]; defenses: ProfileDefense[];
trophyResets: TrophyReset[];
}; };
type ProfileCategorySummary = Summary & { type ProfileCategorySummary = Summary & {
@ -165,6 +174,7 @@ type ErrorState = {
category: string; category: string;
base: string; base: string;
defense: string; defense: string;
trophyReset: string;
}; };
async function request(method: string, url: string, body?: any, options?: { isForm?: boolean }) { async function request(method: string, url: string, body?: any, options?: { isForm?: boolean }) {
@ -197,6 +207,7 @@ const initialErrors: ErrorState = {
category: '', category: '',
base: '', base: '',
defense: '', defense: '',
trophyReset: '',
}; };
export default function Page() { export default function Page() {
@ -300,7 +311,7 @@ export default function Page() {
profileSelectedBase.defenses.forEach((defense) => { profileSelectedBase.defenses.forEach((defense) => {
const key = defense.armyCategoryId || defense.armyCategoryName; const key = defense.armyCategoryId || defense.armyCategoryName;
if (!buckets.has(key)) { if (!buckets.has(key)) {
buckets.set(key, { name: defense.armyCategoryName || 'Unknown Army', items: [] }); buckets.set(key, { name: defense.armyCategoryName || '(No category)', items: [] });
} }
buckets.get(key)!.items.push(defense); buckets.get(key)!.items.push(defense);
}); });
@ -615,6 +626,24 @@ export default function Page() {
} }
} }
async function handleTrophyResetSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!profileSelectedBase) {
return;
}
const form = event.currentTarget;
const formData = new FormData(form);
const payload = Object.fromEntries(formData.entries());
try {
setErrors((prev) => ({ ...prev, trophyReset: '' }));
await request('POST', API.addTrophyReset(profileSelectedBase.id), payload);
form.reset();
await refreshOwnProfileDetail();
} catch (error: any) {
setErrors((prev) => ({ ...prev, trophyReset: error.message }));
}
}
async function handleLogout() { async function handleLogout() {
try { try {
await request('POST', API.logout); await request('POST', API.logout);
@ -985,7 +1014,7 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary {
{defenses.length ? ( {defenses.length ? (
defenses.map((defense) => { defenses.map((defense) => {
const date = new Date(defense.createdAt); const date = new Date(defense.createdAt);
const categoryName = categoryNameMap.get(defense.armyCategoryId) || 'Unknown Army'; const categoryName = categoryNameMap.get(defense.armyCategoryId) || '(No category)';
return ( return (
<li key={defense.id} className="list-item"> <li key={defense.id} className="list-item">
<div className="defense-header"> <div className="defense-header">
@ -1263,9 +1292,9 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary {
</label> </label>
<label> <label>
Army Category Army Category
<select name="armyCategoryId" required defaultValue=""> <select name="armyCategoryId" defaultValue="">
<option value="" disabled> <option value="">
{categories.length ? 'Select an army category' : 'Add an army category first'} {categories.length ? '(No category)' : 'Add an army category first'}
</option> </option>
{categories.map((category) => ( {categories.map((category) => (
<option key={category.id} value={category.id}> <option key={category.id} value={category.id}>
@ -1308,7 +1337,7 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary {
{defenses.length ? ( {defenses.length ? (
defenses.slice(0, 10).map((defense) => { defenses.slice(0, 10).map((defense) => {
const categoryName = const categoryName =
categoryNameMap.get(defense.armyCategoryId) || defense.categoryName || 'Unknown Army'; categoryNameMap.get(defense.armyCategoryId) || defense.categoryName || '(No category)';
return ( return (
<li key={defense.id} className="list-item"> <li key={defense.id} className="list-item">
<div className="defense-header"> <div className="defense-header">
@ -1370,7 +1399,8 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary {
</label> </label>
<label> <label>
Army Category Army Category
<select name="armyCategoryId" required defaultValue={defenseBeingEdited.armyCategoryId}> <select name="armyCategoryId" defaultValue={defenseBeingEdited.armyCategoryId || ''}>
<option value="">(No category)</option>
{categories.map((category) => ( {categories.map((category) => (
<option key={category.id} value={category.id}> <option key={category.id} value={category.id}>
{category.name} {category.name}
@ -1520,7 +1550,7 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary {
.filter((defense) => defense.baseId === selectedBaseId) .filter((defense) => defense.baseId === selectedBaseId)
.map((defense) => { .map((defense) => {
const date = new Date(defense.createdAt); const date = new Date(defense.createdAt);
const categoryName = categoryNameMap.get(defense.armyCategoryId) || 'Unknown Army'; const categoryName = categoryNameMap.get(defense.armyCategoryId) || '(No category)';
return ( return (
<li key={defense.id} className="list-item"> <li key={defense.id} className="list-item">
<div className="defense-header"> <div className="defense-header">
@ -1626,6 +1656,43 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary {
)} )}
</ul> </ul>
</div> </div>
<div className="card">
<h3>Legend League Day Reset</h3>
<form className="form compact" onSubmit={handleTrophyResetSubmit}>
<label>
Trophies at Start
<input type="number" name="trophiesAtStart" min={0} step={1} required className="styled-number" />
</label>
<label>
Trophies Lost
<input type="number" name="trophiesLost" step={1} required className="styled-number" />
</label>
<button type="submit" className="primary">
Add Reset
</button>
<p className="form-error">{errors.trophyReset}</p>
</form>
<div className="subsection">
<h4>Recent Resets</h4>
<ul className="list compact">
{profileSelectedBase.trophyResets.length ? (
profileSelectedBase.trophyResets.map((reset) => (
<li key={reset.id} className="list-item">
<div className="defense-header">
<span>{new Date(reset.createdAt).toLocaleDateString()}</span>
<div className="defense-meta">
<span>{reset.trophiesAtStart} trophies at start</span>
<span>{formatTrophies(reset.trophiesLost)} lost</span>
</div>
</div>
</li>
))
) : (
<li>No resets logged yet.</li>
)}
</ul>
</div>
</div>
<div className="card"> <div className="card">
<h3>Defenses</h3> <h3>Defenses</h3>
<ul className="list"> <ul className="list">
@ -1633,7 +1700,7 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary {
profileSelectedBase.defenses.map((defense) => ( profileSelectedBase.defenses.map((defense) => (
<li key={defense.id} className="list-item"> <li key={defense.id} className="list-item">
<div className="defense-header"> <div className="defense-header">
<strong>{defense.armyCategoryName || 'Unknown Army'}</strong> <strong>{defense.armyCategoryName || '(No category)'}</strong>
<div> <div>
<strong>{defense.stars}</strong> {defense.percent}% {formatTrophies(defense.trophies)} <strong>{defense.stars}</strong> {defense.percent}% {formatTrophies(defense.trophies)}
</div> </div>