Просмотр исходного кода

Add nagging progress messages and a hidden Zombie apocalypse activity

The packing list now shows a short, stage-dependent line under the progress
bar, from "The tent won't pack itself." to "Done! Go on, touch some grass."

Activities can be flagged hidden in the admin panel. Hidden activities are
left off the selection page until someone clicks "Pack it!" ten times in
quick succession with nothing selected. The Pack button is now
aria-disabled instead of disabled so those clicks register. Sample data and
migration 002 add a hidden "Zombie apocalypse" activity with its own items.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sebastian 2 недель назад
Родитель
Сommit
6ed62b87d1
7 измененных файлов с 330 добавлено и 55 удалено
  1. 7 2
      README.md
  2. 13 5
      admin/activities.php
  3. 2 0
      admin/layout.php
  4. 127 34
      index.php
  5. 60 0
      migrations/002_hidden_activities.sql
  6. 76 2
      packing.php
  7. 45 12
      schema.sql

+ 7 - 2
README.md

@@ -26,13 +26,15 @@ PackIt is a smart packing list generator for outdoor activities. Select the acti
 - **Per-mapping quantities** — Each activity-to-item mapping carries a quantity. Items needed in multiples (e.g. 2× Water bottle) are rendered with a leading multiplier, and merging across activities keeps the largest required quantity.
 - **Grouped items** — Items are organised into logical categories (Clothing, Shelter, Navigation, Medical, Water & Food, Safety Gear) for easy scanning.
 - **Interactive checklist** — Click any item to mark it as packed. Checked items are visually struck through and a progress bar updates in real time. Ticks are stored in the browser under the list's hash and restored on the next visit; a **Reset** button appears once something is ticked. Nothing is sent to the server, so each device keeps its own progress, and a browser with site data blocked simply falls back to not remembering.
+- **Nagging progress messages** — A line under the progress bar comments on how the packing is going, from *"The tent won't pack itself."* at zero to *"Just the headlamp + spare batteries left."* for the final item. The line stays the same until you reach the next stage, so it doesn't change on every tick.
+- **Hidden activities** — Activities flagged as hidden in the admin panel are left off the selection page. They appear when someone clicks **Pack it!** ten times in quick succession (at most 0.5 s between clicks) while nothing is selected. The revealed activity is pre-selected, and its lists and short links work like any other. The sample data hides one: *Zombie apocalypse*.
 - **Shareable short URLs** — Each unique activity selection is stored under a deterministic 5-character hash, producing a permanent link short enough to share comfortably over chat or read out loud.
 - **Installable PWA** — A web app manifest, theme color, and maskable icons let PackIt be installed to the home screen on mobile or as a standalone app on desktop.
 - **Works offline** — A service worker (`sw.js`) caches the app shell and every list you have opened, which is what makes an installed PackIt useful at the trailhead. Pages are network-first, so an online visit always reflects the latest admin changes and falls back to the cached copy only when the network is gone. A list never opened on the device shows `offline.html` instead. The admin panel is deliberately never cached.
 - **Collision-safe short links** — Links are a 5-character SHA-256 prefix. If a prefix is ever claimed by a different selection, that selection transparently gets a 6-character link (then 7, …) rather than failing, so links stay short without ever pointing at the wrong list.
 - **Print support** — The packing list page has a print stylesheet that removes UI chrome and renders a clean black-and-white checklist.
 - **Admin panel** — A password-protected admin interface at `/admin/` lets you manage all content:
-  - Activity groups and activities
+  - Activity groups and activities, including the *Hidden* flag for easter-egg activities
   - Item groups and items
   - Activity-to-item mappings (with quantities)
   - Inline editing on every list page — edit fields render in place within the table column, so the page no longer jumps to the top on each edit
@@ -70,14 +72,17 @@ Offline support needs a secure context: browsers only register a service worker
 
 ### Upgrading an existing install
 
