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();

34
server/public/index.html Normal file
View File

@@ -0,0 +1,34 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Personal CRM</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<header>
<h1>Personal CRM</h1>
<div class="stats" id="stats"></div>
<div class="controls">
<input id="search" type="search" placeholder="Search name or email…" autocomplete="off" />
<select id="tag"><option value="">All tags</option></select>
<select id="sort">
<option value="name">Sort: Name</option>
<option value="recent">Sort: Recently contacted</option>
<option value="stale">Sort: Out of touch</option>
</select>
<button id="add-btn" class="primary">+ Contact</button>
</div>
</header>
<main>
<ul id="list" class="list"></ul>
<section id="detail" class="detail">
<p class="empty">Select a contact, or add a new one.</p>
</section>
</main>
<script src="/app.js"></script>
</body>
</html>

102
server/public/styles.css Normal file
View File

@@ -0,0 +1,102 @@
:root {
--bg: #0f1115;
--panel: #171a21;
--panel-2: #1e222b;
--border: #2a2f3a;
--text: #e6e8ec;
--muted: #8b93a1;
--accent: #4f8cff;
--accent-2: #2d6ae0;
--danger: #e5484d;
--radius: 10px;
}
* { box-sizing: border-box; }
body {
margin: 0;
font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
}
header {
padding: 16px 20px;
border-bottom: 1px solid var(--border);
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px 16px;
background: var(--panel);
position: sticky;
top: 0;
z-index: 5;
}
header h1 { font-size: 18px; margin: 0; }
.stats { color: var(--muted); font-size: 13px; }
.controls { margin-left: auto; display: flex; gap: 8px; flex-wrap: wrap; }
input, select, textarea, button {
font: inherit;
color: var(--text);
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 10px;
}
input:focus, select:focus, textarea:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
button { cursor: pointer; }
button.primary { background: var(--accent); border-color: var(--accent-2); color: #fff; font-weight: 600; }
button.primary:hover { background: var(--accent-2); }
button.ghost { background: transparent; }
button.danger { color: var(--danger); border-color: var(--danger); background: transparent; }
main { display: grid; grid-template-columns: 340px 1fr; min-height: calc(100vh - 66px); }
.list { list-style: none; margin: 0; padding: 8px; border-right: 1px solid var(--border); overflow-y: auto; }
.list li {
padding: 10px 12px;
border-radius: 8px;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 3px;
}
.list li:hover { background: var(--panel); }
.list li.active { background: var(--panel-2); outline: 1px solid var(--accent); }
.list .name { font-weight: 600; }
.list .meta { font-size: 12px; color: var(--muted); display: flex; gap: 8px; flex-wrap: wrap; }
.tagchip { font-size: 11px; padding: 1px 7px; border-radius: 999px; background: #23324d; color: #9dbcff; }
.detail { padding: 24px; overflow-y: auto; }
.detail .empty { color: var(--muted); }
.detail h2 { margin: 0 0 4px; }
.detail .sub { color: var(--muted); margin: 0 0 16px; }
.detail section.block { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; margin-bottom: 16px; }
.detail section.block h3 { margin: 0 0 12px; font-size: 13px; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); }
.row { display: flex; gap: 10px; margin-bottom: 10px; }
.row label { flex: 0 0 110px; color: var(--muted); padding-top: 8px; }
.row .field { flex: 1; }
.field input, .field textarea, .field select { width: 100%; }
textarea { resize: vertical; min-height: 60px; }
.timeline { list-style: none; margin: 0; padding: 0; }
.timeline li { padding: 10px 0; border-top: 1px solid var(--border); }
.timeline li:first-child { border-top: none; }
.timeline .when { font-size: 12px; color: var(--muted); }
.badge { font-size: 11px; padding: 1px 8px; border-radius: 999px; background: var(--panel-2); border: 1px solid var(--border); }
.actions { display: flex; gap: 8px; margin-top: 8px; }
.inline-form { display: grid; gap: 8px; }
.hint { color: var(--muted); font-size: 12px; }
.toast {
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
background: var(--panel-2); border: 1px solid var(--border); padding: 10px 16px;
border-radius: 8px; opacity: 0; transition: opacity .2s; pointer-events: none;
}
.toast.show { opacity: 1; }
@media (max-width: 720px) {
main { grid-template-columns: 1fr; }
.list { max-height: 40vh; }
}