diff --git a/frontend/src/index.ts b/frontend/src/index.ts index a9b26cd..6e06e43 100644 --- a/frontend/src/index.ts +++ b/frontend/src/index.ts @@ -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(path: string, opts?: RequestInit): Promise { + 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(`/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(`/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(`/households/${Session.activeHid}/members`); + invites = await api(`/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(`${mem.displayName}`), + 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(`/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(`${e.userName}`), ` ${e.activity.status} `, m.trust(`${e.choreName}`), + 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 { + if (Session.user) return; + return Session.load().then(() => { if (!Session.user) m.route.set("/login"); }); +} + +// ── Mount ─────────────────────────────────────────────────────── const root = document.getElementById("app"); if (root) { - m.mount(root, App); + m.route(root, "/login", routes); + Session.load(); }