3 Commits 7e2c7d34b6 ... 3badc35adf

Auteur SHA1 Message Date
  sebastian 3badc35adf Fix empty Zombie-Apokalypse list and translate its items to German il y a 2 semaines
  sebastian 6ed62b87d1 Add nagging progress messages and a hidden Zombie apocalypse activity il y a 2 semaines
  sebastian b46705174a Show mapping counts on the items page and make the list sortable il y a 3 semaines
9 fichiers modifiés avec 521 ajouts et 62 suppressions
  1. 8 2
      README.md
  2. 13 5
      admin/activities.php
  3. 110 6
      admin/items.php
  4. 2 0
      admin/layout.php
  5. 127 34
      index.php
  6. 60 0
      migrations/002_hidden_activities.sql
  7. 85 0
      migrations/003_zombie_items_german.sql
  8. 70 2
      packing.php
  9. 46 13
      schema.sql

+ 8 - 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 Stirnlampe 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-Apokalypse*.
 - **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,18 @@ 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
+mysql -u <user> -p <your-database> < migrations/003_zombie_items_german.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 activity. Its item lookups only match the English sample-data names, so on a database with its own item groups the activity ends up empty. `003` fixes that and renames everything to German (*Zombie-Apokalypse* in *Überleben*). Each item goes into its German group (*Kleidung*, *Essen*, *Küche*, *Elektronik*, *Sicherheit*, *Kleinzeug*) or, if that group doesn't exist, into a new *Zombie-Abwehr* group. Items you already have, such as *Stirnlampe* or *EH-Set*, are reused, and the English items `002` created are removed. It is safe to run more than once.
+
 ### 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>

+ 110 - 6
admin/items.php

