Compare commits
24
Commits
v1.1
...
aa4e604995
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa4e604995 | ||
|
|
5a357f0aa0 | ||
|
|
a006f483d7 | ||
|
|
1bd2687e2d | ||
|
|
2f66b784ba | ||
|
|
3594967391 | ||
|
|
e4495440f0 | ||
|
|
5783f00888 | ||
|
|
797d13edad | ||
|
|
592f0677f1 | ||
|
|
2ed361f2e0 | ||
|
|
90b89e80aa | ||
|
|
300234a00d | ||
|
|
9be72b9f40 | ||
|
|
00a1b4bb98 | ||
|
|
007280930e | ||
|
|
f1ef1883e9 | ||
|
|
39ee33761b | ||
|
|
f4dcfda334 | ||
|
|
418184651a | ||
|
|
2eeb8285b9 | ||
|
|
87e0d2c2ba | ||
|
|
416c477bbe | ||
|
|
a006fd82f0 |
No files matched your search
@@ -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**
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
+125
-42
@@ -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,7 +343,7 @@
|
||||
<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/JustinOros/step-tracker/releases/download/v1.0/StepMates-Sync-Android.apk" target="_blank" class="bridge-card">
|
||||
<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>
|
||||
@@ -471,7 +482,7 @@
|
||||
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/step-tracker/releases/download/v1.0/StepMates-Sync-Android.apk" target="_blank" style="color:var(--teal)">StepMates Sync Android App</a><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>
|
||||
@@ -480,7 +491,7 @@
|
||||
<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/JustinOros/step-tracker/releases/download/v1.0/StepMates-Sync-Android.apk" target="_blank" class="bridge-card">
|
||||
<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>
|
||||
@@ -518,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">
|
||||
@@ -579,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>
|
||||
@@ -622,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) {
|
||||
@@ -638,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;
|
||||
@@ -699,12 +724,19 @@ function todayKey() {
|
||||
|
||||
function esc(str) { return (str||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
|
||||
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) {
|
||||
@@ -713,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');
|
||||
@@ -757,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);
|
||||
@@ -766,27 +803,23 @@ 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');
|
||||
} else {
|
||||
currentUserData = snap.data();
|
||||
if (currentUserData.approved === false) {
|
||||
showScreen('pending');
|
||||
} else if (!currentUserData.setupComplete) {
|
||||
currentUserData = { email: user.email, webhookToken, approved: true, dailyGoal: 5000 };
|
||||
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);
|
||||
currentStepsData = {};
|
||||
loadDashboard();
|
||||
} else {
|
||||
currentUserData = snap.data();
|
||||
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));
|
||||
@@ -795,13 +828,10 @@ async function initUser(user) {
|
||||
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
|
||||
@@ -859,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() {
|
||||
@@ -918,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) {
|
||||
@@ -952,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);
|
||||
}
|
||||
|
||||
@@ -1138,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();
|
||||
@@ -1146,13 +1177,19 @@ async function doRemoveFriend(friendUid) {
|
||||
}
|
||||
|
||||
window.approveFriend = async function(uid) {
|
||||
await updateDoc(doc(db, 'friends', currentUser.uid, 'list', uid), { approved: true });
|
||||
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', '');
|
||||
}
|
||||
|
||||
@@ -1165,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');
|
||||
}
|
||||
|
||||
@@ -1282,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';
|
||||
@@ -1292,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');
|
||||
@@ -1300,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();
|
||||
@@ -1315,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 {
|
||||
@@ -1388,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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user