From b466e585a1ba27c89e04b5d3e5c705db845e6b78 Mon Sep 17 00:00:00 2001 From: Hymmel Date: Wed, 15 Oct 2025 13:33:56 +0200 Subject: [PATCH] Army category optional. add Trophy Reset --- backend/prisma/schema.prisma | 14 ++++- backend/src/server.js | 103 ++++++++++++++++++++++++++--------- frontend/app/page.tsx | 85 ++++++++++++++++++++++++++--- 3 files changed, 166 insertions(+), 36 deletions(-) diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 9376b7e..4195f09 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -39,6 +39,16 @@ model Base { user User @relation(fields: [userId], references: [id]) userId String 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 { @@ -49,6 +59,6 @@ model Defense { createdAt DateTime @default(now()) base Base @relation(fields: [baseId], references: [id]) baseId String - armyCategory ArmyCategory @relation(fields: [armyCategoryId], references: [id]) - armyCategoryId String + armyCategory ArmyCategory? @relation(fields: [armyCategoryId], references: [id]) + armyCategoryId String? } diff --git a/backend/src/server.js b/backend/src/server.js index ea2746a..e2e7240 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -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) => { try { const { baseId } = req.params; @@ -489,9 +524,6 @@ app.post('/bases/:baseId/defenses', requireAuth, async (req, res) => { const parsedPercent = Number(percent); 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) { 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' }); } - const [base, category] = await Promise.all([ - prisma.base.findFirst({ where: { id: baseId, userId: req.user.id } }), - prisma.armyCategory.findFirst({ where: { id: armyCategoryId, userId: req.user.id } }), - ]); - + const base = await prisma.base.findFirst({ where: { id: baseId, userId: req.user.id } }); if (!base) { return res.status(404).json({ error: 'Base not found' }); } - if (!category) { - return res.status(404).json({ error: 'Army category not found' }); + + let category = null; + if (armyCategoryId) { + category = await prisma.armyCategory.findFirst({ + where: { id: armyCategoryId, userId: req.user.id }, + }); + if (!category) { + return res.status(404).json({ error: 'Army category not found' }); + } } await prisma.defense.create({ data: { baseId: base.id, - armyCategoryId: category.id, + armyCategoryId: category ? category.id : null, stars: parsedStars, percent: parsedPercent, trophies: parsedTrophies, @@ -539,9 +574,6 @@ app.put('/defenses/:defenseId', requireAuth, async (req, res) => { if (!baseId) { 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 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' }); } - const [defense, base, category] = await Promise.all([ - prisma.defense.findFirst({ - 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 } }), - ]); + const defense = await prisma.defense.findFirst({ + where: { id: defenseId, base: { userId: req.user.id } }, + }); if (!defense) { return res.status(404).json({ error: 'Defense not found' }); } + + const base = await prisma.base.findFirst({ where: { id: baseId, userId: req.user.id } }); if (!base) { return res.status(404).json({ error: 'Base not found' }); } - if (!category) { - return res.status(404).json({ error: 'Army category not found' }); + + let category = null; + if (armyCategoryId) { + category = await prisma.armyCategory.findFirst({ + where: { id: armyCategoryId, userId: req.user.id }, + }); + if (!category) { + return res.status(404).json({ error: 'Army category not found' }); + } } await prisma.defense.update({ where: { id: defense.id }, data: { baseId: base.id, - armyCategoryId: category.id, + armyCategoryId: armyCategoryId ? category.id : null, stars: parsedStars, percent: parsedPercent, trophies: parsedTrophies, @@ -628,10 +665,16 @@ app.get('/defenses', requireAuth, async (req, res) => { const categoryLookup = new Map( user.armyCategories.map((category) => [category.id, category.name]) ); + categoryLookup.set(null, '(No category)'); const defenses = []; const baseBuckets = new Map(); const categoryBuckets = new Map(); + categoryBuckets.set(null, { + name: '(No category)', + items: [], + bases: new Map(), + }); user.bases.forEach((base) => { 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, createdAt: defense.createdAt, 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 { id: base.id, @@ -836,6 +888,7 @@ app.get('/profiles/:username', requireAuth, async (req, res) => { createdAt: base.createdAt, summary: summarizeDefenses(defenses), defenses, + trophyResets, }; }); diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 80ed021..16eea77 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -35,6 +35,7 @@ const API = { deleteCategory: (categoryId: string) => `${API_BASE}/army-categories/${categoryId}`, updateBase: (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}`, deleteDefense: (defenseId: string) => `${API_BASE}/defenses/${defenseId}`, profiles: `${API_BASE}/profiles`, @@ -104,6 +105,13 @@ type ProfileDefense = { armyCategoryName: string; }; +type TrophyReset = { + id: string; + trophiesAtStart: number; + trophiesLost: number; + createdAt: string; +}; + type ProfileBase = { id: string; title: string; @@ -114,6 +122,7 @@ type ProfileBase = { createdAt: string; summary: Summary; defenses: ProfileDefense[]; + trophyResets: TrophyReset[]; }; type ProfileCategorySummary = Summary & { @@ -165,6 +174,7 @@ type ErrorState = { category: string; base: string; defense: string; + trophyReset: string; }; async function request(method: string, url: string, body?: any, options?: { isForm?: boolean }) { @@ -197,6 +207,7 @@ const initialErrors: ErrorState = { category: '', base: '', defense: '', + trophyReset: '', }; export default function Page() { @@ -300,7 +311,7 @@ export default function Page() { profileSelectedBase.defenses.forEach((defense) => { const key = defense.armyCategoryId || defense.armyCategoryName; 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); }); @@ -615,6 +626,24 @@ export default function Page() { } } + async function handleTrophyResetSubmit(event: FormEvent) { + 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() { try { await request('POST', API.logout); @@ -985,7 +1014,7 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary { {defenses.length ? ( defenses.map((defense) => { const date = new Date(defense.createdAt); - const categoryName = categoryNameMap.get(defense.armyCategoryId) || 'Unknown Army'; + const categoryName = categoryNameMap.get(defense.armyCategoryId) || '(No category)'; return (
  • @@ -1263,9 +1292,9 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary {
  • @@ -1370,7 +1399,8 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary {
  • @@ -1626,6 +1656,43 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary { )}
    +
    +

    Legend League Day Reset

    +
    + + + +

    {errors.trophyReset}

    +
    +
    +

    Recent Resets

    +
      + {profileSelectedBase.trophyResets.length ? ( + profileSelectedBase.trophyResets.map((reset) => ( +
    • +
      + {new Date(reset.createdAt).toLocaleDateString()} +
      + {reset.trophiesAtStart} trophies at start + {formatTrophies(reset.trophiesLost)} lost +
      +
      +
    • + )) + ) : ( +
    • No resets logged yet.
    • + )} +
    +
    +

    Defenses

      @@ -1633,7 +1700,7 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary { profileSelectedBase.defenses.map((defense) => (
    • - {defense.armyCategoryName || 'Unknown Army'} + {defense.armyCategoryName || '(No category)'}
      {defense.stars}★ • {defense.percent}% • {formatTrophies(defense.trophies)}