-Databases created before this change need one migration:
+Run any migrations your database predates, in order:
 
 ```
 mysql -u <user> -p <your-database> < migrations/001_hardening.sql
+mysql -u <user> -p <your-database> < migrations/002_hidden_activities.sql
 ```
 
 It adds the `login_attempts` table, narrows `selection_hashes.hash` to `VARCHAR(16)`, and indexes `activity_ids`. It also deletes any rows still holding a 64-character hash from the original link scheme — those links have been unreachable since links were shortened to 5 characters, and the table is only a cache of selection → hash, so anything deleted is recreated by re-picking the activities.
 
+`002` adds the `activities.hidden` column and the hidden *Zombie apocalypse* activity (in a new *Survival* group) with its items. It finds item groups and existing items by their sample-data names. An item whose group has been renamed is skipped, and you can add it later in the admin panel.
+
 ### Admin password
 
 Admin credentials live in the `admin_users` table, not in `config.php`. The `password_hash` column stores a bcrypt hash produced by PHP's `password_hash()`; `admin/login.php` verifies the submitted password against it with `password_verify()`.

+ 13 - 5
admin/activities.php

@@ -13,9 +13,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
         $name  = trim($_POST['activityName'] ?? '');
         $gid   = (int)$_POST['groupID'];
         $order = (int)($_POST['sortOrder'] ?? 0);
