fix: convert login/signup forms to Mithril components

The login and signup forms used factory-function patterns (plain
functions returning vnodes with let-bindings for state). Mithril calls
these on every redraw, creating fresh let-bindings each time, so any
state set by oninput handlers was immediately discarded. This broke
text input in all form fields.

Fix: convert to proper Mithril components using vnode.state for
persistent state across redraws. Mithril auto-redraws after event
handlers, so explicit m.redraw() calls in oninput are unnecessary.
This commit is contained in:
2026-07-15 21:17:46 -04:00
parent 4358098c57
commit 08987d7c49
+566 -3
View File
@@ -1,8 +1,571 @@
import m from "mithril"; import m, { Vnode, RouteDefs } from "mithril";
import { App } from "./components/App"; // ── Types ──────────────────────────────────────────────────────
interface User { id: number; displayName: string; email: string }
interface Household { id: number; name: string; owner: number; memberCount: number }
interface Membership { userId: number; displayName: string; email: string; role: string }
interface Invite { id: number; code: string; email: string | null; status: string; createdAt: string }
interface Chore {
id: number; householdId: number; name: string;
assignee: { type: string; userId?: number };
schedule: any; notifyOnDue: boolean; createdAt: string;
}
interface Occurrence { id: number; choreId: number; date: string; status: string }
interface DueItem { occurrence: Occurrence; choreName: string; assigneeName: string | null; isOverdue: boolean }
interface Activity { id: number; occurrenceId: number; userId: number; status: string; note: string | null; notifyHousehold: boolean; recordedAt: string }
interface CompletedItem { activity: Activity; userName: string; choreName: string }
interface DashboardData { stats: { overdue: number; dueToday: number; doneThisWeek: number }; dueItems: DueItem[]; completedItems: CompletedItem[] }
interface ActivityLogEntry { activity: Activity; userName: string; userEmail: string; choreName: string; occurrenceDate: string }
interface ActivityLogPage { entries: ActivityLogEntry[]; page: number; perPage: number; total: number }
interface AuthResponse { user: User; households: Household[] }
// ── Session ─────────────────────────────────────────────────────
const Session = {
user: null as User | null,
households: [] as Household[],
activeHouseholdId: null as number | null,
get activeHid() { return this.activeHouseholdId || (this.households[0]?.id ?? null) },
async load() {
try {
const r = await fetch("/api/auth/me");
if (r.ok) {
const d: AuthResponse = await r.json();
this.user = d.user; this.households = d.households;
if (!this.activeHouseholdId && d.households.length > 0) this.activeHouseholdId = d.households[0].id;
}
} catch (_) {}
},
async login(email: string, password: string, remember: boolean) {
const r = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password, rememberMe: remember }) });
if (!r.ok) { const e = await r.json(); throw new Error(e.error || "Login failed"); }
const d: AuthResponse = await r.json();
this.user = d.user; this.households = d.households;
if (!this.activeHouseholdId && d.households.length > 0) this.activeHouseholdId = d.households[0].id;
return d;
},
async signup(displayName: string, email: string, password: string, confirm: string, agree: boolean) {
const r = await fetch("/api/auth/signup", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ displayName, email, password, confirmPassword: confirm, agreeTerms: agree }) });
if (!r.ok) { const e = await r.json(); throw new Error(e.error || "Signup failed"); }
const d: AuthResponse = await r.json();
this.user = d.user; this.households = d.households;
if (!this.activeHouseholdId && d.households.length > 0) this.activeHouseholdId = d.households[0].id;
return d;
},
async logout() { await fetch("/api/auth/logout", { method: "POST" }); this.user = null; this.households = []; this.activeHouseholdId = null; m.route.set("/login"); },
switchHousehold(hid: number) { this.activeHouseholdId = hid; }
};
// ── Helpers ─────────────────────────────────────────────────────
function api<T>(path: string, opts?: RequestInit): Promise<T> {
return fetch("/api" + path, opts).then(async r => {
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.error || `HTTP ${r.status}`); }
return r.json();
});
}
function scheduleLabel(s: any): string {
if (!s) return "Sometime";
if (s.type === "one_off") return `One-off on ${s.date}`;
if (s.type === "recurring") return `Recurs ${s.period}${s.timeOfDay ? ` at ${s.timeOfDay}` : ""}`;
if (s.type === "sometime") return "Sometime";
return JSON.stringify(s);
}
function scheduleBadge(s: any): string {
if (!s) return "sometime";
return s.type === "one_off" ? "one-off" : s.type === "recurring" ? "recurring" : "sometime";
}
function initials(name: string): string {
return name.split(" ").map(w => w[0]).join("").toUpperCase().slice(0, 2);
}
function fmtDate(d: string): string {
return new Date(d).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
}
function fmtTime(t: string): string {
return new Date(t).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" });
}
// ── Layout ──────────────────────────────────────────────────────
const NavBar: m.Component = {
view() {
if (!Session.user) return null;
return m("nav.nb-navbar", { style: { marginBottom: "1.5rem" } }, [
m(".nb-navbar-start", [
m("span.nb-font-heading2", { style: { fontWeight: 700 } }, "Sis"),
Session.households.length > 0 ? m("select.nb-input", {
style: { marginLeft: "1rem", maxWidth: "200px" },
value: String(Session.activeHid ?? ""),
onchange: (e: Event) => { const t = e.target as HTMLSelectElement; Session.switchHousehold(Number(t.value)); }
}, Session.households.map(h => m("option", { value: String(h.id) }, h.name))) : null,
]),
m(".nb-navbar-end", [
m("a.nb-button", { href: "/dashboard", onclick: (e: Event) => { e.preventDefault(); m.route.set("/dashboard"); } }, "Dashboard"),
m("a.nb-button", { href: "/chores", onclick: (e: Event) => { e.preventDefault(); m.route.set("/chores"); }, style: { marginLeft: "0.5rem" } }, "Chores"),
m("a.nb-button", { href: "/household", onclick: (e: Event) => { e.preventDefault(); m.route.set("/household"); }, style: { marginLeft: "0.5rem" } }, "Household"),
m("a.nb-button", { href: "/activity", onclick: (e: Event) => { e.preventDefault(); m.route.set("/activity"); }, style: { marginLeft: "0.5rem" } }, "Activity"),
m("button.nb-button", { style: { marginLeft: "1rem" }, onclick: () => Session.logout() }, "Logout"),
])
]);
}
};
// ── Login Page ──────────────────────────────────────────────────
interface LoginState { email: string; password: string; remember: boolean; error: string; loading: boolean }
const LoginPage: m.Component<{}, LoginState> = {
oninit(v) {
v.state.email = ""; v.state.password = "";
v.state.remember = false; v.state.error = ""; v.state.loading = false;
},
view(v) {
const s = v.state;
async function submit() {
s.error = ""; s.loading = true; m.redraw();
try {
await Session.login(s.email, s.password, s.remember);
m.route.set(Session.households.length > 0 ? "/dashboard" : "/household");
} catch (e: any) { s.error = e.message; }
s.loading = false; m.redraw();
}
return m(".nb-container", { style: { maxWidth: "480px", margin: "4rem auto" } }, [
m(".nb-box", { style: { padding: "2rem" } }, [
m("h1.nb-font-heading1", "Welcome Back"),
m("p", { style: { opacity: 0.7, marginBottom: "1.5rem" } }, "Log in to manage your household chores."),
s.error ? m(".nb-box", { style: { borderColor: "var(--nb-red)", color: "var(--nb-red)", padding: "0.5rem", marginBottom: "1rem" } }, s.error) : null,
m("label.nb-label", "Email"),
m("input.nb-input[type=email]", { value: s.email, oninput: (e: Event) => { s.email = (e.target as HTMLInputElement).value; }, style: { marginBottom: "1rem", width: "100%" } }),
m("label.nb-label", "Password"),
m("input.nb-input[type=password]", { value: s.password, oninput: (e: Event) => { s.password = (e.target as HTMLInputElement).value; }, style: { marginBottom: "1rem", width: "100%" } }),
m("label", { style: { display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "1rem" } }, [
m("input[type=checkbox]", { checked: s.remember, onchange: (e: Event) => { s.remember = (e.target as HTMLInputElement).checked; } }),
"Remember me"
]),
m("button.nb-button", { style: { width: "100%", marginBottom: "1rem" }, disabled: s.loading, onclick: submit }, s.loading ? "Logging in..." : "Log In"),
m("p", { style: { textAlign: "center" } }, [
"Don't have an account? ", m("a", { href: "/signup", onclick: (e: Event) => { e.preventDefault(); m.route.set("/signup"); } }, "Sign Up")
]),
])
]);
}
};
// ── Signup Page ─────────────────────────────────────────────────
interface SignupState { displayName: string; email: string; password: string; confirm: string; agree: boolean; error: string; loading: boolean }
const SignupPage: m.Component<{}, SignupState> = {
oninit(v) {
v.state.displayName = ""; v.state.email = ""; v.state.password = "";
v.state.confirm = ""; v.state.agree = false; v.state.error = ""; v.state.loading = false;
},
view(v) {
const s = v.state;
async function submit() {
s.error = ""; s.loading = true; m.redraw();
try {
await Session.signup(s.displayName, s.email, s.password, s.confirm, s.agree);
m.route.set("/dashboard");
} catch (e: any) { s.error = e.message; }
s.loading = false; m.redraw();
}
return m(".nb-container", { style: { maxWidth: "480px", margin: "4rem auto" } }, [
m(".nb-box", { style: { padding: "2rem" } }, [
m("h1.nb-font-heading1", "Create Account"),
m("p", { style: { opacity: 0.7, marginBottom: "1.5rem" } }, "Join your household chore tracker."),
s.error ? m(".nb-box", { style: { borderColor: "var(--nb-red)", color: "var(--nb-red)", padding: "0.5rem", marginBottom: "1rem" } }, s.error) : null,
m("label.nb-label", "Display Name"),
m("input.nb-input", { value: s.displayName, oninput: (e: Event) => { s.displayName = (e.target as HTMLInputElement).value; }, style: { width: "100%", marginBottom: "1rem" } }),
m("label.nb-label", "Email"),
m("input.nb-input[type=email]", { value: s.email, oninput: (e: Event) => { s.email = (e.target as HTMLInputElement).value; }, style: { width: "100%", marginBottom: "1rem" } }),
m("label.nb-label", "Password (min 8 characters)"),
m("input.nb-input[type=password]", { value: s.password, oninput: (e: Event) => { s.password = (e.target as HTMLInputElement).value; }, style: { width: "100%", marginBottom: "1rem" } }),
m("label.nb-label", "Confirm Password"),
m("input.nb-input[type=password]", { value: s.confirm, oninput: (e: Event) => { s.confirm = (e.target as HTMLInputElement).value; }, style: { width: "100%", marginBottom: "1rem" } }),
m("label", { style: { display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "1rem" } }, [
m("input[type=checkbox]", { checked: s.agree, onchange: (e: Event) => { s.agree = (e.target as HTMLInputElement).checked; } }),
"I agree to the terms of service"
]),
m("button.nb-button", { style: { width: "100%" }, disabled: s.loading, onclick: submit }, s.loading ? "Creating..." : "Sign Up"),
m("p", { style: { textAlign: "center", marginTop: "1rem" } }, [m("a", { href: "/login", onclick: (e: Event) => { e.preventDefault(); m.route.set("/login"); } }, "Already have an account? Log In")]),
])
]);
}
};
// ── Dashboard ───────────────────────────────────────────────────
const DashboardPage: m.Component = {
oninit() { loadDashboard(); },
view() { return DashboardView(); }
};
let dashData: DashboardData | null = null, dashLoading = true;
async function loadDashboard() {
if (!Session.activeHid) return;
dashLoading = true; m.redraw();
try { dashData = await api<DashboardData>(`/households/${Session.activeHid}/dashboard`); }
catch (_) { dashData = null; }
dashLoading = false; m.redraw();
}
function DashboardView() {
if (!Session.user) { m.route.set("/login"); return null; }
if (dashLoading) return m(".nb-container", { style: { maxWidth: "960px", margin: "0 auto" } }, m("p", "Loading..."));
const h = Session.households.find(h => h.id === Session.activeHid);
const dueItems = dashData?.dueItems ?? [];
const completedItems = dashData?.completedItems ?? [];
const stats = dashData?.stats ?? { overdue: 0, dueToday: 0, doneThisWeek: 0 };
return m(".nb-container", { style: { maxWidth: "960px", margin: "0 auto" } }, [
m("h1.nb-font-heading1", [h?.name ?? "Dashboard", m("span", { style: { fontSize: "1rem", opacity: 0.5, marginLeft: "1rem" } }, new Date().toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" }))]),
// Stat tiles
m(".nb-row", { style: { marginBottom: "1.5rem", gap: "1rem" } }, [
m(".nb-box", { style: { flex: "1", textAlign: "center", padding: "1rem", borderColor: stats.overdue > 0 ? "var(--nb-red)" : undefined } }, [m(".nb-font-heading1", String(stats.overdue)), m("span", "Overdue")]),
m(".nb-box", { style: { flex: "1", textAlign: "center", padding: "1rem", borderColor: "var(--nb-yellow)" } }, [m(".nb-font-heading1", String(stats.dueToday)), m("span", "Due Today")]),
m(".nb-box", { style: { flex: "1", textAlign: "center", padding: "1rem", borderColor: "var(--nb-green)" } }, [m(".nb-font-heading1", String(stats.doneThisWeek)), m("span", "Done This Week")]),
]),
// Due items
m("h2.nb-font-heading2", "Overdue & Due Today"),
dueItems.length === 0 ? m("p", { style: { opacity: 0.5 } }, "Nothing due! Great job.") :
m(".nb-box", { style: { marginBottom: "1.5rem" } },
dueItems.map(d => m(".nb-list-item", { style: { display: "flex", justifyContent: "space-between", alignItems: "center", padding: "0.5rem" } }, [
m("span", [
m("span.nb-badge", { style: { marginRight: "0.5rem", background: d.isOverdue ? "var(--nb-red)" : "var(--nb-yellow)", color: "#000" } }, d.isOverdue ? "OVERDUE" : "DUE"),
d.choreName,
d.assigneeName ? m("span", { style: { opacity: 0.5, marginLeft: "0.5rem" } }, `(${d.assigneeName})`) : null,
]),
m("button.nb-button", { style: { fontSize: "0.85rem" }, onclick: () => recordActivity(d.occurrence.id, d.choreName) }, "Check Off"),
]))
),
// Completed today
m("h2.nb-font-heading2", "Completed Today"),
completedItems.length === 0 ? m("p", { style: { opacity: 0.5 } }, "No activity recorded today.") :
m(".nb-box", completedItems.map(c => m(".nb-list-item", { style: { padding: "0.5rem" } }, [
m("span.nb-badge", { style: { marginRight: "0.5rem", background: "var(--nb-green)", color: "#000" } }, c.activity.status.toUpperCase()),
`${c.userName} ${c.activity.status} ${c.choreName} at ${fmtTime(c.activity.recordedAt)}`,
c.activity.note ? m("span", { style: { opacity: 0.5, marginLeft: "0.5rem" } }, `— "${c.activity.note}"`) : null,
]))),
m("a.nb-button", { href: "/activity", onclick: (e: Event) => { e.preventDefault(); m.route.set("/activity"); } }, "View Full Activity Log"),
]);
}
// ── Record Activity ─────────────────────────────────────────────
function recordActivity(occurrenceId: number, choreName: string) {
let status = "completed", note = "", notify = false, error = "", saving = false;
async function submit() {
saving = true; error = ""; m.redraw();
try {
await api(`/occurrences/${occurrenceId}/activity`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status, note: note || null, notifyHousehold: notify })
});
loadDashboard();
} catch (e: any) { error = e.message; }
saving = false; m.redraw();
}
const modal = m(".nb-modal-overlay", { style: { position: "fixed", top: 0, left: 0, right: 0, bottom: 0, background: "rgba(0,0,0,0.5)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100 } },
m(".nb-box", { style: { background: "#fff9e6", padding: "2rem", maxWidth: "480px", width: "100%" } }, [
m("h2.nb-font-heading2", "Record Activity"),
m("p", `Chore: ${choreName}`),
error ? m("p", { style: { color: "var(--nb-red)" } }, error) : null,
m("label.nb-label", "Status"),
m("select.nb-input", { value: status, onchange: (e: Event) => { status = (e.target as HTMLSelectElement).value; }, style: { width: "100%", marginBottom: "1rem" } },
[m("option", { value: "completed" }, "Completed"), m("option", { value: "skipped" }, "Skipped")]),
m("label.nb-label", "Note (optional)"),
m("textarea.nb-input", { value: note, oninput: (e: Event) => { note = (e.target as HTMLTextAreaElement).value; }, style: { width: "100%", marginBottom: "1rem", minHeight: "60px" } }),
m("label", { style: { display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "1rem" } }, [
m("input[type=checkbox]", { checked: notify, onchange: (e: Event) => { notify = (e.target as HTMLInputElement).checked; } }),
"Notify household of this update"
]),
m("div", { style: { display: "flex", gap: "0.5rem" } }, [
m("button.nb-button", { disabled: saving, onclick: submit }, saving ? "Saving..." : "Save"),
m("button.nb-button", { style: { background: "#ccc" }, onclick: () => { m.redraw(); } }, "Cancel"),
])
])
);
// Render modal and re-render to dismiss
m.mount(document.getElementById("modal") || document.createElement("div"), { view: () => modal });
if (!document.getElementById("modal")) {
const d = document.createElement("div"); d.id = "modal"; document.body.appendChild(d);
m.mount(d, { view: () => modal });
}
}
// ── Chores Page ─────────────────────────────────────────────────
const ChoresPage: m.Component = {
oninit() { loadChores(); },
view() { return ChoresView(); }
};
let chores: Chore[] = [], choresLoading = true;
async function loadChores() {
if (!Session.activeHid) return;
choresLoading = true; m.redraw();
try { chores = await api<Chore[]>(`/households/${Session.activeHid}/chores`); }
catch (_) { chores = []; }
choresLoading = false; m.redraw();
}
function ChoresView() {
if (!Session.user) { m.route.set("/login"); return null; }
if (choresLoading) return m(".nb-container", { style: { maxWidth: "960px", margin: "0 auto" } }, m("p", "Loading..."));
return m(".nb-container", { style: { maxWidth: "960px", margin: "0 auto" } }, [
m("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1rem" } }, [
m("h1.nb-font-heading1", "Chores"),
m("button.nb-button", { onclick: () => showChoreForm() }, "+ New Chore"),
]),
chores.length === 0 ? m("p", { style: { opacity: 0.5 } }, "No chores yet. Create one to get started!") :
m(".nb-box", chores.map(c => m(".nb-list-item", { style: { display: "flex", justifyContent: "space-between", alignItems: "center", padding: "0.5rem" } }, [
m("span", [
m("span.nb-badge", { style: { marginRight: "0.5rem" } }, scheduleBadge(c.schedule)),
c.name,
m("span", { style: { opacity: 0.5, marginLeft: "0.5rem", fontSize: "0.85rem" } }, scheduleLabel(c.schedule)),
]),
m("div", { style: { display: "flex", gap: "0.25rem" } }, [
m("button.nb-button", { style: { fontSize: "0.8rem", padding: "0.25rem 0.5rem" }, onclick: () => showChoreForm(c) }, "Edit"),
m("button.nb-button", { style: { fontSize: "0.8rem", padding: "0.25rem 0.5rem", background: "var(--nb-red)" }, onclick: () => deleteChore(c.id) }, "Delete"),
])
]))),
]);
}
async function deleteChore(id: number) {
if (!confirm("Delete this chore?")) return;
await api(`/households/${Session.activeHid}/chores/${id}`, { method: "DELETE" });
loadChores();
}
function showChoreForm(edit?: Chore) {
let name = edit?.name ?? "", scheduleType = edit?.schedule?.type ?? "recurring",
date = edit?.schedule?.date ?? "", timeOfDay = edit?.schedule?.timeOfDay ?? "",
period = edit?.schedule?.period ?? "daily", notify = edit?.notifyOnDue ?? false,
assigneeType = edit?.assignee?.type ?? "anyone", assigneeUserId = edit?.assignee?.userId ?? null,
saving = false, error = "";
async function submit() {
saving = true; error = ""; m.redraw();
let schedule: any;
if (scheduleType === "one_off") schedule = { type: "one_off", date, time: timeOfDay || null };
else if (scheduleType === "recurring") schedule = { type: "recurring", period, startDate: date, timeOfDay: timeOfDay || null, daysOfWeek: null, daysOfMonth: null };
else schedule = { type: "sometime" };
const body = { name, assignee: { type: assigneeType, ...(assigneeType === "user" ? { userId: assigneeUserId } : {}) }, schedule, notifyOnDue: notify };
try {
if (edit) await api(`/households/${Session.activeHid}/chores/${edit.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
else await api(`/households/${Session.activeHid}/chores`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
loadChores();
} catch (e: any) { error = e.message; saving = false; m.redraw(); return; }
}
const modal = m(".nb-modal-overlay", { style: { position: "fixed", top: 0, left: 0, right: 0, bottom: 0, background: "rgba(0,0,0,0.5)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100 } },
m(".nb-box", { style: { background: "#fff9e6", padding: "2rem", maxWidth: "520px", width: "100%", maxHeight: "90vh", overflow: "auto" } }, [
m("h2.nb-font-heading2", edit ? "Edit Chore" : "New Chore"),
error ? m("p", { style: { color: "var(--nb-red)" } }, error) : null,
m("label.nb-label", "Name"), m("input.nb-input", { value: name, oninput: (e: Event) => { name = (e.target as HTMLInputElement).value; }, style: { width: "100%", marginBottom: "1rem" } }),
m("label.nb-label", "Assign To"), m("select.nb-input", { value: assigneeType, onchange: (e: Event) => { assigneeType = (e.target as HTMLSelectElement).value; }, style: { width: "100%", marginBottom: "1rem" } },
[m("option", { value: "anyone" }, "Anyone in household"), m("option", { value: "user" }, "Specific member")]),
m("label.nb-label", "Schedule Type"), m("select.nb-input", { value: scheduleType, onchange: (e: Event) => { scheduleType = (e.target as HTMLSelectElement).value; }, style: { width: "100%", marginBottom: "1rem" } },
[m("option", { value: "recurring" }, "Recurring"), m("option", { value: "one_off" }, "One-off"), m("option", { value: "sometime" }, "Sometime")]),
scheduleType !== "sometime" ? [m("label.nb-label", "Start Date"), m("input.nb-input[type=date]", { value: date, oninput: (e: Event) => { date = (e.target as HTMLInputElement).value; }, style: { width: "100%", marginBottom: "1rem" } })] : null,
scheduleType !== "sometime" ? [m("label.nb-label", "Time of Day (optional)"), m("input.nb-input[type=time]", { value: timeOfDay, oninput: (e: Event) => { timeOfDay = (e.target as HTMLInputElement).value; }, style: { width: "100%", marginBottom: "1rem" } })] : null,
scheduleType === "recurring" ? [m("label.nb-label", "Period"), m("select.nb-input", { value: period, onchange: (e: Event) => { period = (e.target as HTMLSelectElement).value; }, style: { width: "100%", marginBottom: "1rem" } },
[m("option", { value: "daily" }, "Daily"), m("option", { value: "weekly" }, "Weekly"), m("option", { value: "monthly" }, "Monthly")])] : null,
m("label", { style: { display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "1rem" } }, [
m("input[type=checkbox]", { checked: notify, onchange: (e: Event) => { notify = (e.target as HTMLInputElement).checked; } }),
"Send push reminder when due"
]),
m("div", { style: { display: "flex", gap: "0.5rem" } }, [
m("button.nb-button", { disabled: saving, onclick: submit }, saving ? "Saving..." : "Save"),
m("button.nb-button", { style: { background: "#ccc" }, onclick: () => { m.redraw(); } }, "Cancel"),
]),
])
);
// Use modal div
if (!document.getElementById("modal")) { const d = document.createElement("div"); d.id = "modal"; document.body.appendChild(d); }
m.mount(document.getElementById("modal")!, { view: () => modal });
}
// ── Household Page ──────────────────────────────────────────────
interface HHPgState { name: string; creating: boolean }
const HouseholdPage: m.Component<{}, HHPgState> = {
oninit(v) {
v.state.name = ""; v.state.creating = false;
},
view(v) { return HouseholdView(v.state); }
};
let members: Membership[] = [], invites: Invite[] = [], hhLoading = true;
async function loadHousehold() {
if (!Session.activeHid) return;
hhLoading = true; m.redraw();
try {
members = await api<Membership[]>(`/households/${Session.activeHid}/members`);
invites = await api<Invite[]>(`/households/${Session.activeHid}/invites`);
} catch (_) { members = []; invites = []; }
hhLoading = false; m.redraw();
}
function HouseholdView(s: HHPgState) {
if (!Session.user) { m.route.set("/login"); return null; }
if (hhLoading) { loadHousehold(); return m(".nb-container", { style: { maxWidth: "960px", margin: "0 auto" } }, m("p", "Loading...")); }
const h = Session.households.find(h => h.id === Session.activeHid);
const isOwner = members.some(m => m.userId === Session.user!.id && m.role === "owner");
// If no households, show create form
if (Session.households.length === 0) {
async function create() {
s.creating = true; m.redraw();
await api("/households", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: s.name }) });
await Session.load();
s.creating = false; m.redraw();
}
return m(".nb-container", { style: { maxWidth: "480px", margin: "4rem auto" } }, [
m(".nb-box", { style: { padding: "2rem", textAlign: "center" } }, [
m("h1.nb-font-heading1", "Create Your Household"),
m("p", { style: { marginBottom: "1rem" } }, "You need a household to get started."),
m("input.nb-input", { value: s.name, oninput: (e: Event) => { s.name = (e.target as HTMLInputElement).value; }, placeholder: "Household name", style: { width: "100%", marginBottom: "1rem" } }),
m("button.nb-button", { disabled: s.creating, onclick: create }, s.creating ? "Creating..." : "Create Household"),
])
]);
}
return m(".nb-container", { style: { maxWidth: "960px", margin: "0 auto" } }, [
m("h1.nb-font-heading1", h?.name ?? "Household"),
m("p", { style: { opacity: 0.7 } }, `${members.length} member${members.length !== 1 ? "s" : ""}`),
// Members
m("h2.nb-font-heading2", "Members"),
m(".nb-box", { style: { marginBottom: "1.5rem" } },
members.map((mem: Membership) => m(".nb-list-item", { style: { display: "flex", justifyContent: "space-between", alignItems: "center", padding: "0.5rem" } }, [
m("span", [
m("span.nb-badge", { style: { marginRight: "0.5rem", borderRadius: "50%", width: "2rem", height: "2rem", display: "inline-flex", alignItems: "center", justifyContent: "center", fontSize: "0.8rem" } }, initials(mem.displayName)),
m.trust(`<strong>${mem.displayName}</strong>`),
m("span", { style: { opacity: 0.5, marginLeft: "0.5rem" } }, mem.email),
]),
m("span.nb-badge", { style: mem.role === "owner" ? { background: "var(--nb-yellow)", color: "#000" } : {} }, mem.role.toUpperCase()),
]))
),
// Invites
isOwner ? [
m("h2.nb-font-heading2", "Invite Members"),
m(".nb-box", { style: { marginBottom: "1.5rem" } }, [
m("p", { style: { marginBottom: "0.5rem" } }, "Create an invite link to share:"),
m("button.nb-button", { onclick: createInvite }, "+ Create Invite Link"),
invites.length > 0 ? m("div", { style: { marginTop: "1rem" } }, [
m("h3", "Pending Invites"),
...invites.filter(i => i.status === "pending").map(i => m(".nb-list-item", { style: { display: "flex", justifyContent: "space-between", padding: "0.5rem" } }, [
m("code", { style: { fontSize: "0.85rem" } }, `/invite/${i.code}`),
m("button.nb-button", { style: { fontSize: "0.8rem", background: "var(--nb-red)" }, onclick: () => revokeInvite(i.id) }, "Revoke"),
]))
]) : null,
])
] : null,
]);
}
async function createInvite() {
await api(`/households/${Session.activeHid}/invites`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: null }) });
loadHousehold();
}
async function revokeInvite(id: number) {
await api(`/households/${Session.activeHid}/invites/${id}`, { method: "DELETE" });
loadHousehold();
}
// ── Activity Log Page ───────────────────────────────────────────
const ActivityPage: m.Component = {
view() { return ActivityView(); }
};
let actPage: ActivityLogPage | null = null, actLoading = true;
async function loadActivityLog(page = 1) {
if (!Session.activeHid) return;
actLoading = true; m.redraw();
try { actPage = await api<ActivityLogPage>(`/households/${Session.activeHid}/activity?page=${page}&perPage=20`); }
catch (_) { actPage = null; }
actLoading = false; m.redraw();
}
function ActivityView() {
if (!Session.user) { m.route.set("/login"); return null; }
if (actLoading) { loadActivityLog(); return m(".nb-container", { style: { maxWidth: "960px", margin: "0 auto" } }, m("p", "Loading...")); }
return m(".nb-container", { style: { maxWidth: "960px", margin: "0 auto" } }, [
m("h1.nb-font-heading1", "Activity Log"),
actPage && actPage.entries.length === 0 ? m("p", { style: { opacity: 0.5 } }, "No activity recorded yet.") :
m(".nb-box", (actPage?.entries ?? []).map(e => m(".nb-list-item", { style: { padding: "0.5rem" } }, [
m("span.nb-badge", { style: { marginRight: "0.5rem", background: e.activity.status === "completed" ? "var(--nb-green)" : "var(--nb-orange)", color: "#000" } }, e.activity.status.toUpperCase()),
m.trust(`<strong>${e.userName}</strong>`), ` ${e.activity.status} `, m.trust(`<strong>${e.choreName}</strong>`),
m("span", { style: { opacity: 0.5, marginLeft: "0.5rem" } }, `on ${fmtDate(e.occurrenceDate)} at ${fmtTime(e.activity.recordedAt)}`),
e.activity.note ? m("span", { style: { opacity: 0.5, fontStyle: "italic", marginLeft: "0.5rem" } }, `"${e.activity.note}"`) : null,
]))),
actPage && actPage.entries.length > 0 && actPage.total > actPage.perPage ? m("div", { style: { marginTop: "1rem", display: "flex", gap: "0.5rem", justifyContent: "center" } }, [
m("button.nb-button", { disabled: actPage!.page <= 1, onclick: () => loadActivityLog(actPage!.page - 1) }, "Previous"),
m("span", { style: { alignSelf: "center" } }, `Page ${actPage!.page} of ${Math.ceil(actPage!.total / actPage!.perPage)}`),
m("button.nb-button", { disabled: actPage!.page >= Math.ceil(actPage!.total / actPage!.perPage), onclick: () => loadActivityLog(actPage!.page + 1) }, "Next"),
]) : null,
]);
}
// ── App Shell ───────────────────────────────────────────────────
const AppShell: m.Component = {
view(v: Vnode) {
return m("div", [m(NavBar), m(".nb-container", { style: { maxWidth: "960px", margin: "0 auto" } }, v.children)]);
}
};
// ── Router ──────────────────────────────────────────────────────
const routes: RouteDefs = {
"/login": { onmatch: () => { if (Session.user) { m.route.set("/dashboard"); return; } }, render: () => m(AppShell, m(LoginPage)) },
"/signup": { onmatch: () => { if (Session.user) { m.route.set("/dashboard"); return; } }, render: () => m(AppShell, m(SignupPage)) },
"/dashboard": { onmatch: checkAuth, render: () => m(AppShell, m(DashboardPage)) },
"/chores": { onmatch: checkAuth, render: () => m(AppShell, m(ChoresPage)) },
"/household": { onmatch: checkAuth, render: () => m(AppShell, m(HouseholdPage)) },
"/activity": { onmatch: checkAuth, render: () => m(AppShell, m(ActivityPage)) },
};
function checkAuth(): Promise<void> | void {
if (Session.user) return;
return Session.load().then(() => { if (!Session.user) m.route.set("/login"); });
}
// ── Mount ───────────────────────────────────────────────────────
const root = document.getElementById("app"); const root = document.getElementById("app");
if (root) { if (root) {
m.mount(root, App); m.route(root, "/login", routes);
Session.load();
} }