26 Commits
Author SHA1 Message Date
Sterling Archer aa4e604995 Remove admin approval - users now go straight to dashboard on sign up 2026-06-29 13:20:14 -07:00
Sterling Archer 5a357f0aa0 Add Last Synced date and time to friend profile 2026-06-29 09:41:48 -07:00
Sterling Archer a006f483d7 Add acceptFriendUrl to config 2026-06-28 21:21:23 -07:00
Sterling Archer 1bd2687e2d Fix emoji picker instructions, default to running emoji avatar 2026-06-28 21:08:46 -07:00
Sterling Archer 2f66b784ba Replace avatar URL input with emoji picker in profile modal 2026-06-28 21:02:04 -07:00
Sterling Archer 3594967391 Migrate friend system to status field, fix pending request logic 2026-06-28 20:39:20 -07:00
Sterling Archer e4495440f0 Revamped friend system 2026-06-28 20:33:45 -07:00
Sterling Archer 5783f00888 Fix emoji avatar in friend profile modal 2026-06-28 20:00:16 -07:00
Sterling Archer 797d13edad Show emoji avatars in leaderboard and nav 2026-06-28 19:57:17 -07:00
Sterling Archer 592f0677f1 Add data deletion section to privacy policy 2026-06-27 16:08:02 -07:00
Sterling Archer 2ed361f2e0 Update privacy policy page 2026-06-27 16:00:58 -07:00
Sterling Archer 90b89e80aa Add privacy policy page 2026-06-27 15:56:14 -07:00
Sterling Archer 300234a00d Hide Google sign-in on iOS, popup only on desktop/Android 2026-06-25 11:35:58 -07:00
Sterling Archer 9be72b9f40 Wait for redirect result before showing auth screen on iOS 2026-06-25 11:31:44 -07:00
Sterling Archer 00a1b4bb98 Use redirect on iOS, popup on desktop for Google sign-in 2026-06-25 11:28:47 -07:00
Sterling Archer 007280930e Show Google sign-in button on all devices 2026-06-25 11:23:24 -07:00
Sterling Archer f1ef1883e9 Reset Google sign-in button state on sign out 2026-06-25 11:16:11 -07:00
Sterling Archer 39ee33761b Fix auth loop with initialization guard 2026-06-25 11:14:37 -07:00
Sterling Archer f4dcfda334 Revert to popup for Google sign-in, fix loading loop 2026-06-25 11:11:11 -07:00
Sterling Archer 418184651a Fix Google redirect result handling on iOS 2026-06-25 11:06:25 -07:00
Sterling Archer 2eeb8285b9 Switch Google sign-in to redirect flow for reliability 2026-06-25 10:57:06 -07:00
Sterling Archer 87e0d2c2ba Add Google sign-in button to auth screen 2026-06-25 10:49:46 -07:00
Sterling Archer 416c477bbe Update Android APK download links to stepmates-sync repo 2026-06-21 14:59:01 -07:00
Sterling Archer a006fd82f0 Update Android download links to v1.1 2026-06-21 14:09:29 -07:00
Sterling Archer 77d11d29f7 Replace HC Webhook links with StepMates Sync Android app download 2026-06-21 10:14:36 -07:00
Sterling Archer f4be08db84 Remove APK from repo - hosted in releases instead 2026-06-21 10:14:23 -07:00
5 changed files with 200 additions and 61 deletions
+2 -2
View File
@@ -41,8 +41,8 @@ A private step tracking web app that syncs steps from your phone and lets you co
4. Select **Steps** → set Date Range to **Default** → set schedule to **Daily**
### Android
1. Download the **StepMates Sync** Android app from GitHub:
https://github.com/JustinOros/step-tracker/releases/latest
1. Download the **StepMates Sync** Android app:
[Download StepMates-Sync-Android.apk](https://github.com/JustinOros/stepmates-sync/releases/download/v1.1/StepMates-Sync-Android.apk)
2. Install it (allow unknown sources when prompted)
3. Sign in to StepMates in your browser → hamburger menu → **Setup & Sync** → copy your Webhook URL
4. Open the StepMates Sync app → paste your Webhook URL → tap **Sync My Steps**
Binary file not shown.
+1
View File
@@ -11,5 +11,6 @@ window.STEPMATES_CONFIG = {
adminNotifyUrl: "https://adminnotify-mgc3i5pbxq-uc.a.run.app",
sendMagicLinkUrl: "https://sendmagiclink-mgc3i5pbxq-uc.a.run.app",
friendRequestUrl: "https://friendrequest-mgc3i5pbxq-uc.a.run.app",
acceptFriendUrl: "https://acceptfriendrequest-mgc3i5pbxq-uc.a.run.app",
appUrl: "https://justinoros.github.io/step-tracker"
};
+144 -59
View File
@@ -55,6 +55,8 @@
.btn-primary:hover:not(:disabled) { opacity: 0.9; }
.btn-ghost { background: transparent; color: var(--muted); border: 1.5px solid var(--border); }
.btn-ghost:hover { border-color: var(--teal); color: var(--teal); }
.btn-google { background: #fff; color: #3c4043; border: 1.5px solid #dadce0; font-family: var(--font-body); font-size: 15px; font-weight: 500; width: 100%; margin-bottom: 4px; gap: 10px; }
.btn-google:hover { background: #f8f9fa; border-color: #c6c6c6; }
.btn-danger { background: transparent; color: var(--danger); border: 1.5px solid var(--danger); }
.btn-danger:hover { background: rgba(255,107,107,0.1); }
.btn-sm { padding: 7px 14px; font-size: 13px; min-height: 38px; }
@@ -266,7 +268,16 @@
<p class="auth-tagline">Walk more. Compete with friends.</p>
<div class="auth-card" id="auth-card">
<h2>Welcome</h2>
<p>Enter your email to sign in or create an account — no password needed.</p>
<p>Sign in or create an account to get started.</p>
<button class="btn btn-google" id="google-signin-btn" onclick="signInWithGoogle()" style="display:none">
<svg width="18" height="18" viewBox="0 0 18 18" style="flex-shrink:0"><path fill="#4285F4" d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844c-.209 1.125-.843 2.078-1.796 2.717v2.258h2.908c1.702-1.567 2.684-3.875 2.684-6.615z"/><path fill="#34A853" d="M9 18c2.43 0 4.467-.806 5.956-2.184l-2.908-2.258c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332C2.438 15.983 5.482 18 9 18z"/><path fill="#FBBC05" d="M3.964 10.707c-.18-.54-.282-1.117-.282-1.707s.102-1.167.282-1.707V4.961H.957C.347 6.175 0 7.55 0 9s.348 2.825.957 4.039l3.007-2.332z"/><path fill="#EA4335" d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0 5.482 0 2.438 2.017.957 4.961L3.964 7.293C4.672 5.166 6.656 3.58 9 3.58z"/></svg>
Sign in with Google
</button>
<div id="google-divider" style="display:none;align-items:center;gap:12px;margin:16px 0">
<div style="flex:1;height:1px;background:var(--border)"></div>
<span style="font-size:12px;color:var(--muted)">or</span>
<div style="flex:1;height:1px;background:var(--border)"></div>
</div>
<label for="auth-email">Email address</label>
<input type="email" id="auth-email" placeholder="you@example.com" autocomplete="email">
<div class="error-msg" id="auth-error" style="display:none"></div>
@@ -332,8 +343,8 @@
<div class="bc-icon">🍎</div><div class="bc-name">Health Auto Export</div>
<div class="bc-platform">iPhone · iOS 16+</div><div class="bc-badge">Free tier</div>
</a>
<a href="https://github.com/mcnaveen/health-connect-webhook/releases" target="_blank" class="bridge-card">
<div class="bc-icon">🤖</div><div class="bc-name">HC Webhook</div>
<a href="https://github.com/JustinOros/stepmates-sync/releases/download/v1.1/StepMates-Sync-Android.apk" target="_blank" class="bridge-card">
<div class="bc-icon">🤖</div><div class="bc-name">StepMates Sync</div>
<div class="bc-platform">Android 9+</div><div class="bc-badge">Free & open source</div>
</a>
</div>
@@ -355,8 +366,8 @@
<div style="background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius);padding:14px 16px;font-size:13px;color:var(--muted);line-height:1.8;margin-bottom:16px">
<strong style="color:var(--white)">iPhone (Health Auto Export):</strong><br>
Automations → New Automation → Webhook → paste URL → select Steps → set schedule Daily<br><br>
<strong style="color:var(--white)">Android (HC Webhook):</strong><br>
Add Webhook → paste URL → enable Steps → set sync interval
<strong style="color:var(--white)">Android (StepMates Sync):</strong><br>
Download and install the app → paste your Webhook URL → tap Sync
</div>
<button class="btn btn-primary" onclick="finishOnboarding()">Go to my dashboard →</button>
</div>
@@ -467,19 +478,21 @@
</div>
<div style="background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius);padding:16px;font-size:13px;color:var(--muted);line-height:1.9;margin-bottom:28px">
<strong style="color:var(--white)">iPhone — Health Auto Export:</strong><br>
Automations → New Automation → Webhook → paste URL<br>
Select <em>Steps</em> → set to run Daily<br><br>
<strong style="color:var(--white)">Android — HC Webhook:</strong><br>
<a href="https://github.com/mcnaveen/health-connect-webhook/releases" target="_blank" style="color:var(--teal)">Download from GitHub</a> → Add Webhook → paste URL<br>
Enable Steps → set sync interval
1. Download <a href="https://apps.apple.com/us/app/health-auto-export-json-csv/id1115567069" target="_blank" style="color:var(--teal)">Health Auto Export</a> from the App Store<br>
2. Automations → New Automation → Webhook → paste your URL above<br>
3. Select <em>Steps</em> → set Date Range to <em>Default</em> → set schedule to <em>Daily</em><br><br>
<strong style="color:var(--white)">Android — StepMates Sync App:</strong><br>
1. Download <a href="https://github.com/JustinOros/stepmates-sync/releases/download/v1.1/StepMates-Sync-Android.apk" target="_blank" style="color:var(--teal)">StepMates Sync Android App</a><br>
2. Install it (allow unknown sources when prompted)<br>
3. Paste your Webhook URL above into the app → tap Sync
</div>
<div class="section-header"><div class="section-title">Bridge App Downloads</div></div>
<div class="bridge-cards" style="margin-bottom:32px">
<a href="https://apps.apple.com/us/app/health-auto-export-json-csv/id1115567069" target="_blank" class="bridge-card">
<div class="bc-icon">🍎</div><div class="bc-name">Health Auto Export</div><div class="bc-platform">iPhone · iOS 16+</div><div class="bc-badge">Free tier</div>
</a>
<a href="https://github.com/mcnaveen/health-connect-webhook/releases" target="_blank" class="bridge-card">
<div class="bc-icon">🤖</div><div class="bc-name">HC Webhook</div><div class="bc-platform">Android 9+</div><div class="bc-badge">Free & open source</div>
<a href="https://github.com/JustinOros/stepmates-sync/releases/download/v1.1/StepMates-Sync-Android.apk" target="_blank" class="bridge-card">
<div class="bc-icon">🤖</div><div class="bc-name">StepMates Sync</div><div class="bc-platform">Android 9+</div><div class="bc-badge">Free & open source</div>
</a>
</div>
<div class="section-header"><div class="section-title">Account</div></div>
@@ -516,10 +529,14 @@
<span style="color:var(--muted);font-size:14px">Display Name</span>
<span id="fp-name-text" onclick="openNicknameModal()" style="font-size:14px;font-weight:600;cursor:pointer;color:var(--teal);text-decoration:underline;text-underline-offset:3px;text-decoration-style:dotted">—</span>
</div>
<div style="display:flex;justify-content:space-between;padding:14px 18px">
<div style="display:flex;justify-content:space-between;padding:14px 18px;border-bottom:1px solid var(--border)">
<span style="color:var(--muted);font-size:14px">Date Joined</span>
<span style="font-size:14px;font-weight:500" id="fp-joined">—</span>
</div>
<div style="display:flex;justify-content:space-between;padding:14px 18px">
<span style="color:var(--muted);font-size:14px">Last Synced</span>
<span style="font-size:14px;font-weight:500" id="fp-last-synced">—</span>
</div>
</div>
<div style="display:flex;gap:12px">
@@ -577,8 +594,12 @@
</div>
<label for="profile-name-input">Display name</label>
<input type="text" id="profile-name-input" placeholder="e.g. Alex or RunnerAlex" maxlength="30">
<label for="profile-avatar-input">Avatar image URL <span style="font-weight:400;text-transform:none;font-size:11px;color:var(--muted)">(optional)</span></label>
<input type="text" id="profile-avatar-input" placeholder="https://example.com/photo.jpg">
<label>Avatar Emoji</label>
<div style="display:flex;align-items:center;gap:12px;margin-bottom:4px">
<div id="emoji-preview" style="width:48px;height:48px;border-radius:50%;background:var(--surface-2);border:2px solid var(--teal);display:flex;align-items:center;justify-content:center;font-size:26px;flex-shrink:0">🏃</div>
<input type="text" id="profile-avatar-input" placeholder="Click here, then press the emoji key (⊞+. on Windows, Ctrl+Cmd+Space on Mac, or emoji keyboard on mobile)" style="flex:1;font-size:24px;text-align:center" oninput="updateEmojiPreview(this.value)">
</div>
<div style="font-size:12px;color:var(--muted);margin-bottom:8px">Mac: <strong>Ctrl+Cmd+Space</strong> · Windows: <strong>Win+.</strong> · Mobile: switch to emoji keyboard</div>
<div class="error-msg" id="profile-error" style="display:none"></div>
<div class="modal-actions">
<button class="btn btn-ghost" onclick="closeProfileModal()">Cancel</button>
@@ -620,7 +641,7 @@
<script src="config.js"></script>
<script type="module">
import { initializeApp } from 'https://www.gstatic.com/firebasejs/10.12.0/firebase-app.js';
import { getAuth, isSignInWithEmailLink, signInWithEmailLink, signOut as fbSignOut, onAuthStateChanged } from 'https://www.gstatic.com/firebasejs/10.12.0/firebase-auth.js';
import { getAuth, isSignInWithEmailLink, signInWithEmailLink, signInWithPopup, signInWithRedirect, getRedirectResult, GoogleAuthProvider, signOut as fbSignOut, onAuthStateChanged } from 'https://www.gstatic.com/firebasejs/10.12.0/firebase-auth.js';
import { getFirestore, doc, getDoc, setDoc, updateDoc, deleteField, collection, query, where, getDocs, addDoc, onSnapshot, serverTimestamp } from 'https://www.gstatic.com/firebasejs/10.12.0/firebase-firestore.js';
if (!window.STEPMATES_CONFIG) {
@@ -636,6 +657,12 @@ const fbApp = initializeApp(FIREBASE_CONFIG);
const auth = getAuth(fbApp);
const db = getFirestore(fbApp);
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
if (!isIOS) {
document.getElementById('google-signin-btn').style.display = 'flex';
document.getElementById('google-divider').style.display = 'flex';
}
let currentUser = null;
let currentUserData = null;
let currentProfileData = null;
@@ -697,12 +724,19 @@ function todayKey() {
function esc(str) { return (str||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function isEmoji(str) {
if (!str) return false;
const cp = str.codePointAt(0);
return (cp >= 0x1F300 && cp <= 0x1FAFF) || (cp >= 0x2600 && cp <= 0x27BF) || (cp >= 0xFE00 && cp <= 0xFE0F);
}
function avatarHtml(profile, size=40, nickname='') {
const displayName = profile?.displayName || 'No Display Name';
const initial = (nickname || displayName)[0].toUpperCase();
const url = profile?.avatarUrl || '';
if (url) return `<div class="lb-avatar" style="width:${size}px;height:${size}px"><img src="${esc(url)}" alt="${initial}" onerror="this.parentElement.innerHTML='${initial}'"></div>`;
return `<div class="lb-avatar" style="width:${size}px;height:${size}px">${initial}</div>`;
if (url && isEmoji(url)) return `<div class="lb-avatar" style="width:${size}px;height:${size}px;font-size:${Math.round(size*0.55)}px">${url}</div>`;
if (url && url.startsWith('http')) return `<div class="lb-avatar" style="width:${size}px;height:${size}px"><img src="${esc(url)}" alt="${initial}" onerror="this.parentElement.innerHTML='🏃'"></div>`;
return `<div class="lb-avatar" style="width:${size}px;height:${size}px;font-size:${Math.round(size*0.55)}px">🏃</div>`;
}
function getFriendDisplayName(uid) {
@@ -711,8 +745,12 @@ function getFriendDisplayName(uid) {
return nick || displayName;
}
let _initializingUser = false;
// AUTH
onAuthStateChanged(auth, async (user) => {
if (_initializingUser) return;
if (isSignInWithEmailLink(auth, window.location.href)) {
const urlParams = new URLSearchParams(window.location.search);
const email = urlParams.get('email') || localStorage.getItem('emailForSignIn');
@@ -755,6 +793,7 @@ window.confirmEmailSignIn = async function() {
}
async function initUser(user) {
_initializingUser = true;
currentUser = user;
const userRef = doc(db, 'users', user.uid);
const snap = await getDoc(userRef);
@@ -764,42 +803,35 @@ async function initUser(user) {
await setDoc(userRef, {
email: user.email,
webhookToken,
approved: false,
approved: true,
dailyGoal: 5000,
createdAt: serverTimestamp()
});
await setDoc(doc(db, 'profiles', user.uid), { displayName: '', avatarUrl: '' });
await setDoc(doc(db, 'profiles', user.uid), { displayName: user.displayName || '', avatarUrl: '🏃' });
await setDoc(doc(db, 'steps', user.uid), { stepsByDate: {} });
currentUserData = { email: user.email, webhookToken, approved: false, dailyGoal: 5000 };
await notifyAdminOfNewUser(user.email, user.uid);
showScreen('pending');
currentUserData = { email: user.email, webhookToken, approved: true, dailyGoal: 5000 };
const profileSnap = await getDoc(doc(db, 'profiles', user.uid));
currentProfileData = profileSnap.exists() ? profileSnap.data() : {};
currentStepsData = {};
loadDashboard();
} else {
currentUserData = snap.data();
if (currentUserData.approved === false) {
showScreen('pending');
} else if (!currentUserData.setupComplete) {
const profileSnap = await getDoc(doc(db, 'profiles', user.uid));
currentProfileData = profileSnap.exists() ? profileSnap.data() : {};
const stepsSnap = await getDoc(doc(db, 'steps', user.uid));
currentStepsData = stepsSnap.exists() ? stepsSnap.data().stepsByDate || {} : {};
showScreen('onboarding');
document.getElementById('ob-webhook-url').textContent = buildWebhookUrl(currentUserData.webhookToken);
} else {
const profileSnap = await getDoc(doc(db, 'profiles', user.uid));
currentProfileData = profileSnap.exists() ? profileSnap.data() : {};
const stepsSnap = await getDoc(doc(db, 'steps', user.uid));
currentStepsData = stepsSnap.exists() ? stepsSnap.data().stepsByDate || {} : {};
loadDashboard();
const pendingCount = getPendingFriendUids().length;
if (pendingCount > 0) switchPanel('friends');
if (!currentUserData.setupComplete && currentUserData.approved === false) {
await updateDoc(userRef, { approved: true });
currentUserData.approved = true;
}
const profileSnap = await getDoc(doc(db, 'profiles', user.uid));
currentProfileData = profileSnap.exists() ? profileSnap.data() : {};
const stepsSnap = await getDoc(doc(db, 'steps', user.uid));
currentStepsData = stepsSnap.exists() ? stepsSnap.data().stepsByDate || {} : {};
loadDashboard();
const pendingCount = getPendingFriendUids().length;
if (pendingCount > 0) switchPanel('friends');
}
}
async function notifyAdminOfNewUser(email, uid) {
try {
await fetch(`${window.STEPMATES_CONFIG.adminNotifyUrl}?uid=${uid}&email=${encodeURIComponent(email)}`);
} catch(e) { console.warn('Admin notify failed', e); }
// Admin approval removed - auto-approval enabled
}
// ONBOARDING
@@ -857,8 +889,9 @@ function refreshProfileUI() {
}
function setAvatarEl(wrapEl, initialId, initial, url, size) {
if (url) wrapEl.innerHTML = `<img src="${esc(url)}" alt="${initial}" onerror="this.parentElement.innerHTML='${initial}'">`;
else wrapEl.innerHTML = `<span id="${initialId}">${initial}</span>`;
if (url && isEmoji(url)) wrapEl.innerHTML = `<span style="font-size:${Math.round(size*0.55)}px">${url}</span>`;
else if (url && url.startsWith('http')) wrapEl.innerHTML = `<img src="${esc(url)}" alt="${initial}" onerror="this.parentElement.innerHTML='🏃'">`;
else wrapEl.innerHTML = `<span style="font-size:${Math.round(size*0.55)}px">🏃</span>`;
}
function checkSetupReminder() {
@@ -916,10 +949,10 @@ function loadFriendsRealtime() {
friendSteps = {};
snap.forEach(d => { friendsData[d.id] = d.data(); });
const approvedUids = Object.entries(friendsData)
.filter(([uid, data]) => data.approved === true && data.enabled === true)
.filter(([uid, data]) => data.status === 'active')
.map(([uid]) => uid);
const pendingUids = Object.entries(friendsData)
.filter(([uid, data]) => data.approved === false && data.enabled !== false)
.filter(([uid, data]) => data.status === 'pending' && data.initiator !== 'me')
.map(([uid]) => uid);
await loadFriendProfilesAndSteps(approvedUids);
for (const uid of pendingUids) {
@@ -950,13 +983,13 @@ function loadFriendsRealtime() {
function getMutualFriendUids() {
return Object.entries(friendsData)
.filter(([uid, data]) => data.approved === true && data.enabled === true)
.filter(([uid, data]) => data.status === 'active')
.map(([uid]) => uid);
}
function getPendingFriendUids() {
return Object.entries(friendsData)
.filter(([uid, data]) => data.approved === false && data.enabled !== false)
.filter(([uid, data]) => data.status === 'pending' && data.initiator !== 'me')
.map(([uid]) => uid);
}
@@ -1136,7 +1169,7 @@ window.bulkRemoveFriends = async function() {
async function doRemoveFriend(friendUid) {
const idToken = await currentUser.getIdToken();
await fetch(`${window.STEPMATES_CONFIG.friendRequestUrl}?action=remove&fromUid=${currentUser.uid}&toUid=${friendUid}&idToken=${idToken}`);
if (friendsData[friendUid]) friendsData[friendUid].enabled = false;
if (friendsData[friendUid]) friendsData[friendUid].status = 'removed';
delete friendProfiles[friendUid];
delete friendSteps[friendUid];
renderLeaderboard();
@@ -1144,13 +1177,19 @@ async function doRemoveFriend(friendUid) {
}
window.approveFriend = async function(uid) {
await updateDoc(doc(db, 'friends', currentUser.uid, 'list', uid), { approved: true });
await loadFriendProfilesAndSteps([uid]);
showToast('Friend added! 🎉', 'success');
try {
const idToken = await currentUser.getIdToken();
await fetch(`${STEPMATES_CONFIG.acceptFriendUrl}?fromUid=${currentUser.uid}&requesterUid=${uid}&idToken=${encodeURIComponent(idToken)}`);
await loadFriendProfilesAndSteps([uid]);
showToast('Friend added! 🎉', 'success');
} catch(e) {
await updateDoc(doc(db, 'friends', currentUser.uid, 'list', uid), { status: 'active' });
showToast('Friend added! 🎉', 'success');
}
}
window.denyFriend = async function(uid) {
await updateDoc(doc(db, 'friends', currentUser.uid, 'list', uid), { enabled: false });
await updateDoc(doc(db, 'friends', currentUser.uid, 'list', uid), { status: 'removed' });
showToast('Request declined', '');
}
@@ -1163,14 +1202,29 @@ window.openFriendProfile = async function(uid) {
const displayName = profile.displayName || 'No Display Name';
const avatarEl = document.getElementById('fp-avatar');
if (profile.avatarUrl) avatarEl.innerHTML = `<img src="${esc(profile.avatarUrl)}" style="width:100%;height:100%;object-fit:cover;border-radius:50%">`;
else avatarEl.textContent = (nick || displayName)[0].toUpperCase();
if (profile.avatarUrl && isEmoji(profile.avatarUrl)) {
avatarEl.innerHTML = `<span style="font-size:32px">${profile.avatarUrl}</span>`;
} else if (profile.avatarUrl) {
avatarEl.innerHTML = `<img src="${esc(profile.avatarUrl)}" style="width:100%;height:100%;object-fit:cover;border-radius:50%">`;
} else {
avatarEl.textContent = (nick || displayName)[0].toUpperCase();
}
document.getElementById('fp-name-text').textContent = nick || displayName;
const addedAt = friendsData[uid]?.addedAt?.toDate?.();
document.getElementById('fp-joined').textContent = addedAt ? addedAt.toLocaleDateString('en-US', {month:'long', day:'numeric', year:'numeric'}) : '—';
try {
const stepsDoc = await getDoc(doc(db, 'steps', uid));
const syncTs = stepsDoc.data()?.lastSynced?.toDate?.();
document.getElementById('fp-last-synced').textContent = syncTs
? syncTs.toLocaleString('en-US', {month:'short', day:'numeric', year:'numeric', hour:'numeric', minute:'2-digit'})
: 'Never';
} catch(e) {
document.getElementById('fp-last-synced').textContent = 'Never';
}
showScreen('friend-profile');
}
@@ -1280,6 +1334,25 @@ window.sendMagicLink = async function() {
btn.disabled = false; btn.innerHTML = 'Continue with Email';
}
window.signInWithGoogle = async function() {
const btn = document.getElementById('google-signin-btn');
btn.disabled = true;
btn.innerHTML = '<span class="spinner" style="color:#4285F4"></span> Signing in…';
try {
const provider = new GoogleAuthProvider();
const result = await signInWithPopup(auth, provider);
_initializingUser = true;
await initUser(result.user);
} catch(e) {
btn.disabled = false;
btn.innerHTML = `<svg width="18" height="18" viewBox="0 0 18 18" style="flex-shrink:0"><path fill="#4285F4" d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844c-.209 1.125-.843 2.078-1.796 2.717v2.258h2.908c1.702-1.567 2.684-3.875 2.684-6.615z"/><path fill="#34A853" d="M9 18c2.43 0 4.467-.806 5.956-2.184l-2.908-2.258c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332C2.438 15.983 5.482 18 9 18z"/><path fill="#FBBC05" d="M3.964 10.707c-.18-.54-.282-1.117-.282-1.707s.102-1.167.282-1.707V4.961H.957C.347 6.175 0 7.55 0 9s.348 2.825.957 4.039l3.007-2.332z"/><path fill="#EA4335" d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0 5.482 0 2.438 2.017.957 4.961L3.964 7.293C4.672 5.166 6.656 3.58 9 3.58z"/></svg> Sign in with Google`;
if (e.code !== 'auth/popup-closed-by-user' && e.code !== 'auth/cancelled-popup-request') {
document.getElementById('auth-error').textContent = 'Google sign-in failed. Please try again or use email.';
document.getElementById('auth-error').style.display = 'block';
}
}
}
window.showAuthForm = function() {
document.getElementById('auth-card').style.display = 'block';
document.getElementById('magic-sent-card').style.display = 'none';
@@ -1290,7 +1363,8 @@ window.showAuthForm = function() {
// PROFILE MODAL
window.openProfileModal = function() {
document.getElementById('profile-name-input').value = currentProfileData?.displayName || '';
document.getElementById('profile-avatar-input').value = currentProfileData?.avatarUrl || '';
const avatarUrl = currentProfileData?.avatarUrl || '';
document.getElementById('profile-avatar-input').value = isEmoji(avatarUrl) ? avatarUrl : '';
document.getElementById('profile-error').style.display = 'none';
updateProfilePreview();
document.getElementById('profile-modal').classList.add('open');
@@ -1298,14 +1372,22 @@ window.openProfileModal = function() {
}
window.closeProfileModal = function() { document.getElementById('profile-modal').classList.remove('open'); }
window.closeProfileModalOutside = function(e) { if (e.target===document.getElementById('profile-modal')) closeProfileModal(); }
function updateEmojiPreview(val) {
const preview = document.getElementById('emoji-preview');
if (!preview) return;
const emoji = val.trim();
preview.textContent = emoji || '🏃';
}
function updateProfilePreview() {
const name = document.getElementById('profile-name-input').value.trim() || 'Your Name';
const url = document.getElementById('profile-avatar-input').value.trim();
const initial = name[0].toUpperCase();
document.getElementById('profile-modal-name-preview').textContent = name;
const avatarEl = document.getElementById('profile-modal-avatar');
if (url) avatarEl.innerHTML = `<img src="${esc(url)}" alt="${initial}" onerror="this.parentElement.innerHTML='${initial}'">`;
if (url && isEmoji(url)) avatarEl.innerHTML = `<span style="font-size:28px">${url}</span>`;
else if (url) avatarEl.innerHTML = `<img src="${esc(url)}" alt="${initial}" onerror="this.parentElement.innerHTML='${initial}'">`;
else avatarEl.innerHTML = `<span id="profile-modal-initial">${initial}</span>`;
updateEmojiPreview(url);
}
window.saveProfile = async function() {
const name = document.getElementById('profile-name-input').value.trim();
@@ -1313,7 +1395,7 @@ window.saveProfile = async function() {
const errEl = document.getElementById('profile-error');
errEl.style.display = 'none';
if (!name || name.length < 2) { errEl.textContent = 'Display name must be at least 2 characters.'; errEl.style.display = 'block'; return; }
if (avatarUrl && !avatarUrl.startsWith('http')) { errEl.textContent = 'Avatar URL must start with http:// or https://'; errEl.style.display = 'block'; return; }
if (avatarUrl && !avatarUrl.startsWith('http') && !isEmoji(avatarUrl)) { errEl.textContent = 'Please enter a valid emoji'; errEl.style.display = 'block'; return; }
const btn = document.querySelector('#profile-modal .btn-primary');
btn.disabled = true; btn.innerHTML = '<span class="spinner"></span>';
try {
@@ -1386,8 +1468,11 @@ window.signOut = async function() {
if (!confirm('Sign out?')) return;
teardown();
await fbSignOut(auth);
_initializingUser = false;
currentUser = null; currentUserData = null; currentProfileData = null; currentStepsData = null;
friendsData = {}; friendProfiles = {}; friendSteps = {};
const btn = document.getElementById('google-signin-btn');
if (btn) { btn.disabled = false; btn.innerHTML = `<svg width="18" height="18" viewBox="0 0 18 18" style="flex-shrink:0"><path fill="#4285F4" d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844c-.209 1.125-.843 2.078-1.796 2.717v2.258h2.908c1.702-1.567 2.684-3.875 2.684-6.615z"/><path fill="#34A853" d="M9 18c2.43 0 4.467-.806 5.956-2.184l-2.908-2.258c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332C2.438 15.983 5.482 18 9 18z"/><path fill="#FBBC05" d="M3.964 10.707c-.18-.54-.282-1.117-.282-1.707s.102-1.167.282-1.707V4.961H.957C.347 6.175 0 7.55 0 9s.348 2.825.957 4.039l3.007-2.332z"/><path fill="#EA4335" d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0 5.482 0 2.438 2.017.957 4.961L3.964 7.293C4.672 5.166 6.656 3.58 9 3.58z"/></svg> Sign in with Google`; }
showScreen('auth'); showAuthForm();
}
+53
View File
@@ -0,0 +1,53 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>StepMates — Privacy Policy</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0F1923; color: #F8F9FA; padding: 40px 20px; max-width: 720px; margin: 0 auto; line-height: 1.7; }
h1 { color: #00C9B1; font-size: 28px; margin-bottom: 8px; }
h2 { color: #F8F9FA; font-size: 18px; margin: 32px 0 10px; }
p { color: #8899A6; margin-bottom: 14px; }
a { color: #00C9B1; }
.updated { color: #8899A6; font-size: 13px; margin-bottom: 40px; }
</style>
</head>
<body>
<h1>StepMates Privacy Policy</h1>
<p class="updated">Last updated: June 27, 2026</p>
<p>StepMates ("we", "our", or "us") is a private step tracking app. This policy explains how we handle your data.</p>
<h2>Data We Collect</h2>
<p>We collect the following information when you use StepMates:</p>
<p><strong>Account information:</strong> Your email address and display name, provided via Google Sign-In.</p>
<p><strong>Step data:</strong> Daily step counts read from Google Health Connect (Android) or Apple Health via Health Auto Export (iPhone). We only read step count data — we do not access any other health information.</p>
<p><strong>Friend data:</strong> Display names and step counts of friends you choose to connect with inside the app.</p>
<h2>How We Use Your Data</h2>
<p>Your step data is stored in Firebase Firestore and used solely to display your personal step history and to show your ranking on the leaderboard shared with your approved friends.</p>
<p>We do not sell, share, or use your data for advertising or any purpose other than operating the StepMates app.</p>
<h2>Health Connect Data</h2>
<p>On Android, StepMates uses Google Health Connect to read your step count. We only request read access to Steps. We do not write any data to Health Connect. Step data is synced to our secure Firebase database and displayed only to you and your approved friends.</p>
<h2>Data Sharing</h2>
<p>Your step data is visible only to friends you have mutually approved within the app. No data is shared with third parties.</p>
<h2>Data Storage</h2>
<p>Your data is stored securely using Google Firebase, hosted in the United States. You can request deletion of your account and all associated data by contacting us.</p>
<h2>Account Access</h2>
<p>StepMates is a private app — new accounts require admin approval. Access can be revoked at any time.</p>
<h2>Data Deletion</h2>
<p>You may request complete deletion of your account and all associated data (including step history, profile, and friend connections) by emailing <a href="mailto:justin@slothware.net">justin@slothware.net</a> with the subject line "Data Deletion Request". We will process your request within 30 days.</p>
<h2>Contact</h2>
<p>For privacy questions or data deletion requests, contact us at <a href="mailto:justin@slothware.net">justin@slothware.net</a>.</p>
<p style="margin-top:40px"><a href="https://justinoros.github.io/step-tracker">← Back to StepMates</a></p>
</body>
</html>