+        $hidden = isset($_POST['hidden']) ? 1 : 0;
         if ($name !== '' && $gid > 0) {
-            $pdo->prepare("INSERT INTO activities (groupID,activityName,sortOrder) VALUES (?,?,?)")
-                ->execute([$gid, $name, $order]);
+            $pdo->prepare("INSERT INTO activities (groupID,activityName,sortOrder,hidden) VALUES (?,?,?,?)")
+                ->execute([$gid, $name, $order, $hidden]);
             flash('success', "Activity '$name' added.");
         } else {
             flash('error', 'Please fill all fields.');
@@ -27,9 +28,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
         $name  = trim($_POST['activityName'] ?? '');
         $gid   = (int)$_POST['groupID'];
         $order = (int)($_POST['sortOrder'] ?? 0);
+        $hidden = isset($_POST['hidden']) ? 1 : 0;
         if ($name !== '') {
-            $pdo->prepare("UPDATE activities SET activityName=?,groupID=?,sortOrder=? WHERE activityID=?")
-                ->execute([$name, $gid, $order, $id]);
+            $pdo->prepare("UPDATE activities SET activityName=?,groupID=?,sortOrder=?,hidden=? WHERE activityID=?")
+                ->execute([$name, $gid, $order, $hidden, $id]);
             flash('success', 'Activity updated.');
         } else {
             flash('error', 'Name cannot be empty.');
@@ -83,6 +85,7 @@ show_alerts();
       <label>Sort Order</label>
       <input type="number" name="sortOrder" value="0" min="0" style="max-width:120px;">
     </div>
+    <label class="check-label"><input type="checkbox" name="hidden" value="1"> Hidden (easter egg, revealed by clicking "Pack it!" 10&times; fast with nothing selected)</label>
     <div style="display:flex;gap:.5rem;margin-top:.25rem;">
       <button type="submit" class="btn btn-primary">➕ Add</button>
     </div>
@@ -103,9 +106,10 @@ show_alerts();
     <col>
     <col style="width:180px">
     <col style="width:100px">
+    <col style="width:90px">
     <col style="width:200px">
   </colgroup>
-  <thead><tr><th>#</th><th>Name</th><th>Group</th><th>Sort</th><th></th></tr></thead>
+  <thead><tr><th>#</th><th>Name</th><th>Group</th><th>Sort</th><th>Hidden</th><th></th></tr></thead>
   <tbody>
   <?php foreach ($activities as $a):
     $id = $a['activityID'];
@@ -130,6 +134,10 @@ show_alerts();
       <div class="view"><?= $a['sortOrder'] ?></div>
       <div class="edit"><input type="number" name="sortOrder" form="edit-form-<?= $id ?>" value="<?= (int)$a['sortOrder'] ?>" min="0"></div>
     </td>
+    <td>
+      <div class="view"><?php if ($a['hidden']): ?><span class="badge badge-secret">Secret</span><?php endif; ?></div>
+      <div class="edit"><input type="checkbox" name="hidden" value="1" form="edit-form-<?= $id ?>" <?= $a['hidden'] ? 'checked' : '' ?> aria-label="Hidden"></div>
+    </td>
     <td>
       <div class="view actions-buttons">
         <button type="button" onclick="startEdit(<?= $id ?>)" class="btn btn-sm btn-teal">Edit</button>

+ 2 - 0
admin/layout.php

@@ -79,6 +79,8 @@ input:focus,select:focus,textarea:focus{border-color:var(--accent2);}
 /* badges */
 .badge{display:inline-block;padding:.15rem .55rem;border-radius:999px;font-size:.72rem;font-weight:600;letter-spacing:.04em;}
 .badge-group{background:rgba(79,209,197,.1);color:var(--accent2);border:1px solid rgba(79,209,197,.3);}
+.badge-secret{background:rgba(224,85,85,.1);color:var(--danger);border:1px solid rgba(224,85,85,.3);}
+.check-label{display:flex;align-items:center;gap:.5rem;text-transform:none;letter-spacing:0;margin-bottom:.9rem;cursor:pointer;}
 </style>
 </head>
 <body>

+ 127 - 34
index.php

@@ -12,25 +12,54 @@ $sql = "
         ag.sortOrder   AS groupSort,
         a.activityID,
         a.activityName,
-        a.sortOrder    AS actSort
+        a.sortOrder    AS actSort,
+        a.hidden
     FROM activity_groups ag
     JOIN activities a ON a.groupID = ag.groupID
     ORDER BY ag.sortOrder, ag.groupID, a.sortOrder, a.activityID
 ";
 $rows = $pdo->query($sql)->fetchAll();
 
-// Group by category
-$groups = [];
+// Group by category. Hidden activities are easter eggs: they are rendered into
+// a section that stays hidden until the page script reveals it.
+$byVisibility = ['visible' => [], 'secret' => []];
 foreach ($rows as $row) {
     $gid = $row['groupID'];
-    if (!isset($groups[$gid])) {
-        $groups[$gid] = ['name' => $row['groupName'], 'activities' => []];
+    $vis = $row['hidden'] ? 'secret' : 'visible';
+    if (!isset($byVisibility[$vis][$gid])) {
+        $byVisibility[$vis][$gid] = ['name' => $row['groupName'], 'activities' => []];
     }
-    $groups[$gid]['activities'][] = [
+    $byVisibility[$vis][$gid]['activities'][] = [
         'id'   => $row['activityID'],
         'name' => $row['activityName'],
     ];
 }
+$groups       = $byVisibility['visible'];
+$secretGroups = $byVisibility['secret'];
+
+function render_activity_group(array $group): void { ?>
+    <div class="activity-group">
+      <div class="section-title"><?= htmlspecialchars($group['name']) ?></div>
+      <div class="activity-grid">
+        <?php foreach ($group['activities'] as $act): ?>
+        <div class="activity-card">
+          <input type="checkbox" name="aID[]"
+                 id="act_<?= $act['id'] ?>"
+                 value="<?= (int)$act['id'] ?>"
+                 onchange="updateCount()">
+          <label for="act_<?= $act['id'] ?>">
+            <span class="check-icon">
+              <svg width="10" height="8" viewBox="0 0 10 8" fill="none">
+                <path d="M1 4l3 3 5-6" stroke="#0f1117" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+              </svg>
+            </span>
+            <?= htmlspecialchars($act['name']) ?>
+          </label>
+        </div>
+        <?php endforeach; ?>
+      </div>
+    </div>
+<?php }
 ?>
 <!DOCTYPE html>
 <html lang="en">
@@ -219,10 +248,35 @@ foreach ($rows as $row) {
     border-radius: var(--radius);
     cursor: pointer;
     transition: background .15s, transform .1s, opacity .15s;
+    touch-action: manipulation;   /* rapid taps must not turn into double-tap zoom */
   }
   .btn-primary:hover { background: #f0d260; transform: translateY(-1px); }
   .btn-primary:active { transform: translateY(0); }
-  .btn-primary:disabled { opacity: .35; cursor: not-allowed; transform: none; }
+  .btn-primary[aria-disabled="true"] { opacity: .35; cursor: not-allowed; transform: none; }
+  .btn-primary.nope { animation: nope .3s; }
+  @keyframes nope {
+    20%, 60% { transform: translateX(-4px); }
+    40%, 80% { transform: translateX(4px); }
+  }
+
+  /* Easter egg: hidden activities flicker in like a failing fluorescent tube. */
+  .secret-activities .section-title { color: var(--danger); }
+  .secret-activities .activity-card input:checked + label {
+    border-color: var(--danger);
+    background: rgba(224,85,85,.08);
+  }
+  .secret-activities .activity-card input:checked + label .check-icon {
+    background: var(--danger);
+    border-color: var(--danger);
+  }
+  .secret-activities.revealing { animation: flicker 1.2s linear; }
+  @keyframes flicker {
+    0%, 12%, 22%, 48% { opacity: 0; }
+    10%, 20%, 45%, 100% { opacity: 1; }
+  }
+  @media (prefers-reduced-motion: reduce) {
+    .btn-primary.nope, .secret-activities.revealing { animation: none; }
+  }
 
   /* select-all row */
   .controls {
@@ -271,36 +325,22 @@ foreach ($rows as $row) {
   </div>
 
   <form id="actForm" action="list.php" method="GET">
-    <?php foreach ($groups as $gid => $group): ?>
-    <div class="activity-group">
-      <div class="section-title"><?= htmlspecialchars($group['name']) ?></div>
-      <div class="activity-grid">
-        <?php foreach ($group['activities'] as $act): ?>
-        <div class="activity-card">
-          <input type="checkbox" name="aID[]"
-                 id="act_<?= $act['id'] ?>"
-                 value="<?= (int)$act['id'] ?>"
-                 onchange="updateCount()">
-          <label for="act_<?= $act['id'] ?>">
-            <span class="check-icon">
-              <svg width="10" height="8" viewBox="0 0 10 8" fill="none">
-                <path d="M1 4l3 3 5-6" stroke="#0f1117" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-              </svg>
-            </span>
-            <?= htmlspecialchars($act['name']) ?>
-          </label>
-        </div>
-        <?php endforeach; ?>
-      </div>
+    <?php foreach ($groups as $group) render_activity_group($group); ?>
+
+    <?php if ($secretGroups): ?>
+    <div id="secretActivities" class="secret-activities" hidden>
+      <?php foreach ($secretGroups as $group) render_activity_group($group); ?>
     </div>
-    <?php endforeach; ?>
+    <?php endif; ?>
   </form>
 </main>
 
 <div class="action-bar">
   <div class="selection-count"><strong id="cnt">0</strong> activities selected</div>
-  <button class="btn-primary" id="packBtn" disabled
-          onclick="document.getElementById('actForm').submit()">
+  <!-- aria-disabled rather than disabled: a disabled button swallows clicks,
+       and the clicks on an empty selection are what reveal the easter egg. -->
+  <button class="btn-primary" id="packBtn" type="button" aria-disabled="true"
+          onclick="packIt()">
     Pack it! →
   </button>
 </div>
@@ -308,16 +348,69 @@ foreach ($rows as $row) {
 <footer><a href="admin/">Admin</a></footer>
 
 <script>
+function selectedCount() {
+  return document.querySelectorAll('input[name="aID[]"]:checked').length;
+}
 function updateCount() {
-  const n = document.querySelectorAll('input[name="aID[]"]:checked').length;
+  const n = selectedCount();
   document.getElementById('cnt').textContent = n;
-  document.getElementById('packBtn').disabled = n === 0;
+  document.getElementById('packBtn').setAttribute('aria-disabled', n === 0 ? 'true' : 'false');
 }
 function toggleAll(on) {
-  document.querySelectorAll('input[name="aID[]"]').forEach(cb => cb.checked = on);
+  // Secret activities stay out of "Select all" until they have been found.
+  document.querySelectorAll('input[name="aID[]"]').forEach(cb => {
+    if (!cb.closest('[hidden]')) cb.checked = on;
+  });
   updateCount();
 }
 
+// Easter egg: hammering the button with nothing selected reveals the hidden
+// activities. Each click must follow the previous one within EGG_MAX_GAP_MS.
+const EGG_CLICKS     = 10;
+const EGG_MAX_GAP_MS = 500;
+let eggClicks = 0;
+let eggLastClick = 0;
+
+function packIt() {
+  if (selectedCount() > 0) {
+    document.getElementById('actForm').submit();
+    return;
+  }
+
+  const btn = document.getElementById('packBtn');
+  btn.classList.remove('nope');
+  void btn.offsetWidth;            // restart the shake on every click
+  btn.classList.add('nope');
+
+  const now = Date.now();
+  eggClicks = now - eggLastClick <= EGG_MAX_GAP_MS ? eggClicks + 1 : 1;
+  eggLastClick = now;
+  if (eggClicks >= EGG_CLICKS) {
+    eggClicks = 0;
+    revealSecrets(true);
+  }
+}
+
+function revealSecrets(select) {
+  const secret = document.getElementById('secretActivities');
+  if (!secret || !secret.hidden) return;
+  secret.hidden = false;
+  if (select) {
+    secret.querySelectorAll('input[name="aID[]"]').forEach(cb => cb.checked = true);
+    secret.classList.add('revealing');
+    secret.addEventListener('animationend', () => secret.classList.remove('revealing'), { once: true });
+    secret.scrollIntoView({ behavior: 'smooth', block: 'center' });
+  }
+  updateCount();
+}
+
+// Back/forward navigation restores ticked checkboxes, including a secret one
+// that would otherwise be ticked but invisible.
+window.addEventListener('pageshow', () => {
+  if (document.querySelector('#secretActivities input:checked')) revealSecrets(false);
+  updateCount();
+});
+
 // Offline support: installs the worker that keeps visited lists readable
 // without signal.
 if ('serviceWorker' in navigator) {

+ 60 - 0
migrations/002_hidden_activities.sql

@@ -0,0 +1,60 @@
+-- Migration 002 – hidden (easter egg) activities + the Zombie apocalypse list.
+-- Run once against an existing PackIt database:
+--     mysql -u <user> -p <your-database> < migrations/002_hidden_activities.sql
+-- New installs get all of this from schema.sql and can skip it.
+
+-- 1. Hidden activities are left off the selection page until revealed.
+ALTER TABLE activities ADD COLUMN hidden TINYINT(1) NOT NULL DEFAULT 0;
+
+-- 2. The Zombie apocalypse activity in its own group.
+INSERT INTO activity_groups (groupName, sortOrder)
+SELECT 'Survival', COALESCE(MAX(sortOrder), 0) + 1 FROM activity_groups;
+SET @survivalGroup = LAST_INSERT_ID();
+
+INSERT INTO activities (groupID, activityName, sortOrder, hidden)
+VALUES (@survivalGroup, 'Zombie apocalypse', 1, 1);
+SET @zombie = LAST_INSERT_ID();
+
+-- 3. Items only this list needs. Item groups are looked up by their sample-data
+--    names; an item whose group was renamed or removed is skipped rather than
+--    failing the migration, and can be added through the admin panel instead.
+INSERT INTO items (groupID, itemName, sortOrder)
+SELECT ig.groupID, n.itemName, n.sortOrder
+FROM (
+    SELECT 'Clothing'     AS groupName, 'Running shoes (cardio is rule #1)' AS itemName, 12 AS sortOrder
+    UNION ALL SELECT 'Clothing',     'Bite-proof arm guards',      13
+    UNION ALL SELECT 'Navigation',   'Map with safe zones marked',  4
+    UNION ALL SELECT 'Water & Food', 'Canned beans',                6
+    UNION ALL SELECT 'Water & Food', 'Manual can opener',           7
+    UNION ALL SELECT 'Safety Gear',  'Cricket bat',                 9
+    UNION ALL SELECT 'Safety Gear',  'Crowbar',                    10
+    UNION ALL SELECT 'Safety Gear',  'Duct tape',                  11
+    UNION ALL SELECT 'Safety Gear',  'Walkie-talkies',             12
+) n
+JOIN item_groups ig ON ig.groupName = n.groupName
+WHERE NOT EXISTS (SELECT 1 FROM items i WHERE i.itemName = n.itemName);
+
+-- 4. Map new and existing items to the activity by name. MIN(itemID) guards
+--    against an install that happens to hold two items with the same name.
+INSERT INTO activity_item_map (activityID, itemID, quantity)
+SELECT @zombie, MIN(i.itemID), n.quantity
+FROM (
+    SELECT 'Running shoes (cardio is rule #1)' AS itemName, 1 AS quantity
+    UNION ALL SELECT 'Bite-proof arm guards',       2
+    UNION ALL SELECT 'Sleeping bag',                1
+    UNION ALL SELECT 'Map with safe zones marked',  1
+    UNION ALL SELECT 'Compass',                     1
+    UNION ALL SELECT 'First aid kit',               1
+    UNION ALL SELECT 'Water bottle',                2
+    UNION ALL SELECT 'Water filter',                1
+    UNION ALL SELECT 'Canned beans',               12
+    UNION ALL SELECT 'Manual can opener',           1
+    UNION ALL SELECT 'Headlamp + spare batteries',  1
+    UNION ALL SELECT 'Rope (30m)',                  1
+    UNION ALL SELECT 'Cricket bat',                 1
+    UNION ALL SELECT 'Crowbar',                     1
+    UNION ALL SELECT 'Duct tape',                   1
+    UNION ALL SELECT 'Walkie-talkies',              2
+) n
+JOIN items i ON i.itemName = n.itemName
+GROUP BY n.itemName, n.quantity;

+ 76 - 2
packing.php

@@ -213,9 +213,17 @@ $totalItems = count($allItems);
     padding: 1rem 1.25rem;
     margin-bottom: 2.5rem;
     display: flex;
+    flex-wrap: wrap;
     align-items: center;
-    gap: 1.5rem;
+    gap: .6rem 1.5rem;
+  }
+  .progress-nag {
+    flex-basis: 100%;
+    font-size: .82rem;
+    font-style: italic;
+    color: var(--muted);
   }
+  .progress-nag:empty { display: none; }
   .progress-track {
     flex: 1;
     height: 6px;
@@ -393,6 +401,7 @@ $totalItems = count($allItems);
   <div class="progress-bar-wrap">
     <div class="progress-track"><div class="progress-fill" id="progressFill"></div></div>
     <div class="progress-label"><strong id="checkedCnt">0</strong> / <?= $totalItems ?> packed</div>
+    <div class="progress-nag" id="nag" aria-live="polite"></div>
   </div>
 
   <!-- Grouped item list -->
@@ -401,7 +410,7 @@ $totalItems = count($allItems);
     <div class="section-title"><?= htmlspecialchars($group['name']) ?></div>
     <div class="item-list">
       <?php foreach ($group['items'] as $item): ?>
-      <div class="item-row" data-item-id="<?= (int)$item['id'] ?>" onclick="toggleItem(this)">
+      <div class="item-row" data-item-id="<?= (int)$item['id'] ?>" data-name="<?= htmlspecialchars($item['name']) ?>" onclick="toggleItem(this)">
         <div class="item-check">
           <svg width="10" height="8" viewBox="0 0 10 8" fill="none">
             <path d="M1 4l3 3 5-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
@@ -466,6 +475,71 @@ function updateProgress() {
   document.getElementById('checkedCnt').textContent = checked;
   document.getElementById('progressFill').style.width = (total ? checked / total * 100 : 0) + '%';
   document.getElementById('resetBtn').hidden = checked === 0;
+  updateNag(checked);
+}
+
+// A little encouragement (read: nagging) under the progress bar. {left} is the
+// number of items still to pack, {item} a random one of them.
+const NAGS = {
+  none:  ["The tent won't pack itself.",
+          "Staring at the list doesn't count as packing.",
+          "Step one: pick up an item. Any item."],
+  start: ["A start! A small, fragile start.",
+          "Nice. Now do that {left} more times.",
+          "Only {left} to go. Easy. Probably."],
+  low:   ["Still more list than luggage.",
+          "Keep going. The snacks are watching.",
+          "Your bag is still suspiciously light."],
+  half:  ["Halfway there. No stopping for snacks.",
+          "Over half. Your backpack is starting to believe in you.",
+          "Past the point of no return. Well, you could still unpack."],
+  close: ["So close. It's the {item}, isn't it.",
+          "{left} to go. Don't get sloppy now.",
+          "Almost there. Is the {item} hiding somewhere?"],
+  last:  ["Just the {item} left. You know what to do.",
+          "One item. The {item}. It's waiting for you."],
+  done:  ["All packed. Now go outside.",
+          "Done! Go on, touch some grass.",
+          "Packed and ready. Remember where you put the keys?"],
+};
+const nag = { tier: null, text: '', item: null };
+
+function nagTier(checked) {
+  const left = total - checked;
+  if (left === 0)             return 'done';
+  if (left === 1)             return 'last';
+  if (checked === 0)          return 'none';
+  if (checked / total < .25)  return 'start';
+  if (checked / total < .5)   return 'low';
+  if (checked / total < .75)  return 'half';
+  return 'close';
+}
+
+// "It's the Headlamp, isn't it" reads better as "the headlamp", but leave
+// acronyms like "GPS device" alone.
+function midSentence(name) {
+  return /^[A-Z][a-z]/.test(name) ? name[0].toLowerCase() + name.slice(1) : name;
+}
+
+function updateNag(checked) {
+  const el = document.getElementById('nag');
+  if (total === 0) { el.textContent = ''; return; }
+
+  // Keep the same line while the tier holds, so it doesn't flicker on every tick.
+  const tier = nagTier(checked);
+  if (tier !== nag.tier) {
+    const pool = NAGS[tier];
+    nag.tier = tier;
+    nag.text = pool[Math.floor(Math.random() * pool.length)];
+  }
+
+  let item = '';
+  if (nag.text.includes('{item}')) {
+    const open = Array.from(document.querySelectorAll('.item-row:not(.checked)'), r => r.dataset.name);
+    if (!open.includes(nag.item)) nag.item = open[Math.floor(Math.random() * open.length)];
+    item = midSentence(nag.item);
+  }
+  el.textContent = nag.text.replace('{left}', total - checked).replace('{item}', item);
 }
 
 function restoreChecked() {

+ 45 - 12
schema.sql

@@ -17,6 +17,9 @@ CREATE TABLE IF NOT EXISTS activities (
     groupID      INT NOT NULL,
     activityName VARCHAR(150) NOT NULL,
     sortOrder    INT DEFAULT 0,
+    -- Easter eggs: left off the selection page until the visitor finds the
+    -- trick that reveals them (see index.php). Lists and links work as usual.
+    hidden       TINYINT(1) NOT NULL DEFAULT 0,
     FOREIGN KEY (groupID) REFERENCES activity_groups(groupID) ON DELETE CASCADE
 );
 
@@ -83,20 +86,23 @@ INSERT INTO activity_groups (groupName, sortOrder) VALUES
 ('Overnight',        1),   -- groupID 1
 ('Rescue & Safety',  2),   -- groupID 2
 ('Water Activities', 3),   -- groupID 3
-('Winter Sports',    4);   -- groupID 4
+('Winter Sports',    4),   -- groupID 4
+('Survival',         5);   -- groupID 5 (only hidden activities)
 
 -- activityID 1=Camping 2=Hiking 3=Bivouac 4=First Aid 5=Mountain Rescue
 -- activityID 6=Kayaking 7=Swimming 8=Skiing 9=Snowshoeing
-INSERT INTO activities (groupID, activityName, sortOrder) VALUES
-(1, 'Camping',         1),
-(1, 'Hiking',          2),
-(1, 'Bivouac',         3),
-(2, 'First Aid',       1),
-(2, 'Mountain Rescue', 2),
-(3, 'Kayaking',        1),
-(3, 'Swimming',        2),
-(4, 'Skiing',          1),
-(4, 'Snowshoeing',     2);
+-- activityID 10=Zombie apocalypse (hidden easter egg)
+INSERT INTO activities (groupID, activityName, sortOrder, hidden) VALUES
+(1, 'Camping',           1, 0),
+(1, 'Hiking',            2, 0),
+(1, 'Bivouac',           3, 0),
+(2, 'First Aid',         1, 0),
+(2, 'Mountain Rescue',   2, 0),
+(3, 'Kayaking',          1, 0),
+(3, 'Swimming',          2, 0),
+(4, 'Skiing',            1, 0),
+(4, 'Snowshoeing',       2, 0),
+(5, 'Zombie apocalypse', 1, 1);
 
 INSERT INTO item_groups (groupName, sortOrder) VALUES
 ('Clothing',     1),   -- groupID 1
@@ -142,6 +148,15 @@ INSERT INTO item_groups (groupName, sortOrder) VALUES
 -- 33  Shovel                (Safety Gear)
 -- 34  Life jacket           (Safety Gear)
 -- 35  Paddle float          (Safety Gear)
+-- 36  Running shoes         (Clothing)      \
+-- 37  Bite-proof arm guards (Clothing)       |
+-- 38  Map with safe zones   (Navigation)     |
+-- 39  Canned beans          (Water & Food)   |  Zombie apocalypse
+-- 40  Manual can opener     (Water & Food)   |  only
+-- 41  Cricket bat           (Safety Gear)    |
+-- 42  Crowbar               (Safety Gear)    |
+-- 43  Duct tape             (Safety Gear)    |
+-- 44  Walkie-talkies        (Safety Gear)   /
 
 INSERT INTO items (groupID, itemName, sortOrder) VALUES
 (1, 'Base layer top',             1),
@@ -178,7 +193,16 @@ INSERT INTO items (groupID, itemName, sortOrder) VALUES
 (6, 'Avalanche transceiver',      5),
 (6, 'Shovel',                     6),
 (6, 'Life jacket',                7),
-(6, 'Paddle float',               8);
+(6, 'Paddle float',               8),
+(1, 'Running shoes (cardio is rule #1)', 12),
+(1, 'Bite-proof arm guards',     13),
+(3, 'Map with safe zones marked', 4),
+(5, 'Canned beans',               6),
+(5, 'Manual can opener',          7),
+(6, 'Cricket bat',                9),
+(6, 'Crowbar',                   10),
+(6, 'Duct tape',                 11),
+(6, 'Walkie-talkies',            12);
 
 -- -----------------------------------------------
 -- Activity -> Item mappings (all IDs verified)
@@ -254,6 +278,15 @@ INSERT INTO activity_item_map (activityID, itemID) VALUES
 (9,28),(9,29),
 (9,31);
 
+-- activityID 10: Zombie apocalypse (hidden)
+INSERT INTO activity_item_map (activityID, itemID, quantity) VALUES
+(10,36, 1),(10,37, 2),
+(10,13, 1),
+(10,38, 1),(10,17, 1),
+(10,19, 1),
+(10,23, 2),(10,24, 1),(10,39,12),(10,40, 1),
+(10,28, 1),(10,30, 1),(10,41, 1),(10,42, 1),(10,43, 1),(10,44, 2);
+
 -- -----------------------------------------------
 -- Default admin user
 -- Username: admin   Password: packit-admin