Daumi’s Debt Implementation Plan
Daumi’s Debt Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build a private couples expense tracker PWA with multi-currency support, settlement tracking, and weekly mini-games, hosted on GitHub Pages with Firebase backend.
Architecture: Single-page vanilla JS app using ES modules, served from /daumis-debt/ on the existing GitHub Pages site. Firebase provides auth (Google Sign-In) and data storage (Firestore). Exchange rates from frankfurter.app. No build step — static files only.
Tech Stack: Vanilla HTML/CSS/JS (ES modules), Firebase Auth + Firestore, frankfurter.app API, Service Worker for PWA
Note on testing: This is a vanilla JS app with no build step and no test framework. Each task includes manual verification steps. The app is for two users — correctness is validated by running in the browser against the live Firebase project.
Task 0: Firebase Project Setup (Manual)
This task is done by the user in the Firebase Console, not by code.
- Step 1: Create Firebase project
Go to https://console.firebase.google.com. Create a new project called “daumis-debt”. Disable Google Analytics (not needed).
- Step 2: Enable Authentication
In the Firebase Console → Authentication → Sign-in method → Enable “Google” provider. Add both Gal’s and Daum’s email addresses as authorized users (this is done via Firestore rules, not here — just enable the provider).
- Step 3: Enable Firestore
In Firebase Console → Firestore Database → Create database → Start in test mode (we’ll deploy proper rules in Task 15). Choose the closest region.
- Step 4: Register web app
In Firebase Console → Project settings → Add app → Web. Register app name “Daumi’s Debt”. Copy the Firebase config object (apiKey, authDomain, projectId, etc.) — you’ll need it in Task 2.
- Step 5: Add GitHub Pages domain to authorized domains
In Firebase Console → Authentication → Settings → Authorized domains → Add galraz.github.io.
Task 1: Project Scaffold + PWA Shell
Files:
- Create:
daumis-debt/index.html - Create:
daumis-debt/manifest.json - Create:
daumis-debt/sw.js Create:
daumis-debt/css/style.css- Step 1: Create directory structure
mkdir -p daumis-debt/css daumis-debt/js/games daumis-debt/assets/icons
- Step 2: Create index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#1a1a2e">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>Daumi's Debt</title>
<link rel="manifest" href="manifest.json">
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<div id="app">
<!-- Auth screen -->
<div id="screen-auth" class="screen active">
<div class="auth-container">
<h1>Daumi's Debt</h1>
<p class="subtitle">Expense tracker for two</p>
<button id="btn-google-login" class="btn btn-primary">Sign in with Google</button>
</div>
</div>
<!-- Dashboard screen -->
<div id="screen-dashboard" class="screen">
<div class="dashboard-content">
<div id="balance-display" class="balance-card">
<p class="balance-label">Loading...</p>
<p class="balance-amount"></p>
</div>
<div id="duel-banner" class="duel-banner hidden">
<p>Weekly Duel available!</p>
<button id="btn-play-duel" class="btn btn-accent">Play</button>
</div>
<div id="recent-activity">
<h3>Recent Activity</h3>
<ul id="activity-list" class="activity-list"></ul>
</div>
</div>
</div>
<!-- Add Expense screen -->
<div id="screen-add-expense" class="screen">
<h2>Add Expense</h2>
<form id="form-expense" class="form">
<input type="text" id="expense-desc" placeholder="What was it for?" required>
<div class="row">
<input type="number" id="expense-amount" placeholder="Amount" step="0.01" required>
<select id="expense-currency">
<option value="USD">USD</option>
<option value="THB">THB</option>
<option value="BTN">BTN</option>
<option value="JPY">JPY</option>
<option value="EUR">EUR</option>
</select>
</div>
<div class="toggle-group">
<label>Paid by</label>
<div class="toggle" id="expense-paid-by">
<button type="button" class="toggle-btn active" data-value="self">Me</button>
<button type="button" class="toggle-btn" data-value="partner">Partner</button>
</div>
</div>
<div class="toggle-group">
<label>Split</label>
<div class="toggle" id="expense-split">
<button type="button" class="toggle-btn active" data-value="even">Split evenly</button>
<button type="button" class="toggle-btn" data-value="full">Owed fully</button>
</div>
</div>
<input type="date" id="expense-date">
<button type="submit" class="btn btn-primary">Save Expense</button>
</form>
</div>
<!-- Add Payment screen -->
<div id="screen-add-payment" class="screen">
<h2>Record Payment</h2>
<form id="form-payment" class="form">
<div class="row">
<input type="number" id="payment-amount" placeholder="Amount" step="0.01" required>
<select id="payment-currency">
<option value="USD">USD</option>
<option value="THB">THB</option>
<option value="BTN">BTN</option>
<option value="JPY">JPY</option>
<option value="EUR">EUR</option>
</select>
</div>
<div class="toggle-group">
<label>Who paid</label>
<div class="toggle" id="payment-direction">
<button type="button" class="toggle-btn active" data-value="self">I paid</button>
<button type="button" class="toggle-btn" data-value="partner">Partner paid</button>
</div>
</div>
<input type="date" id="payment-date">
<button type="submit" class="btn btn-primary">Save Payment</button>
</form>
</div>
<!-- History screen -->
<div id="screen-history" class="screen">
<h2>History</h2>
<ul id="history-list" class="history-list"></ul>
</div>
<!-- Duel screen -->
<div id="screen-duel" class="screen">
<div id="duel-content"></div>
</div>
</div>
<!-- Bottom nav -->
<nav id="bottom-nav" class="bottom-nav hidden">
<button data-screen="dashboard" class="nav-btn active">Home</button>
<button data-screen="add-expense" class="nav-btn">Expense</button>
<button data-screen="add-payment" class="nav-btn">Payment</button>
<button data-screen="history" class="nav-btn">History</button>
</nav>
<!-- Firebase SDK (compat for no-build-step usage) -->
<script src="https://www.gstatic.com/firebasejs/10.12.0/firebase-app-compat.js"></script>
<script src="https://www.gstatic.com/firebasejs/10.12.0/firebase-auth-compat.js"></script>
<script src="https://www.gstatic.com/firebasejs/10.12.0/firebase-firestore-compat.js"></script>
<script type="module" src="js/app.js"></script>
</body>
</html>
- Step 3: Create manifest.json
{
"name": "Daumi's Debt",
"short_name": "Daumi's Debt",
"start_url": "/daumis-debt/",
"display": "standalone",
"background_color": "#1a1a2e",
"theme_color": "#1a1a2e",
"icons": [
{
"src": "assets/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "assets/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
- Step 4: Create sw.js
const CACHE_NAME = 'daumis-debt-v1';
const ASSETS = [
'/daumis-debt/',
'/daumis-debt/index.html',
'/daumis-debt/css/style.css',
'/daumis-debt/js/app.js',
'/daumis-debt/js/firebase-config.js',
'/daumis-debt/js/exchange.js',
'/daumis-debt/js/balance.js',
'/daumis-debt/js/expenses.js',
'/daumis-debt/js/payments.js',
'/daumis-debt/js/history.js',
'/daumis-debt/js/duel.js',
'/daumis-debt/js/games/coin-flip.js',
'/daumis-debt/js/games/wheel.js',
'/daumis-debt/js/games/rps.js',
'/daumis-debt/js/games/lucky-number.js',
'/daumis-debt/js/games/scratch-card.js',
'/daumis-debt/manifest.json'
];
self.addEventListener('install', (e) => {
e.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(ASSETS)));
self.skipWaiting();
});
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
)
);
self.clients.claim();
});
self.addEventListener('fetch', (e) => {
// Network-first for API calls, cache-first for app shell
if (e.request.url.includes('firestore.googleapis.com') ||
e.request.url.includes('frankfurter.app') ||
e.request.url.includes('googleapis.com/identitytoolkit')) {
return; // Let network handle Firebase and API calls
}
e.respondWith(
caches.match(e.request).then((cached) => cached || fetch(e.request))
);
});
- Step 5: Create css/style.css — base styles and layout
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #1a1a2e;
--surface: #16213e;
--surface-2: #0f3460;
--accent: #e94560;
--text: #eee;
--text-muted: #999;
--green: #4ecca3;
--red: #e94560;
--radius: 12px;
--nav-height: 60px;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100dvh;
padding-bottom: var(--nav-height);
-webkit-font-smoothing: antialiased;
}
/* Screens */
.screen { display: none; padding: 20px; max-width: 480px; margin: 0 auto; }
.screen.active { display: block; }
/* Auth */
.auth-container {
display: flex; flex-direction: column; align-items: center;
justify-content: center; min-height: 80dvh; text-align: center;
}
.auth-container h1 { font-size: 2rem; margin-bottom: 8px; }
.subtitle { color: var(--text-muted); margin-bottom: 32px; }
/* Buttons */
.btn {
padding: 12px 24px; border: none; border-radius: var(--radius);
font-size: 1rem; cursor: pointer; width: 100%;
transition: opacity 0.2s;
}
.btn:active { opacity: 0.7; }
.btn-primary { background: var(--accent); color: white; }
.btn-accent { background: var(--green); color: var(--bg); font-weight: 600; }
/* Balance card */
.balance-card {
background: var(--surface); border-radius: var(--radius);
padding: 24px; text-align: center; margin-bottom: 20px;
}
.balance-label { color: var(--text-muted); font-size: 0.9rem; margin-bottom: 4px; }
.balance-amount { font-size: 2.2rem; font-weight: 700; }
.balance-amount.positive { color: var(--green); }
.balance-amount.negative { color: var(--red); }
/* Duel banner */
.duel-banner {
background: linear-gradient(135deg, var(--surface-2), var(--accent));
border-radius: var(--radius); padding: 16px; margin-bottom: 20px;
display: flex; align-items: center; justify-content: space-between;
}
.duel-banner .btn { width: auto; }
/* Forms */
.form { display: flex; flex-direction: column; gap: 12px; }
.form input, .form select {
padding: 12px; border-radius: var(--radius); border: 1px solid var(--surface-2);
background: var(--surface); color: var(--text); font-size: 1rem;
}
.row { display: flex; gap: 8px; }
.row input { flex: 1; }
.row select { width: 90px; }
/* Toggle */
.toggle-group { display: flex; flex-direction: column; gap: 6px; }
.toggle-group label { font-size: 0.85rem; color: var(--text-muted); }
.toggle {
display: flex; background: var(--surface); border-radius: var(--radius);
overflow: hidden;
}
.toggle-btn {
flex: 1; padding: 10px; border: none; background: transparent;
color: var(--text-muted); font-size: 0.9rem; cursor: pointer;
transition: all 0.2s;
}
.toggle-btn.active { background: var(--surface-2); color: var(--text); }
/* Activity / History list */
.activity-list, .history-list { list-style: none; }
.activity-list li, .history-list li {
background: var(--surface); border-radius: var(--radius);
padding: 12px 16px; margin-bottom: 8px;
display: flex; justify-content: space-between; align-items: center;
}
.entry-info { flex: 1; }
.entry-desc { font-size: 0.95rem; }
.entry-meta { font-size: 0.8rem; color: var(--text-muted); }
.entry-amount { font-weight: 600; text-align: right; }
.entry-amount.credit { color: var(--green); }
.entry-amount.debit { color: var(--red); }
.entry-type {
font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.5px;
padding: 2px 6px; border-radius: 4px; margin-right: 8px;
}
.entry-type.expense { background: var(--accent); color: white; }
.entry-type.payment { background: var(--green); color: var(--bg); }
.entry-type.duel { background: #f0a500; color: var(--bg); }
/* Bottom nav */
.bottom-nav {
position: fixed; bottom: 0; left: 0; right: 0;
background: var(--surface); border-top: 1px solid var(--surface-2);
display: flex; height: var(--nav-height);
padding-bottom: env(safe-area-inset-bottom);
}
.nav-btn {
flex: 1; border: none; background: none; color: var(--text-muted);
font-size: 0.75rem; cursor: pointer; padding: 8px 0;
transition: color 0.2s;
}
.nav-btn.active { color: var(--accent); }
/* Utilities */
.hidden { display: none !important; }
h2 { margin-bottom: 16px; }
h3 { font-size: 1rem; color: var(--text-muted); margin-bottom: 12px; }
/* Duel game styles */
.duel-game { text-align: center; padding: 20px 0; }
.duel-game h2 { font-size: 1.5rem; margin-bottom: 20px; }
.duel-result {
font-size: 1.8rem; font-weight: 700; margin: 20px 0;
padding: 16px; border-radius: var(--radius); background: var(--surface);
}
/* Coin flip */
.coin {
width: 120px; height: 120px; border-radius: 50%;
background: linear-gradient(135deg, #f0a500, #d4900a);
display: flex; align-items: center; justify-content: center;
font-size: 2rem; margin: 30px auto;
transition: transform 0.6s;
}
.coin.flipping { animation: coinFlip 1s ease-out; }
@keyframes coinFlip {
0% { transform: rotateY(0); }
100% { transform: rotateY(1800deg); }
}
/* Wheel */
.wheel-container { position: relative; width: 280px; height: 280px; margin: 20px auto; }
.wheel {
width: 100%; height: 100%; border-radius: 50%;
transition: transform 3s cubic-bezier(0.17, 0.67, 0.12, 0.99);
}
.wheel-pointer {
position: absolute; top: -10px; left: 50%; transform: translateX(-50%);
width: 0; height: 0;
border-left: 12px solid transparent; border-right: 12px solid transparent;
border-top: 24px solid var(--accent); z-index: 1;
}
/* RPS */
.rps-choices { display: flex; gap: 12px; justify-content: center; margin: 20px 0; }
.rps-choice {
width: 80px; height: 80px; border-radius: var(--radius);
background: var(--surface); border: 2px solid var(--surface-2);
font-size: 2.5rem; cursor: pointer; display: flex;
align-items: center; justify-content: center;
transition: border-color 0.2s;
}
.rps-choice.selected { border-color: var(--accent); }
.rps-choice:active { transform: scale(0.95); }
/* Lucky Number */
.number-grid {
display: grid; grid-template-columns: repeat(5, 1fr);
gap: 8px; max-width: 300px; margin: 20px auto;
}
.number-btn {
padding: 12px; border-radius: var(--radius); background: var(--surface);
border: 2px solid var(--surface-2); color: var(--text);
font-size: 1.2rem; cursor: pointer; transition: all 0.2s;
}
.number-btn.selected { border-color: var(--accent); background: var(--surface-2); }
.number-btn.target { border-color: var(--green); background: var(--green); color: var(--bg); }
/* Scratch Card */
.scratch-card {
width: 200px; height: 140px; margin: 20px auto;
border-radius: var(--radius); position: relative; overflow: hidden;
cursor: pointer; user-select: none;
}
.scratch-card canvas {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
}
.scratch-value {
display: flex; align-items: center; justify-content: center;
width: 100%; height: 100%; font-size: 2rem; font-weight: 700;
background: var(--surface);
}
- Step 6: Verify scaffold
Open daumis-debt/index.html in a browser (can use python3 -m http.server from the repo root). Confirm: dark themed page with “Daumi’s Debt” heading and sign-in button. No JS errors in console (Firebase SDK loads, app.js will 404 — that’s expected, we create it next).
- Step 7: Commit
git add daumis-debt/
git commit -m "feat: scaffold Daumi's Debt PWA shell with HTML, CSS, manifest, and service worker"
Task 2: Firebase Config + Auth
Files:
- Create:
daumis-debt/js/firebase-config.js Create:
daumis-debt/js/app.js- Step 1: Create js/firebase-config.js
Replace the placeholder values with the config from Task 0, Step 4.
// Firebase configuration — these values are public by design.
// Security is enforced by Firestore rules, not by hiding this config.
const firebaseConfig = {
apiKey: "YOUR_API_KEY",
authDomain: "YOUR_PROJECT.firebaseapp.com",
projectId: "YOUR_PROJECT_ID",
storageBucket: "YOUR_PROJECT.appspot.com",
messagingSenderId: "YOUR_SENDER_ID",
appId: "YOUR_APP_ID"
};
firebase.initializeApp(firebaseConfig);
export const auth = firebase.auth();
export const db = firebase.firestore();
export const googleProvider = new firebase.auth.GoogleAuthProvider();
- Step 2: Create js/app.js — auth + routing
import { auth, googleProvider } from './firebase-config.js';
// --- State ---
let currentUser = null;
let partnerInfo = null; // { uid, name, email } — resolved after first data load
// User name mapping — populated after auth. Keys are UIDs, values are display names.
const userNames = {};
// --- Auth ---
document.getElementById('btn-google-login').addEventListener('click', () => {
auth.signInWithPopup(googleProvider).catch((err) => {
console.error('Auth error:', err);
alert('Sign-in failed. Make sure you use an authorized Google account.');
});
});
auth.onAuthStateChanged((user) => {
if (user) {
currentUser = user;
userNames[user.uid] = user.displayName || user.email;
showApp();
} else {
currentUser = null;
showScreen('auth');
document.getElementById('bottom-nav').classList.add('hidden');
}
});
// --- Routing ---
function showScreen(name) {
document.querySelectorAll('.screen').forEach((s) => s.classList.remove('active'));
document.getElementById(`screen-${name}`).classList.add('active');
document.querySelectorAll('.nav-btn').forEach((b) => {
b.classList.toggle('active', b.dataset.screen === name);
});
}
document.querySelectorAll('.nav-btn').forEach((btn) => {
btn.addEventListener('click', () => {
showScreen(btn.dataset.screen);
if (btn.dataset.screen === 'dashboard') loadDashboard();
if (btn.dataset.screen === 'history') loadHistory();
});
});
// --- Toggle buttons ---
document.querySelectorAll('.toggle').forEach((toggle) => {
toggle.querySelectorAll('.toggle-btn').forEach((btn) => {
btn.addEventListener('click', () => {
toggle.querySelectorAll('.toggle-btn').forEach((b) => b.classList.remove('active'));
btn.classList.add('active');
});
});
});
// --- App entry ---
async function showApp() {
document.getElementById('bottom-nav').classList.remove('hidden');
// Set default dates to today
const today = new Date().toISOString().split('T')[0];
document.getElementById('expense-date').value = today;
document.getElementById('payment-date').value = today;
// Load dashboard
showScreen('dashboard');
const { loadDashboard } = await import('./balance.js');
loadDashboard();
}
// Expose for other modules
export { currentUser, userNames, showScreen };
export function getCurrentUser() { return currentUser; }
export function getPartnerUid() {
// Find the other user's UID from userNames
return Object.keys(userNames).find((uid) => uid !== currentUser.uid) || null;
}
export function getUserName(uid) {
return userNames[uid] || 'Partner';
}
export function setPartnerInfo(uid, name) {
userNames[uid] = name;
}
- Step 3: Verify auth flow
Run python3 -m http.server 8080 from the repo root. Open http://localhost:8080/daumis-debt/. Click “Sign in with Google”. Confirm: Google popup appears, sign-in works, dashboard screen shows. Check console for errors.
- Step 4: Commit
git add daumis-debt/js/firebase-config.js daumis-debt/js/app.js
git commit -m "feat: add Firebase config and Google Sign-In auth flow"
Task 3: Exchange Rate Module
Files:
Create:
daumis-debt/js/exchange.jsStep 1: Create js/exchange.js
// Exchange rate cache — avoids repeated API calls in a single session
const rateCache = {};
/**
* Get exchange rate from a currency to USD.
* Uses frankfurter.app (ECB data, free, no API key).
* Returns the rate (multiply by this to get USD).
* For USD → USD, returns 1.
* Note: BTN is pegged 1:1 to INR. frankfurter.app doesn't support BTN,
* so we use INR rate as a proxy.
*/
export async function getExchangeRate(currency) {
if (currency === 'USD') return 1;
const cacheKey = currency;
if (rateCache[cacheKey]) return rateCache[cacheKey];
// BTN (Bhutanese Ngultrum) is pegged 1:1 to INR
const queryCurrency = currency === 'BTN' ? 'INR' : currency;
const response = await fetch(
`https://api.frankfurter.app/latest?from=${queryCurrency}&to=USD`
);
if (!response.ok) {
throw new Error(`Exchange rate fetch failed for ${currency}`);
}
const data = await response.json();
const rate = data.rates.USD;
rateCache[cacheKey] = rate;
return rate;
}
/**
* Convert an amount in a given currency to USD.
* Returns { usdAmount, exchangeRate }.
*/
export async function convertToUSD(amount, currency) {
const exchangeRate = await getExchangeRate(currency);
return {
usdAmount: Math.round(amount * exchangeRate * 100) / 100,
exchangeRate
};
}
- Step 2: Verify exchange rates
Open browser console on the app page and run:
import('./js/exchange.js').then(m => m.convertToUSD(1000, 'THB').then(console.log));
Expected: { usdAmount: ~28-30, exchangeRate: ~0.028-0.030 } (varies with live rates).
- Step 3: Commit
git add daumis-debt/js/exchange.js
git commit -m "feat: add exchange rate module with BTN/INR proxy support"
Task 4: Expense Module
Files:
Create:
daumis-debt/js/expenses.jsStep 1: Create js/expenses.js
import { db } from './firebase-config.js';
import { getCurrentUser, getPartnerUid, setPartnerInfo } from './app.js';
import { convertToUSD } from './exchange.js';
const form = document.getElementById('form-expense');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const user = getCurrentUser();
if (!user) return;
const description = document.getElementById('expense-desc').value.trim();
const amount = parseFloat(document.getElementById('expense-amount').value);
const currency = document.getElementById('expense-currency').value;
const paidByValue = document.querySelector('#expense-paid-by .toggle-btn.active').dataset.value;
const splitType = document.querySelector('#expense-split .toggle-btn.active').dataset.value;
const date = document.getElementById('expense-date').value;
if (!description || !amount || amount <= 0) {
alert('Please fill in all fields.');
return;
}
const submitBtn = form.querySelector('button[type="submit"]');
submitBtn.disabled = true;
submitBtn.textContent = 'Saving...';
try {
const { usdAmount, exchangeRate } = await convertToUSD(amount, currency);
const paidBy = paidByValue === 'self' ? user.uid : getPartnerUid();
const owedBy = paidByValue === 'self' ? getPartnerUid() : user.uid;
await db.collection('expenses').add({
description,
amount,
currency,
usdAmount,
exchangeRate,
paidBy,
splitType,
owedBy,
date: new Date(date + 'T12:00:00'),
addedBy: user.uid,
createdAt: firebase.firestore.FieldValue.serverTimestamp()
});
form.reset();
document.getElementById('expense-date').value = new Date().toISOString().split('T')[0];
// Reset toggles to default
document.querySelectorAll('#expense-paid-by .toggle-btn').forEach((b, i) =>
b.classList.toggle('active', i === 0)
);
document.querySelectorAll('#expense-split .toggle-btn').forEach((b, i) =>
b.classList.toggle('active', i === 0)
);
alert('Expense saved!');
} catch (err) {
console.error('Error saving expense:', err);
alert('Failed to save expense. Check your connection.');
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Save Expense';
}
});
/** Fetch all expenses ordered by date descending. */
export async function getAllExpenses() {
const snapshot = await db.collection('expenses').orderBy('date', 'desc').get();
return snapshot.docs.map((doc) => ({ id: doc.id, ...doc.data() }));
}
- Step 2: Verify expense creation
Open the app, sign in, navigate to “Expense” tab. Fill in a test expense (e.g. “Lunch”, 500, THB, split evenly). Submit. Check Firebase Console → Firestore → expenses collection to confirm the document was created with correct fields including usdAmount.
- Step 3: Commit
git add daumis-debt/js/expenses.js
git commit -m "feat: add expense creation with currency conversion"
Task 5: Payment Module
Files:
Create:
daumis-debt/js/payments.jsStep 1: Create js/payments.js
import { db } from './firebase-config.js';
import { getCurrentUser, getPartnerUid } from './app.js';
import { convertToUSD } from './exchange.js';
const form = document.getElementById('form-payment');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const user = getCurrentUser();
if (!user) return;
const amount = parseFloat(document.getElementById('payment-amount').value);
const currency = document.getElementById('payment-currency').value;
const directionValue = document.querySelector('#payment-direction .toggle-btn.active').dataset.value;
const date = document.getElementById('payment-date').value;
if (!amount || amount <= 0) {
alert('Please enter a valid amount.');
return;
}
const submitBtn = form.querySelector('button[type="submit"]');
submitBtn.disabled = true;
submitBtn.textContent = 'Saving...';
try {
const { usdAmount, exchangeRate } = await convertToUSD(amount, currency);
const paidBy = directionValue === 'self' ? user.uid : getPartnerUid();
const paidTo = directionValue === 'self' ? getPartnerUid() : user.uid;
await db.collection('payments').add({
amount,
currency,
usdAmount,
exchangeRate,
paidBy,
paidTo,
date: new Date(date + 'T12:00:00'),
addedBy: user.uid,
createdAt: firebase.firestore.FieldValue.serverTimestamp()
});
form.reset();
document.getElementById('payment-date').value = new Date().toISOString().split('T')[0];
document.querySelectorAll('#payment-direction .toggle-btn').forEach((b, i) =>
b.classList.toggle('active', i === 0)
);
alert('Payment recorded!');
} catch (err) {
console.error('Error saving payment:', err);
alert('Failed to save payment. Check your connection.');
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Save Payment';
}
});
/** Fetch all payments ordered by date descending. */
export async function getAllPayments() {
const snapshot = await db.collection('payments').orderBy('date', 'desc').get();
return snapshot.docs.map((doc) => ({ id: doc.id, ...doc.data() }));
}
- Step 2: Verify payment creation
Open the app, go to “Payment” tab. Record a test payment ($50, USD). Check Firestore to confirm the document.
- Step 3: Commit
git add daumis-debt/js/payments.js
git commit -m "feat: add payment recording with currency conversion"
Task 6: Balance Computation + Dashboard
Files:
Create:
daumis-debt/js/balance.jsStep 1: Create js/balance.js
import { db } from './firebase-config.js';
import { getCurrentUser, getPartnerUid, getUserName, setPartnerInfo } from './app.js';
/**
* Compute net balance from all expenses, payments, and duels.
* Returns a number: positive means the current user is owed money,
* negative means the current user owes money.
*/
export async function computeBalance() {
const user = getCurrentUser();
let balance = 0; // positive = current user is owed
// Process expenses
const expenses = await db.collection('expenses').get();
expenses.forEach((doc) => {
const e = doc.data();
// Track partner info for display
if (e.paidBy !== user.uid) setPartnerInfo(e.paidBy, e.paidByName || 'Partner');
if (e.owedBy && e.owedBy !== user.uid) setPartnerInfo(e.owedBy, '');
if (e.splitType === 'even') {
// paidBy is owed half by the other person
if (e.paidBy === user.uid) {
balance += e.usdAmount / 2; // partner owes me half
} else {
balance -= e.usdAmount / 2; // I owe partner half
}
} else {
// "full" — owedBy owes the full amount to paidBy
if (e.paidBy === user.uid && e.owedBy !== user.uid) {
balance += e.usdAmount; // partner owes me full
} else if (e.owedBy === user.uid && e.paidBy !== user.uid) {
balance -= e.usdAmount; // I owe partner full
}
}
});
// Process payments
const payments = await db.collection('payments').get();
payments.forEach((doc) => {
const p = doc.data();
if (p.paidBy !== user.uid) setPartnerInfo(p.paidBy, '');
if (p.paidTo !== user.uid) setPartnerInfo(p.paidTo, '');
if (p.paidBy === user.uid) {
balance += p.usdAmount; // I paid partner, so they owe me more (or I owe less)
} else {
balance -= p.usdAmount; // Partner paid me
}
});
// Process duels
const duels = await db.collection('duels').get();
duels.forEach((doc) => {
const d = doc.data();
if (d.favoredUser === user.uid) {
balance += d.balanceAdjust;
} else if (d.favoredUser) {
balance -= d.balanceAdjust;
}
});
return Math.round(balance * 100) / 100;
}
/**
* Load and render the dashboard.
*/
export async function loadDashboard() {
const user = getCurrentUser();
const balanceEl = document.getElementById('balance-display');
try {
const balance = await computeBalance();
const label = balanceEl.querySelector('.balance-label');
const amount = balanceEl.querySelector('.balance-amount');
if (balance > 0.005) {
const partnerName = getUserName(getPartnerUid());
label.textContent = `${partnerName} owes you`;
amount.textContent = `$${balance.toFixed(2)}`;
amount.className = 'balance-amount positive';
} else if (balance < -0.005) {
const partnerName = getUserName(getPartnerUid());
label.textContent = `You owe ${partnerName}`;
amount.textContent = `$${Math.abs(balance).toFixed(2)}`;
amount.className = 'balance-amount negative';
} else {
label.textContent = "You're all settled up!";
amount.textContent = '$0.00';
amount.className = 'balance-amount';
}
// Check for weekly duel availability
const { isDuelAvailable } = await import('./duel.js');
const duelBanner = document.getElementById('duel-banner');
if (await isDuelAvailable()) {
duelBanner.classList.remove('hidden');
} else {
duelBanner.classList.add('hidden');
}
// Load recent activity
await loadRecentActivity();
} catch (err) {
console.error('Error loading dashboard:', err);
}
}
async function loadRecentActivity() {
const user = getCurrentUser();
const list = document.getElementById('activity-list');
list.innerHTML = '';
// Fetch recent expenses and payments, merge and sort
const [expSnap, paySnap, duelSnap] = await Promise.all([
db.collection('expenses').orderBy('createdAt', 'desc').limit(10).get(),
db.collection('payments').orderBy('createdAt', 'desc').limit(5).get(),
db.collection('duels').orderBy('playedAt', 'desc').limit(3).get()
]);
const items = [];
expSnap.forEach((doc) => {
const d = doc.data();
items.push({ type: 'expense', date: d.date?.toDate?.() || new Date(d.date), ...d });
});
paySnap.forEach((doc) => {
const d = doc.data();
items.push({ type: 'payment', date: d.date?.toDate?.() || new Date(d.date), ...d });
});
duelSnap.forEach((doc) => {
const d = doc.data();
items.push({ type: 'duel', date: d.playedAt?.toDate?.() || new Date(), ...d });
});
items.sort((a, b) => b.date - a.date);
items.slice(0, 10).forEach((item) => {
const li = document.createElement('li');
if (item.type === 'expense') {
const isCredit = item.paidBy === user.uid;
li.innerHTML = `
<span class="entry-type expense">Expense</span>
<div class="entry-info">
<div class="entry-desc">${item.description}</div>
<div class="entry-meta">${item.amount} ${item.currency} · ${item.splitType}</div>
</div>
<div class="entry-amount ${isCredit ? 'credit' : 'debit'}">
${isCredit ? '+' : '-'}$${(item.splitType === 'even' ? item.usdAmount / 2 : item.usdAmount).toFixed(2)}
</div>`;
} else if (item.type === 'payment') {
const isCredit = item.paidBy === user.uid;
li.innerHTML = `
<span class="entry-type payment">Payment</span>
<div class="entry-info">
<div class="entry-desc">Settlement</div>
<div class="entry-meta">${item.amount} ${item.currency}</div>
</div>
<div class="entry-amount ${isCredit ? 'credit' : 'debit'}">
${isCredit ? '+' : '-'}$${item.usdAmount.toFixed(2)}
</div>`;
} else if (item.type === 'duel') {
const won = item.favoredUser === user.uid;
li.innerHTML = `
<span class="entry-type duel">Duel</span>
<div class="entry-info">
<div class="entry-desc">${item.game}</div>
<div class="entry-meta">Week ${item.week}</div>
</div>
<div class="entry-amount ${won ? 'credit' : 'debit'}">
${won ? '+' : '-'}$${item.balanceAdjust.toFixed(2)}
</div>`;
}
list.appendChild(li);
});
if (items.length === 0) {
list.innerHTML = '<li style="justify-content:center;color:var(--text-muted)">No activity yet</li>';
}
}
- Step 2: Update app.js to import balance module on dashboard load
In js/app.js, update the showApp function to properly import and call loadDashboard:
Replace the existing showApp function:
async function showApp() {
document.getElementById('bottom-nav').classList.remove('hidden');
const today = new Date().toISOString().split('T')[0];
document.getElementById('expense-date').value = today;
document.getElementById('payment-date').value = today;
showScreen('dashboard');
const { loadDashboard } = await import('./balance.js');
loadDashboard();
}
Also add imports for expenses and payments at the top of app.js so their form listeners register:
import './expenses.js';
import './payments.js';
And update the nav click handler to call loadDashboard:
document.querySelectorAll('.nav-btn').forEach((btn) => {
btn.addEventListener('click', async () => {
showScreen(btn.dataset.screen);
if (btn.dataset.screen === 'dashboard') {
const { loadDashboard } = await import('./balance.js');
loadDashboard();
}
if (btn.dataset.screen === 'history') {
const { loadHistory } = await import('./history.js');
loadHistory();
}
});
});
- Step 3: Verify dashboard
Sign in, add a couple test expenses via the Expense tab, return to Dashboard. Confirm the balance displays correctly and recent activity shows the entries.
- Step 4: Commit
git add daumis-debt/js/balance.js daumis-debt/js/app.js
git commit -m "feat: add balance computation and dashboard with recent activity"
Task 7: History View
Files:
Create:
daumis-debt/js/history.jsStep 1: Create js/history.js
import { db } from './firebase-config.js';
import { getCurrentUser } from './app.js';
export async function loadHistory() {
const user = getCurrentUser();
const list = document.getElementById('history-list');
list.innerHTML = '<li style="justify-content:center;color:var(--text-muted)">Loading...</li>';
try {
const [expSnap, paySnap, duelSnap] = await Promise.all([
db.collection('expenses').orderBy('date', 'desc').get(),
db.collection('payments').orderBy('date', 'desc').get(),
db.collection('duels').orderBy('playedAt', 'desc').get()
]);
const items = [];
expSnap.forEach((doc) => {
const d = doc.data();
items.push({
type: 'expense',
date: d.date?.toDate?.() || new Date(d.date),
...d
});
});
paySnap.forEach((doc) => {
const d = doc.data();
items.push({
type: 'payment',
date: d.date?.toDate?.() || new Date(d.date),
...d
});
});
duelSnap.forEach((doc) => {
const d = doc.data();
items.push({
type: 'duel',
date: d.playedAt?.toDate?.() || new Date(),
...d
});
});
items.sort((a, b) => b.date - a.date);
list.innerHTML = '';
if (items.length === 0) {
list.innerHTML = '<li style="justify-content:center;color:var(--text-muted)">No history yet</li>';
return;
}
items.forEach((item) => {
const li = document.createElement('li');
const dateStr = item.date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
if (item.type === 'expense') {
const isCredit = item.paidBy === user.uid;
const effectiveAmount = item.splitType === 'even' ? item.usdAmount / 2 : item.usdAmount;
li.innerHTML = `
<span class="entry-type expense">Expense</span>
<div class="entry-info">
<div class="entry-desc">${item.description}</div>
<div class="entry-meta">${dateStr} · ${item.amount} ${item.currency} · ${item.splitType}</div>
</div>
<div class="entry-amount ${isCredit ? 'credit' : 'debit'}">
${isCredit ? '+' : '-'}$${effectiveAmount.toFixed(2)}
</div>`;
} else if (item.type === 'payment') {
const isCredit = item.paidBy === user.uid;
li.innerHTML = `
<span class="entry-type payment">Payment</span>
<div class="entry-info">
<div class="entry-desc">Settlement</div>
<div class="entry-meta">${dateStr} · ${item.amount} ${item.currency}</div>
</div>
<div class="entry-amount ${isCredit ? 'credit' : 'debit'}">
${isCredit ? '+' : '-'}$${item.usdAmount.toFixed(2)}
</div>`;
} else if (item.type === 'duel') {
const won = item.favoredUser === user.uid;
li.innerHTML = `
<span class="entry-type duel">Duel</span>
<div class="entry-info">
<div class="entry-desc">${item.game}</div>
<div class="entry-meta">${dateStr} · Week ${item.week}</div>
</div>
<div class="entry-amount ${won ? 'credit' : 'debit'}">
${won ? '+' : '-'}$${item.balanceAdjust.toFixed(2)}
</div>`;
}
list.appendChild(li);
});
} catch (err) {
console.error('Error loading history:', err);
list.innerHTML = '<li style="justify-content:center;color:var(--text-muted)">Error loading history</li>';
}
}
- Step 2: Verify history
Sign in, navigate to History tab. Confirm all test expenses and payments from earlier tasks appear in reverse chronological order with correct formatting.
- Step 3: Commit
git add daumis-debt/js/history.js
git commit -m "feat: add history view with merged expense/payment/duel timeline"
Task 8: Weekly Duel Engine + Game Selection
Files:
Create:
daumis-debt/js/duel.jsStep 1: Create js/duel.js
import { db } from './firebase-config.js';
import { getCurrentUser, showScreen } from './app.js';
const GAMES = ['coin-flip', 'wheel', 'rps', 'lucky-number', 'scratch-card'];
const GAME_NAMES = {
'coin-flip': 'Coin Flip',
'wheel': 'Wheel of Fortune',
'rps': 'Rock Paper Scissors',
'lucky-number': 'Lucky Number',
'scratch-card': 'Scratch Card'
};
/**
* Simple seeded PRNG (mulberry32).
* Returns a function that produces deterministic floats in [0, 1).
*/
function seededRandom(seed) {
return function() {
seed |= 0;
seed = seed + 0x6D2B79F5 | 0;
let t = Math.imul(seed ^ seed >>> 15, 1 | seed);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
/** Get ISO week number for a date. */
function getISOWeek(date) {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const dayNum = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
return Math.ceil((((d - yearStart) / 86400000) + 1) / 7);
}
/** Get the current week's seed, year, and week number. */
function getCurrentWeekInfo() {
const now = new Date();
const week = getISOWeek(now);
const year = now.getFullYear();
const seed = year * 100 + week;
return { year, week, seed };
}
/**
* Select this week's game deterministically from the seed.
* Picks 3 candidates, then selects 1.
*/
export function getWeeklyGame(seed) {
const rng = seededRandom(seed);
// Shuffle and pick 3
const shuffled = [...GAMES];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
const candidates = shuffled.slice(0, 3);
// Pick 1 from the 3
const picked = candidates[Math.floor(rng() * 3)];
return picked;
}
/** Check if a duel has been played this week. */
export async function isDuelAvailable() {
const { year, week } = getCurrentWeekInfo();
const snapshot = await db.collection('duels')
.where('year', '==', year)
.where('week', '==', week)
.get();
return snapshot.empty;
}
/** Start the weekly duel. */
export async function startDuel() {
const available = await isDuelAvailable();
if (!available) {
alert('Duel already played this week!');
return;
}
const { year, week, seed } = getCurrentWeekInfo();
const game = getWeeklyGame(seed);
showScreen('duel');
const content = document.getElementById('duel-content');
content.innerHTML = `
<div class="duel-game">
<h2>Weekly Duel</h2>
<p class="subtitle">Week ${week} · ${GAME_NAMES[game]}</p>
<div id="game-area"></div>
</div>`;
// Dynamically load the game module
const gameModule = await import(`./games/${game}.js`);
gameModule.play(document.getElementById('game-area'), { year, week, seed });
}
/**
* Record duel result to Firestore.
* Called by individual game modules when the game completes.
*/
export async function recordDuelResult({ game, result, balanceAdjust, favoredUser, seed, year, week }) {
await db.collection('duels').add({
year,
week,
game: GAME_NAMES[game] || game,
result,
balanceAdjust: Math.abs(balanceAdjust),
favoredUser,
playedAt: firebase.firestore.FieldValue.serverTimestamp(),
seed,
submissions: null
});
}
// Wire up the duel banner button
document.getElementById('btn-play-duel').addEventListener('click', startDuel);
export { GAME_NAMES, getCurrentWeekInfo, seededRandom };
- Step 2: Import duel module in app.js
Add to the top of js/app.js:
import './duel.js';
- Step 3: Verify game selection is deterministic
In the browser console:
import('./js/duel.js').then(m => {
console.log('This week:', m.getWeeklyGame(202613));
console.log('Same seed again:', m.getWeeklyGame(202613));
console.log('Different week:', m.getWeeklyGame(202614));
});
Confirm: same seed produces same game, different seed produces (likely) different game.
- Step 4: Commit
git add daumis-debt/js/duel.js daumis-debt/js/app.js
git commit -m "feat: add weekly duel engine with deterministic game selection"
Task 9: Coin Flip Game
Files:
Create:
daumis-debt/js/games/coin-flip.jsStep 1: Create js/games/coin-flip.js
import { getCurrentUser, getPartnerUid } from '../app.js';
import { recordDuelResult } from '../duel.js';
import { computeBalance } from '../balance.js';
export async function play(container, { year, week, seed }) {
const user = getCurrentUser();
const balance = await computeBalance();
// Debtor is the person who owes; if balance > 0, partner is debtor; if < 0, user is debtor
const userIsDebtor = balance < 0;
const debtorName = userIsDebtor ? 'You' : 'Partner';
container.innerHTML = `
<p>${debtorName} flip${userIsDebtor ? '' : 's'} the coin.</p>
<p style="margin-top:8px;color:var(--text-muted)">Heads: $10 forgiven. Tails: $10 added.</p>
<div class="coin" id="coin">?</div>
<button class="btn btn-primary" id="btn-flip" style="max-width:200px;margin:0 auto">Flip!</button>
<div id="flip-result"></div>`;
document.getElementById('btn-flip').addEventListener('click', async () => {
const btn = document.getElementById('btn-flip');
btn.disabled = true;
const coinEl = document.getElementById('coin');
// Random result
const isHeads = Math.random() < 0.5;
coinEl.classList.add('flipping');
coinEl.textContent = '';
setTimeout(async () => {
coinEl.classList.remove('flipping');
coinEl.textContent = isHeads ? 'H' : 'T';
const resultEl = document.getElementById('flip-result');
// If heads, debtor gets $10 forgiven (favored). If tails, $10 added to their debt.
// favoredUser: heads → debtor (their debt decreases), tails → creditor (debt increases)
const debtorUid = userIsDebtor ? user.uid : getPartnerUid();
const creditorUid = userIsDebtor ? getPartnerUid() : user.uid;
const favoredUser = isHeads ? debtorUid : creditorUid;
if (isHeads) {
resultEl.innerHTML = `<div class="duel-result" style="color:var(--green)">Heads! $10 forgiven!</div>`;
} else {
resultEl.innerHTML = `<div class="duel-result" style="color:var(--red)">Tails! $10 added to debt.</div>`;
}
await recordDuelResult({
game: 'coin-flip',
result: { side: isHeads ? 'heads' : 'tails' },
balanceAdjust: 10,
favoredUser,
seed, year, week
});
btn.textContent = 'Done!';
}, 1000);
});
}
- Step 2: Verify coin flip
Sign in, if a duel is available, click “Play” on the banner. If the game selected isn’t coin flip, temporarily override in console:
import('./js/games/coin-flip.js').then(m => m.play(document.getElementById('game-area'), {year:2026, week:13, seed:202613}));
Confirm: coin animates, result shows, Firestore duels collection gets a new document.
- Step 3: Commit
git add daumis-debt/js/games/coin-flip.js
git commit -m "feat: add coin flip duel game"
Task 10: Wheel of Fortune Game
Files:
Create:
daumis-debt/js/games/wheel.jsStep 1: Create js/games/wheel.js
import { getCurrentUser, getPartnerUid } from '../app.js';
import { recordDuelResult } from '../duel.js';
import { computeBalance } from '../balance.js';
const SLICES = [
{ value: -10, label: '-$10', color: '#e94560' },
{ value: -5, label: '-$5', color: '#c73e54' },
{ value: 0, label: '$0', color: '#16213e' },
{ value: 0, label: '$0', color: '#0f3460' },
{ value: 5, label: '+$5', color: '#3a8a6a' },
{ value: 10, label: '+$10', color: '#4ecca3' }
];
export async function play(container, { year, week, seed }) {
const user = getCurrentUser();
const balance = await computeBalance();
const userIsDebtor = balance < 0;
// Draw the wheel using canvas
container.innerHTML = `
<p>Values are from the debtor's perspective.</p>
<div class="wheel-container">
<div class="wheel-pointer"></div>
<canvas id="wheel-canvas" width="280" height="280"></canvas>
</div>
<button class="btn btn-primary" id="btn-spin" style="max-width:200px;margin:0 auto">Spin!</button>
<div id="spin-result"></div>`;
const canvas = document.getElementById('wheel-canvas');
const ctx = canvas.getContext('2d');
let currentAngle = 0;
function drawWheel(angle) {
ctx.clearRect(0, 0, 280, 280);
const cx = 140, cy = 140, r = 130;
const sliceAngle = (2 * Math.PI) / SLICES.length;
SLICES.forEach((slice, i) => {
const start = angle + i * sliceAngle;
const end = start + sliceAngle;
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.arc(cx, cy, r, start, end);
ctx.closePath();
ctx.fillStyle = slice.color;
ctx.fill();
ctx.strokeStyle = '#1a1a2e';
ctx.lineWidth = 2;
ctx.stroke();
// Label
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(start + sliceAngle / 2);
ctx.textAlign = 'center';
ctx.fillStyle = '#eee';
ctx.font = 'bold 16px sans-serif';
ctx.fillText(slice.label, r * 0.65, 5);
ctx.restore();
});
}
drawWheel(0);
document.getElementById('btn-spin').addEventListener('click', async () => {
const btn = document.getElementById('btn-spin');
btn.disabled = true;
// Determine result
const resultIndex = Math.floor(Math.random() * SLICES.length);
const resultSlice = SLICES[resultIndex];
// Calculate target angle: spin several full rotations + land on the slice
// The pointer is at top (angle 0). Slice i occupies from i*60deg to (i+1)*60deg.
// To land pointer on slice `resultIndex`, we need the center of that slice at angle 0 (top).
const sliceAngle = (2 * Math.PI) / SLICES.length;
const targetSliceCenter = resultIndex * sliceAngle + sliceAngle / 2;
// Wheel rotates clockwise, pointer is fixed at top
const spins = 5 + Math.random() * 3; // 5-8 full spins
const totalAngle = spins * 2 * Math.PI + (2 * Math.PI - targetSliceCenter);
// Animate
const duration = 3000;
const start = performance.now();
const startAngle = currentAngle;
function animate(now) {
const elapsed = now - start;
const progress = Math.min(elapsed / duration, 1);
// Ease out cubic
const eased = 1 - Math.pow(1 - progress, 3);
currentAngle = startAngle + totalAngle * eased;
drawWheel(-currentAngle); // negative because we rotate the wheel opposite to pointer
if (progress < 1) {
requestAnimationFrame(animate);
} else {
// Show result
const resultEl = document.getElementById('spin-result');
const debtorUid = userIsDebtor ? user.uid : getPartnerUid();
const creditorUid = userIsDebtor ? getPartnerUid() : user.uid;
let favoredUser = null;
if (resultSlice.value > 0) {
favoredUser = debtorUid; // debt reduced
} else if (resultSlice.value < 0) {
favoredUser = creditorUid; // debt increased
}
if (resultSlice.value > 0) {
resultEl.innerHTML = `<div class="duel-result" style="color:var(--green)">${resultSlice.label} — debt reduced!</div>`;
} else if (resultSlice.value < 0) {
resultEl.innerHTML = `<div class="duel-result" style="color:var(--red)">${resultSlice.label} — debt increased!</div>`;
} else {
resultEl.innerHTML = `<div class="duel-result">$0 — no change!</div>`;
}
recordDuelResult({
game: 'wheel',
result: { value: resultSlice.value },
balanceAdjust: Math.abs(resultSlice.value),
favoredUser,
seed, year, week
});
btn.textContent = 'Done!';
}
}
requestAnimationFrame(animate);
});
}
- Step 2: Verify wheel
Test in browser by directly calling:
import('./js/games/wheel.js').then(m => m.play(document.getElementById('game-area'), {year:2026, week:13, seed:202613}));
Confirm: wheel renders with colored slices and labels, spins with easing, lands on a slice, result is recorded.
- Step 3: Commit
git add daumis-debt/js/games/wheel.js
git commit -m "feat: add Wheel of Fortune duel game with spin animation"
Task 11: Rock Paper Scissors Game
Files:
Create:
daumis-debt/js/games/rps.jsStep 1: Create js/games/rps.js
This game requires both players to submit. The first player submits their choice (stored in Firestore under the duel doc’s submissions field). When the second player opens the game, they see that the opponent has submitted. They submit their choice, and the result is revealed and recorded.
import { db } from '../firebase-config.js';
import { getCurrentUser, getPartnerUid, getUserName } from '../app.js';
import { recordDuelResult, getCurrentWeekInfo } from '../duel.js';
const CHOICES = { rock: '✊', paper: '✋', scissors: '✌️' };
const BEATS = { rock: 'scissors', paper: 'rock', scissors: 'paper' };
export async function play(container, { year, week, seed }) {
const user = getCurrentUser();
const partnerUid = getPartnerUid();
// Check if there's a pending duel doc for this week (with submissions)
const pendingSnap = await db.collection('duels')
.where('year', '==', year)
.where('week', '==', week)
.get();
let duelDocRef = null;
let existingSubmissions = {};
if (!pendingSnap.empty) {
const doc = pendingSnap.docs[0];
const data = doc.data();
if (data.submissions) {
existingSubmissions = data.submissions;
duelDocRef = doc.ref;
}
// If the duel has a result already, it's been played
if (data.result) {
container.innerHTML = `<p>Duel already played this week!</p>`;
return;
}
}
const mySubmission = existingSubmissions[user.uid];
const partnerSubmission = existingSubmissions[partnerUid];
if (mySubmission && !partnerSubmission) {
// I already submitted, waiting for partner
container.innerHTML = `
<p>You picked ${CHOICES[mySubmission]}. Waiting for ${getUserName(partnerUid)} to play...</p>
<button class="btn btn-primary" id="btn-refresh" style="max-width:200px;margin:0 auto">Refresh</button>`;
document.getElementById('btn-refresh').addEventListener('click', () => play(container, { year, week, seed }));
return;
}
if (partnerSubmission && !mySubmission) {
// Partner submitted, my turn
container.innerHTML = `
<p>${getUserName(partnerUid)} has played! Your turn.</p>
${renderChoices()}
<div id="rps-result"></div>`;
setupChoiceHandlers(container, { year, week, seed, duelDocRef, partnerSubmission, existingSubmissions });
return;
}
// Nobody has submitted yet — first player
container.innerHTML = `
<p>Pick your weapon! Your choice is hidden until ${getUserName(partnerUid) || 'your partner'} plays.</p>
${renderChoices()}
<div id="rps-result"></div>`;
setupChoiceHandlers(container, { year, week, seed, duelDocRef: null, partnerSubmission: null, existingSubmissions });
}
function renderChoices() {
return `<div class="rps-choices">
${Object.entries(CHOICES).map(([key, emoji]) =>
`<div class="rps-choice" data-choice="${key}">${emoji}</div>`
).join('')}
</div>`;
}
function setupChoiceHandlers(container, { year, week, seed, duelDocRef, partnerSubmission, existingSubmissions }) {
const user = getCurrentUser();
const partnerUid = getPartnerUid();
container.querySelectorAll('.rps-choice').forEach((el) => {
el.addEventListener('click', async () => {
// Highlight selection
container.querySelectorAll('.rps-choice').forEach((c) => c.classList.remove('selected'));
el.classList.add('selected');
const myChoice = el.dataset.choice;
if (partnerSubmission) {
// Both have chosen — resolve!
const resultEl = document.getElementById('rps-result');
let favoredUser = null;
let resultText = '';
if (myChoice === partnerSubmission) {
resultText = `Tie! Both picked ${CHOICES[myChoice]}. No change.`;
} else if (BEATS[myChoice] === partnerSubmission) {
favoredUser = user.uid;
resultText = `You win! ${CHOICES[myChoice]} beats ${CHOICES[partnerSubmission]}. $10 in your favor!`;
} else {
favoredUser = partnerUid;
resultText = `You lose! ${CHOICES[partnerSubmission]} beats ${CHOICES[myChoice]}. $10 to ${getUserName(partnerUid)}.`;
}
const color = favoredUser === user.uid ? 'var(--green)' : favoredUser ? 'var(--red)' : 'var(--text)';
resultEl.innerHTML = `<div class="duel-result" style="color:${color}">${resultText}</div>`;
// Update the existing duel doc with result
if (duelDocRef) {
await duelDocRef.update({
submissions: { ...existingSubmissions, [user.uid]: myChoice },
result: { [user.uid]: myChoice, [partnerUid]: partnerSubmission },
balanceAdjust: myChoice === partnerSubmission ? 0 : 10,
favoredUser: favoredUser || null,
playedAt: firebase.firestore.FieldValue.serverTimestamp()
});
} else {
await recordDuelResult({
game: 'rps',
result: { [user.uid]: myChoice, [partnerUid]: partnerSubmission },
balanceAdjust: myChoice === partnerSubmission ? 0 : 10,
favoredUser: favoredUser || null,
seed, year, week
});
}
// Disable further clicks
container.querySelectorAll('.rps-choice').forEach((c) => {
c.style.pointerEvents = 'none';
});
} else {
// First player — save submission and wait
if (duelDocRef) {
await duelDocRef.update({
submissions: { ...existingSubmissions, [user.uid]: myChoice }
});
} else {
// Create a pending duel doc
await db.collection('duels').add({
year, week, seed,
game: 'Rock Paper Scissors',
submissions: { [user.uid]: myChoice },
result: null,
balanceAdjust: 0,
favoredUser: null,
playedAt: null
});
}
const resultEl = document.getElementById('rps-result');
resultEl.innerHTML = `<div class="duel-result">You picked ${CHOICES[myChoice]}. Waiting for ${getUserName(partnerUid) || 'partner'}...</div>`;
container.querySelectorAll('.rps-choice').forEach((c) => {
c.style.pointerEvents = 'none';
});
}
});
});
}
- Step 2: Verify RPS
Test by calling directly. Since RPS needs two players, test the first-player flow: pick a choice, confirm a duel doc is created in Firestore with submissions containing only your UID. Then test with a second account (or manually add a partner submission in Firestore) to confirm the resolution flow.
- Step 3: Commit
git add daumis-debt/js/games/rps.js
git commit -m "feat: add Rock Paper Scissors duel game with async two-player submission"
Task 12: Lucky Number Game
Files:
Create:
daumis-debt/js/games/lucky-number.jsStep 1: Create js/games/lucky-number.js
Same async two-player pattern as RPS.
import { db } from '../firebase-config.js';
import { getCurrentUser, getPartnerUid, getUserName } from '../app.js';
import { recordDuelResult, getCurrentWeekInfo, seededRandom } from '../duel.js';
export async function play(container, { year, week, seed }) {
const user = getCurrentUser();
const partnerUid = getPartnerUid();
// Generate target number from seed (deterministic, but hidden until both submit)
const rng = seededRandom(seed * 7 + 31); // offset so it's different from game-selection RNG
const targetNumber = Math.floor(rng() * 10) + 1;
// Check for existing duel doc with submissions
const pendingSnap = await db.collection('duels')
.where('year', '==', year)
.where('week', '==', week)
.get();
let duelDocRef = null;
let existingSubmissions = {};
if (!pendingSnap.empty) {
const doc = pendingSnap.docs[0];
const data = doc.data();
if (data.result) {
container.innerHTML = `<p>Duel already played this week!</p>`;
return;
}
if (data.submissions) {
existingSubmissions = data.submissions;
duelDocRef = doc.ref;
}
}
const mySubmission = existingSubmissions[user.uid];
const partnerSubmission = existingSubmissions[partnerUid];
if (mySubmission && !partnerSubmission) {
container.innerHTML = `
<p>You picked ${mySubmission}. Waiting for ${getUserName(partnerUid)} to pick...</p>
<button class="btn btn-primary" id="btn-refresh" style="max-width:200px;margin:0 auto">Refresh</button>`;
document.getElementById('btn-refresh').addEventListener('click', () => play(container, { year, week, seed }));
return;
}
const showGrid = (disabled = false) => {
const preamble = partnerSubmission && !mySubmission
? `<p>${getUserName(partnerUid)} has picked! Your turn.</p>`
: `<p>Pick a number 1-10. Closest to the target wins $10!</p>`;
container.innerHTML = `
${preamble}
<div class="number-grid">
${Array.from({ length: 10 }, (_, i) => i + 1).map((n) =>
`<button class="number-btn" data-num="${n}" ${disabled ? 'disabled' : ''}>${n}</button>`
).join('')}
</div>
<div id="lucky-result"></div>`;
};
showGrid();
container.querySelectorAll('.number-btn').forEach((btn) => {
btn.addEventListener('click', async () => {
const myPick = parseInt(btn.dataset.num);
container.querySelectorAll('.number-btn').forEach((b) => b.classList.remove('selected'));
btn.classList.add('selected');
container.querySelectorAll('.number-btn').forEach((b) => { b.disabled = true; });
if (partnerSubmission) {
// Both have picked — reveal
const partnerPick = parseInt(partnerSubmission);
const myDist = Math.abs(myPick - targetNumber);
const partnerDist = Math.abs(partnerPick - targetNumber);
// Highlight target
container.querySelector(`.number-btn[data-num="${targetNumber}"]`).classList.add('target');
const resultEl = document.getElementById('lucky-result');
let favoredUser = null;
let resultText = '';
if (myDist < partnerDist) {
favoredUser = user.uid;
resultText = `Target: ${targetNumber}. You picked ${myPick}, ${getUserName(partnerUid)} picked ${partnerPick}. You win! $10 in your favor!`;
} else if (partnerDist < myDist) {
favoredUser = partnerUid;
resultText = `Target: ${targetNumber}. You picked ${myPick}, ${getUserName(partnerUid)} picked ${partnerPick}. ${getUserName(partnerUid)} wins! $10 to them.`;
} else {
resultText = `Target: ${targetNumber}. Both equally close (${myPick} vs ${partnerPick}). No change!`;
}
const color = favoredUser === user.uid ? 'var(--green)' : favoredUser ? 'var(--red)' : 'var(--text)';
resultEl.innerHTML = `<div class="duel-result" style="color:${color}">${resultText}</div>`;
if (duelDocRef) {
await duelDocRef.update({
submissions: { ...existingSubmissions, [user.uid]: myPick },
result: { target: targetNumber, [user.uid]: myPick, [partnerUid]: partnerPick },
balanceAdjust: favoredUser ? 10 : 0,
favoredUser,
playedAt: firebase.firestore.FieldValue.serverTimestamp()
});
} else {
await recordDuelResult({
game: 'lucky-number',
result: { target: targetNumber, [user.uid]: myPick, [partnerUid]: partnerPick },
balanceAdjust: favoredUser ? 10 : 0,
favoredUser,
seed, year, week
});
}
} else {
// First player — save and wait
if (duelDocRef) {
await duelDocRef.update({
submissions: { ...existingSubmissions, [user.uid]: myPick }
});
} else {
await db.collection('duels').add({
year, week, seed,
game: 'Lucky Number',
submissions: { [user.uid]: myPick },
result: null,
balanceAdjust: 0,
favoredUser: null,
playedAt: null
});
}
document.getElementById('lucky-result').innerHTML =
`<div class="duel-result">You picked ${myPick}. Waiting for ${getUserName(partnerUid)}...</div>`;
}
});
});
}
- Step 2: Verify Lucky Number
Test first-player flow in browser. Confirm number grid renders, clicking a number saves submission to Firestore. Test resolution by adding a partner submission manually.
- Step 3: Commit
git add daumis-debt/js/games/lucky-number.js
git commit -m "feat: add Lucky Number duel game with async two-player submission"
Task 13: Scratch Card Game
Files:
Create:
daumis-debt/js/games/scratch-card.jsStep 1: Create js/games/scratch-card.js
import { getCurrentUser, getPartnerUid } from '../app.js';
import { recordDuelResult, seededRandom } from '../duel.js';
import { computeBalance } from '../balance.js';
const VALUES = [-10, -5, 0, 5, 10];
export async function play(container, { year, week, seed }) {
const user = getCurrentUser();
const balance = await computeBalance();
const userIsDebtor = balance < 0;
// Use seed to assign values to both cards (deterministic)
const rng = seededRandom(seed * 13 + 7);
const userValue = VALUES[Math.floor(rng() * VALUES.length)];
const partnerValue = VALUES[Math.floor(rng() * VALUES.length)];
// Net adjustment from debtor's perspective
const debtorCard = userIsDebtor ? userValue : partnerValue;
const creditorCard = userIsDebtor ? partnerValue : userValue;
// Positive debtorCard = good for debtor, negative = bad
// Net: debtorCard value is the adjustment from debtor's POV
const netAdjust = debtorCard;
container.innerHTML = `
<p>Scratch your card to reveal the result!</p>
<p style="color:var(--text-muted);margin-top:4px">Values from debtor's perspective.</p>
<div class="scratch-card" id="scratch-card">
<div class="scratch-value" id="scratch-value">
${netAdjust >= 0 ? '+' : ''}$${netAdjust}
</div>
<canvas id="scratch-canvas" width="200" height="140"></canvas>
</div>
<p id="scratch-hint" style="color:var(--text-muted);font-size:0.85rem;margin-top:8px">
Drag or tap to scratch
</p>
<div id="scratch-result"></div>`;
const canvas = document.getElementById('scratch-canvas');
const ctx = canvas.getContext('2d');
// Fill canvas with scratch-off coating
ctx.fillStyle = '#0f3460';
ctx.fillRect(0, 0, 200, 140);
ctx.fillStyle = '#eee';
ctx.font = 'bold 16px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('SCRATCH ME', 100, 75);
let isScratching = false;
let scratchedPixels = 0;
const totalPixels = 200 * 140;
let revealed = false;
function scratch(x, y) {
ctx.globalCompositeOperation = 'destination-out';
ctx.beginPath();
ctx.arc(x, y, 20, 0, 2 * Math.PI);
ctx.fill();
// Check how much has been scratched
const imageData = ctx.getImageData(0, 0, 200, 140);
let cleared = 0;
for (let i = 3; i < imageData.data.length; i += 4) {
if (imageData.data[i] === 0) cleared++;
}
scratchedPixels = cleared;
if (scratchedPixels / totalPixels > 0.4 && !revealed) {
revealed = true;
revealResult();
}
}
function getPos(e) {
const rect = canvas.getBoundingClientRect();
const touch = e.touches ? e.touches[0] : e;
return {
x: (touch.clientX - rect.left) * (200 / rect.width),
y: (touch.clientY - rect.top) * (140 / rect.height)
};
}
canvas.addEventListener('mousedown', (e) => { isScratching = true; const p = getPos(e); scratch(p.x, p.y); });
canvas.addEventListener('mousemove', (e) => { if (isScratching) { const p = getPos(e); scratch(p.x, p.y); } });
canvas.addEventListener('mouseup', () => { isScratching = false; });
canvas.addEventListener('touchstart', (e) => { e.preventDefault(); isScratching = true; const p = getPos(e); scratch(p.x, p.y); });
canvas.addEventListener('touchmove', (e) => { e.preventDefault(); if (isScratching) { const p = getPos(e); scratch(p.x, p.y); } });
canvas.addEventListener('touchend', () => { isScratching = false; });
async function revealResult() {
// Clear remaining coating
ctx.clearRect(0, 0, 200, 140);
document.getElementById('scratch-hint').textContent = '';
const resultEl = document.getElementById('scratch-result');
const debtorUid = userIsDebtor ? user.uid : getPartnerUid();
const creditorUid = userIsDebtor ? getPartnerUid() : user.uid;
let favoredUser = null;
if (netAdjust > 0) {
favoredUser = debtorUid;
resultEl.innerHTML = `<div class="duel-result" style="color:var(--green)">+$${netAdjust} — debt reduced!</div>`;
} else if (netAdjust < 0) {
favoredUser = creditorUid;
resultEl.innerHTML = `<div class="duel-result" style="color:var(--red)">-$${Math.abs(netAdjust)} — debt increased!</div>`;
} else {
resultEl.innerHTML = `<div class="duel-result">$0 — no change!</div>`;
}
await recordDuelResult({
game: 'scratch-card',
result: { userCard: userValue, partnerCard: partnerValue, netAdjust },
balanceAdjust: Math.abs(netAdjust),
favoredUser,
seed, year, week
});
}
}
- Step 2: Verify scratch card
Test in browser. Confirm: card renders with scratch coating, dragging/tapping reveals the value underneath, after ~40% scratched the result auto-reveals and records to Firestore.
- Step 3: Commit
git add daumis-debt/js/games/scratch-card.js
git commit -m "feat: add Scratch Card duel game with touch/mouse scratch interaction"
Task 14: PWA Icons
Files:
- Create:
daumis-debt/assets/icons/icon-192.png Create:
daumis-debt/assets/icons/icon-512.png- Step 1: Generate simple PWA icons
Create minimal icons using an inline SVG → PNG approach. We’ll generate a simple icon with “DD” initials.
# Generate a 512x512 SVG icon and convert to PNG using sips (macOS built-in)
cat > /tmp/icon.svg << 'SVGEOF'
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<rect width="512" height="512" rx="96" fill="#1a1a2e"/>
<text x="256" y="300" text-anchor="middle" font-family="Arial,sans-serif" font-weight="bold" font-size="220" fill="#e94560">DD</text>
</svg>
SVGEOF
# Use rsvg-convert if available, otherwise use python or a web tool
# On macOS with Homebrew: brew install librsvg
# Alternative: open the SVG in a browser, screenshot, and crop
# If rsvg-convert is not available, use python:
python3 -c "
import subprocess, os
# Create a simple 512x512 PNG with PIL if available
try:
from PIL import Image, ImageDraw, ImageFont
img = Image.new('RGBA', (512, 512), (26, 26, 46, 255))
draw = ImageDraw.Draw(img)
# Draw rounded rect background
draw.rounded_rectangle([0, 0, 512, 512], radius=96, fill=(26, 26, 46, 255))
# Draw text
try:
font = ImageFont.truetype('/System/Library/Fonts/Helvetica.ttc', 200)
except:
font = ImageFont.load_default()
draw.text((256, 256), 'DD', fill=(233, 69, 96, 255), font=font, anchor='mm')
img.save('daumis-debt/assets/icons/icon-512.png')
img.resize((192, 192), Image.LANCZOS).save('daumis-debt/assets/icons/icon-192.png')
print('Icons created successfully')
except ImportError:
print('PIL not available - create icons manually')
# Create minimal valid 1x1 PNGs as placeholders
import struct, zlib
def make_png(w, h, r, g, b):
def chunk(ctype, data):
c = ctype + data
return struct.pack('>I', len(data)) + c + struct.pack('>I', zlib.crc32(c) & 0xffffffff)
raw = b''
for _ in range(h):
raw += b'\x00' + bytes([r,g,b]) * w
return b'\x89PNG\r\n\x1a\n' + chunk(b'IHDR', struct.pack('>IIBBBBB', w, h, 8, 2, 0, 0, 0)) + chunk(b'IDAT', zlib.compress(raw)) + chunk(b'IEND', b'')
with open('daumis-debt/assets/icons/icon-512.png','wb') as f: f.write(make_png(512,512,26,26,46))
with open('daumis-debt/assets/icons/icon-192.png','wb') as f: f.write(make_png(192,192,26,26,46))
print('Placeholder icons created - replace with proper icons later')
"
- Step 2: Verify PWA installability
Serve the app locally, open in Chrome. Check Application → Manifest in DevTools. Confirm: manifest loads, icons are referenced, “Add to Home Screen” criteria are met (manifest + service worker + served over HTTPS — localhost counts for dev).
- Step 3: Commit
git add daumis-debt/assets/icons/
git commit -m "feat: add PWA icons"
Task 15: Firestore Security Rules
Files:
Create:
firestore.rules(in repo root, for reference — deploy via Firebase Console)Step 1: Create firestore.rules
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
function isAllowedUser() {
return request.auth != null &&
request.auth.token.email in ['REPLACE_WITH_GAL_EMAIL', 'REPLACE_WITH_DAUM_EMAIL'];
}
function isOwnEntry() {
return request.resource.data.addedBy == request.auth.uid;
}
match /expenses/{doc} {
allow read: if isAllowedUser();
allow create: if isAllowedUser() && isOwnEntry();
allow update, delete: if isAllowedUser() && resource.data.addedBy == request.auth.uid;
}
match /payments/{doc} {
allow read: if isAllowedUser();
allow create: if isAllowedUser() && isOwnEntry();
allow update, delete: if isAllowedUser() && resource.data.addedBy == request.auth.uid;
}
match /duels/{doc} {
allow read: if isAllowedUser();
allow create: if isAllowedUser();
allow update: if isAllowedUser();
allow delete: if false;
}
}
}
- Step 2: Deploy rules
Replace the placeholder emails with Gal’s and Daum’s actual Google email addresses. Then deploy:
Option A — Firebase Console: Go to Firestore → Rules → paste the rules → Publish.
Option B — Firebase CLI (if installed):
npm install -g firebase-tools
firebase login
firebase init firestore # select existing project
firebase deploy --only firestore:rules
- Step 3: Verify rules
Sign in with an authorized email — confirm you can read/write expenses and payments. Sign in with a different Google account (or use incognito) — confirm access is denied.
- Step 4: Commit
git add firestore.rules
git commit -m "feat: add Firestore security rules restricting access to two users"
Task 16: Final Integration + Register Service Worker
Files:
Modify:
daumis-debt/js/app.js(add service worker registration)Step 1: Add service worker registration to app.js
Add at the very end of js/app.js:
// Register service worker for PWA
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/daumis-debt/sw.js')
.then(() => console.log('SW registered'))
.catch((err) => console.error('SW registration failed:', err));
}
- Step 2: End-to-end manual test
Full test checklist:
- Open
https://galraz.github.io/daumis-debt/(after pushing to GitHub) - Sign in with Google — confirm it works
- Add an expense in THB — confirm USD conversion is correct
- Add a payment in USD — confirm it appears
- Check Dashboard — balance reflects expenses and payments correctly
- Check History — all entries appear in order
- If a weekly duel is available, play it — confirm game works and result is recorded
- On mobile: “Add to Home Screen” — confirm PWA installs and opens standalone
- Sign in on partner’s phone — confirm they see the same data
- Have partner add an expense — confirm it appears on your phone after refresh
- Step 3: Commit
git add daumis-debt/js/app.js
git commit -m "feat: register service worker for PWA support"
- Step 4: Push to GitHub Pages
git push origin master
Wait ~2 minutes for GitHub Pages to deploy. Open https://galraz.github.io/daumis-debt/ and run through the test checklist above.
