dev #1

Merged
Hymmel merged 10 commits from dev into main 2025-10-15 14:43:35 +02:00
6 changed files with 584 additions and 131 deletions

View file

@ -39,6 +39,18 @@ 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())
date DateTime
trophiesAtStart Int
trophiesLost Int
numberOfDefenses Int
createdAt DateTime @default(now())
base Base @relation(fields: [baseId], references: [id])
baseId String
} }
model Defense { model Defense {
@ -49,6 +61,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

@ -271,6 +271,11 @@ app.get('/bases', requireAuth, async (req, res) => {
const bases = await prisma.base.findMany({ const bases = await prisma.base.findMany({
where: { userId: req.user.id }, where: { userId: req.user.id },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
include: {
trophyResets: {
orderBy: { date: 'desc' },
},
},
}); });
res.json({ res.json({
bases: bases.map((base) => ({ bases: bases.map((base) => ({
@ -281,6 +286,14 @@ app.get('/bases', requireAuth, async (req, res) => {
imageUrl: buildImageUrl(base), imageUrl: buildImageUrl(base),
isPrivate: base.isPrivate, isPrivate: base.isPrivate,
createdAt: base.createdAt, createdAt: base.createdAt,
trophyResets: base.trophyResets.map((reset) => ({
id: reset.id,
date: reset.date,
trophiesAtStart: reset.trophiesAtStart,
trophiesLost: reset.trophiesLost,
numberOfDefenses: reset.numberOfDefenses,
createdAt: reset.createdAt,
})),
})), })),
}); });
}); });
@ -480,6 +493,51 @@ app.delete('/bases/:baseId', requireAuth, async (req, res) => {
} }
}); });
app.post('/bases/:baseId/trophy-resets', requireAuth, async (req, res) => {
try {
const { baseId } = req.params;
const { date, trophiesAtStart, trophiesLost, numberOfDefenses } = req.body || {};
const parsedDate = new Date(date);
const parsedTrophiesAtStart = Number(trophiesAtStart);
const parsedTrophiesLost = Number(trophiesLost);
const parsedNumberOfDefenses = Number(numberOfDefenses);
if (isNaN(parsedDate.getTime())) {
return res.status(400).json({ error: 'Invalid date' });
}
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' });
}
if (!Number.isFinite(parsedNumberOfDefenses) || parsedNumberOfDefenses < 0) {
return res.status(400).json({ error: 'Number of defenses must be a positive 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,
date: parsedDate,
trophiesAtStart: parsedTrophiesAtStart,
trophiesLost: parsedTrophiesLost,
numberOfDefenses: parsedNumberOfDefenses,
},
});
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 +547,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 +557,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' });
} }
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({ 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,
@ -531,6 +589,81 @@ app.post('/bases/:baseId/defenses', requireAuth, async (req, res) => {
} }
}); });
app.put('/trophy-resets/:resetId', requireAuth, async (req, res) => {
try {
const { resetId } = req.params;
const { date, trophiesAtStart, trophiesLost, numberOfDefenses, baseId } = req.body || {};
const parsedDate = new Date(date);
const parsedTrophiesAtStart = Number(trophiesAtStart);
const parsedTrophiesLost = Number(trophiesLost);
const parsedNumberOfDefenses = Number(numberOfDefenses);
if (isNaN(parsedDate.getTime())) {
return res.status(400).json({ error: 'Invalid date' });
}
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' });
}
if (!Number.isFinite(parsedNumberOfDefenses) || parsedNumberOfDefenses < 0) {
return res.status(400).json({ error: 'Number of defenses must be a positive number' });
}
if (!baseId) {
return res.status(400).json({ error: 'Base is required' });
}
const reset = await prisma.trophyReset.findFirst({
where: { id: resetId, base: { userId: req.user.id } },
});
if (!reset) {
return res.status(404).json({ error: 'Trophy reset 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' });
}
await prisma.trophyReset.update({
where: { id: reset.id },
data: {
date: parsedDate,
trophiesAtStart: parsedTrophiesAtStart,
trophiesLost: parsedTrophiesLost,
numberOfDefenses: parsedNumberOfDefenses,
baseId: base.id,
},
});
return res.json({ message: 'Trophy reset updated' });
} catch (error) {
console.error(error);
return res.status(500).json({ error: 'Internal server error' });
}
});
app.delete('/trophy-resets/:resetId', requireAuth, async (req, res) => {
try {
const { resetId } = req.params;
const result = await prisma.trophyReset.deleteMany({
where: { id: resetId, base: { userId: req.user.id } },
});
if (!result.count) {
return res.status(404).json({ error: 'Trophy reset not found' });
}
return res.json({ message: 'Trophy reset deleted' });
} catch (error) {
console.error(error);
return res.status(500).json({ error: 'Internal server error' });
}
});
app.put('/defenses/:defenseId', requireAuth, async (req, res) => { app.put('/defenses/:defenseId', requireAuth, async (req, res) => {
try { try {
const { defenseId } = req.params; const { defenseId } = req.params;
@ -539,9 +672,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 +687,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' });
} }
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({ 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 +763,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 +954,9 @@ app.get('/profiles/:username', requireAuth, async (req, res) => {
}, },
}, },
}, },
trophyResets: {
orderBy: { createdAt: 'desc' },
},
}, },
}); });
@ -824,7 +968,15 @@ 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,
date: reset.date,
trophiesAtStart: reset.trophiesAtStart,
trophiesLost: reset.trophiesLost,
numberOfDefenses: reset.numberOfDefenses,
createdAt: reset.createdAt,
})); }));
return { return {
id: base.id, id: base.id,
@ -836,6 +988,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

@ -5,14 +5,10 @@ services:
environment: environment:
DATABASE_URL: ${DATABASE_URL:?Set DATABASE_URL in Dokploy secrets} DATABASE_URL: ${DATABASE_URL:?Set DATABASE_URL in Dokploy secrets}
JWT_SECRET: ${JWT_SECRET:?Set JWT_SECRET in Dokploy secrets} JWT_SECRET: ${JWT_SECRET:?Set JWT_SECRET in Dokploy secrets}
FRONTEND_ORIGIN: https://basetracker.lona-development.org FRONTEND_ORIGINS: https://basetracker.lona-development.org,https://dev.basetracker.lona-development.org
COOKIE_SECURE: "true" COOKIE_SECURE: "true"
volumes: volumes:
- backend-uploads:/app/uploads - backend-uploads:/app/uploads
ports:
- "4000:4000"
expose:
- "4000"
labels: labels:
- traefik.enable=true - traefik.enable=true
- traefik.http.routers.backend.rule=Host(`backend.basetracker.lona-development.org`) - traefik.http.routers.backend.rule=Host(`backend.basetracker.lona-development.org`)

View file

@ -6,10 +6,6 @@ services:
NEXT_PUBLIC_BACKEND_URL: ${NEXT_PUBLIC_BACKEND_URL:-https://backend.basetracker.lona-development.org} NEXT_PUBLIC_BACKEND_URL: ${NEXT_PUBLIC_BACKEND_URL:-https://backend.basetracker.lona-development.org}
environment: environment:
NEXT_PUBLIC_BACKEND_URL: ${NEXT_PUBLIC_BACKEND_URL:-https://backend.basetracker.lona-development.org} NEXT_PUBLIC_BACKEND_URL: ${NEXT_PUBLIC_BACKEND_URL:-https://backend.basetracker.lona-development.org}
ports:
- "3100:3000"
expose:
- "31000"
labels: labels:
- traefik.enable=true - traefik.enable=true
- traefik.http.routers.frontend.rule=Host(`basetracker.lona-development.org`) - traefik.http.routers.frontend.rule=Host(`basetracker.lona-development.org`)

View file

@ -5,7 +5,7 @@ import { FormEvent, useEffect, useMemo, useRef, useState } from 'react';
const defaultApiBase = const defaultApiBase =
process.env.NODE_ENV === 'production' process.env.NODE_ENV === 'production'
? 'https://backend.basetracker.lona-development.org' ? 'https://backend.basetracker.lona-development.org'
: 'http://localhost:4100'; : 'https://backend.dev.basetracker.lona-development.org';
// Normalize backend URL so production always uses HTTPS while keeping local HTTP. // Normalize backend URL so production always uses HTTPS while keeping local HTTP.
function normalizeApiBase(url: string) { function normalizeApiBase(url: string) {
@ -35,6 +35,9 @@ 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`,
updateTrophyReset: (resetId: string) => `${API_BASE}/trophy-resets/${resetId}`,
deleteTrophyReset: (resetId: string) => `${API_BASE}/trophy-resets/${resetId}`,
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`,
@ -64,6 +67,15 @@ type ArmyCategory = {
createdAt: string; createdAt: string;
}; };
type TrophyReset = {
id: string;
date: string;
trophiesAtStart: number;
trophiesLost: number;
numberOfDefenses: number;
createdAt: string;
};
type BaseItem = { type BaseItem = {
id: string; id: string;
title: string; title: string;
@ -72,6 +84,7 @@ type BaseItem = {
imageUrl: string; imageUrl: string;
isPrivate: boolean; isPrivate: boolean;
createdAt: string; createdAt: string;
trophyResets: TrophyReset[];
}; };
type DefenseItem = { type DefenseItem = {
@ -114,6 +127,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 +179,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 +212,7 @@ const initialErrors: ErrorState = {
category: '', category: '',
base: '', base: '',
defense: '', defense: '',
trophyReset: '',
}; };
export default function Page() { export default function Page() {
@ -216,6 +232,7 @@ export default function Page() {
const [editImageMode, setEditImageMode] = useState<'keep' | 'upload' | 'url' | 'remove'>('keep'); const [editImageMode, setEditImageMode] = useState<'keep' | 'upload' | 'url' | 'remove'>('keep');
const [editingBaseId, setEditingBaseId] = useState<string | null>(null); const [editingBaseId, setEditingBaseId] = useState<string | null>(null);
const [editingDefenseId, setEditingDefenseId] = useState<string | null>(null); const [editingDefenseId, setEditingDefenseId] = useState<string | null>(null);
const [editingTrophyResetId, setEditingTrophyResetId] = useState<string | null>(null);
const [profileSearchTerm, setProfileSearchTerm] = useState(''); const [profileSearchTerm, setProfileSearchTerm] = useState('');
const [profileResults, setProfileResults] = useState<ProfileSummaryItem[]>([]); const [profileResults, setProfileResults] = useState<ProfileSummaryItem[]>([]);
const [profileDetail, setProfileDetail] = useState<ProfileDetail | null>(null); const [profileDetail, setProfileDetail] = useState<ProfileDetail | null>(null);
@ -292,6 +309,12 @@ export default function Page() {
return defenses.find((defense) => defense.id === editingDefenseId) ?? null; return defenses.find((defense) => defense.id === editingDefenseId) ?? null;
}, [editingDefenseId, defenses]); }, [editingDefenseId, defenses]);
const trophyResetBeingEdited = useMemo(() => {
if (!editingTrophyResetId) return null;
const allResets = bases.flatMap((base) => base.trophyResets);
return allResets.find((reset) => reset.id === editingTrophyResetId) ?? null;
}, [editingTrophyResetId, bases]);
const profileSelectedBaseCategories = useMemo(() => { const profileSelectedBaseCategories = useMemo(() => {
if (!profileSelectedBase) { if (!profileSelectedBase) {
return [] as ProfileCategorySummary[]; return [] as ProfileCategorySummary[];
@ -300,7 +323,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);
}); });
@ -502,6 +525,15 @@ export default function Page() {
setEditingDefenseId(null); setEditingDefenseId(null);
} }
function startEditingTrophyReset(resetId: string) {
setEditingTrophyResetId(resetId);
setErrors((prev) => ({ ...prev, trophyReset: '' }));
}
function cancelEditingTrophyReset() {
setEditingTrophyResetId(null);
}
async function handleDefenseEditSubmit(event: FormEvent<HTMLFormElement>) { async function handleDefenseEditSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
if (!editingDefenseId || !defenseBeingEdited) { if (!editingDefenseId || !defenseBeingEdited) {
@ -538,6 +570,42 @@ export default function Page() {
} }
} }
async function handleTrophyResetEditSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!editingTrophyResetId) {
return;
}
const form = event.currentTarget;
const formData = new FormData(form);
const payload = Object.fromEntries(formData.entries());
try {
setErrors((prev) => ({ ...prev, trophyReset: '' }));
await request('PUT', API.updateTrophyReset(editingTrophyResetId), payload);
setEditingTrophyResetId(null);
await refreshData();
await refreshOwnProfileDetail();
} catch (error: any) {
setErrors((prev) => ({ ...prev, trophyReset: error.message }));
}
}
async function handleDeleteTrophyReset(resetId: string) {
const confirmDelete = window.confirm('Delete this reset?');
if (!confirmDelete) {
return;
}
try {
await request('DELETE', API.deleteTrophyReset(resetId));
if (editingTrophyResetId === resetId) {
setEditingTrophyResetId(null);
}
await refreshData();
await refreshOwnProfileDetail();
} catch (error: any) {
setErrors((prev) => ({ ...prev, trophyReset: error.message }));
}
}
async function handleProfileSearch(event: FormEvent<HTMLFormElement>) { async function handleProfileSearch(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
setProfileError(''); setProfileError('');
@ -615,6 +683,26 @@ export default function Page() {
} }
} }
async function handleTrophyResetSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = event.currentTarget;
const formData = new FormData(form);
const payload = Object.fromEntries(formData.entries());
const baseId = payload.baseId as string;
if (!baseId) {
setErrors((prev) => ({ ...prev, trophyReset: 'Base is required' }));
return;
}
try {
setErrors((prev) => ({ ...prev, trophyReset: '' }));
await request('POST', API.addTrophyReset(baseId), payload);
form.reset();
await refreshData();
} 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);
@ -663,8 +751,11 @@ export default function Page() {
}, [categories]); }, [categories]);
function formatTrophies(value: number) { function formatTrophies(value: number) {
const sign = value > 0 ? '+' : ''; const sign = value > 0 ? '' : '+';
return `${sign}${value} trophies`; let gained = false;
if(sign === '+') gained = true;
let suffix = gained ? 'gained' : 'lost';
return `${sign}${value} trophies ${suffix}`;
} }
function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary { function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary {
@ -985,7 +1076,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">
@ -1026,32 +1117,34 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary {
</p> </p>
</form> </form>
<div className="subsection"> <div className="subsection">
<h3>Existing Categories</h3> <details>
<ul className="list compact"> <summary><h3>Existing Categories</h3></summary>
{categories.length ? ( <ul className="list compact">
categories.map((category) => ( {categories.length ? (
<li key={category.id} className="list-item"> categories.map((category) => (
<div className="defense-header"> <li key={category.id} className="list-item">
<span>{category.name}</span> <div className="defense-header">
<div className="defense-meta"> <span>{category.name}</span>
<button <div className="defense-meta">
type="button" <button
className="ghost small" type="button"
onClick={() => handleDeleteCategory(category.id)} className="ghost small"
> onClick={() => handleDeleteCategory(category.id)}
Delete >
</button> Delete
</button>
</div>
</div> </div>
</div> <div className="defense-meta">
<div className="defense-meta"> <span>{new Date(category.createdAt).toLocaleDateString()}</span>
<span>{new Date(category.createdAt).toLocaleDateString()}</span> </div>
</div> </li>
</li> ))
)) ) : (
) : ( <li>No categories yet.</li>
<li>No categories yet.</li> )}
)} </ul>
</ul> </details>
</div> </div>
</div> </div>
<div className="card"> <div className="card">
@ -1110,42 +1203,44 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary {
</p> </p>
</form> </form>
<div className="subsection"> <div className="subsection">
<h3>Manage Bases</h3> <details>
<ul className="list compact"> <summary><h3>Manage Bases</h3></summary>
{bases.length ? ( <ul className="list compact">
bases.map((base) => ( {bases.length ? (
<li key={base.id} className="list-item"> bases.map((base) => (
<div className="defense-header"> <li key={base.id} className="list-item">
<div> <div className="defense-header">
<strong>{base.title}</strong>{' '} <div>
{base.isPrivate ? <span className="badge muted">Private</span> : null} <strong>{base.title}</strong>{' '}
{base.isPrivate ? <span className="badge muted">Private</span> : null}
</div>
<div className="defense-meta">
<button
type="button"
className="ghost small"
onClick={() => startEditingBase(base.id)}
>
Edit
</button>
<button
type="button"
className="ghost small"
onClick={() => handleDeleteBase(base.id)}
>
Delete
</button>
</div>
</div> </div>
<div className="defense-meta"> <div className="defense-meta">
<button <span>{new Date(base.createdAt).toLocaleDateString()}</span>
type="button"
className="ghost small"
onClick={() => startEditingBase(base.id)}
>
Edit
</button>
<button
type="button"
className="ghost small"
onClick={() => handleDeleteBase(base.id)}
>
Delete
</button>
</div> </div>
</div> </li>
<div className="defense-meta"> ))
<span>{new Date(base.createdAt).toLocaleDateString()}</span> ) : (
</div> <li>No bases yet.</li>
</li> )}
)) </ul>
) : ( </details>
<li>No bases yet.</li>
)}
</ul>
</div> </div>
{baseBeingEdited && ( {baseBeingEdited && (
<div className="subsection"> <div className="subsection">
@ -1263,9 +1358,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}>
@ -1303,12 +1398,13 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary {
</p> </p>
</form> </form>
<div className="subsection"> <div className="subsection">
<h3>Manage Attacks</h3> <details>
<ul className="list compact"> <summary><h3>Manage Attacks</h3></summary>
<ul className="list compact">
{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">
@ -1349,6 +1445,7 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary {
{defenses.length > 10 ? ( {defenses.length > 10 ? (
<p className="muted">Showing the latest 10 entries.</p> <p className="muted">Showing the latest 10 entries.</p>
) : null} ) : null}
</details>
</div> </div>
{defenseBeingEdited && ( {defenseBeingEdited && (
<div className="subsection"> <div className="subsection">
@ -1370,7 +1467,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}
@ -1434,6 +1532,134 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary {
</div> </div>
)} )}
</div> </div>
<div className="card">
<h2>Log Legend League Day Reset</h2>
<form id="trophy-reset-form" className="form compact" onSubmit={handleTrophyResetSubmit}>
<label>
Base
<select name="baseId" required defaultValue="">
<option value="" disabled>
{bases.length ? 'Select a base' : 'Add a base first'}
</option>
{bases.map((base) => (
<option key={base.id} value={base.id}>
{base.title}
</option>
))}
</select>
</label>
<label>
Date
<input type="date" name="date" required />
</label>
<label>
Trophies
<input type="number" name="trophiesAtStart" min={0} step={1} required className="styled-number" />
</label>
<label>
Trophies Gained/Lost
<input type="number" name="trophiesLost" step={1} required className="styled-number" />
</label>
<label>
Number of Defenses
<input type="number" name="numberOfDefenses" min={0} step={1} required className="styled-number" />
</label>
<button type="submit" className="primary">
Record Reset
</button>
<p className="form-error" data-for="trophyReset">
{errors.trophyReset}
</p>
</form>
<div className="subsection">
<details>
<summary><h3>Manage Resets</h3></summary>
<ul className="list compact">
{bases.flatMap((base) => base.trophyResets).length ? (
bases.flatMap((base) => base.trophyResets).slice(0, 10).map((reset) => (
<li key={reset.id} className="list-item">
<div className="defense-header">
<div>
<strong>{bases.find((b) => b.id === bases.find((b) => b.trophyResets.some((r) => r.id === reset.id))?.id)?.title}</strong>{' '}
<span className="badge">{new Date(reset.date).toLocaleDateString()}</span>
</div>
<div className="defense-meta">
<button
type="button"
className="ghost small"
onClick={() => startEditingTrophyReset(reset.id)}
>
Edit
</button>
<button
type="button"
className="ghost small"
onClick={() => handleDeleteTrophyReset(reset.id)}
>
Delete
</button>
</div>
</div>
<div className="defense-meta">
<span>{reset.trophiesAtStart} trophies</span>
<span>{formatTrophies(reset.trophiesLost)}</span>
<span>{reset.numberOfDefenses} defenses</span>
</div>
</li>
))
) : (
<li>No resets logged yet.</li>
)}
</ul>
</details>
</div>
{editingTrophyResetId && trophyResetBeingEdited && (
<div className="subsection">
<h3>Edit Reset</h3>
<form
key={editingTrophyResetId}
className="form compact"
onSubmit={handleTrophyResetEditSubmit}
>
<label>
Base
<select name="baseId" required defaultValue={bases.find(b => b.trophyResets.some(r => r.id === editingTrophyResetId))?.id}>
{bases.map((base) => (
<option key={base.id} value={base.id}>
{base.title}
</option>
))}
</select>
</label>
<label>
Date
<input type="date" name="date" required defaultValue={new Date(trophyResetBeingEdited.date).toISOString().split('T')[0]} />
</label>
<label>
Trophies
<input type="number" name="trophiesAtStart" min={0} step={1} required className="styled-number" defaultValue={trophyResetBeingEdited.trophiesAtStart} />
</label>
<label>
Trophies Gained/Lost
<input type="number" name="trophiesLost" step={1} required className="styled-number" defaultValue={trophyResetBeingEdited.trophiesLost} />
</label>
<label>
Number of Defenses
<input type="number" name="numberOfDefenses" min={0} step={1} required className="styled-number" defaultValue={trophyResetBeingEdited.numberOfDefenses} />
</label>
<div className="defense-meta">
<button type="submit" className="primary">
Save Reset
</button>
<button type="button" className="ghost" onClick={cancelEditingTrophyReset}>
Cancel
</button>
</div>
<p className="form-error">{errors.trophyReset}</p>
</form>
</div>
)}
</div>
</section> </section>
<section id="base-detail-view" className={`view-section ${view !== 'baseDetail' ? 'hidden' : ''}`}> <section id="base-detail-view" className={`view-section ${view !== 'baseDetail' ? 'hidden' : ''}`}>
@ -1487,8 +1713,9 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary {
</div> </div>
</div> </div>
<div className="card"> <div className="card">
<h3>Army Categories vs This Base</h3> <details open>
<ul id="base-detail-categories" className="list"> <summary><h3>Army Categories vs This Base</h3></summary>
<ul id="base-detail-categories" className="list">
{baseDetail && baseDetail.categories.length ? ( {baseDetail && baseDetail.categories.length ? (
baseDetail.categories.map((category) => ( baseDetail.categories.map((category) => (
<li <li
@ -1511,16 +1738,57 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary {
<li>No army categories have attacked this base yet.</li> <li>No army categories have attacked this base yet.</li>
)} )}
</ul> </ul>
</details>
</div> </div>
<div className="card"> <div className="card">
<h3>Defenses</h3> <details open>
<ul id="base-detail-defenses" className="list"> <summary><h3>Recent Resets</h3></summary>
<ul className="list compact">
{baseDetailMeta?.trophyResets?.length ? (
baseDetailMeta.trophyResets.map((reset) => (
<li key={reset.id} className="list-item">
<div className="defense-header">
<span>{new Date(reset.date).toLocaleDateString()}</span>
<div className="defense-meta">
<button
type="button"
className="ghost small"
onClick={() => startEditingTrophyReset(reset.id)}
>
Edit
</button>
<button
type="button"
className="ghost small"
onClick={() => handleDeleteTrophyReset(reset.id)}
>
Delete
</button>
</div>
</div>
<div className="defense-meta">
<span>{reset.trophiesAtStart} trophies</span>
<span>{formatTrophies(reset.trophiesLost)}</span>
<span>{reset.numberOfDefenses} defenses</span>
</div>
</li>
))
) : (
<li>No resets logged yet.</li>
)}
</ul>
</details>
</div>
<div className="card">
<details open>
<summary><h3>Defenses</h3></summary>
<ul id="base-detail-defenses" className="list">
{defenses.filter((defense) => defense.baseId === selectedBaseId).length ? ( {defenses.filter((defense) => defense.baseId === selectedBaseId).length ? (
defenses defenses
.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">
@ -1539,6 +1807,7 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary {
<li>No defenses recorded for this base yet.</li> <li>No defenses recorded for this base yet.</li>
)} )}
</ul> </ul>
</details>
</div> </div>
</section> </section>
@ -1627,26 +1896,51 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary {
</ul> </ul>
</div> </div>
<div className="card"> <div className="card">
<h3>Defenses</h3> <details open>
<ul className="list"> <summary><h3>Recent Resets</h3></summary>
{profileSelectedBase.defenses.length ? ( <ul className="list compact">
profileSelectedBase.defenses.map((defense) => ( {profileSelectedBase.trophyResets.length ? (
<li key={defense.id} className="list-item"> profileSelectedBase.trophyResets.map((reset) => (
<div className="defense-header"> <li key={reset.id} className="list-item">
<strong>{defense.armyCategoryName || 'Unknown Army'}</strong> <div className="defense-header">
<div> <span>{new Date(reset.date).toLocaleDateString()}</span>
<strong>{defense.stars}</strong> {defense.percent}% {formatTrophies(defense.trophies)} <div className="defense-meta">
<span>{reset.trophiesAtStart} trophies at start</span>
<span>{formatTrophies(reset.trophiesLost)} lost</span>
<span>{reset.numberOfDefenses} defenses</span>
</div>
</div> </div>
</div> </li>
<div className="defense-meta"> ))
<span>{new Date(defense.createdAt).toLocaleString()}</span> ) : (
</div> <li>No resets logged yet.</li>
</li> )}
)) </ul>
) : ( </details>
<li>No defenses recorded yet.</li> </div>
)} <div className="card">
</ul> <details>
<summary><h3>Defenses</h3></summary>
<ul className="list">
{profileSelectedBase.defenses.length ? (
profileSelectedBase.defenses.map((defense) => (
<li key={defense.id} className="list-item">
<div className="defense-header">
<strong>{defense.armyCategoryName || '(No category)'}</strong>
<div>
<strong>{defense.stars}</strong> {defense.percent}% {formatTrophies(defense.trophies)}
</div>
</div>
<div className="defense-meta">
<span>{new Date(defense.createdAt).toLocaleString()}</span>
</div>
</li>
))
) : (
<li>No defenses recorded yet.</li>
)}
</ul>
</details>
</div> </div>
</> </>
) : ( ) : (

View file

@ -28,7 +28,9 @@ export function ServiceWorkerProvider() {
} }
}; };
if (process.env.NODE_ENV === 'production') { const isProduction = window.location.hostname === 'basetracker.lona-development.org';
if (isProduction) {
registerServiceWorker(); registerServiceWorker();
} else { } else {
navigator.serviceWorker.getRegistration(SW_PATH).then((registration) => { navigator.serviceWorker.getRegistration(SW_PATH).then((registration) => {