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:
+566
-3
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user