@@ -37,7 +37,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
 
 $igroups = $pdo->query("SELECT * FROM item_groups ORDER BY sortOrder,groupID")->fetchAll();
 $items   = $pdo->query("
-    SELECT i.*, ig.groupName
+    SELECT i.*, ig.groupName,
+           (SELECT COUNT(*) FROM activity_item_map m WHERE m.itemID = i.itemID) AS mappingCount
     FROM items i
     JOIN item_groups ig ON ig.groupID = i.groupID
     ORDER BY ig.sortOrder, i.sortOrder, i.itemID
@@ -66,6 +67,18 @@ show_alerts();
 .dup-hint-float{position:absolute;z-index:30;top:calc(100% + 4px);left:0;margin:0;
                 min-width:100%;width:max-content;max-width:min(420px,60vw);
                 background:var(--surface);box-shadow:0 8px 24px rgba(0,0,0,.45);}
+
+/* Clickable column headers. The arrow is driven by aria-sort, so the visual
+   state and the one screen readers announce can never drift apart. */
+.th-sort{cursor:pointer;user-select:none;}
+.th-sort:hover{color:var(--text);}
+.th-sort:focus-visible{outline:2px solid var(--accent2);outline-offset:-2px;}
+.th-sort::after{content:'\2195';margin-left:.35rem;opacity:.3;}
+.th-sort[aria-sort="ascending"]{color:var(--accent);}
+.th-sort[aria-sort="ascending"]::after{content:'\2191';opacity:1;}
+.th-sort[aria-sort="descending"]{color:var(--accent);}
+.th-sort[aria-sort="descending"]::after{content:'\2193';opacity:1;}
+.count-zero{color:var(--muted);}
 </style>
 
 <div class="card">
@@ -107,20 +120,32 @@ show_alerts();
 </form>
 <?php endforeach; ?>
 
-<table class="tbl tbl-fixed">
+<table class="tbl tbl-fixed" id="items-table">
   <colgroup>
     <col style="width:60px">
     <col>
     <col style="width:180px">
-    <col style="width:100px">
+    <col style="width:90px">
+    <col style="width:120px">
     <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 class="th-sort" data-sort-key="id"    data-sort-type="num"  tabindex="0" aria-sort="none">#</th>
+    <th class="th-sort" data-sort-key="name"  data-sort-type="text" tabindex="0" aria-sort="none">Name</th>
+    <th class="th-sort" data-sort-key="group" data-sort-type="text" tabindex="0" aria-sort="none">Group</th>
+    <th class="th-sort" data-sort-key="order" data-sort-type="num"  tabindex="0" aria-sort="none">Sort</th>
+    <th class="th-sort" data-sort-key="maps"  data-sort-type="num"  tabindex="0" aria-sort="none">Mappings</th>
+    <th></th>
+  </tr></thead>
   <tbody>
   <?php foreach ($items as $item):
-    $id = $item['itemID'];
+    $id   = $item['itemID'];
+    $maps = (int)$item['mappingCount'];
   ?>
-  <tr id="row-<?= $id ?>" data-editing="false">
+  <tr id="row-<?= $id ?>" data-editing="false"
+      data-id="<?= $id ?>" data-name="<?= htmlspecialchars($item['itemName']) ?>"
+      data-group="<?= htmlspecialchars($item['groupName']) ?>"
+      data-order="<?= (int)$item['sortOrder'] ?>" data-maps="<?= $maps ?>">
     <td style="color:var(--muted)"><?= $id ?></td>
     <td>
       <div class="view"><?= htmlspecialchars($item['itemName']) ?></div>
@@ -147,6 +172,13 @@ show_alerts();
       <div class="view"><?= $item['sortOrder'] ?></div>
       <div class="edit"><input type="number" name="sortOrder" form="edit-form-<?= $id ?>" value="<?= (int)$item['sortOrder'] ?>" min="0"></div>
     </td>
+    <td>
+      <?php if ($maps > 0): ?>
+      <span class="badge badge-group" title="Used in <?= $maps ?> activity mapping<?= $maps === 1 ? '' : 's' ?>"><?= $maps ?></span>
+      <?php else: ?>
+      <span class="count-zero" title="Not used in any activity">0</span>
+      <?php endif; ?>
+    </td>
     <td>
       <div class="view actions-buttons">
         <button type="button" onclick="startEdit(<?= $id ?>)" class="btn btn-sm btn-teal">Edit</button>
@@ -179,6 +211,78 @@ function cancelEdit(id) {
 }
 </script>
 
+<script>
+// Column sorting. Each row carries its sort keys as data attributes, so the
+// comparison never has to dig through the view/edit markup in the cells.
+(function () {
+  var table = document.getElementById('items-table');
+  if (!table || !table.tBodies.length) return;
+
+  var tbody = table.tBodies[0];
+  var rows  = Array.prototype.slice.call(tbody.rows);
+  // The order the server sent - group, then sort order, then ID - is not
+  // reproducible from any single column, so it is kept as the third state of
+  // the cycle rather than being lost after the first click.
+  rows.forEach(function (row, i) { row.dataset.defaultPos = i; });
+
+  var headers = Array.prototype.slice.call(table.querySelectorAll('th.th-sort'));
+  var STORE   = 'packit.items.sort';
+  var collator = new Intl.Collator(undefined, {sensitivity: 'base', numeric: true});
+
+  function compare(th, a, b) {
+    var k = th.dataset.sortKey;
+    return th.dataset.sortType === 'num'
+      ? (+a.dataset[k]) - (+b.dataset[k])
+      : collator.compare(a.dataset[k], b.dataset[k]);
+  }
+
+  // dir: 1 ascending, -1 descending, 0 back to the server's order.
+  function apply(th, dir) {
+    headers.forEach(function (h) { h.setAttribute('aria-sort', 'none'); });
+    if (th && dir) {
+      th.setAttribute('aria-sort', dir > 0 ? 'ascending' : 'descending');
+    }
+
+    rows.slice().sort(function (a, b) {
+      // Equal keys keep the server's order, so sorting by Group still lists
+      // each group's items in their configured sequence.
+      return (dir && compare(th, a, b) * dir) || (a.dataset.defaultPos - b.dataset.defaultPos);
+    }).forEach(function (row) { tbody.appendChild(row); });
+
+    try {
+      if (th && dir) {
+        sessionStorage.setItem(STORE, th.dataset.sortKey + ':' + dir);
+      } else {
+        sessionStorage.removeItem(STORE);
+      }
+    } catch (e) { /* private mode - sorting still works, it just will not stick */ }
+  }
+
+  function toggle(th) {
+    var state = th.getAttribute('aria-sort');
+    apply(th, state === 'ascending' ? -1 : state === 'descending' ? 0 : 1);
+  }
+
+  headers.forEach(function (th) {
+    th.addEventListener('click', function () { toggle(th); });
+    th.addEventListener('keydown', function (e) {
+      if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') {
+        e.preventDefault();
+        toggle(th);
+      }
+    });
+  });
+
+  // Saving an item redirects back to this page, so the chosen order is
+  // restored instead of snapping back to the default on every edit.
+  try {
+    var saved = (sessionStorage.getItem(STORE) || '').split(':');
+    var th = headers.filter(function (h) { return h.dataset.sortKey === saved[0]; })[0];
+    if (th) apply(th, saved[1] === '-1' ? -1 : 1);
+  } catch (e) { /* nothing stored, or storage blocked */ }
+})();
+</script>
+
 <script>
 // Duplicate hints: purely advisory. They never block a save - they only show
 // which existing items resemble the name being typed, both on the add form and

+ 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;

+ 85 - 0
migrations/003_zombie_items_german.sql

