Bring the checklist app under version control

The project has lived only as a folder on the Mac Studio and, briefly, as a
transfer tarball. This is the first commit: the Express server and its web
front end, the Capacitor iOS and Android wrappers, and the release and backup
scripts.

The repo is the source, not a backup of the machine. Left out on purpose, by
the .gitignore files that were already in the tree: node_modules, build output,
mobile/www (regenerated from server/public on every build), and server/data,
which holds the live checklists and belongs on the NAS rather than in git.

Every credential is excluded too — server/.env, the Play upload keystore and
its properties file, the App Store Connect key and ios-signing.config. The one
secret that was not in an ignored file was the NAS SSH password, hard-coded in
build-deploy.sh and grepped back out of it by backup-to-drive.sh. Both now read
it from the login keychain (service checklist-nas, account Christian), falling
back to NAS_PASS in the environment, so rotating it is one keychain entry
rather than a history rewrite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-09-24 13:00:57 -04:00
co-authored by Claude Opus 5
commit 469db5bc60
177 changed files with 33247 additions and 0 deletions
+7655
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512" role="img" aria-label="Checklist">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#39a8ff"/>
<stop offset="1" stop-color="#2ecc71"/>
</linearGradient>
</defs>
<rect width="512" height="512" rx="112" fill="url(#g)"/>
<path d="M150 262 L224 338 L366 178" fill="none" stroke="#ffffff" stroke-width="46" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 512 B

