dev #1

Merged
Hymmel merged 10 commits from dev into main 2025-10-15 14:43:35 +02:00
3 changed files with 166 additions and 36 deletions
Showing only changes of commit b466e585a1 - Show all commits

View file

@ -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?
}

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) => {
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' });
}
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({
const defense = await 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 } }),
]);
});
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' });
}
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,
};
});

View file

@ -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<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() {
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 (
<li key={defense.id} className="list-item">
<div className="defense-header">
@ -1263,9 +1292,9 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary {
</label>
<label>
Army Category
<select name="armyCategoryId" required defaultValue="">
<option value="" disabled>
{categories.length ? 'Select an army category' : 'Add an army category first'}
<select name="armyCategoryId" defaultValue="">
<option value="">
{categories.length ? '(No category)' : 'Add an army category first'}
</option>
{categories.map((category) => (
<option key={category.id} value={category.id}>
@ -1308,7 +1337,7 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary {
{defenses.length ? (
defenses.slice(0, 10).map((defense) => {
const categoryName =
categoryNameMap.get(defense.armyCategoryId) || defense.categoryName || 'Unknown Army';
categoryNameMap.get(defense.armyCategoryId) || defense.categoryName || '(No category)';
return (
<li key={defense.id} className="list-item">
<div className="defense-header">
@ -1370,7 +1399,8 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary {
</label>
<label>
Army Category
<select name="armyCategoryId" required defaultValue={defenseBeingEdited.armyCategoryId}>
<select name="armyCategoryId" defaultValue={defenseBeingEdited.armyCategoryId || ''}>
<option value="">(No category)</option>
{categories.map((category) => (
<option key={category.id} value={category.id}>
{category.name}
@ -1520,7 +1550,7 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary {
.filter((defense) => defense.baseId === selectedBaseId)
.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 (
<li key={defense.id} className="list-item">
<div className="defense-header">
@ -1626,6 +1656,43 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary {
)}
</ul>
</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">
<h3>Defenses</h3>
<ul className="list">
@ -1633,7 +1700,7 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary {
profileSelectedBase.defenses.map((defense) => (
<li key={defense.id} className="list-item">
<div className="defense-header">
<strong>{defense.armyCategoryName || 'Unknown Army'}</strong>
<strong>{defense.armyCategoryName || '(No category)'}</strong>
<div>
<strong>{defense.stars}</strong> {defense.percent}% {formatTrophies(defense.trophies)}
</div>