Files
kin/server/public/app.js
Marcus Rehbock ac0b82e6a0
Some checks failed
Build & Release APK / build (push) Failing after 13s
Kin: personal relationships app — server (cadence/due/migrations) + Expo Android app
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 00:50:06 -07:00

233 lines
9.6 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// --- tiny helpers ---------------------------------------------------------
const $ = (sel) => document.querySelector(sel);
const el = (tag, props = {}, ...kids) => {
const n = Object.assign(document.createElement(tag), props);
for (const k of kids) n.append(k?.nodeType ? k : document.createTextNode(k ?? ""));
return n;
};
const api = async (path, opts) => {
const res = await fetch("/api" + path, {
headers: { "Content-Type": "application/json" },
...opts,
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || res.statusText);
}
return res.status === 204 ? null : res.json();
};
function toast(msg) {
let t = $(".toast");
if (!t) { t = el("div", { className: "toast" }); document.body.append(t); }
t.textContent = msg;
t.classList.add("show");
clearTimeout(t._timer);
t._timer = setTimeout(() => t.classList.remove("show"), 2200);
}
function relTime(iso) {
if (!iso) return "never";
const d = new Date(iso), now = new Date();
const days = Math.floor((now - d) / 86400000);
if (days <= 0) return "today";
if (days === 1) return "yesterday";
if (days < 30) return days + "d ago";
if (days < 365) return Math.floor(days / 30) + "mo ago";
return Math.floor(days / 365) + "y ago";
}
const fmtDate = (iso) => (iso ? new Date(iso).toLocaleDateString() : "");
// --- state ----------------------------------------------------------------
let state = { q: "", tag: "", sort: "name", selectedId: null };
// --- list -----------------------------------------------------------------
async function refresh() {
const [stats, list] = await Promise.all([
api("/stats"),
api(`/people?q=${encodeURIComponent(state.q)}&tag=${encodeURIComponent(state.tag)}&sort=${state.sort}`),
]);
$("#stats").textContent = `${stats.people} contacts · ${stats.interactions} interactions · ${stats.relationships} links`;
renderList(list);
}
function renderList(list) {
const ul = $("#list");
ul.innerHTML = "";
if (!list.length) { ul.append(el("li", { className: "hint" }, "No matches.")); return; }
for (const p of list) {
const meta = el("div", { className: "meta" },
el("span", {}, `📇 ${relTime(p.last_contacted)}`),
p.location ? el("span", {}, `📍 ${p.location}`) : "",
p.interaction_count ? el("span", {}, `${p.interaction_count}×`) : "",
);
(p.tags || []).forEach((t) => meta.append(el("span", { className: "tagchip" }, t)));
const li = el("li", { className: p.id === state.selectedId ? "active" : "" },
el("span", { className: "name" }, p.full_name),
meta,
);
li.onclick = () => selectPerson(p.id);
ul.append(li);
}
}
async function loadTags() {
const tags = await api("/tags");
const sel = $("#tag");
sel.innerHTML = '<option value="">All tags</option>';
tags.forEach((t) => sel.append(el("option", { value: t.tag }, `${t.tag} (${t.count})`)));
sel.value = state.tag;
}
// --- detail ---------------------------------------------------------------
async function selectPerson(id) {
state.selectedId = id;
document.querySelectorAll(".list li").forEach((li) => li.classList.remove("active"));
const p = await api(`/people/${id}`);
renderDetail(p);
refresh();
}
function field(label, input) {
return el("div", { className: "row" }, el("label", {}, label), el("div", { className: "field" }, input));
}
function renderDetail(p) {
const d = $("#detail");
d.innerHTML = "";
// --- editable info block ---
const nameI = el("input", { value: p.full_name });
const emailI = el("input", { value: p.email || "", type: "email" });
const phoneI = el("input", { value: p.phone || "", type: "tel", placeholder: "+61…" });
const locationI = el("input", { value: p.location || "", placeholder: "City, country" });
const tagsI = el("input", { value: (p.tags || []).join(", ") });
const notesI = el("textarea", { value: p.notes || "" });
const save = el("button", { className: "primary" }, "Save");
save.onclick = async () => {
try {
await api(`/people/${p.id}`, {
method: "PATCH",
body: JSON.stringify({ full_name: nameI.value, email: emailI.value, phone: phoneI.value, location: locationI.value, tags: tagsI.value, notes: notesI.value }),
});
toast("Saved");
await loadTags();
selectPerson(p.id);
} catch (e) { toast(e.message); }
};
const del = el("button", { className: "danger" }, "Delete");
del.onclick = async () => {
if (!confirm(`Delete ${p.full_name}? This removes their interactions too.`)) return;
try {
await api(`/people/${p.id}`, { method: "DELETE" });
toast("Deleted");
state.selectedId = null;
d.innerHTML = '<p class="empty">Select a contact, or add a new one.</p>';
loadTags(); refresh();
} catch (e) { toast(e.message); }
};
const info = el("section", { className: "block" },
el("h2", {}, p.full_name),
el("p", { className: "sub" }, `Last contacted ${relTime(p.last_contacted || (p.interactions[0]?.occurred_at))}`),
field("Name", nameI), field("Email", emailI), field("Phone", phoneI), field("Location", locationI), field("Tags", tagsI), field("Notes", notesI),
el("div", { className: "actions" }, save, del),
);
d.append(info);
// --- relationships ---
if (p.relationships?.length) {
const rl = el("ul", { className: "timeline" });
p.relationships.forEach((r) => {
const link = el("a", { href: "#", style: "color:var(--accent)" }, r.other_name);
link.onclick = (e) => { e.preventDefault(); selectPerson(r.other_id); };
rl.append(el("li", {}, el("span", { className: "badge" }, r.type), " ", link, r.notes ? `${r.notes}` : ""));
});
d.append(el("section", { className: "block" }, el("h3", {}, "Relationships"), rl));
}
// --- interactions ---
const typeI = el("select", {},
...["call", "message", "email", "meetup", "note", "other"].map((t) => el("option", { value: t }, t)));
const whenI = el("input", { type: "date", value: new Date().toISOString().slice(0, 10) });
const summaryI = el("input", { placeholder: "What happened? (short summary)" });
const inotesI = el("textarea", { placeholder: "Details (optional)" });
const logBtn = el("button", { className: "primary" }, "Log interaction");
logBtn.onclick = async () => {
if (!summaryI.value.trim() && !inotesI.value.trim()) return toast("Add a summary first");
try {
await api(`/people/${p.id}/interactions`, {
method: "POST",
body: JSON.stringify({ type: typeI.value, occurred_at: whenI.value, summary: summaryI.value, notes: inotesI.value }),
});
toast("Logged");
selectPerson(p.id);
} catch (e) { toast(e.message); }
};
const timeline = el("ul", { className: "timeline" });
if (!p.interactions.length) timeline.append(el("li", { className: "hint" }, "No interactions logged yet."));
p.interactions.forEach((i) => {
const delI = el("button", { className: "ghost", style: "float:right;color:var(--muted)" }, "✕");
delI.onclick = async () => {
if (!confirm("Delete this interaction?")) return;
await api(`/interactions/${i.id}`, { method: "DELETE" });
toast("Removed"); selectPerson(p.id);
};
timeline.append(el("li", {},
delI,
el("div", { className: "when" }, `${fmtDate(i.occurred_at)} · `, el("span", { className: "badge" }, i.type || "note")),
el("div", {}, i.summary || ""),
i.notes ? el("div", { className: "hint" }, i.notes) : "",
));
});
d.append(el("section", { className: "block" },
el("h3", {}, "Log an interaction"),
el("div", { className: "inline-form" }, field("Type", typeI), field("Date", whenI), field("Summary", summaryI), field("Notes", inotesI), logBtn),
));
d.append(el("section", { className: "block" }, el("h3", {}, "History"), timeline));
}
// --- add contact ----------------------------------------------------------
function showAddForm() {
state.selectedId = null;
document.querySelectorAll(".list li").forEach((li) => li.classList.remove("active"));
const d = $("#detail");
d.innerHTML = "";
const nameI = el("input", { placeholder: "Full name *" });
const emailI = el("input", { type: "email", placeholder: "email@example.com" });
const phoneI = el("input", { type: "tel", placeholder: "+61…" });
const locationI = el("input", { placeholder: "City, country" });
const tagsI = el("input", { placeholder: "comma, separated, tags" });
const notesI = el("textarea", { placeholder: "Notes" });
const create = el("button", { className: "primary" }, "Create contact");
create.onclick = async () => {
if (!nameI.value.trim()) return toast("Name is required");
try {
const p = await api("/people", {
method: "POST",
body: JSON.stringify({ full_name: nameI.value, email: emailI.value, phone: phoneI.value, location: locationI.value, tags: tagsI.value, notes: notesI.value }),
});
toast("Created");
await loadTags();
selectPerson(p.id);
} catch (e) { toast(e.message); }
};
d.append(el("section", { className: "block" },
el("h3", {}, "New contact"),
field("Name", nameI), field("Email", emailI), field("Phone", phoneI), field("Location", locationI), field("Tags", tagsI), field("Notes", notesI),
el("div", { className: "actions" }, create),
));
}
// --- wire up --------------------------------------------------------------
let searchTimer;
$("#search").oninput = (e) => { state.q = e.target.value; clearTimeout(searchTimer); searchTimer = setTimeout(refresh, 200); };
$("#tag").onchange = (e) => { state.tag = e.target.value; refresh(); };
$("#sort").onchange = (e) => { state.sort = e.target.value; refresh(); };
$("#add-btn").onclick = showAddForm;
loadTags();
refresh();