@@ -0,0 +1,85 @@
+-- Migration 003 – German item list for the hidden Zombie apocalypse activity.
+-- Run once, after 002, against an existing PackIt database:
+--     mysql -u <user> -p <your-database> < migrations/003_zombie_items_german.sql
+-- New installs get all of this from schema.sql and can skip it.
+--
+-- 002 looked item groups and items up by the English sample-data names, so on
+-- a database with its own (e.g. German) groups the activity ended up empty.
+-- This rebuilds the list without depending on any particular group existing:
+-- an item whose group is missing lands in the new "Zombie-Abwehr" group.
+-- Safe to run more than once.
+
+-- 1. Activity and its group in German.
+SET @zombie = (SELECT activityID FROM activities
+               WHERE hidden = 1 AND activityName IN ('Zombie apocalypse', 'Zombie-Apokalypse')
+               ORDER BY activityID LIMIT 1);
+
+UPDATE activities SET activityName = 'Zombie-Apokalypse' WHERE activityID = @zombie;
+UPDATE activity_groups ag
+JOIN activities a ON a.groupID = ag.groupID AND a.activityID = @zombie
+SET ag.groupName = 'Überleben'
+WHERE ag.groupName = 'Survival';
+
+-- 2. Start the list over. The English items 002 created are only removed when
+--    no other activity uses them.
+DELETE FROM activity_item_map WHERE activityID = @zombie;
+DELETE FROM items
+WHERE itemName IN ('Running shoes (cardio is rule #1)', 'Bite-proof arm guards',
+                   'Map with safe zones marked', 'Canned beans', 'Manual can opener',
+                   'Cricket bat', 'Crowbar', 'Duct tape', 'Walkie-talkies')
+  AND itemID NOT IN (SELECT itemID FROM activity_item_map);
+
+-- 3. Item group for the gear no normal trip needs. The guard sits in HAVING:
+--    an aggregate still yields a row when WHERE filters every row out.
+INSERT INTO item_groups (groupName, sortOrder)
+SELECT 'Zombie-Abwehr', COALESCE(MAX(sortOrder), 0) + 1 FROM item_groups
+HAVING SUM(groupName = 'Zombie-Abwehr') = 0;
+SET @zgroup = (SELECT groupID FROM item_groups WHERE groupName = 'Zombie-Abwehr'
+               ORDER BY groupID LIMIT 1);
+
+-- 4. The list. altName is the English sample-data name of an item that may
+--    already exist; an item found under either name is reused, not duplicated.
+CREATE TEMPORARY TABLE zombie_list (
+    itemName  VARCHAR(150) NOT NULL,
+    altName   VARCHAR(150) NULL,
+    groupName VARCHAR(100) NOT NULL,
+    quantity  INT          NOT NULL
+);
+INSERT INTO zombie_list (itemName, altName, groupName, quantity) VALUES
+('Laufschuhe (Regel Nr. 1: Cardio)',        NULL,                         'Kleidung',      1),
+('Bissfeste Armschützer (Paar)',            NULL,                         'Kleidung',      1),
+('Schlafsack',                              'Sleeping bag',               'Kleidung',      1),
+('Dosenbohnen',                             NULL,                         'Essen',         12),
+('Twinkies (letzte Packung der Welt)',      NULL,                         'Essen',         1),
+('Trinken (1 l)',                           'Water bottle',               'Essen',         2),
+('Dosenöffner (manuell)',                   NULL,                         'Küche',         1),
+('Feuerzeug',                               NULL,                         'Küche',         1),
+('Stirnlampe',                              'Headlamp + spare batteries', 'Elektronik',    1),
+('Funkgerät',                               NULL,                         'Elektronik',    2),
+('Kurbelradio',                             NULL,                         'Elektronik',    1),
+('EH-Set',                                  'First aid kit',              'Sicherheit',    1),
+('Multitool',                               NULL,                         'Sicherheit',    1),
+('Karte mit sicheren Zonen',                NULL,                         'Kleinzeug',     1),
+('Zombie-Wörterbuch (Grrr = Hallo)',        NULL,                         'Kleinzeug',     1),
+('Glaubwürdige Ausrede für die Bisswunde',  NULL,                         'Kleinzeug',     1),
+('Cricketschläger',                         NULL,                         'Zombie-Abwehr', 1),
+('Brecheisen',                              NULL,                         'Zombie-Abwehr', 1),
+('Panzertape',                              NULL,                         'Zombie-Abwehr', 1),
+('Bolzenschneider',                         NULL,                         'Zombie-Abwehr', 1),
+('Knoblauch (hilft nicht, beruhigt aber)',  NULL,                         'Zombie-Abwehr', 1),
+('Gartenzwerg zur Ablenkung',               NULL,                         'Zombie-Abwehr', 1);
+
+INSERT INTO items (groupID, itemName, sortOrder)
+SELECT COALESCE((SELECT ig.groupID FROM item_groups ig
+                 WHERE ig.groupName = z.groupName ORDER BY ig.groupID LIMIT 1), @zgroup),
+       z.itemName, 0
+FROM zombie_list z
+WHERE NOT EXISTS (SELECT 1 FROM items i WHERE i.itemName IN (z.itemName, z.altName));
+
+INSERT INTO activity_item_map (activityID, itemID, quantity)
+SELECT @zombie, MIN(i.itemID), z.quantity
+FROM zombie_list z
+JOIN items i ON i.itemName IN (z.itemName, z.altName)
+GROUP BY z.itemName, z.quantity;
+
+DROP TEMPORARY TABLE zombie_list;