+798
View File
@@ -0,0 +1,798 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<!-- Stop iOS Safari's data detectors from auto-linking IPs/ports/credentials in
task descriptions as "phone numbers" — that drew a stray underline and hijacked
taps into the call/share sheet. Nothing in this app is ever a phone number. -->
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no">
<title>Recurring Checklist</title>
<!-- Inter — modern productivity typeface (falls back to the system stack offline) -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap">
<link rel="stylesheet" href="styles.css">
<!-- PWA / installable web app -->
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#1d2026">
<meta name="color-scheme" content="dark">
<!-- iOS Safari support -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Checklist">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png?v=2">
<!-- Favicons (v2 = new checkmark logo; query busts the browser's favicon cache) -->
<link rel="icon" type="image/svg+xml" href="/icons/icon.svg?v=2">
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png?v=2">
</head>
<body>
<div id="loadingOverlay" class="loading-overlay">
<div class="loading-spinner"></div>
<div class="loading-text">Loading checklist...</div>
</div>
<!-- Shown when offline or while queued check-offs are syncing. -->
<div id="offlineBadge" class="offline-badge" style="display:none;"></div>
<!-- First-launch setup, native apps only. The store build ships to anyone, so the
server is a per-install choice rather than something baked into the binary. -->
<div id="setupOverlay" class="setup-overlay" style="display:none;">
<div class="setup-card">
<div class="setup-logo" aria-hidden="true">
<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs><linearGradient id="sg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#39a8ff"/><stop offset="1" stop-color="#2ecc71"/>
</linearGradient></defs>
<rect width="512" height="512" rx="112" fill="url(#sg)"/>
<path d="M150 262 L224 338 L366 178" fill="none" stroke="#fff" stroke-width="46"
stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<h2>Set up Checklist</h2>
<p class="setup-sub">How do you want to use it? You can change this later.</p>
<div id="setupChoices">
<button type="button" class="setup-choice" data-setup="local">
<span class="setup-choice-title">Just on this device</span>
<span class="setup-choice-desc">Everything stays on your phone. No server,
no account, works offline &mdash; but only you see it.</span>
</button>
<button type="button" class="setup-choice" data-setup="join">
<span class="setup-choice-title">Join a server</span>
<span class="setup-choice-desc">Your church already runs one. Everyone on your
team shares the same lists.</span>
</button>
<button type="button" class="setup-choice" data-setup="host">
<span class="setup-choice-title">Host it yourself</span>
<span class="setup-choice-desc">Run it on your own computer or NAS. Your data
stays on your hardware.</span>
</button>
</div>
<!-- Address entry, shared by both choices above. -->
<div id="setupJoin" style="display:none;">
<label for="setupAddress">Server address</label>
<input id="setupAddress" type="url" inputmode="url" autocapitalize="off"
autocorrect="off" spellcheck="false" placeholder="checklist.yourchurch.org" />
<div id="setupError" class="setup-error" style="display:none;"></div>
<div class="setup-actions">
<button type="button" id="setupBack" class="btn btn-secondary btn-small">Back</button>
<button type="button" id="setupConnect" class="btn btn-primary btn-small">Connect</button>
</div>
</div>
<div id="setupHostInfo" style="display:none;">
<p class="setup-sub" style="text-align:left;">
Checklist runs as a small Docker service on any always-on machine — a NAS, a
spare computer, or a cheap VPS. Once it is running, come back and enter its
address.
</p>
<div class="setup-actions">
<button type="button" id="setupHostBack" class="btn btn-secondary btn-small">Back</button>
<button type="button" id="setupHostNext" class="btn btn-primary btn-small">I have it running</button>
</div>
</div>
</div>
</div>
<div id="loginModal" class="modal-overlay" style="display: none;">
<div class="modal">
<h3>Sign in</h3>
<div class="form-group">
<label for="loginLocation">Location</label>
<select id="loginLocation"></select>
</div>
<div class="form-group">
<label for="loginTeam">Team</label>
<select id="loginTeam"></select>
</div>
<div class="form-group" id="loginExecNameGroup" style="display:none;">
<label for="loginExecName">Who are you?</label>
<input id="loginExecName" type="text" autocomplete="off" autocapitalize="words" placeholder="Your name" />
</div>
<div class="form-group">
<label for="loginPassword">Password</label>
<div class="password-field">
<input id="loginPassword" type="password" autocomplete="current-password" />
<button id="loginPasswordToggle" class="password-toggle" type="button" aria-label="Show password" title="Show password">🐵</button>
</div>
</div>
<div id="loginError" class="modal-message danger" style="display:none;"></div>
<p style="font-size:11px;color:#888;margin:0;">Your location, team, and password. Editors: choose <code>Executive</code>. <a href="/privacy" target="_blank" rel="noopener noreferrer" style="color:#7c9cc4;">Privacy</a><span id="changeServerWrap" style="display:none;"> · <a href="#" id="changeServerLink" style="color:#7c9cc4;">Change server</a></span></p>
<div class="modal-actions">
<button id="loginSubmit" class="btn btn-primary btn-small" type="button">Sign in</button>
</div>
</div>
</div>
<div id="userMgmtModal" class="modal-overlay" style="display: none;">
<div class="modal mgmt">
<div class="mgmt-head">
<h3>Manage Access</h3>
<button id="userMgmtClose" class="btn btn-secondary btn-small" type="button">Close</button>
</div>
<div id="userMgmtError" class="modal-message danger" style="display:none;"></div>
<div class="mgmt-grid">
<section class="mgmt-section">
<h4>Team logins</h4>
<input id="userMgmtSearch" class="mgmt-search" type="search" placeholder="🔍 Filter by location, team, or email…" />
<div id="userMgmtList" class="user-mgmt-list"></div>
<div class="mgmt-add">
<h5>Add a team login</h5>
<div class="user-mgmt-grid">
<select id="newUserLocation" aria-label="Location"></select>
<select id="newUserTeam" aria-label="Team"></select>
<input id="newUserPassword" type="password" placeholder="Password (4+ chars)" />
<input id="newUserEmails" type="text" placeholder="Report email(s), comma-separated — optional" />
<button id="newUserCreate" class="btn btn-primary btn-small" type="button">Create login</button>
</div>
<p class="mgmt-tip">One shared login per location + team. Report emails get the weekly digest.</p>
</div>
</section>
<section class="mgmt-section">
<h4>Executives</h4>
<input id="execSearch" class="mgmt-search" type="search" placeholder="🔍 Filter by name…" />
<div id="execList" class="user-mgmt-list"></div>
<div class="mgmt-add">
<h5>Add an executive</h5>
<div class="user-mgmt-grid">
<input id="newExecName" type="text" placeholder="Name (e.g. Jordan)" autocapitalize="words" />
<input id="newExecPassword" type="password" placeholder="Password (4+ chars)" />
<button id="newExecCreate" class="btn btn-primary btn-small" type="button">Create executive</button>
</div>
<p class="mgmt-tip">Sign in under &ldquo;Executive&rdquo; with their name. &ldquo;Edit access&rdquo; sets the locations/teams they can edit.</p>
</div>
</section>
<section class="mgmt-section mgmt-wide">
<h4>Locations &amp; teams</h4>
<div id="directoryEditor" class="directory-editor"></div>
</section>
</div>
</div>
</div>
<div id="listModal" class="modal-overlay" style="display: none;">
<div class="modal">
<h3 id="listModalTitle"></h3>
<div id="listModalMessage" class="modal-message" style="display:none;"></div>
<input id="listModalInput" type="text" placeholder="List name" />
<div id="listModalTemplateGroup" class="form-group" style="margin-top:8px; display:none;">
<label for="listModalTemplate" style="font-size:12px;color:#b0b0b0;">Start from</label>
<select id="listModalTemplate"></select>
</div>
<div id="listModalLocationGroup" class="form-group" style="margin-top:8px;">
<label for="listModalLocation" style="font-size:12px;color:#b0b0b0;">Assign to location</label>
<select id="listModalLocation"></select>
</div>
<div id="listModalTeamGroup" class="form-group" style="margin-top:8px;">
<label for="listModalTeam" style="font-size:12px;color:#b0b0b0;">Assign to team</label>
<select id="listModalTeam"></select>
</div>
<div id="listModalColorGroup" class="form-group" style="margin-top:8px;">
<label style="font-size:12px;color:#b0b0b0;">Color</label>
<div id="listModalColors" class="color-swatches">
<button type="button" class="swatch swatch-none" data-color="" title="No color"></button>
<button type="button" class="swatch" data-color="blue" title="Blue"></button>
<button type="button" class="swatch" data-color="green" title="Green"></button>
<button type="button" class="swatch" data-color="amber" title="Amber"></button>
<button type="button" class="swatch" data-color="red" title="Red"></button>
<button type="button" class="swatch" data-color="purple" title="Purple"></button>
<button type="button" class="swatch" data-color="teal" title="Teal"></button>
<button type="button" class="swatch" data-color="pink" title="Pink"></button>
<button type="button" class="swatch" data-color="slate" title="Slate"></button>
<button type="button" class="swatch" data-color="orange" title="Orange"></button>
<button type="button" class="swatch" data-color="yellow" title="Yellow"></button>
<button type="button" class="swatch" data-color="lime" title="Lime"></button>
<button type="button" class="swatch" data-color="cyan" title="Cyan"></button>
<button type="button" class="swatch" data-color="indigo" title="Indigo"></button>
<button type="button" class="swatch" data-color="brown" title="Brown"></button>
<label class="swatch swatch-custom" title="Custom color — pick your own">
<input type="color" id="listModalCustomColor" value="#3498db" aria-label="Custom color" />
</label>
</div>
</div>
<div id="listModalHiddenGroup" class="form-group" style="margin-top:10px;">
<label class="checkbox-label" style="font-size:13px;"><input type="checkbox" id="listModalHidden" /> Hidden — only editors see it</label>
<label class="checkbox-label" style="font-size:13px;margin-top:6px;"><input type="checkbox" id="listModalShowNotes" /> Show a Notes column — reference info, no checking off</label>
<label class="checkbox-label" style="font-size:13px;margin-top:6px;"><input type="checkbox" id="listModalNotesOnly" /> Notes only — hide the To&nbsp;Do and Done columns</label>
<label class="checkbox-label" style="font-size:13px;margin-top:6px;"><input type="checkbox" id="listModalShowFeedback" /> Show a Feedback column — anyone on the list can add input</label>
<div class="form-group" id="listModalSectionsGroup" style="margin-top:10px;">
<label style="font-size:13px;">Task sections — extra To&nbsp;Do columns (e.g. Monday, Tuesday). The first is the default and can be renamed.</label>
<div id="listModalSections"></div>
<button type="button" class="btn btn-secondary btn-small" id="listModalSectionAdd">+ Add section</button>
</div>
</div>
<div id="listModalRepeatGroup" class="form-group" style="margin-top:8px;">
<label for="listModalResetType" style="font-size:12px;color:#b0b0b0;">🔁 Repeat this list <span style="color:#7c828c;">— every item resets to To&nbsp;Do on this schedule</span></label>
<select id="listModalResetType">
<option value="">Don't repeat — reset manually</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
<option value="yearly">Yearly</option>
</select>
<div id="listModalResetEvery" class="reset-row" style="display:none;">
<span>Every</span>
<input type="number" id="listModalResetInterval" min="1" max="99" value="1" />
<span id="listModalResetUnit">weeks</span>
<span id="listModalResetHint" class="reset-hint"></span>
</div>
<div id="listModalResetStartRow" class="reset-row" style="display:none;">
<span>starting on</span>
<select id="listModalResetStartDow">
<option value="0">Sunday</option><option value="1">Monday</option><option value="2">Tuesday</option>
<option value="3">Wednesday</option><option value="4">Thursday</option><option value="5">Friday</option><option value="6">Saturday</option>
</select>
</div>
<div id="listModalResetDowRow" class="reset-row" style="display:none;">
<span>on</span>
<select id="listModalResetDow">
<option value="0">Sunday</option><option value="1">Monday</option><option value="2">Tuesday</option>
<option value="3">Wednesday</option><option value="4">Thursday</option><option value="5">Friday</option><option value="6">Saturday</option>
</select>
</div>
<div id="listModalResetMonthRow" class="reset-row" style="display:none;">
<span>in</span>
<select id="listModalResetMonth">
<option value="0">January</option><option value="1">February</option><option value="2">March</option>
<option value="3">April</option><option value="4">May</option><option value="5">June</option>
<option value="6">July</option><option value="7">August</option><option value="8">September</option>
<option value="9">October</option><option value="10">November</option><option value="11">December</option>
</select>
</div>
<div id="listModalResetDomRow" class="reset-row" style="display:none;">
<span>on day</span>
<input type="number" id="listModalResetDom" min="1" max="31" value="1" />
<span>of the month</span>
</div>
<div id="listModalResetTimesRow" class="reset-row reset-times-row" style="display:none;">
<span>at</span>
<div id="listModalResetTimesList" class="reset-times-list"></div>
<button type="button" id="listModalResetTimesAdd" class="btn btn-secondary btn-small reset-time-add">+ Add time</button>
<span class="reset-hint reset-times-hint">leave empty to reset once a day</span>
</div>
</div>
<div class="modal-actions">
<button id="listModalCancel" class="btn btn-secondary btn-small" type="button">Cancel</button>
<button id="listModalSave" class="btn btn-primary btn-small" type="button">Save</button>
</div>
</div>
</div>
<div id="editTaskModal" class="modal-overlay" style="display: none;">
<div class="modal wide">
<h3>Edit Task</h3>
<div class="modal-body">
<div class="form-group">
<label for="editTitle">Title</label>
<input id="editTitle" type="text" placeholder="Task title..." />
</div>
<div class="form-group" id="editGroupRow" style="display:none;">
<label for="editGroupSelect">Section</label>
<select id="editGroupSelect"></select>
</div>
<div class="form-group">
<label for="editDescription">Description</label>
<div class="md-toolbar" data-md-target="editDescription">
<button type="button" class="md-btn" data-md="bold" title="Bold"><b>B</b></button>
<button type="button" class="md-btn" data-md="italic" title="Italic"><i>I</i></button>
<button type="button" class="md-btn" data-md="h" title="Heading">H</button>
<button type="button" class="md-btn" data-md="ul" title="Bullet list">• List</button>
<button type="button" class="md-btn" data-md="ol" title="Numbered list">1. List</button>
<button type="button" class="md-btn" data-md="task" title="Checkbox — a step users tick off (the task auto-completes when all are checked)">☑ Step</button>
<button type="button" class="md-btn" data-md="link" title="Add a link (web page, Google Doc…)">🔗 Link</button>
<button type="button" class="md-btn" data-md="attach" title="Attach a document (PDF, Word, Excel, PowerPoint…) — uploads it and links it here">📎 File</button>
<button type="button" class="md-btn md-preview-btn" data-md="preview" aria-pressed="false" title="Toggle formatted markdown view">Markdown</button>
</div>
<textarea id="editDescription" placeholder="Description — format with the buttons above." style="display:none;"></textarea>
<div class="md-rich" contenteditable="true" data-md-rich-for="editDescription" data-placeholder="Description — format with the buttons above." role="textbox" aria-multiline="true"></div>
</div>
<div class="form-group">
<label class="checkbox-label"><input type="checkbox" id="editIsRecurring" /> Recurring Task</label>
</div>
<div class="form-group" id="editRecurrenceOptions" style="display: none;">
<div class="rec-editor"></div>
</div>
</div>
<div class="modal-actions">
<button id="editModalCancel" class="btn btn-secondary btn-small" type="button">Cancel</button>
<button id="editModalSave" class="btn btn-primary btn-small" type="button">Save</button>
</div>
</div>
</div>
<div id="moveItemModal" class="modal-overlay" style="display: none;">
<div class="modal">
<h3>Move Task</h3>
<div class="modal-body">
<div class="form-group">
<label for="moveItemSelect">Select destination list</label>
<select id="moveItemSelect"></select>
</div>
</div>
<div class="modal-actions">
<button id="moveItemCancel" class="btn btn-secondary btn-small" type="button">Cancel</button>
<button id="moveItemSave" class="btn btn-primary btn-small" type="button">Move</button>
</div>
</div>
</div>
<!-- Inline "Move to…" dropdown anchored to a task's Move button (replaces the modal flow). -->
<div id="moveMenuBackdrop" class="move-menu-backdrop" style="display:none;"></div>
<div id="moveMenu" class="move-menu" role="menu" aria-label="Move task to list" style="display:none;"></div>
<!-- Cross-department request (editors only): lands in the target team's Requests inbox. -->
<div id="requestModal" class="modal-overlay" style="display:none;">
<div class="modal">
<div class="modal-header"><h3>✋ Request something</h3></div>
<div class="modal-body">
<div id="requestError" class="modal-message danger" style="display:none;"></div>
<div class="form-group">
<label for="requestTarget">From which department?</label>
<select id="requestTarget"></select>
</div>
<div class="form-group">
<label for="requestTitle">What do you need?</label>
<input id="requestTitle" type="text" maxlength="120" placeholder="e.g. Toilet paper restock in the office bathroom" />
</div>
<div class="form-group">
<label for="requestDetails">Details (optional)</label>
<textarea id="requestDetails" rows="3" maxlength="2000" placeholder="Where exactly, how urgent, anything helpful…"></textarea>
</div>
<div id="requestPhotos" class="equip-photos"></div>
<div class="equip-photo-row">
<label class="btn btn-secondary btn-small equip-upload-label">
📷 Add photo
<input id="requestPhotoFile" type="file" accept="image/*" hidden />
</label>
<span id="requestUploadStatus" class="equip-upload-status"></span>
</div>
<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:12px;">
<button id="requestCancel" class="btn btn-secondary" type="button">Cancel</button>
<button id="requestSend" class="btn btn-primary" type="button">Send request</button>
</div>
</div>
</div>
</div>
<!-- Equipment photo popup (shown when anyone taps an equipment word).
The image sits in a pan/zoom stage: pinch or scroll to zoom, drag to pan,
double-tap to reset. The note panel below scrolls independently. -->
<div id="equipModal" class="modal-overlay equip-lightbox" style="display: none;">
<div class="modal equip-modal">
<div class="equip-lb-head">
<h3 id="equipModalName"></h3>
<button id="equipReportBtn" class="equip-report-btn" type="button" title="Report a problem with this gear">⚠ Report a problem</button>
<button id="equipModalClose" class="equip-lb-close" type="button" aria-label="Close">✕</button>
</div>
<div id="equipStage" class="equip-stage">
<img id="equipModalImg" class="equip-img" alt="" draggable="false" />
<button id="equipPrev" class="equip-nav equip-nav-prev" type="button" aria-label="Previous photo">‹</button>
<button id="equipNext" class="equip-nav equip-nav-next" type="button" aria-label="Next photo">›</button>
<div id="equipZoomHint" class="equip-zoom-hint"></div>
<div class="equip-zoom-controls">
<button id="equipZoomOut" class="equip-zoom-btn" type="button" aria-label="Zoom out">−</button>
<button id="equipZoomReset" class="equip-zoom-btn" type="button" aria-label="Fit to screen">⤢</button>
<button id="equipZoomIn" class="equip-zoom-btn" type="button" aria-label="Zoom in">+</button>
</div>
</div>
<div id="equipCaption" class="equip-caption"></div>
<div id="equipThumbs" class="equip-thumbs"></div>
<div id="equipModalNote" class="equip-note"></div>
<div id="equipIssues" class="equip-issues"></div>
</div>
</div>
<!-- Equipment library manager (master only) -->
<div id="equipMgmtModal" class="modal-overlay" style="display: none;">
<div class="modal mgmt">
<div class="mgmt-head">
<h3>Equipment Library</h3>
<button id="equipMgmtClose" class="btn btn-secondary btn-small" type="button">Close</button>
</div>
<div id="equipMgmtError" class="modal-message danger" style="display:none;"></div>
<p class="mgmt-tip">Add a photo for any gear — its name becomes tappable in descriptions for the locations/teams you pick. Each location can keep its own photo.</p>
<input id="equipSearch" class="mgmt-search" type="search" placeholder="🔍 Filter equipment…" />
<div id="equipMgmtList" class="user-mgmt-list"></div>
<div class="mgmt-add" id="equipAddSection">
<button type="button" class="mgmt-add-toggle" id="equipAddToggle" aria-expanded="false" aria-controls="equipAddBody">
<span class="mgmt-add-caret" aria-hidden="true">+</span>
<span id="equipFormTitle">Add equipment</span>
</button>
<div class="mgmt-add-body" id="equipAddBody">
<div class="user-mgmt-grid">
<input id="equipName" type="text" placeholder="Name" />
<input id="equipAliases" type="text" placeholder="Aliases, comma-separated (optional)" />
<textarea id="equipNote" class="equip-note-input" rows="2" placeholder="Note / troubleshooting steps (optional) — Markdown & line breaks supported"></textarea>
</div>
<div id="equipPhotos" class="equip-photos"></div>
<div class="equip-photo-row">
<label class="btn btn-secondary btn-small equip-upload-label">
📁 Add photo / video
<input id="equipFile" type="file" accept="image/*,video/mp4,video/webm,video/quicktime" hidden />
</label>
<button id="equipCamBtn" class="btn btn-secondary btn-small" type="button">📷 Take photo</button>
<span id="equipUploadStatus" class="equip-upload-status"></span>
</div>
<div class="equip-url-row">
<input id="equipImage" type="text" class="equip-url-input" placeholder="…or paste an image / video URL (JPG, PNG, MP4)" />
<button id="equipAddUrl" class="btn btn-secondary btn-small" type="button">Add</button>
</div>
<div id="equipDocs" class="equip-docs-editor"></div>
<div class="equip-photo-row">
<label class="btn btn-secondary btn-small equip-upload-label">
📎 Add document
<input id="equipDocFile" type="file" accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.csv,.rtf" hidden />
</label>
</div>
<div class="equip-url-row">
<input id="equipDocUrl" type="text" class="equip-url-input" placeholder="…or paste a link (Google Doc, manual, web page)" />
<button id="equipDocAdd" class="btn btn-secondary btn-small" type="button">Add link</button>
</div>
<div class="equip-scope">
<div id="equipScopeHelp" class="exec-access-help">Where it appears — check location(s) and/or team(s).</div>
<div class="dir-head">Locations</div>
<div id="equipLocRow" class="exec-cb-row"></div>
<div class="dir-head" style="margin-top:6px;">Teams</div>
<div id="equipTeamRow" class="exec-cb-row"></div>
</div>
<div class="equip-form-actions">
<button id="equipSave" class="btn btn-primary btn-small" type="button">Add equipment</button>
<button id="equipCancelEdit" class="btn btn-secondary btn-small" type="button" style="display:none;">Cancel edit</button>
</div>
</div><!-- /.mgmt-add-body -->
</div>
</div>
</div>
<!-- In-app camera capture (works on desktop + mobile via getUserMedia) -->
<div id="equipCamModal" class="modal-overlay" style="display:none; z-index:10002;">
<div class="modal equip-cam">
<div class="mgmt-head">
<h3>Take a photo</h3>
<button id="equipCamCancel" class="btn btn-secondary btn-small" type="button">Cancel</button>
</div>
<div class="equip-cam-stage">
<video id="equipCamVideo" playsinline autoplay muted></video>
</div>
<div id="equipCamError" class="modal-message danger" style="display:none;"></div>
<div class="equip-cam-actions">
<button id="equipCamCapture" class="btn btn-primary" type="button">📸 Capture</button>
<button id="equipCamSwitch" class="btn btn-secondary btn-small" type="button">🔄 Switch camera</button>
</div>
</div>
</div>
<!-- Overview: each team's progress today + recent activity (master + execs) -->
<div id="overviewModal" class="modal-overlay" style="display:none;">
<div class="modal mgmt">
<div class="mgmt-head">
<h3>Today at a glance</h3>
<button id="overviewClose" class="btn btn-secondary btn-small" type="button">Close</button>
</div>
<div id="overviewBody" class="overview-body"></div>
</div>
</div>
<div id="activityModal" class="modal-overlay" style="display:none;">
<div class="modal mgmt">
<div class="mgmt-head">
<h3>🛡 Activity Log</h3>
<button id="activityClose" class="btn btn-secondary btn-small" type="button">Close</button>
</div>
<input id="activitySearch" class="activity-search" type="search" placeholder="Filter by person, action, or list…" autocomplete="off" />
<div id="activityBody" class="activity-body"></div>
</div>
</div>
<!-- "Who's checking?" name picker (shared team logins only) -->
<div id="checkerModal" class="modal-overlay" style="display:none;">
<div class="modal">
<h3>Who's checking?</h3>
<p style="font-size:12px;color:#888;margin:0;">Pick your name so your check-offs are recorded as you — no password needed.</p>
<select id="checkerSelect" style="margin-top:8px;"></select>
<div id="checkerAddRow" style="display:none; gap:8px; margin-top:8px;">
<input id="checkerNewName" type="text" placeholder="Type your name" autocapitalize="words" autocomplete="off" style="flex:1;" />
<button id="checkerAddBtn" class="btn btn-primary btn-small" type="button">Add</button>
</div>
<div class="modal-actions">
<button id="checkerClose" class="btn btn-secondary btn-small" type="button">Done</button>
</div>
</div>
</div>
<!-- Team name rosters — manage who's on each team (master + execs) -->
<div id="rosterModal" class="modal-overlay" style="display:none;">
<div class="modal mgmt">
<div class="mgmt-head">
<h3>Team names</h3>
<button id="rosterClose" class="btn btn-secondary btn-small" type="button">Close</button>
</div>
<p class="mgmt-tip">Names checkers can tap when signing in. Add or remove names for any team you manage.</p>
<input id="rosterSearch" class="mgmt-search" type="search" placeholder="🔍 Filter by location or team…" />
<div id="rosterBody" class="roster-body user-mgmt-list"></div>
</div>
</div>
<div class="app-container">
<header>
<div class="header-left">
<svg class="app-logo" viewBox="0 0 44 44" role="img" aria-label="Checklist" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="appLogoGrad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#39a8ff"/>
<stop offset="100%" stop-color="#2ecc71"/>
</linearGradient>
</defs>
<rect x="3" y="3" width="38" height="38" rx="12" fill="url(#appLogoGrad)"/>
<path class="app-logo-check" d="M13.5 22.5 L19.5 28.5 L30.5 15.5" fill="none" stroke="#fff" stroke-width="3.6" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<div class="header-controls admin-only">
<div class="list-tabs-controls">
<button id="listViewToggle" class="lists-toggle" type="button" title="Switch between the tab strip and a Location → Team → List tree">🌳 Tree view</button>
<button id="listTabsToggle" class="lists-toggle" type="button" aria-expanded="true" title="Show/hide the lists">Hide lists</button>
<div class="list-filter master-only">
<select id="adminFilterLocation" aria-label="Filter by location"></select>
<select id="adminFilterTeam" aria-label="Filter by team"></select>
</div>
</div>
<div class="undo-redo admin-only" role="group" aria-label="Undo and redo">
<button id="undoBtn" class="icon-btn" type="button" title="Undo (Ctrl/⌘+Z)" aria-label="Undo" disabled>↶</button>
<button id="redoBtn" class="icon-btn" type="button" title="Redo (Ctrl/⌘+Shift+Z)" aria-label="Redo" disabled>↷</button>
</div>
<div id="listMenu" class="list-menu">
<button id="listMenuHandle" class="list-menu-btn" type="button" aria-haspopup="true" aria-expanded="false" aria-controls="listMenuPanel" aria-label="List tools menu"><span class="lmh-icon" aria-hidden="true">☰</span><span class="lmh-label">Menu</span></button>
<div id="listMenuBackdrop" class="list-menu-backdrop"></div>
<div id="listMenuPanel" class="list-menu-panel" role="menu" aria-label="List tools">
<div class="menu-group-label">This list</div>
<button id="addTaskBtn" class="btn btn-primary btn-small">+ Add Task</button>
<button id="addNoteBtn" class="btn btn-secondary btn-small" title="Add a reference note (no checkbox)">+ Add Note</button>
<button id="renameListBtn" class="btn btn-secondary btn-small">✏️ Edit List</button>
<button id="duplicateListBtn" class="btn btn-secondary btn-small" title="Make a copy of this list (items copied, checkmarks fresh)">⧉ Duplicate List</button>
<button id="resetListBtn" class="btn btn-secondary btn-small" title="Move every item back to To Do">↺ Reset List</button>
<button id="deleteListBtn" class="btn btn-danger btn-small">🗑 Delete List</button>
<div class="menu-group-label">Lists</div>
<button id="addListBtn" class="btn btn-primary btn-small">+ Add List</button>
<div class="menu-group-label">Tools</div>
<button id="helpTipsBtn" class="btn btn-secondary btn-small" type="button" title="Quick tips on how this works">❔ How this works</button>
<button id="overviewBtn" class="btn btn-secondary btn-small admin-only" title="See each team's progress today">📊 Overview</button>
<button id="rosterBtn" class="btn btn-secondary btn-small admin-only" title="Manage the names checkers can pick">🧑 Team Names</button>
<button id="manageUsersBtn" class="btn btn-secondary btn-small master-only">👥 Manage Users</button>
<button id="activityBtn" class="btn btn-secondary btn-small master-only" title="See everything done in the app — who checked, created, edited, moved, or deleted">🛡 Activity Log</button>
<button id="incidentsBtn" class="btn btn-secondary btn-small admin-only" title="See and resolve reported gear problems in your area">⚠ Incidents<span id="incidentsBtnCount" class="menu-count" style="display:none;"></span></button>
<button id="reliabilityBtn" class="btn btn-secondary btn-small admin-only" title="Which gear breaks most — ranked by reported issues">📊 Reliability</button>
<button id="equipMgmtBtn" class="btn btn-secondary btn-small admin-only">🔧 Equipment</button>
<button id="sendDigestBtn" class="btn btn-secondary btn-small admin-only" title="Email the checklist report now to the configured recipients for your area">📧 Send Report</button>
<button id="exportJsonBtn" class="btn btn-secondary btn-small admin-only" title="Download a backup of your lists (master = full backup)">⬇ Export JSON</button>
<button id="importJsonBtn" class="btn btn-secondary btn-small admin-only">⬆ Import JSON</button>
<input id="importJsonInput" type="file" accept="application/json,.json" style="display:none;" />
</div>
</div>
</div>
<div class="header-right">
<button id="searchBtn" class="icon-btn search-bar-btn" type="button" title="Search gear, notes & tasks (press /)" aria-label="Search"><span class="sb-icon" aria-hidden="true">🔍</span><span class="sb-label">Search…</span></button>
<button id="reportIssueBtn" class="icon-btn report-issue-btn" type="button" title="Report a gear problem" aria-label="Report a gear problem">⚠</button>
<button id="requestsBadge" class="requests-badge" type="button" style="display:none;" title="Open requests waiting — tap to view" aria-label="Open requests">✋ <span id="requestsBadgeCount">0</span></button>
<button id="requestSendBtn" class="icon-btn request-send-btn admin-only" type="button" title="Request something from another department" aria-label="Request something from another department">✋</button>
<button id="checkerChip" class="checker-chip checker-only" type="button" title="Who's checking? Tap to pick your name">
<span class="checker-chip-icon">👤</span>
<span id="checkerChipName">Who's checking?</span>
</button>
<button id="userBadge" class="user-badge" type="button" title="Click to sign out">
<span class="user-badge-icon">👤</span>
<span id="userBadgeName">Sign in</span>
</button>
</div>
</header>
<div class="main-content">
<div id="tasksView" class="tab-view active">
<div class="list-tabs-row">
<div id="activeListName" class="active-list-name admin-only"></div>
<div id="listTabs" class="list-tabs"></div>
<div id="listTree" class="list-tree admin-only"></div>
</div>
<div class="checklists-section">
<div class="checklist-column" data-col="todo">
<h2><span class="col-title">To Do</span></h2>
<div id="todoList" class="checklist-list"></div>
</div>
<div class="checklist-column" data-col="done">
<h2><span class="col-title">Done</span></h2>
<div id="doneList" class="checklist-list"></div>
</div>
<div class="checklist-column notes-column" data-col="notes">
<h2><span class="col-title">Notes</span></h2>
<div id="notesList" class="checklist-list"></div>
</div>
<div class="checklist-column feedback-column" data-col="feedback">
<h2><span class="col-title">Feedback</span></h2>
<div id="feedbackList" class="checklist-list"></div>
</div>
</div>
</div>
</div>
<div id="searchOverlay" class="search-overlay" style="display:none;">
<div class="search-panel" role="dialog" aria-label="Search">
<div class="search-inputrow">
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
<input id="globalSearchInput" type="search" placeholder="Search gear, notes, tasks & lists…" autocomplete="off" />
<button id="searchClose" type="button" class="icon-btn" aria-label="Close search">✕</button>
</div>
<div id="searchResults" class="search-results"></div>
</div>
</div>
<!-- Report a gear problem (any signed-in user) -->
<div id="incidentModal" class="modal-overlay" style="display: none;">
<div class="modal">
<div class="modal-header">
<h3>⚠ Report a problem</h3>
<button id="incidentClose" class="btn btn-secondary btn-small" type="button">Cancel</button>
</div>
<div class="modal-body">
<div id="incidentGearRow" class="incident-gear-row" style="display:none;">
<span class="incident-gear-label">Gear:</span>
<span id="incidentGearName" class="incident-gear-name"></span>
</div>
<div class="form-group">
<label>How bad is it?</label>
<div class="severity-toggle" id="incidentSeverity">
<button type="button" class="sev-btn sev-down" data-sev="down">
<span class="sev-emoji">🔴</span>
<span class="sev-title">It's down now</span>
<span class="sev-sub">Can't be used — alerts the team right away</span>
</button>
<button type="button" class="sev-btn sev-minor is-selected" data-sev="minor">
<span class="sev-emoji">🟡</span>
<span class="sev-title">Minor issue</span>
<span class="sev-sub">Still works — goes in the report</span>
</button>
</div>
</div>
<div class="form-group">
<label for="incidentWhat">What's wrong?</label>
<input type="text" id="incidentWhat" maxlength="120" placeholder="e.g. HDMI port loose, won't hold signal" />
</div>
<div class="form-group">
<label for="incidentDetails">Details <span class="label-hint">(optional)</span></label>
<textarea id="incidentDetails" rows="3" maxlength="2000" placeholder="Anything that helps whoever fixes it…"></textarea>
</div>
<div id="incidentReportingAs" class="incident-reporting-as" style="display:none;"></div>
<div class="modal-footer">
<button id="incidentSubmit" class="btn btn-primary" type="button">Send report</button>
</div>
</div>
</div>
</div>
<!-- Reported gear problems (editors) -->
<div id="incidentsModal" class="modal-overlay" style="display: none;">
<div class="modal mgmt">
<div class="mgmt-head">
<h3>⚠ Gear Issues</h3>
<button id="incidentsClose" class="btn btn-secondary btn-small" type="button">Close</button>
</div>
<div class="incidents-controls">
<label class="incidents-openonly"><input type="checkbox" id="incidentsOpenOnly" checked /> Open only</label>
<input type="search" id="incidentsSearch" class="incidents-search" placeholder="Filter by gear, place, or text…" autocomplete="off" />
</div>
<div id="incidentsList" class="incidents-list"></div>
</div>
</div>
<!-- Reliability dashboard (editors) -->
<div id="reliabilityModal" class="modal-overlay" style="display: none;">
<div class="modal mgmt">
<div class="mgmt-head">
<h3>📊 Gear Reliability</h3>
<button id="reliabilityClose" class="btn btn-secondary btn-small" type="button">Close</button>
</div>
<div class="reliability-controls">
<label class="incidents-openonly"><input type="checkbox" id="reliabilityOpenFirst" checked /> Sort by open issues</label>
<span id="reliabilitySummary" class="reliability-summary"></span>
</div>
<div id="reliabilityList" class="reliability-list"></div>
</div>
</div>
<div id="addTaskModal" class="modal-overlay" style="display: none;">
<div class="modal wide">
<div class="add-header">
<h3 id="addModalTitle">Add Task</h3>
<div class="add-list-target">
<label for="addListSelect">Add to list:</label>
<select id="addListSelect"></select>
</div>
</div>
<div class="modal-body">
<div class="form-group">
<input type="text" id="itemTitle" placeholder="Task title..." />
</div>
<div class="form-group" id="itemGroupRow" style="display:none;">
<label for="itemGroupSelect">Section</label>
<select id="itemGroupSelect"></select>
</div>
<div class="form-group">
<div class="md-toolbar" data-md-target="itemDescription">
<button type="button" class="md-btn" data-md="bold" title="Bold"><b>B</b></button>
<button type="button" class="md-btn" data-md="italic" title="Italic"><i>I</i></button>
<button type="button" class="md-btn" data-md="h" title="Heading">H</button>
<button type="button" class="md-btn" data-md="ul" title="Bullet list">• List</button>
<button type="button" class="md-btn" data-md="ol" title="Numbered list">1. List</button>
<button type="button" class="md-btn" data-md="task" title="Checkbox — a step users tick off (the task auto-completes when all are checked)">☑ Step</button>
<button type="button" class="md-btn" data-md="link" title="Add a link (web page, Google Doc…)">🔗 Link</button>
<button type="button" class="md-btn" data-md="attach" title="Attach a document (PDF, Word, Excel, PowerPoint…) — uploads it and links it here">📎 File</button>
<button type="button" class="md-btn md-preview-btn" data-md="preview" aria-pressed="false" title="Toggle formatted markdown view">Markdown</button>
</div>
<textarea id="itemDescription" placeholder="Description (optional)" style="display:none;"></textarea>
<div class="md-rich" contenteditable="true" data-md-rich-for="itemDescription" data-placeholder="Description (optional)" role="textbox" aria-multiline="true"></div>
</div>
<div class="form-group" id="recurringFieldGroup">
<label class="checkbox-label">
<input type="checkbox" id="isRecurring" />
Recurring Task
</label>
</div>
<div class="form-group" id="recurrenceOptions" style="display: none;">
<div class="rec-editor"></div>
</div>
</div>
<div class="modal-actions">
<button id="addTaskCancel" class="btn btn-secondary btn-small" type="button">Close</button>
<button id="addItemBtn" class="btn btn-primary btn-small">Add Task</button>
</div>
</div>
</div>
</div>
<div id="toast" class="toast"></div>
<!-- Motion One (vendored, self-contained ~15KB): exposes window.Motion synchronously
before app.js so item FLIP, spring modals and the completion burst can use it.
If it ever fails to load, app.js falls back to the pure-CSS animations. -->
<script src="vendor/motion.global.js"></script>
<!-- Native shim: a no-op on the web; inside the iOS/Android app bundles it points
server-absolute URLs (/api/…, /equip-images/…) at the real server. The mobile
build injects native-config.js + native-shell.js ahead of it. -->
<script src="native-bridge.js"></script>
<script src="md.js"></script>
<script src="app.js"></script>
</body>
</html>
+37
View File
@@ -0,0 +1,37 @@
{
"name": "Recurring Checklist",
"short_name": "Checklist",
"description": "Weekly startup and shutdown checklist for each location",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#14161b",
"theme_color": "#1d2026",
"icons": [
{
"src": "/icons/icon.svg",
"type": "image/svg+xml",
"sizes": "any",
"purpose": "any"
},
{
"src": "/icons/icon-192.png",
"type": "image/png",
"sizes": "192x192",
"purpose": "any"
},
{
"src": "/icons/icon-512.png",
"type": "image/png",
"sizes": "512x512",
"purpose": "any"
},
{
"src": "/icons/icon-512.png",
"type": "image/png",
"sizes": "512x512",
"purpose": "maskable"
}
]
}
+110
View File
@@ -0,0 +1,110 @@
/*
* Minimal, self-hosted Markdown renderer for task descriptions.
* Safe by construction: every piece of source text is HTML-escaped BEFORE any
* markup is applied, and only a fixed whitelist of tags is emitted
* (h4–h6, p, br, ul/ol/li, strong, em, a[href to http/https/mailto]).
* Equipment [[markers]] and auto-linked terms are intentionally LEFT as text
* here — the app layers those on afterward via a safe text-node pass.
* Exposes a global: window.MD.render(text) -> html string.
*/
(function () {
'use strict';
function escapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// Inline formatting on already-escaped text.
function inline(escaped) {
var s = escaped;
// links: [label](http(s)://…, mailto:…, or a site-relative /doc-files/… upload)
// — label already escaped. /doc-files is the ONLY relative prefix allowed, so
// the href whitelist stays as tight as before.
s = s.replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+|mailto:[^\s)]+|\/doc-files\/[^\s)]+)\)/g,
function (m, label, url) {
var cls = url.indexOf('/doc-files/') === 0 ? ' class="doc-link"' : '';
return '<a href="' + url + '"' + cls + ' target="_blank" rel="noopener noreferrer">' + label + '</a>';
});
// bold+italic ***text*** (must run before the ** and * rules)
s = s.replace(/\*\*\*([^*\n]+)\*\*\*/g, '<strong><em>$1</em></strong>');
// bold **text**
s = s.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
// italic *text* / _text_ (require a boundary so it doesn't eat stray asterisks)
s = s.replace(/(^|[\s(])\*([^*\n]+)\*(?=$|[\s).,!?])/g, '$1<em>$2</em>');
s = s.replace(/(^|[\s(])_([^_\n]+)_(?=$|[\s).,!?])/g, '$1<em>$2</em>');
return s;
}
// CANONICAL task-list line regex. MUST stay byte-identical to server.js TASK_LINE_RE and
// app.js parseTaskList — the count/index/label must agree across all three or per-location
// checkmarks misalign. Group 1 = mark char ( / x / X), group 2 = label (may be empty).
var TASK_RE = /^\s*[-*]\s+\[([ xX])\]\s*(.*)$/;
function render(src) {
var lines = String(src == null ? '' : src).replace(/\r\n?/g, '\n').split('\n');
var out = [];
var para = [];
var taskIndex = 0; // 0-based position among task-list lines, in document order (per render)
function flushPara() {
if (para.length) { out.push('<p>' + para.map(inline).join('<br>') + '</p>'); para = []; }
}
var i = 0;
while (i < lines.length) {
var raw = lines[i];
if (raw.trim() === '') { flushPara(); i++; continue; }
var hm = /^(#{1,3})\s+(.*)$/.exec(raw);
if (hm) { flushPara(); var lvl = hm[1].length + 3; out.push('<h' + lvl + '>' + inline(escapeHtml(hm[2])) + '</h' + lvl + '>'); i++; continue; }
if (/^\s*[-*]\s+/.test(raw)) {
flushPara();
var ul = [];
while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) {
var tm = TASK_RE.exec(lines[i]);
if (tm) {
// A checkbox line "- [ ] label". The box is an EMPTY, non-editable span (glyph
// drawn in CSS — no unicode/emoji, iPad-safe) so it adds zero text: the WYSIWYG
// round-trip (htmlToMarkdown) and equipment auto-linking both stay clean. The
// label runs through inline(escapeHtml(...)) IDENTICALLY to a plain bullet, so
// **bold**, links and equipment terms all work inside it. data-checked is the
// AUTHORED mark (round-trip fidelity); the live view overrides the visual from
// per-location state, keyed by data-task-index.
var on = tm[1].toLowerCase() === 'x';
ul.push('<li class="md-task' + (on ? ' is-done' : '') + '" data-task-index="' + (taskIndex++) +
'" data-checked="' + (on ? 'true' : 'false') + '">' +
'<span class="md-task-box" contenteditable="false" aria-hidden="true"></span>' +
'<span class="md-task-label">' + inline(escapeHtml(tm[2])) + '</span></li>');
} else {
ul.push('<li>' + inline(escapeHtml(lines[i].replace(/^\s*[-*]\s+/, ''))) + '</li>');
}
i++;
}
out.push('<ul>' + ul.join('') + '</ul>');
continue;
}
if (/^\s*\d+\.\s+/.test(raw)) {
flushPara();
var ol = [];
while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) {
ol.push('<li>' + inline(escapeHtml(lines[i].replace(/^\s*\d+\.\s+/, ''))) + '</li>');
i++;
}
out.push('<ol>' + ol.join('') + '</ol>');
continue;
}
para.push(escapeHtml(raw));
i++;
}
flushPara();
return out.join('');
}
window.MD = { render: render, escapeHtml: escapeHtml };
})();
+257
View File
@@ -0,0 +1,257 @@
// native-bridge.js — loaded by BOTH the website and the iOS/Android app bundles.
//
// The website and the two store apps run the exact same front-end files; this shim is the
// only thing that knows the difference. In a browser it is a deliberate NO-OP: it defines
// window.CHECKLIST_NATIVE with active:false and returns before touching anything. Every
// behaviour change below happens only inside a Capacitor shell.
//
// Why it's needed: on the web the front-end and the API share an origin, so `/api/state`
// and `/equip-images/x.jpg` just work. Inside a store app the front-end is served from the
// app bundle (capacitor://localhost on iOS, https://localhost on Android) and the API lives
// on a different origin, so every server-absolute path has to be re-pointed at the real
// server. Rather than rewrite hundreds of call sites we patch fetch() once and expose a
// resolveUrl() the handful of <img>/<a> sites use.
//
// The API base is injected by the mobile build (mobile/scripts/sync-web.js writes a
// native-config.js into the bundle). It is intentionally NOT hard-coded here so the website
// copy of this file carries no server URL and dev builds can point somewhere else.
(function () {
'use strict';
var cfg = window.CHECKLIST_NATIVE_CONFIG || null; // present only in native bundles
var cap = window.Capacitor;
var isNative = !!(cap && typeof cap.isNativePlatform === 'function' && cap.isNativePlatform());
// Which server this install talks to.
//
// The app ships to anyone, not just Waters, so the server is a per-INSTALL choice rather
// than something baked into the binary: the user picks it once at first launch. The
// bundled apiBase is only a SUGGESTION pre-filled into that screen — using it
// automatically would silently point every download at Waters' NAS.
// Three modes, chosen once on the setup screen:
// 'local' — no server at all; server.js itself runs in this WebView (local-server.js)
// 'server' — talk to the address in checklist:server
// '' — nothing chosen yet, show the setup screen
var mode = '';
var chosen = '';
try {
mode = localStorage.getItem('checklist:mode') || '';
chosen = localStorage.getItem('checklist:server') || '';
} catch (_) {}
if (!mode && chosen) mode = 'server'; // installs from before local mode existed
var base = String(chosen).replace(/\/+$/, '');
// Published surface. `active` is what app.js branches on; resolveUrl is safe to call
// on the web too (it returns the path unchanged), so call sites need no platform test.
var api = {
active: isNative,
platform: isNative && typeof cap.getPlatform === 'function' ? cap.getPlatform() : 'web',
apiBase: isNative ? base : '',
bundleVersion: (cfg && cfg.bundleVersion) || '',
bundledAt: (cfg && cfg.bundledAt) || '',
// Pre-filled into the setup screen so Waters volunteers just tap Continue.
suggestedServer: (cfg && cfg.apiBase) || '',
mode: mode,
// app.js shows the setup screen instead of the sign-in sheet when this is true.
needsSetup: isNative && mode !== 'local' && !base,
setMode: function (m) { try { localStorage.setItem('checklist:mode', m); } catch (_) {} },
setServer: function (url) {
try { localStorage.setItem('checklist:server', String(url).replace(/\/+$/, '')); } catch (_) {}
},
clearServer: function () {
try {
localStorage.removeItem('checklist:server');
localStorage.removeItem('checklist:mode');
} catch (_) {}
},
resolveUrl: function (u) { return u; }
};
window.CHECKLIST_NATIVE = api;
if (!isNative) return; // ── website: stop here, change nothing ──
// ── Local mode ────────────────────────────────────────────────────────────────────
// No server anywhere. local-server.js runs the real server.js in this WebView, so fetch
// is answered in-process rather than over the network. app.js is untouched by this: it
// still calls apiGet('/api/state') and still gets exactly the response the real server
// would have produced, because it IS the real server.
if (mode === 'local') {
api.apiBase = '';
setupLocalRouting();
return;
}
// No server chosen yet. Deliberately do NOT patch fetch — app.js will show the setup
// screen and make no API calls until a server exists, and a half-configured patch would
// turn that into a screen of confusing 404s.
if (!base) return;
// Server-absolute paths only: "/api/state" yes, "//cdn/x" no (protocol-relative),
// "https://…" no (already absolute), "app.js" no (a bundled asset, must stay local).
function isServerPath(u) {
return typeof u === 'string' && u.charAt(0) === '/' && u.charAt(1) !== '/';
}
api.resolveUrl = function (u) { return isServerPath(u) ? base + u : u; };
// ── fetch() ───────────────────────────────────────────────────────────────────────
// Everything in app.js goes through apiFetch()/apiGet()/apiPost() plus three raw
// fetches, and all of them pass a server-absolute string. Patching here covers the lot
// without touching a single call site. Request/URL inputs are handled defensively so a
// future call site can't quietly break.
var nativeFetch = window.fetch.bind(window);
window.fetch = function (input, init) {
try {
if (isServerPath(input)) {
input = base + input;
} else if (typeof Request !== 'undefined' && input instanceof Request) {
// A Request's url is already absolutised against the shell origin, so match
// on that origin rather than on a leading slash.
var abs = input.url;
if (abs.indexOf(location.origin + '/') === 0) {
input = new Request(base + abs.slice(location.origin.length), input);
}
} else if (typeof URL !== 'undefined' && input instanceof URL) {
if (input.origin === location.origin) input = base + input.pathname + input.search;
}
} catch (_) { /* never let URL rewriting break the request itself */ }
return nativeFetch(input, init);
};
// Media stored by the local server lives in IndexedDB, and an <img src> cannot read that.
// Blob URLs bridge the gap: every stored file gets one, resolveUrl hands it out, and new
// uploads register theirs as soon as the upload response comes back.
// NOTE: assigned inside setupLocalRouting, not out here. The local-mode branch above
// returns early, so a `var` initialised at this position would still be undefined by the
// time resolveUrl ran — hoisting lifts the declaration, never the assignment.
var mediaUrls;
// A Blob built with no type gets an empty content type, and WebKit can refuse to render
// that in an <img>. The stored filename carries the extension, so use it.
function mimeFor(p) {
var m = /\.([A-Za-z0-9]+)$/.exec(p || '');
var e = m ? m[1].toLowerCase() : '';
return ({
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
webp: 'image/webp', heic: 'image/heic',
mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',
pdf: 'application/pdf'
})[e] || 'application/octet-stream';
}
function setupLocalRouting() {
mediaUrls = Object.create(null);
if (!window.LocalServer) {
console.error('[native-bridge] local mode selected but local-server.js is missing');
return;
}
var LS = window.LocalServer;
api.resolveUrl = function (u) {
if (typeof u !== 'string') return u;
return mediaUrls[u] || u;
};
// Turn every file already in the virtual filesystem into a blob URL, so photos taken
// in a previous session show up immediately on this launch.
api.indexLocalMedia = function () {
return LS.ready().then(function () {
var keys = LS.vfs.keys();
return Promise.all(keys.map(function (k) {
var web = k.indexOf('/data/uploads/') === 0 ? '/equip-images/' + k.slice(14)
: k.indexOf('/data/docs/') === 0 ? '/doc-files/' + k.slice(11) : '';
if (!web || mediaUrls[web]) return null;
return LS.vfs.raw(k).then(function (content) {
if (content == null) return;
mediaUrls[web] = URL.createObjectURL(new Blob([content], { type: mimeFor(web) }));
}).catch(function () {});
}));
});
};
var nativeFetchLocal = window.fetch.bind(window);
window.fetch = function (input, init) {
var url = typeof input === 'string' ? input
: (input && input.url ? input.url.replace(location.origin, '') : '');
var isApi = url.indexOf('/api/') === 0;
var isMedia = url.indexOf('/equip-images/') === 0 || url.indexOf('/doc-files/') === 0;
if (!isApi && !isMedia) return nativeFetchLocal(input, init);
var opts = init || {};
var body;
try { body = opts.body ? JSON.parse(opts.body) : undefined; } catch (_) { body = undefined; }
var headers = {};
try {
var h = opts.headers || {};
Object.keys(h).forEach(function (k) { headers[k.toLowerCase()] = h[k]; });
} catch (_) {}
return LS.handle(opts.method || 'GET', url, body, headers).then(function (r) {
// Register any file the server just wrote, so the <img> that renders a moment
// later already has a blob URL to point at.
if (r.body && typeof r.body === 'object' && typeof r.body.path === 'string') {
var p = r.body.path;
if (!mediaUrls[p]) {
var disk = p.indexOf('/equip-images/') === 0 ? '/data/uploads/' + p.slice(14)
: p.indexOf('/doc-files/') === 0 ? '/data/docs/' + p.slice(11) : '';
if (disk) {
return LS.vfs.raw(disk).then(function (c) {
if (c != null) mediaUrls[p] = URL.createObjectURL(new Blob([c], { type: mimeFor(p) }));
return toResponse(r);
});
}
}
}
return toResponse(r);
});
};
function toResponse(r) {
var isJson = r.body !== null && typeof r.body === 'object' && !r.isBuffer;
var payload = r.isBuffer ? r.body : (isJson ? JSON.stringify(r.body) : String(r.body == null ? '' : r.body));
return new Response(payload, {
status: r.status,
headers: Object.assign({ 'Content-Type': isJson ? 'application/json' : 'application/octet-stream' }, r.headers || {}),
});
}
}
// ── Haptics ───────────────────────────────────────────────────────────────────────
// app.js already calls navigator.vibrate() on check-off. iOS has no Vibration API at
// all (it threw benign console errors), and inside a WebView Android's is unreliable.
// Routing it to the native Haptics plugin makes check-offs feel right on both, with
// zero changes in app.js. Durations map onto the three native impact weights.
var plugins = window.CapPlugins || {};
if (plugins.Haptics) {
navigator.vibrate = function (pattern) {
try {
var ms = Array.isArray(pattern) ? pattern[0] : pattern;
ms = Number(ms) || 0;
if (ms <= 0) return true;
var style = ms <= 15 ? 'LIGHT' : (ms <= 40 ? 'MEDIUM' : 'HEAVY');
plugins.Haptics.impact({ style: style });
} catch (_) {}
return true; // the Vibration API contract: report success synchronously
};
}
// ── External links ────────────────────────────────────────────────────────────────
// Attached documents and pasted URLs render as target="_blank". In a WebView that
// either does nothing or replaces the app's own page with no way back — both look like
// a crash. Hand them to the system browser instead (in-app Safari/Custom Tab), which
// is also what App Review expects. Capture phase so we win before other handlers.
document.addEventListener('click', function (ev) {
var a = ev.target && ev.target.closest && ev.target.closest('a[href]');
if (!a) return;
var href = a.getAttribute('href') || '';
if (!href || href.charAt(0) === '#') return;
var abs = api.resolveUrl(href);
if (!/^https?:\/\//i.test(abs)) return; // mailto:, tel:, blob: — leave to the OS
if (abs.indexOf(location.origin) === 0) return; // a link inside our own bundle
ev.preventDefault();
if (plugins.Browser) plugins.Browser.open({ url: abs, presentationStyle: 'popover' });
else window.open(abs, '_system');
}, true);
}());
+230
View File
@@ -0,0 +1,230 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Privacy Policy — Waters Checklist</title>
<meta name="color-scheme" content="dark light">
<link rel="icon" type="image/svg+xml" href="/icons/icon.svg?v=2">
<!-- Deliberately self-contained: system font stack, no webfont, no script, no external
asset of any kind. A privacy page that itself phones out to a third party to render is
a bad look, and this one has to load for App Review and Play reviewers on any network. -->
<style>
:root {
--bg: #14161b; --panel: #1d2026; --line: #2e333d;
--text: #eceef2; --muted: #9aa2ad; --accent: #39a8ff;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
font-size: 16px;
line-height: 1.65;
-webkit-font-smoothing: antialiased;
}
.wrap {
max-width: 720px;
margin: 0 auto;
padding: max(32px, env(safe-area-inset-top)) 22px max(48px, env(safe-area-inset-bottom));
}
header { border-bottom: 1px solid var(--line); padding-bottom: 20px; margin-bottom: 28px; }
.logo { display: flex; align-items: center; gap: 12px; margin-bottom: 18px; }
.logo svg { width: 34px; height: 34px; border-radius: 8px; display: block; }
.logo span { font-weight: 600; font-size: 15px; letter-spacing: .01em; }
h1 { font-size: 27px; line-height: 1.25; margin: 0 0 8px; letter-spacing: -.02em; }
h2 {
font-size: 17px; margin: 34px 0 10px; letter-spacing: -.01em;
padding-top: 22px; border-top: 1px solid var(--line);
}
h2:first-of-type { border-top: 0; padding-top: 0; }
p, li { color: #d5d9e0; }
.muted { color: var(--muted); font-size: 13.5px; }
ul { padding-left: 20px; margin: 12px 0; }
li { margin: 7px 0; }
li strong { color: var(--text); }
a { color: var(--accent); }
.card {
background: var(--panel); border: 1px solid var(--line);
border-radius: 10px; padding: 16px 18px; margin: 18px 0;
}
.card p:first-child { margin-top: 0; }
.card p:last-child { margin-bottom: 0; }
.lede { font-size: 17px; color: #dfe3e9; }
table { width: 100%; border-collapse: collapse; margin: 16px 0; font-size: 14.5px; }
th, td { text-align: left; padding: 9px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
th { color: var(--muted); font-weight: 600; font-size: 12.5px; text-transform: uppercase; letter-spacing: .05em; }
footer { margin-top: 40px; padding-top: 20px; border-top: 1px solid var(--line); }
code {
background: #262a32; padding: 1.5px 5px; border-radius: 4px;
font-size: 13px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
@media (prefers-color-scheme: light) {
:root { --bg: #ffffff; --panel: #f6f7f9; --line: #e2e5ea; --text: #16181d; --muted: #5c636e; --accent: #0a66c2; }
p, li { color: #2c313a; }
code { background: #eceef2; }
}
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="logo">
<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#39a8ff"/><stop offset="1" stop-color="#2ecc71"/>
</linearGradient></defs>
<rect width="512" height="512" rx="112" fill="url(#g)"/>
<path d="M150 262 L224 338 L366 178" fill="none" stroke="#fff" stroke-width="46"
stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span>Waters Checklist</span>
</div>
<h1>Privacy Policy</h1>
<p class="muted">Last updated 14 September 2026 · Living Waters Church</p>
</header>
<p class="lede">
Waters Checklist is a private tool for Living Waters Church volunteers and staff. It tracks
the startup, shutdown and service checklists our ministry teams run each week. It is not a
consumer product, it is not advertising-supported, and it has no business model that
involves your data.
</p>
<div class="card">
<p><strong>The short version.</strong> We collect only what the checklists need to work: who
ticked off which task and when, plus anything you type or photograph yourself. There is no
analytics, no advertising and no tracking of any kind. Nothing is sold or shared. The data
lives on a server owned and operated by the church, not on a commercial cloud platform.</p>
</div>
<h2>What we collect</h2>
<table>
<tr><th>Data</th><th>Why</th></tr>
<tr>
<td><strong>Sign-in details</strong><br><span class="muted">campus, team, password</span></td>
<td>Most logins are shared by a whole team rather than tied to a person. Passwords are stored
only as a one-way bcrypt hash — we cannot read yours, and neither can anyone with access
to the server.</td>
</tr>
<tr>
<td><strong>Your name</strong></td>
<td>Only if you sign in with an editor account, which asks who you are so changes can be
attributed. Shared team logins do not ask for a name.</td>
</tr>
<tr>
<td><strong>Email addresses</strong></td>
<td>Added by an administrator so a team can receive the weekly summary and urgent
equipment-failure alerts. Used for nothing else — no newsletters, no marketing.</td>
</tr>
<tr>
<td><strong>Checklist activity</strong></td>
<td>Which task was completed, the date and time, and which team or person completed it.
This is the point of the app.</td>
</tr>
<tr>
<td><strong>What you write</strong></td>
<td>Task and note text, feedback, and requests sent between departments.</td>
</tr>
<tr>
<td><strong>Photos, video and files</strong></td>
<td>Equipment pictures, short clips and documents you attach to a checklist item.</td>
</tr>
<tr>
<td><strong>A short activity log</strong></td>
<td>A rolling record of edits — time, who, and a one-line description — so a team can see
what changed. It is capped and older entries are discarded automatically.</td>
</tr>
</table>
<h2>What we do not collect</h2>
<ul>
<li><strong>No analytics or tracking.</strong> The app contains no analytics service, no
advertising network, no tracking pixel and no third-party software development kit.</li>
<li><strong>No physical location.</strong> "Location" here means a campus you pick from a
list — Woonsocket, Apollo Beach and so on. The app never requests or receives GPS or any
device location.</li>
<li><strong>No contacts, health, or financial information.</strong></li>
<li><strong>No advertising identifiers</strong>, and no profile is built about you.</li>
</ul>
<h2>Device permissions</h2>
<ul>
<li><strong>Camera</strong> — requested only at the moment you choose to photograph a piece of
equipment or an issue. The picture goes to the church's own server and nowhere else. The
camera is never accessed in the background.</li>
<li><strong>Photo library</strong> — used only when you pick an existing picture to attach.
The app receives the one file you select; it cannot browse your library.</li>
<li><strong>On-device storage</strong> — your sign-in token and a few display preferences (which
list you had open, how you like the columns) stay on your own device. If you are offline,
check-offs are held on the device until the connection returns.</li>
</ul>
<h2>Where the data lives</h2>
<p>
On a server owned and run by Living Waters Church, on church premises. It is not hosted with a
commercial cloud provider, and no outside company administers it. All traffic between the app
and the server is encrypted with HTTPS. The church keeps automatic nightly backups so a
mistaken deletion can be undone.
</p>
<h2>Other services</h2>
<p>Deliberately few. In normal use the app talks only to the church's own server.</p>
<ul>
<li><strong>Google Fonts</strong> — the app loads its typeface from Google's font service, which
means Google receives the request, including your IP address. If the font cannot be
fetched, the app falls back to your device's built-in fonts and works normally.</li>
<li><strong>Google Drive</strong> — only if somebody pastes a Google Drive link for a picture.
The image preview is then fetched from Google. No account information is exchanged.</li>
<li><strong>Email delivery</strong> — weekly summaries and urgent alerts are sent through a
standard email provider, which necessarily handles the recipient's address and the message.</li>
</ul>
<p class="muted">
We do not use any other third-party service, and we never send your data to one for
advertising, analytics or resale.
</p>
<h2>Who can see your information</h2>
<p>
Members of your own team, church administrators, and ministry leaders whose oversight covers
your campus. Nobody outside the church has access. We do not sell, rent or trade any of it. We
would disclose information only if the law required it.
</p>
<h2>Keeping and deleting it</h2>
<p>
Checklist records are kept while they remain useful for reviewing how services ran. Church
administrators can delete lists, photos, documents and accounts at any time, and photos that
are no longer attached to anything are removed automatically. To ask what is held about you,
to correct it, or to have it deleted, contact us at the address below and we will act on it.
</p>
<h2>Children</h2>
<p>
The app is built for volunteers and staff serving on ministry teams and is not directed at
children. We do not knowingly collect information from anyone under 13.
</p>
<h2>Changes</h2>
<p>
If this policy changes we will update the date at the top of this page. Significant changes
will also be mentioned to team leaders directly.
</p>
<h2>Contact</h2>
<p>
Questions about this policy, or a request about your information:<br>
<a href="mailto:media@waterschurch.org">media@waterschurch.org</a>
</p>
<footer>
<p class="muted">
Waters Checklist · Living Waters Church · <a href="/">Return to the checklist</a>
</p>
</footer>
</div>
</body>
</html>
File diff suppressed because it is too large Load Diff
+70
View File
@@ -0,0 +1,70 @@
// Service worker.
// Goal: keep the app usable on flaky/no network without ever serving stale DATA.
// • App SHELL (HTML/CSS/JS/manifest) — network-first, fall back to cache so the
// app still loads when offline. Successful fetches refresh the cache.
// • API (/api/*) — never cached. The client handles offline itself (it queues
// check-offs in localStorage and syncs on reconnect).
const SW_VERSION = 'v5';
const SHELL_CACHE = 'shell-' + SW_VERSION;
const SHELL = [
'/', '/index.html', '/app.js', '/styles.css', '/md.js', '/manifest.webmanifest',
'/vendor/motion.global.js'
];
self.addEventListener('install', (event) => {
event.waitUntil(
// Per-URL (not atomic addAll): one missing/404 entry must not reject the whole batch and
// leave the new shell cache empty — otherwise a hard-offline navigation right after a
// version bump would fall through to the offline page. allSettled caches what it can.
caches.open(SHELL_CACHE)
.then((c) => Promise.allSettled(SHELL.map((u) => c.add(u))))
.catch(() => {})
);
self.skipWaiting(); // activate the new worker on next load
});
self.addEventListener('activate', (event) => {
event.waitUntil((async () => {
const keys = await caches.keys();
await Promise.all(keys.filter((k) => k !== SHELL_CACHE).map((k) => caches.delete(k)));
await self.clients.claim();
})());
});
const OFFLINE_HTML =
'<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Offline</title><style>body{font-family:-apple-system,Segoe UI,sans-serif;background:#1a1a1a;color:#e0e0e0;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;text-align:center;padding:20px;}h1{color:#3498db;margin-bottom:8px;}p{color:#b0b0b0;max-width:380px;}</style></head><body><div><h1>Offline</h1><p>The checklist needs an internet connection the first time. Try again when you\'re back online.</p></div></body></html>';
self.addEventListener('fetch', (event) => {
const req = event.request;
if (req.method !== 'GET') return;
const url = new URL(req.url);
if (url.origin !== self.location.origin) return; // third-party: passthrough
if (url.pathname.startsWith('/api/')) return; // never cache API; client handles offline
// Full-resolution equipment photos can be several MB each and pile up fast; caching them
// in the shell risks blowing the (small, all-or-nothing) iOS cache quota and evicting the
// app shell. They already carry an immutable 1-year HTTP cache header, so let the browser's
// HTTP cache handle them and keep them out of the shell cache entirely.
if (url.pathname.startsWith('/equip-images/')) return;
event.respondWith((async () => {
try {
const fresh = await fetch(req);
// Cache successful shell/static GETs for offline fallback.
if (fresh && fresh.ok && fresh.type === 'basic') {
const cache = await caches.open(SHELL_CACHE);
cache.put(req, fresh.clone());
}
return fresh;
} catch (_) {
const cached = await caches.match(req);
if (cached) return cached;
if (req.mode === 'navigate') {
const idx = (await caches.match('/index.html')) || (await caches.match('/'));
if (idx) return idx;
return new Response(OFFLINE_HTML, { headers: { 'Content-Type': 'text/html; charset=utf-8' } });
}
throw new Error('Offline');
}
})());
});
File diff suppressed because one or more lines are too long