Kin: personal relationships app — server (cadence/due/migrations) + Expo Android app
Some checks failed
Build & Release APK / build (push) Failing after 13s

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-09 00:49:45 -07:00
commit ac0b82e6a0
101 changed files with 11534 additions and 0 deletions

232
server/public/app.js Normal file
View File

@@ -0,0 +1,232 @@
// --- 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();