+ 70 - 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,65 @@ 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';
+}
+
+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 = nag.item;   // as written: lowercasing would mangle German nouns
+  }
+  el.textContent = nag.text.replace('{left}', total - checked).replace('{item}', item);
 }
 
 function restoreChecked() {

+ 46 - 13
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
+('Überleben',        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-Apokalypse (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-Apokalypse', 1, 1);
 
 INSERT INTO item_groups (groupName, sortOrder) VALUES
 ('Clothing',     1),   -- groupID 1
@@ -104,7 +110,8 @@ INSERT INTO item_groups (groupName, sortOrder) VALUES
 ('Navigation',   3),   -- groupID 3
 ('Medical',      4),   -- groupID 4
 ('Water & Food', 5),   -- groupID 5
-('Safety Gear',  6);   -- groupID 6
+('Safety Gear',  6),   -- groupID 6
+('Zombie-Abwehr', 7);  -- groupID 7 (Zombie-Apokalypse only)
 
 -- Items with their resulting auto_increment IDs:
 --  1  Base layer top        (Clothing)
@@ -142,6 +149,7 @@ INSERT INTO item_groups (groupName, sortOrder) VALUES
 -- 33  Shovel                (Safety Gear)
 -- 34  Life jacket           (Safety Gear)
 -- 35  Paddle float          (Safety Gear)
+-- 36-53  Zombie-Abwehr items, used only by the hidden Zombie-Apokalypse
 
 INSERT INTO items (groupID, itemName, sortOrder) VALUES
 (1, 'Base layer top',             1),
@@ -178,7 +186,25 @@ 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),
+(7, 'Laufschuhe (Regel Nr. 1: Cardio)',        1),   -- 36
+(7, 'Bissfeste Armschützer (Paar)',            2),   -- 37
+(7, 'Dosenbohnen',                             3),   -- 38
+(7, 'Twinkies (letzte Packung der Welt)',      4),   -- 39
+(7, 'Dosenöffner (manuell)',                   5),   -- 40
+(7, 'Feuerzeug',                               6),   -- 41
+(7, 'Funkgerät',                               7),   -- 42
+(7, 'Kurbelradio',                             8),   -- 43
+(7, 'Multitool',                               9),   -- 44
+(7, 'Karte mit sicheren Zonen',               10),   -- 45
+(7, 'Zombie-Wörterbuch (Grrr = Hallo)',       11),   -- 46
+(7, 'Glaubwürdige Ausrede für die Bisswunde', 12),   -- 47
+(7, 'Cricketschläger',                        13),   -- 48
+(7, 'Brecheisen',                             14),   -- 49
+(7, 'Panzertape',                             15),   -- 50
+(7, 'Bolzenschneider',                        16),   -- 51
+(7, 'Knoblauch (hilft nicht, beruhigt aber)', 17),   -- 52
+(7, 'Gartenzwerg zur Ablenkung',              18);   -- 53
 
 -- -----------------------------------------------
 -- Activity -> Item mappings (all IDs verified)
@@ -254,6 +280,13 @@ INSERT INTO activity_item_map (activityID, itemID) VALUES
 (9,28),(9,29),
 (9,31);
 
+-- activityID 10: Zombie-Apokalypse (hidden)
+INSERT INTO activity_item_map (activityID, itemID, quantity) VALUES
+(10,13, 1),(10,19, 1),(10,23, 2),(10,28, 1),
+(10,36, 1),(10,37, 1),(10,38,12),(10,39, 1),(10,40, 1),(10,41, 1),
+(10,42, 2),(10,43, 1),(10,44, 1),(10,45, 1),(10,46, 1),(10,47, 1),
+(10,48, 1),(10,49, 1),(10,50, 1),(10,51, 1),(10,52, 1),(10,53, 1);
+
 -- -----------------------------------------------
 -- Default admin user
 -- Username: admin   Password: packit-admin