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:
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 |
@@ -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 |
@@ -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 — 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 “Executive” with their name. “Edit access” sets the locations/teams they can edit.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mgmt-section mgmt-wide">
|
||||
<h4>Locations & 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 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 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 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>
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// 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 };
|
||||
})();
|
||||
@@ -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);
|
||||
}());
|
||||
@@ -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
@@ -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');
|
||||
}
|
||||
})());
|
||||
});
|
||||
+1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user