From 70ed43f12b587acca6d77dcf14d8362f1eefa0b2 Mon Sep 17 00:00:00 2001 From: mudabbir-ahmad Date: Thu, 9 Apr 2026 00:13:28 +0100 Subject: [PATCH] Implement business subgroup management: add create, update, and delete functionality for subgroups; enhance team settings with subgroup selection and cache claim distance adjustments also fixed the naming scheme on the db from game-data.json to caches.json and refactored code to reflect that change. plus added player highlighting --- server/server.js | 243 ++++++++----------- src/components/screens/GameSettingsScreen.js | 174 +++++++++++-- src/components/screens/LeaderboardScreen.js | 148 +++++++++-- src/components/screens/MapScreen.js | 26 +- src/components/screens/PlayersScreen.js | 73 ++++-- src/components/screens/TeamScreen.js | 102 +++++++- src/hooks/useGameHook.js | 72 ++++-- 7 files changed, 604 insertions(+), 234 deletions(-) diff --git a/server/server.js b/server/server.js index 7478dc0..1b83aa5 100644 --- a/server/server.js +++ b/server/server.js @@ -250,10 +250,6 @@ addRoute('POST', '/groups', async (req, res) => { }); writeJson('subgroup-memberships.json', memberships); - // Initialise empty game-data entry - const gameData = readJson('game-data.json'); - gameData.push({ Gid: group.Gid, GeoCaches: [] }); - writeJson('game-data.json', gameData); // Update the creating user const users = readJson('users.json'); @@ -666,136 +662,131 @@ addRoute('POST', '/admin-waitlist/:id/approve', async (req, res, params) => { }); // ====================================================================== -// GAME DATA CRUDL +// CACHES CRUDL (standalone — reads/writes caches.json) // ====================================================================== -addRoute('GET', '/game-data', (req, res) => { - sendJson(res, 200, readJson('game-data.json')); +// Helper: convert storage row to API response format +const toCacheApi = (cache) => ({ + id: cache.CacheId, + name: cache.Title || '', + clue: cache.Clue || '', + coordinates: { latitude: cache.Latitude, longitude: cache.Longitude }, + radius: cache.TriggerMeters, + groupId: cache.Gid, + subgroupId: cache.SGid, + Claims: cache.Claims || [], }); -addRoute('GET', '/game-data/:gid', (req, res, params) => { - const rows = readJson('game-data.json'); - const row = rows.find((r) => String(r.Gid) === params.gid); - if (!row) return sendJson(res, 404, { message: 'Game data not found' }); - sendJson(res, 200, row); +// GET /caches — list caches for a group (optional SGid filter) +addRoute('GET', '/caches', (req, res, params, query) => { + let rows = readJson('caches.json'); + if (query.Gid) rows = rows.filter((r) => String(r.Gid) === query.Gid); + if (query.SGid) rows = rows.filter((r) => String(r.SGid) === query.SGid); + sendJson(res, 200, rows.map(toCacheApi)); }); -// ====================================================================== -// CACHES (nested under game-data) CRUDL -// ====================================================================== +// POST /caches — create a new cache +addRoute('POST', '/caches', async (req, res) => { + const payload = await parseBody(req); + const rows = readJson('caches.json'); -// Helper: convert storage cache to API response format -const toCacheApi = (cache, gid) => { - // Migrate old single-claim format to Claims array - let claims = cache.Claims || []; - if (claims.length === 0 && cache.ClaimedByUid) { - claims = [{ Uid: cache.ClaimedByUid, Tid: cache.ClaimedByTid || null, ClaimedAt: null }]; - } - return { - id: cache.CacheId, - coordinates: { latitude: cache.Latitude, longitude: cache.Longitude }, - radius: cache.TriggerMeters, - clue: cache.Title, - groupId: Number(gid), - subgroupId: cache.SGid, - Claims: claims, + const cache = { + CacheId: nextId(rows, 'CacheId'), + Gid: payload.Gid, + SGid: payload.SGid || null, + Title: payload.Title || '', + Clue: payload.Clue || '', + Latitude: payload.Latitude || 0, + Longitude: payload.Longitude || 0, + TriggerMeters: payload.TriggerMeters || 20, + Claims: [], }; -}; -// Helper: convert API payload to storage cache format -const toCacheStorage = (data, cacheId) => ({ - CacheId: cacheId, - Title: data.clue || data.Title || '', - Latitude: data.latitude != null ? data.latitude : (data.Latitude || 0), - Longitude: data.longitude != null ? data.longitude : (data.Longitude || 0), - TriggerMeters: data.radius || data.TriggerMeters || 20, - SGid: data.subgroupId != null ? data.subgroupId : (data.SGid || null), + rows.push(cache); + writeJson('caches.json', rows); + sendJson(res, 201, toCacheApi(cache)); }); -// GET /game-data/:gid/caches — list caches for a group -addRoute('GET', '/game-data/:gid/caches', (req, res, params, query) => { - const rows = readJson('game-data.json'); - const entry = rows.find((r) => String(r.Gid) === params.gid); - if (!entry) return sendJson(res, 404, { message: 'Game data not found' }); +// POST /caches/reset — clear all claims for every cache in a game +addRoute('POST', '/caches/reset', async (req, res, params, query) => { + const rows = readJson('caches.json'); + const gid = query.Gid; + if (!gid) return sendJson(res, 400, { message: 'Gid query parameter required' }); - let caches = (entry.GeoCaches || []).map((c) => toCacheApi(c, params.gid)); - if (query.SGid) caches = caches.filter((c) => String(c.subgroupId) === query.SGid); - sendJson(res, 200, caches); + for (const cache of rows) { + if (String(cache.Gid) === gid) cache.Claims = []; + } + + writeJson('caches.json', rows); + sendJson(res, 200, { message: 'Game reset successfully' }); }); -// GET /game-data/:gid/caches/:cacheId — read single cache -addRoute('GET', '/game-data/:gid/caches/:cacheId', (req, res, params) => { - const rows = readJson('game-data.json'); - const entry = rows.find((r) => String(r.Gid) === params.gid); - if (!entry) return sendJson(res, 404, { message: 'Game data not found' }); +// POST /caches/reset-player/:uid — remove all claims by a specific user +addRoute('POST', '/caches/reset-player/:uid', async (req, res, params, query) => { + const rows = readJson('caches.json'); + const gid = query.Gid; + if (!gid) return sendJson(res, 400, { message: 'Gid query parameter required' }); - const cache = (entry.GeoCaches || []).find((c) => c.CacheId === params.cacheId); - if (!cache) return sendJson(res, 404, { message: 'Cache not found' }); - sendJson(res, 200, toCacheApi(cache, params.gid)); + const uid = Number(params.uid); + for (const cache of rows) { + if (String(cache.Gid) === gid && cache.Claims) { + cache.Claims = cache.Claims.filter((c) => c.Uid !== uid); + } + } + + writeJson('caches.json', rows); + sendJson(res, 200, { message: 'Player progress reset' }); }); -// POST /game-data/:gid/caches — create a new cache -addRoute('POST', '/game-data/:gid/caches', async (req, res, params) => { +// GET /caches/:id — read a single cache +addRoute('GET', '/caches/:id', (req, res, params) => { + const rows = readJson('caches.json'); + const row = rows.find((r) => String(r.CacheId) === params.id); + if (!row) return sendJson(res, 404, { message: 'Cache not found' }); + sendJson(res, 200, toCacheApi(row)); +}); + +// PUT /caches/:id — update a cache +addRoute('PUT', '/caches/:id', async (req, res, params) => { const payload = await parseBody(req); - const rows = readJson('game-data.json'); - const entry = rows.find((r) => String(r.Gid) === params.gid); - if (!entry) return sendJson(res, 404, { message: 'Game data not found' }); - - const existingIds = (entry.GeoCaches || []).map((c) => { - const num = parseInt(String(c.CacheId).replace(/\D/g, ''), 10); - return isNaN(num) ? 0 : num; - }); - const nextNum = existingIds.length > 0 ? Math.max(...existingIds) + 1 : 1; - const cacheId = `C${nextNum}`; - - const stored = { ...toCacheStorage(payload, cacheId), Claims: [] }; - if (!entry.GeoCaches) entry.GeoCaches = []; - entry.GeoCaches.push(stored); - writeJson('game-data.json', rows); - sendJson(res, 201, toCacheApi(stored, params.gid)); -}); - -// PUT /game-data/:gid/caches/:cacheId — update a cache -addRoute('PUT', '/game-data/:gid/caches/:cacheId', async (req, res, params) => { - const payload = await parseBody(req); - const rows = readJson('game-data.json'); - const entry = rows.find((r) => String(r.Gid) === params.gid); - if (!entry) return sendJson(res, 404, { message: 'Game data not found' }); - - const idx = (entry.GeoCaches || []).findIndex((c) => c.CacheId === params.cacheId); + const rows = readJson('caches.json'); + const idx = rows.findIndex((r) => String(r.CacheId) === params.id); if (idx === -1) return sendJson(res, 404, { message: 'Cache not found' }); - const existing = entry.GeoCaches[idx]; - const updated = { ...existing, ...toCacheStorage(payload, params.cacheId) }; - // Preserve claims — property updates must not overwrite the Claims array - updated.Claims = existing.Claims || []; - entry.GeoCaches[idx] = updated; - writeJson('game-data.json', rows); - sendJson(res, 200, toCacheApi(updated, params.gid)); + const existing = rows[idx]; + rows[idx] = { + ...existing, + Title: payload.Title != null ? payload.Title : existing.Title, + Clue: payload.Clue != null ? payload.Clue : existing.Clue, + Latitude: payload.Latitude != null ? payload.Latitude : existing.Latitude, + Longitude: payload.Longitude != null ? payload.Longitude : existing.Longitude, + TriggerMeters: payload.TriggerMeters != null ? payload.TriggerMeters : existing.TriggerMeters, + SGid: payload.SGid != null ? payload.SGid : existing.SGid, + // Preserve CacheId, Gid and Claims — updates must not overwrite these + CacheId: existing.CacheId, + Gid: existing.Gid, + Claims: existing.Claims || [], + }; + + writeJson('caches.json', rows); + sendJson(res, 200, toCacheApi(rows[idx])); }); -// DELETE /game-data/:gid/caches/:cacheId — delete a cache -addRoute('DELETE', '/game-data/:gid/caches/:cacheId', (req, res, params) => { - const rows = readJson('game-data.json'); - const entry = rows.find((r) => String(r.Gid) === params.gid); - if (!entry) return sendJson(res, 404, { message: 'Game data not found' }); - - const idx = (entry.GeoCaches || []).findIndex((c) => c.CacheId === params.cacheId); +// DELETE /caches/:id — delete a cache +addRoute('DELETE', '/caches/:id', (req, res, params) => { + const rows = readJson('caches.json'); + const idx = rows.findIndex((r) => String(r.CacheId) === params.id); if (idx === -1) return sendJson(res, 404, { message: 'Cache not found' }); - - entry.GeoCaches.splice(idx, 1); - writeJson('game-data.json', rows); + rows.splice(idx, 1); + writeJson('caches.json', rows); sendJson(res, 204, null); }); -// POST /game-data/:gid/caches/:cacheId/claim — claim a cache (multi-team) -addRoute('POST', '/game-data/:gid/caches/:cacheId/claim', async (req, res, params) => { +// POST /caches/:id/claim — claim a cache (team-aware: prevents same team from claiming again) +addRoute('POST', '/caches/:id/claim', async (req, res, params) => { const payload = await parseBody(req); - const rows = readJson('game-data.json'); - const entry = rows.find((r) => String(r.Gid) === params.gid); - if (!entry) return sendJson(res, 404, { message: 'Game data not found' }); - - const cache = (entry.GeoCaches || []).find((c) => c.CacheId === params.cacheId); + const rows = readJson('caches.json'); + const cache = rows.find((r) => String(r.CacheId) === params.id); if (!cache) return sendJson(res, 404, { message: 'Cache not found' }); if (!cache.Claims) cache.Claims = []; @@ -814,42 +805,8 @@ addRoute('POST', '/game-data/:gid/caches/:cacheId/claim', async (req, res, param ClaimedAt: new Date().toISOString(), }); - writeJson('game-data.json', rows); - sendJson(res, 200, toCacheApi(cache, params.gid)); -}); - -// POST /game-data/:gid/reset — reset all cache claims for a game -addRoute('POST', '/game-data/:gid/reset', async (req, res, params) => { - const rows = readJson('game-data.json'); - const entry = rows.find((r) => String(r.Gid) === params.gid); - if (!entry) return sendJson(res, 404, { message: 'Game data not found' }); - - for (const cache of (entry.GeoCaches || [])) { - cache.Claims = []; - // Clean up legacy fields - delete cache.ClaimedByUid; - delete cache.ClaimedByTid; - } - - writeJson('game-data.json', rows); - sendJson(res, 200, { message: 'Game reset successfully' }); -}); - -// POST /game-data/:gid/reset-player/:uid — remove all claims by a specific user -addRoute('POST', '/game-data/:gid/reset-player/:uid', async (req, res, params) => { - const rows = readJson('game-data.json'); - const entry = rows.find((r) => String(r.Gid) === params.gid); - if (!entry) return sendJson(res, 404, { message: 'Game data not found' }); - - const uid = Number(params.uid); - for (const cache of (entry.GeoCaches || [])) { - if (cache.Claims) { - cache.Claims = cache.Claims.filter((c) => c.Uid !== uid); - } - } - - writeJson('game-data.json', rows); - sendJson(res, 200, { message: 'Player progress reset' }); + writeJson('caches.json', rows); + sendJson(res, 200, toCacheApi(cache)); }); // ====================================================================== diff --git a/src/components/screens/GameSettingsScreen.js b/src/components/screens/GameSettingsScreen.js index 9531ce9..547ac09 100644 --- a/src/components/screens/GameSettingsScreen.js +++ b/src/components/screens/GameSettingsScreen.js @@ -10,7 +10,7 @@ const GameSettingsScreen = () => { // Initialisation ------------ const session = getSession(); - const {getLobby, updateGroup, getSubgroups, resetGame, getAdminWaitlist, approveAdmin, rejectAdmin, getUser} = useGameHook(); + const {getLobby, updateGroup, getSubgroups, createSubgroup, updateSubgroup, deleteSubgroup, resetGame, getAdminWaitlist, approveAdmin, rejectAdmin, getUser} = useGameHook(); const isBusiness = Boolean(session.isBusiness); // State ---------------------- @@ -19,12 +19,16 @@ const GameSettingsScreen = () => { const [saving, setSaving] = useState(false); const [groupName, setGroupName] = useState(''); const [teamsEnabled, setTeamsEnabled] = useState(session.teamsEnabled); - const [maxSubgroups, setMaxSubgroups] = useState('1'); const [cacheTriggerMeters, setCacheTriggerMeters] = useState('20'); const [adminJoinCode, setAdminJoinCode] = useState(''); const [memberJoinCode, setMemberJoinCode] = useState(''); const [waitlist, setWaitlist] = useState([]); const [waitlistNames, setWaitlistNames] = useState({}); + // Business subgroup management + const [subgroupList, setSubgroupList] = useState([]); + const [newDeptName, setNewDeptName] = useState(''); + const [editingSGid, setEditingSGid] = useState(null); + const [editDeptTrigger, setEditDeptTrigger] = useState('20'); // Handlers ------------------- @@ -40,6 +44,15 @@ const GameSettingsScreen = () => { setWaitlistNames(names); }; + const loadSubgroups = async () => { + if (!session.currentGid) return; + const sgs = await getSubgroups(session.currentGid); + setSubgroupList(sgs || []); + // Set the default member join code from first non-admin subgroup + const memberSg = (sgs || []).find((sg) => !sg.IsAdminGroup); + if (memberSg) setMemberJoinCode(memberSg.JoinCode || ''); + }; + useEffect(() => { const load = async () => { if (!session.currentGid) { @@ -53,13 +66,10 @@ const GameSettingsScreen = () => { setTeamsEnabled(serverTeams); // Keep session in sync so the toggle survives app reloads setSessionTeamsEnabled(serverTeams); - setMaxSubgroups(String(group.MaxMemberSubgroups || 1)); setCacheTriggerMeters(String(group.CacheTriggerMeters || 20)); setAdminJoinCode(group.AdminJoinCode || ''); } - const sgs = await getSubgroups(session.currentGid); - const memberSg = (sgs || []).find((sg) => !sg.IsAdminGroup); - if (memberSg) setMemberJoinCode(memberSg.JoinCode || ''); + await loadSubgroups(); await loadWaitlist(); setLoading(false); }; @@ -72,7 +82,6 @@ const GameSettingsScreen = () => { const result = await updateGroup(session.currentGid, { GroupName: groupName.trim(), TeamsEnabled: teamsEnabled, - MaxMemberSubgroups: parseInt(maxSubgroups) || 1, CacheTriggerMeters: parseInt(cacheTriggerMeters) || 20, }); // Persist the new toggle value so the session reflects it after a reload @@ -81,14 +90,14 @@ const GameSettingsScreen = () => { }; const handleResetGame = () => { - Alert.alert('Reset Entire Game', 'This will clear ALL cache claims for every player. Are you sure?', [ + Alert.alert('Reset Entire Game', 'This will remove ALL saved caches and reset progress for every player. Are you sure?', [ {text: 'Cancel', style: 'cancel'}, { text: 'Reset', style: 'destructive', onPress: async () => { await resetGame(session.currentGid); - Alert.alert('Done', 'All cache claims have been reset.'); + Alert.alert('Done', 'All caches and player progress have been reset.'); }, }, ]); @@ -104,6 +113,41 @@ const GameSettingsScreen = () => { await loadWaitlist(); }; + // Business — create a new department (subgroup) + const handleCreateDepartment = async () => { + if (!newDeptName.trim() || !session.currentGid) return; + await createSubgroup({ + SubGroupName: newDeptName.trim(), + Gid: session.currentGid, + IsAdminGroup: false, + CacheTriggerMeters: parseInt(cacheTriggerMeters) || 20, + }); + setNewDeptName(''); + await loadSubgroups(); + }; + + // Business — save per-subgroup trigger distance + const handleSaveDeptTrigger = async (sgid) => { + await updateSubgroup(sgid, {CacheTriggerMeters: parseInt(editDeptTrigger) || 20}); + setEditingSGid(null); + await loadSubgroups(); + }; + + // Business — delete a department + const handleDeleteDepartment = (sg) => { + Alert.alert('Delete Department', `Delete "${sg.SubGroupName}" and all its data?`, [ + {text: 'Cancel', style: 'cancel'}, + { + text: 'Delete', + style: 'destructive', + onPress: async () => { + await deleteSubgroup(sg.SGid); + await loadSubgroups(); + }, + }, + ]); + }; + // View ----------------------- if (loading) { @@ -122,6 +166,9 @@ const GameSettingsScreen = () => { ); } + // Filter subgroups for the department list (non-admin only) + const departments = subgroupList.filter((sg) => !sg.IsAdminGroup); + return ( @@ -146,20 +193,6 @@ const GameSettingsScreen = () => { /> - {isBusiness && ( - <> - Max Member Subgroups - - - )} - Cache Claim Distance (metres) { + {/* Business: Departments / Subgroups management */} + {isBusiness && ( + + Departments + {departments.map((sg) => ( + + + + {sg.SubGroupName} + Join Code: {sg.JoinCode || '—'} + Claim Distance: {sg.CacheTriggerMeters || 20}m + + +