allow for a starting date and hidden hunt if it hasnt started
All checks were successful
Build Images and Deploy / Update-PROD-Stack (push) Successful in 31s

This commit is contained in:
2026-03-19 14:39:06 -04:00
parent 79c0b883f7
commit 34b3a4cbd0
12 changed files with 107 additions and 30 deletions

View File

@@ -2,18 +2,23 @@
(function () {
function timeAgo(date) {
const now = new Date();
const seconds = Math.floor((now - date) / 1000);
if (seconds < 5) return 'just now';
if (seconds < 60) return seconds + 's ago';
const diff = now - date;
const seconds = Math.floor(Math.abs(diff) / 1000);
const isFuture = diff < 0;
const prefix = isFuture ? 'in ' : '';
const suffix = isFuture ? '' : ' ago';
if (seconds < 5) return isFuture ? 'in a moment' : 'just now';
if (seconds < 60) return prefix + seconds + 's' + suffix;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return minutes + 'm ago';
if (minutes < 60) return prefix + minutes + 'm' + suffix;
const hours = Math.floor(minutes / 60);
if (hours < 24) return hours + 'h ago';
if (hours < 24) return prefix + hours + 'h' + suffix;
const days = Math.floor(hours / 24);
if (days < 7) return days + 'd ago';
if (days < 30) return Math.floor(days / 7) + 'w ago';
if (days < 365) return Math.floor(days / 30) + 'mo ago';
return Math.floor(days / 365) + 'y ago';
if (days < 7) return prefix + days + 'd' + suffix;
if (days < 30) return prefix + Math.floor(days / 7) + 'w' + suffix;
if (days < 365) return prefix + Math.floor(days / 30) + 'mo' + suffix;
return prefix + Math.floor(days / 365) + 'y' + suffix;
}
function updateTimes() {

View File

@@ -129,7 +129,8 @@ async function start() {
// Home page
app.get('/', (req, res) => {
const { Hunts, Scans } = require('./models');
const hunts = Hunts.getAll();
const allHunts = Hunts.getAll();
const hunts = allHunts.filter(h => !Hunts.isHidden(h));
const recentActivity = Scans.getRecentActivity(5);
res.render('home', { title: 'Loot Hunt', hunts, recentActivity });
});

View File

@@ -224,7 +224,9 @@ const ready = new Promise(resolve => { _readyResolve = resolve; });
// Migrations — add columns to existing databases
const migrations = [
'ALTER TABLE users ADD COLUMN is_organizer INTEGER DEFAULT 0'
'ALTER TABLE users ADD COLUMN is_organizer INTEGER DEFAULT 0',
'ALTER TABLE hunts ADD COLUMN start_date DATETIME',
'ALTER TABLE hunts ADD COLUMN hidden_until_start INTEGER DEFAULT 0'
];
for (const m of migrations) {
try { _db.run(m); } catch (e) { /* column already exists */ }

View File

@@ -141,11 +141,11 @@ const Users = {
// ─── Hunts ────────────────────────────────────────────────
const Hunts = {
create(name, shortName, description, packageCount, expiryDate, createdBy) {
create(name, shortName, description, packageCount, expiryDate, createdBy, startDate, hiddenUntilStart) {
const stmt = db.prepare(
'INSERT INTO hunts (name, short_name, description, package_count, expiry_date, created_by) VALUES (?, ?, ?, ?, ?, ?)'
'INSERT INTO hunts (name, short_name, description, package_count, expiry_date, created_by, start_date, hidden_until_start) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
);
const result = stmt.run(name, shortName.toUpperCase(), description, packageCount, expiryDate || null, createdBy);
const result = stmt.run(name, shortName.toUpperCase(), description, packageCount, expiryDate || null, createdBy, startDate || null, hiddenUntilStart ? 1 : 0);
const huntId = result.lastInsertRowid;
// Generate packages
@@ -217,9 +217,19 @@ const Hunts = {
return !!row;
},
update(id, name, description, expiryDate) {
db.prepare('UPDATE hunts SET name = ?, description = ?, expiry_date = ? WHERE id = ?')
.run(name, description, expiryDate || null, id);
update(id, name, description, expiryDate, startDate, hiddenUntilStart) {
db.prepare('UPDATE hunts SET name = ?, description = ?, expiry_date = ?, start_date = ?, hidden_until_start = ? WHERE id = ?')
.run(name, description, expiryDate || null, startDate || null, hiddenUntilStart ? 1 : 0, id);
},
isHidden(hunt) {
if (!hunt.hidden_until_start || !hunt.start_date) return false;
return new Date(hunt.start_date + 'Z') > new Date();
},
hasStarted(hunt) {
if (!hunt.start_date) return true;
return new Date(hunt.start_date + 'Z') <= new Date();
},
resetScans(id) {

View File

@@ -36,7 +36,7 @@ router.get('/hunts/new', (req, res) => {
// Create hunt
router.post('/hunts', (req, res) => {
const { name, short_name, description, package_count, expiry_date } = req.body;
const { name, short_name, description, package_count, expiry_date, start_date, hidden_until_start } = req.body;
// Validation
if (!name || !short_name || !package_count) {
@@ -70,7 +70,7 @@ router.post('/hunts', (req, res) => {
}
try {
const huntId = Hunts.create(name, shortName, description, count, expiry_date, req.session.userId);
const huntId = Hunts.create(name, shortName, description, count, expiry_date, req.session.userId, start_date, hidden_until_start);
req.session.flash = { type: 'success', message: `Hunt "${name}" created with ${count} packages.` };
res.redirect(`/admin/hunts/${huntId}`);
} catch (err) {
@@ -102,12 +102,12 @@ router.get('/hunts/:id/edit', requireHuntAccess, (req, res) => {
router.post('/hunts/:id/edit', requireHuntAccess, (req, res) => {
const hunt = req.hunt;
const { name, description, expiry_date } = req.body;
const { name, description, expiry_date, start_date, hidden_until_start } = req.body;
if (!name || !name.trim()) {
return res.render('admin/edit-hunt', { title: `Edit: ${hunt.name}`, hunt: { ...hunt, ...req.body }, error: 'Hunt name is required.' });
}
Hunts.update(hunt.id, name.trim(), (description || '').trim(), expiry_date);
Hunts.update(hunt.id, name.trim(), (description || '').trim(), expiry_date, start_date, hidden_until_start);
req.session.flash = { type: 'success', message: 'Hunt updated successfully.' };
res.redirect(`/admin/hunts/${hunt.id}`);
});

View File

@@ -10,10 +10,16 @@ router.get('/hunt/:shortName', (req, res) => {
return res.status(404).render('error', { title: 'Not Found', message: 'Hunt not found.' });
}
// Block access if hidden and not started (unless admin/organizer)
if (Hunts.isHidden(hunt) && !(req.session && (req.session.isAdmin || req.session.isOrganizer))) {
return res.status(404).render('error', { title: 'Not Found', message: 'Hunt not found.' });
}
const packages = Packages.getByHunt(hunt.id);
const isExpired = Hunts.isExpired(hunt);
const hasStarted = Hunts.hasStarted(hunt);
res.render('hunt/profile', { title: hunt.name, hunt, packages, isExpired });
res.render('hunt/profile', { title: hunt.name, hunt, packages, isExpired, hasStarted });
});
// ─── Hunt leaderboard ─────────────────────────────────────
@@ -23,6 +29,10 @@ router.get('/hunt/:shortName/leaderboard', (req, res) => {
return res.status(404).render('error', { title: 'Not Found', message: 'Hunt not found.' });
}
if (Hunts.isHidden(hunt) && !(req.session && (req.session.isAdmin || req.session.isOrganizer))) {
return res.status(404).render('error', { title: 'Not Found', message: 'Hunt not found.' });
}
const perPage = 25;
const page = Math.max(1, parseInt(req.query.page) || 1);
const totalCount = Hunts.getLeaderboardCount(hunt.id);
@@ -128,7 +138,8 @@ router.post('/player/:username/password', requireAuth, (req, res) => {
// ─── Browse all hunts ─────────────────────────────────────
router.get('/hunts', (req, res) => {
const hunts = Hunts.getAll();
const allHunts = Hunts.getAll();
const hunts = allHunts.filter(h => !Hunts.isHidden(h));
res.render('hunt/list', { title: 'All Hunts', hunts });
});

View File

@@ -40,6 +40,18 @@
<div class="form-hint">Leave blank for no expiry.</div>
</div>
<div class="form-group">
<label for="start_date">Start Date (optional)</label>
<input type="datetime-local" id="start_date" name="start_date" class="form-control">
<div class="form-hint">When the hunt becomes active. Leave blank to start immediately.</div>
</div>
<div class="form-group" style="display: flex; align-items: center; gap: 0.5rem;">
<input type="checkbox" id="hidden_until_start" name="hidden_until_start" value="1">
<label for="hidden_until_start" style="margin: 0; cursor: pointer;">Hidden until start date</label>
<div class="form-hint" style="margin-left: auto;">Keep this hunt secret until the start time.</div>
</div>
<button type="submit" class="btn btn-primary" style="width: 100%; justify-content: center;">Create Hunt</button>
</form>
</div>

View File

@@ -18,10 +18,18 @@
<h3><%= hunt.name %></h3>
<span class="meta"><%= hunt.short_name %> &middot; <%= hunt.package_count %> packages
<% if (isAdmin && hunt.creator_name) { %> &middot; by <%= hunt.creator_name %><% } %>
<% if (hunt.expiry_date) { %> &middot; Expires: <%= new Date(hunt.expiry_date).toLocaleDateString() %><% } %>
<% if (hunt.expiry_date) { %> &middot; Expires <time datetime="<%= hunt.expiry_date %>"><%= new Date(hunt.expiry_date).toLocaleDateString() %></time><% } %>
<% if (hunt.start_date) { %> &middot; Starts <time datetime="<%= hunt.start_date %>"><%= new Date(hunt.start_date).toLocaleDateString() %></time><% } %>
</span>
</div>
<span class="badge">Manage</span>
<div style="display: flex; gap: 0.4rem; align-items: center;">
<% if (hunt.hidden_until_start && hunt.start_date && new Date(hunt.start_date + 'Z') > new Date()) { %>
<span class="badge expired" style="font-size: 0.7rem;">Hidden</span>
<% } else if (hunt.start_date && new Date(hunt.start_date + 'Z') > new Date()) { %>
<span class="badge" style="font-size: 0.7rem;">Upcoming</span>
<% } %>
<span class="badge">Manage</span>
</div>
</a>
<% }) %>
<% } %>

View File

@@ -36,6 +36,19 @@
<div class="form-hint">Leave blank for no expiry.</div>
</div>
<div class="form-group">
<label for="start_date">Start Date (optional)</label>
<input type="datetime-local" id="start_date" name="start_date" class="form-control"
value="<%= hunt.start_date ? new Date(hunt.start_date).toISOString().slice(0, 16) : '' %>">
<div class="form-hint">When the hunt becomes active. Leave blank to start immediately.</div>
</div>
<div class="form-group" style="display: flex; align-items: center; gap: 0.5rem;">
<input type="checkbox" id="hidden_until_start" name="hidden_until_start" value="1" <%= hunt.hidden_until_start ? 'checked' : '' %>>
<label for="hidden_until_start" style="margin: 0; cursor: pointer;">Hidden until start date</label>
<div class="form-hint" style="margin-left: auto;">Keep this hunt secret until the start time.</div>
</div>
<button type="submit" class="btn btn-primary" style="width: 100%; justify-content: center;">Save Changes</button>
</form>
</div>

View File

@@ -42,9 +42,15 @@
<div class="label">Discovery Rate</div>
</div>
<div class="stat-box">
<div class="value"><%= hunt.expiry_date ? new Date(hunt.expiry_date).toLocaleDateString() : 'Never' %></div>
<div class="value"><% if (hunt.expiry_date) { %><time datetime="<%= hunt.expiry_date %>"><%= new Date(hunt.expiry_date).toLocaleDateString() %></time><% } else { %>Never<% } %></div>
<div class="label">Expires</div>
</div>
<% if (hunt.start_date) { %>
<div class="stat-box">
<div class="value"><time datetime="<%= hunt.start_date %>"><%= new Date(hunt.start_date).toLocaleDateString() %></time></div>
<div class="label">Starts</div>
</div>
<% } %>
</div>
<% if (typeof stats !== 'undefined' && stats.topFinders.length > 0) { %>

View File

@@ -52,10 +52,14 @@
<a href="/hunt/<%= hunt.short_name %>" class="hunt-card">
<div class="hunt-info">
<h3><%= hunt.name %></h3>
<span class="meta"><%= hunt.short_name %> &middot; <%= hunt.package_count %> packages &middot; by <%= hunt.creator_name %></span>
<span class="meta"><%= hunt.short_name %> &middot; <%= hunt.package_count %> packages &middot; by <%= hunt.creator_name %>
<% if (hunt.expiry_date) { %> &middot; Expires <time datetime="<%= hunt.expiry_date %>"><%= new Date(hunt.expiry_date).toLocaleDateString() %></time><% } %>
</span>
</div>
<% if (hunt.expiry_date && new Date(hunt.expiry_date) < new Date()) { %>
<% if (hunt.expiry_date && new Date(hunt.expiry_date + 'Z') < new Date()) { %>
<span class="badge expired">Expired</span>
<% } else if (hunt.start_date && new Date(hunt.start_date + 'Z') > new Date()) { %>
<span class="badge">Upcoming</span>
<% } else { %>
<span class="badge"><%= hunt.package_count %> packages</span>
<% } %>

View File

@@ -14,12 +14,17 @@
<h3><%= hunt.name %></h3>
<span class="meta"><%= hunt.short_name %> &middot; <%= hunt.package_count %> packages &middot; by <%= hunt.creator_name %>
<% if (hunt.expiry_date) { %>
&middot; Expires: <%= new Date(hunt.expiry_date).toLocaleDateString() %>
&middot; Expires <time datetime="<%= hunt.expiry_date %>"><%= new Date(hunt.expiry_date).toLocaleDateString() %></time>
<% } %>
<% if (hunt.start_date && new Date(hunt.start_date + 'Z') > new Date()) { %>
&middot; Starts <time datetime="<%= hunt.start_date %>"><%= new Date(hunt.start_date).toLocaleDateString() %></time>
<% } %>
</span>
</div>
<% if (hunt.expiry_date && new Date(hunt.expiry_date) < new Date()) { %>
<% if (hunt.expiry_date && new Date(hunt.expiry_date + 'Z') < new Date()) { %>
<span class="badge expired">Expired</span>
<% } else if (hunt.start_date && new Date(hunt.start_date + 'Z') > new Date()) { %>
<span class="badge">Upcoming</span>
<% } else { %>
<span class="badge"><%= hunt.package_count %> packages</span>
<% } %>