Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 01d64b682b | |||
| 8157e95066 | |||
| 86e51bb851 |
+16
-3
@@ -92,19 +92,32 @@ func (cs *ConfigStore) get(userID string) Config {
|
||||
// merge applies an incoming config for one user with last-write-wins by
|
||||
// UpdatedAt and returns the resulting stored config (which the caller sends back).
|
||||
func (cs *ConfigStore) merge(userID string, in Config) (Config, error) {
|
||||
// The upsert's WHERE clause enforces last-write-wins: the incoming row only
|
||||
// replaces the stored one when it is strictly newer.
|
||||
// Name/birthday/updated are last-write-wins: the incoming row replaces the
|
||||
// stored one only when strictly newer. The pedigree id is stickier — an empty
|
||||
// incoming value never clears a stored one, so a clock race between devices
|
||||
// can't drop it; when both are set, the newer profile's id wins with the rest.
|
||||
_, err := cs.db.Exec(`
|
||||
INSERT INTO config (user_id, name, birthday, pedigree_id, updated)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
name = excluded.name, birthday = excluded.birthday,
|
||||
pedigree_id = excluded.pedigree_id, updated = excluded.updated
|
||||
pedigree_id = CASE WHEN excluded.pedigree_id != '' THEN excluded.pedigree_id ELSE config.pedigree_id END,
|
||||
updated = excluded.updated
|
||||
WHERE excluded.updated > config.updated`,
|
||||
userID, in.Name, in.Birthday, in.PedigreeID, in.UpdatedAt)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
// Adopt a pedigree id the server is missing even from an older-stamped profile,
|
||||
// so a device that set it isn't blocked by another device's newer name/birthday
|
||||
// edit. (A set id is only ever changed by a newer profile that also sets one.)
|
||||
if in.PedigreeID != "" {
|
||||
if _, err := cs.db.Exec(
|
||||
`UPDATE config SET pedigree_id = ? WHERE user_id = ? AND pedigree_id = ''`,
|
||||
in.PedigreeID, userID); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
}
|
||||
return cs.get(userID), nil
|
||||
}
|
||||
|
||||
|
||||
+104
-22
@@ -230,6 +230,28 @@
|
||||
localStorage.setItem(configKey(), JSON.stringify(cfg));
|
||||
}
|
||||
|
||||
// Reconcile a local and a server profile. Name/birthday/updatedAt are plain
|
||||
// last-write-wins by timestamp. The pedigree id is sticky: a non-empty value
|
||||
// never loses to an empty one — so it can't be dropped by a clock race between
|
||||
// devices — and when both are set the newer profile's id wins with the rest.
|
||||
// (The server merge mirrors this, so a set id is only changed, never cleared,
|
||||
// by sync.)
|
||||
function reconcileConfig(local, server) {
|
||||
const base = server.updatedAt >= local.updatedAt ? server : local;
|
||||
return {
|
||||
name: base.name,
|
||||
birthday: base.birthday,
|
||||
pedigreeId: (local.pedigreeId && server.pedigreeId)
|
||||
? base.pedigreeId
|
||||
: (local.pedigreeId || server.pedigreeId),
|
||||
updatedAt: base.updatedAt,
|
||||
};
|
||||
}
|
||||
function sameConfig(a, b) {
|
||||
return a.name === b.name && a.birthday === b.birthday
|
||||
&& a.pedigreeId === b.pedigreeId && a.updatedAt === b.updatedAt;
|
||||
}
|
||||
|
||||
// Age in whole days / weeks / calendar months from a "YYYY-MM-DD" birthday,
|
||||
// measured at `at` (defaults to now — pass a weigh-in's timestamp for its age
|
||||
// at that point). Returns null for a missing/invalid birthday or a date before it.
|
||||
@@ -2040,12 +2062,14 @@
|
||||
pedigreeId: body.pedigreeId || "",
|
||||
updatedAt: Number.isFinite(body.updatedAt) ? body.updatedAt : 0,
|
||||
};
|
||||
if (server.updatedAt > local.updatedAt) {
|
||||
saveConfig(server);
|
||||
const merged = reconcileConfig(local, server);
|
||||
if (!sameConfig(merged, local)) {
|
||||
saveConfig(merged);
|
||||
renderHeader();
|
||||
refreshPedigreeButton();
|
||||
} else if (local.updatedAt > server.updatedAt) {
|
||||
await pushConfig(local);
|
||||
}
|
||||
if (!sameConfig(merged, server)) {
|
||||
await pushConfig(merged);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("config sync failed:", err);
|
||||
@@ -2434,8 +2458,9 @@
|
||||
const pedTree = document.getElementById("pedigree-tree");
|
||||
const pedBtn = document.getElementById("pedigree-btn");
|
||||
const pedRefresh = document.getElementById("pedigree-refresh");
|
||||
const pedRepeatNote = document.getElementById("pedigree-repeat-note");
|
||||
|
||||
const PED_OPEN_DEPTH = 4; // generations shown expanded by default; deeper collapse
|
||||
const PED_OPEN_DEPTH = 3; // show 3 generations expanded by default; deeper collapses
|
||||
let pedPollTimer = null;
|
||||
let pedNodes = {}; // latest ancestry map, for the progress count
|
||||
|
||||
@@ -2556,20 +2581,24 @@
|
||||
// fills many positions); "generations back" is the depth of the deepest
|
||||
// position (floor(log2(pos)), since sire = 2·pos and dam = 2·pos+1).
|
||||
function pedCounts() {
|
||||
const seen = new Set();
|
||||
const counts = new Map();
|
||||
let maxPos = 1;
|
||||
for (const k in pedNodes) {
|
||||
const n = pedNodes[k];
|
||||
const key = n.reg || n.name;
|
||||
if (key) seen.add(key);
|
||||
if (key) counts.set(key, (counts.get(key) || 0) + 1);
|
||||
const p = Number(k);
|
||||
if (p > maxPos) maxPos = p;
|
||||
}
|
||||
return { distinct: seen.size, gens: Math.floor(Math.log2(maxPos)) };
|
||||
let repeated = 0;
|
||||
counts.forEach((c) => { if (c > 1) repeated++; });
|
||||
return { distinct: counts.size, repeated, gens: Math.floor(Math.log2(maxPos)) };
|
||||
}
|
||||
function pedCountText() {
|
||||
const { distinct: a, gens: g } = pedCounts();
|
||||
return `${a} ancestor${a === 1 ? "" : "s"} back ${g} generation${g === 1 ? "" : "s"}`;
|
||||
const { distinct: a, repeated: r, gens: g } = pedCounts();
|
||||
let s = `${a} ancestor${a === 1 ? "" : "s"} back ${g} generation${g === 1 ? "" : "s"}`;
|
||||
if (r > 0) s += `, ${r} appearing more than once`;
|
||||
return s;
|
||||
}
|
||||
function setPedDone() { setPedStatus(`Traced ${pedCountText()}.`, "done"); }
|
||||
function setPedStatus(text, kind) {
|
||||
@@ -2622,12 +2651,49 @@
|
||||
}
|
||||
|
||||
// The tree is ahnentafel-indexed: the dog is position 1, its sire 2n and dam
|
||||
// 2n+1. We build recursively (sire above dam) and collapse below PED_OPEN_DEPTH.
|
||||
// 2n+1. We render it top-down like a family tree — the dog on top, parents
|
||||
// branching below — as a nested <ul>/<li> so CSS can draw the connectors.
|
||||
// Built recursively (sire left, dam right) and collapsed below PED_OPEN_DEPTH.
|
||||
// A dog's identity for spotting pedigree collapse: its registration number,
|
||||
// or its name when it has none. Ancestors sharing a key are the same dog.
|
||||
function dogKey(n) { return n ? (n.reg || n.name || "") : ""; }
|
||||
// Stable hue per repeated dog so its badge/highlight colour is consistent
|
||||
// everywhere it appears.
|
||||
function hueFor(s) {
|
||||
let h = 0;
|
||||
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
|
||||
return h % 360;
|
||||
}
|
||||
let pedRepeat = {}; // dogKey -> occurrence count, for the current tree
|
||||
|
||||
function renderTree(nodes) {
|
||||
pedNodes = nodes;
|
||||
pedTree.textContent = "";
|
||||
// Count how many positions each dog fills, to flag pedigree collapse.
|
||||
pedRepeat = {};
|
||||
for (const k in nodes) {
|
||||
const id = dogKey(nodes[k]);
|
||||
if (id) pedRepeat[id] = (pedRepeat[id] || 0) + 1;
|
||||
}
|
||||
const anyRepeat = Object.values(pedRepeat).some((c) => c > 1);
|
||||
if (pedRepeatNote) pedRepeatNote.hidden = !anyRepeat;
|
||||
const root = buildPedNode(nodes, 1, 0);
|
||||
if (root) pedTree.append(root);
|
||||
if (!root) return;
|
||||
const ul = document.createElement("ul");
|
||||
ul.className = "ped-tree-h";
|
||||
ul.append(root);
|
||||
pedTree.append(ul);
|
||||
}
|
||||
|
||||
// Highlight (or unhighlight) every card that is the same dog as `key`.
|
||||
function togglePedHighlight(key) {
|
||||
const cards = pedTree.querySelectorAll(".ped-card[data-dogkey]");
|
||||
let lit = false;
|
||||
cards.forEach((c) => {
|
||||
if (c.dataset.dogkey === key && c.classList.contains("ped-lit")) lit = true;
|
||||
});
|
||||
cards.forEach((c) => c.classList.remove("ped-lit"));
|
||||
if (!lit) cards.forEach((c) => { if (c.dataset.dogkey === key) c.classList.add("ped-lit"); });
|
||||
}
|
||||
|
||||
function buildPedNode(nodes, pos, depth) {
|
||||
@@ -2636,8 +2702,7 @@
|
||||
const hasDam = !!nodes[String(pos * 2 + 1)];
|
||||
if (!n && !hasSire && !hasDam) return null;
|
||||
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "ped-node";
|
||||
const li = document.createElement("li");
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.className = "ped-card";
|
||||
@@ -2659,31 +2724,48 @@
|
||||
card.append(r);
|
||||
}
|
||||
|
||||
// Mark dogs that fill more than one position (pedigree collapse). A ×N badge
|
||||
// shows how many times, a stable colour ties the copies together, and tapping
|
||||
// the card lights up every place this dog appears.
|
||||
const key = dogKey(n);
|
||||
if (key && pedRepeat[key] > 1) {
|
||||
card.classList.add("ped-repeat");
|
||||
card.dataset.dogkey = key;
|
||||
card.style.setProperty("--repeat-hue", hueFor(key));
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "ped-repeat-badge";
|
||||
badge.textContent = "×" + pedRepeat[key];
|
||||
badge.title = "Appears " + pedRepeat[key] + " times in this pedigree — tap to highlight them all";
|
||||
card.append(badge);
|
||||
card.addEventListener("click", (e) => {
|
||||
if (e.target.closest(".ped-toggle")) return; // let the expander do its job
|
||||
togglePedHighlight(key);
|
||||
});
|
||||
}
|
||||
li.append(card);
|
||||
|
||||
if (hasSire || hasDam) {
|
||||
const kids = document.createElement("div");
|
||||
kids.className = "ped-children";
|
||||
const kids = document.createElement("ul");
|
||||
const s = buildPedNode(nodes, pos * 2, depth + 1);
|
||||
const d = buildPedNode(nodes, pos * 2 + 1, depth + 1);
|
||||
if (s) { s.classList.add("ped-sire"); kids.append(s); }
|
||||
if (d) { d.classList.add("ped-dam"); kids.append(d); }
|
||||
|
||||
const collapsed = depth >= PED_OPEN_DEPTH;
|
||||
if (collapsed) wrap.classList.add("collapsed");
|
||||
if (collapsed) li.classList.add("collapsed");
|
||||
const toggle = document.createElement("button");
|
||||
toggle.type = "button";
|
||||
toggle.className = "ped-toggle";
|
||||
toggle.setAttribute("aria-label", "Toggle ancestors");
|
||||
toggle.textContent = collapsed ? "+" : "−";
|
||||
toggle.addEventListener("click", () => {
|
||||
const nowCollapsed = wrap.classList.toggle("collapsed");
|
||||
const nowCollapsed = li.classList.toggle("collapsed");
|
||||
toggle.textContent = nowCollapsed ? "+" : "−";
|
||||
});
|
||||
card.prepend(toggle);
|
||||
wrap.append(card, kids);
|
||||
} else {
|
||||
wrap.append(card);
|
||||
li.append(kids);
|
||||
}
|
||||
return wrap;
|
||||
return li;
|
||||
}
|
||||
|
||||
// ---------- changelog dialog ----------
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
[
|
||||
{ "date": "2026-07-26", "text": "In the pedigree, a dog that fills more than one spot (pedigree collapse, common in a breed's older lines) now carries a ×N badge — tap it to highlight every place that dog appears in the tree" },
|
||||
{ "date": "2026-07-26", "text": "The pedigree now reads top-down like a family tree — your dog on top with its sire and dam branching below — showing three generations at a glance, with each dog expandable to trace the line further back" },
|
||||
{ "date": "2026-07-26", "text": "The pedigree ID set in Settings now syncs reliably to your other devices — it's no longer dropped when two devices' clocks disagree" },
|
||||
{ "date": "2026-07-26", "text": "New 🌳 Pedigree page: add your dog's SKK chip or registration number in Settings to unlock it, then explore its ancestry as a tree — the first generations show at once and the line fills in further back as it's traced from SKK Hunddata. It's cached, so it reopens instantly and works offline" },
|
||||
{ "date": "2026-07-24", "text": "The age counter reads \"16 weeks (3 months and 3 weeks) old\" so weeks and months line up; past 4 months it drops the weeks and shows just months (e.g. \"5 months and 2 weeks old\")" },
|
||||
{ "date": "2026-07-17", "text": "The Sleep trend chart follows the selected day — pick a past day to see its full curve against the day before and the average leading up to it" },
|
||||
|
||||
@@ -291,6 +291,7 @@
|
||||
<p id="pedigree-status" class="pedigree-status" hidden></p>
|
||||
<div id="pedigree-choose" class="pedigree-choose" hidden></div>
|
||||
<div id="pedigree-subject" class="pedigree-subject" hidden></div>
|
||||
<p id="pedigree-repeat-note" class="muted-note pedigree-repeat-note" hidden>Some ancestors appear in more than one place further back (pedigree collapse). Expand the tree to reveal their ×N badges, then tap one to highlight every spot that dog appears.</p>
|
||||
<div id="pedigree-tree" class="pedigree-tree"></div>
|
||||
</main>
|
||||
</div><!-- /#pedigree-screen -->
|
||||
|
||||
+101
-28
@@ -1123,59 +1123,132 @@ section.collapsed > :not(h2) { display: none; }
|
||||
.ped-subject-name { font-size: 1.15rem; font-weight: 700; }
|
||||
.ped-subject-meta { font-size: 0.85rem; color: var(--muted); margin-top: 2px; }
|
||||
|
||||
/* the tree */
|
||||
/* Top-down family tree: the dog on top, parents branching below, connected by
|
||||
lines drawn with each <li>'s ::before/::after (the classic CSS tree). Wider
|
||||
than the screen once expanded, so the container scrolls horizontally. */
|
||||
.pedigree-tree {
|
||||
overflow-x: auto;
|
||||
padding-bottom: 24px;
|
||||
padding: 8px 0 28px;
|
||||
}
|
||||
.ped-node { position: relative; }
|
||||
.ped-tree-h, .ped-tree-h ul {
|
||||
display: flex;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.ped-tree-h {
|
||||
/* "safe" centers when the tree fits and falls back to start-aligned (no
|
||||
clipped/unreachable left edge) once it's wider than the screen. */
|
||||
justify-content: safe center;
|
||||
min-width: max-content;
|
||||
padding: 4px 16px 12px;
|
||||
}
|
||||
.ped-tree-h ul {
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
padding-top: 22px; /* room for the connector from the parent above */
|
||||
}
|
||||
.ped-tree-h li {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 22px 6px 0;
|
||||
}
|
||||
/* Elbow from each child up to the horizontal bar shared by its siblings. */
|
||||
.ped-tree-h li::before,
|
||||
.ped-tree-h li::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 50%;
|
||||
height: 22px;
|
||||
border-top: 2px solid var(--border);
|
||||
}
|
||||
.ped-tree-h li::before { right: 50%; }
|
||||
.ped-tree-h li::after { left: 50%; border-left: 2px solid var(--border); }
|
||||
/* Vertical drop from a parent card down to its children's bar. */
|
||||
.ped-tree-h ul::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
height: 22px;
|
||||
border-left: 2px solid var(--border);
|
||||
}
|
||||
/* A lone parent connects with a straight line, no elbow. */
|
||||
.ped-tree-h li:only-child::before,
|
||||
.ped-tree-h li:only-child::after { display: none; }
|
||||
/* Trim the outer half-lines at the ends of a sibling row. */
|
||||
.ped-tree-h li:first-child::before,
|
||||
.ped-tree-h li:last-child::after { border: 0 none; }
|
||||
.ped-tree-h li:last-child::before { border-right: 2px solid var(--border); }
|
||||
/* The dog sits on top with no connector above it. */
|
||||
.ped-tree-h > li { padding-top: 0; }
|
||||
.ped-tree-h > li::before,
|
||||
.ped-tree-h > li::after { display: none; }
|
||||
/* Collapsed: hide the ancestry below this dog (and its connectors go with it). */
|
||||
.ped-tree-h li.collapsed > ul { display: none; }
|
||||
|
||||
.ped-card {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
padding: 6px 24px 6px 28px;
|
||||
margin: 3px 0;
|
||||
width: 140px;
|
||||
box-sizing: border-box;
|
||||
padding: 7px 18px 7px 20px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow);
|
||||
text-align: center;
|
||||
}
|
||||
.ped-name { font-weight: 600; font-size: 0.95rem; }
|
||||
.ped-name { font-weight: 600; font-size: 0.85rem; line-height: 1.2; }
|
||||
.ped-name.ped-unknown { color: var(--muted); font-weight: 500; font-style: italic; }
|
||||
.ped-titles { font-size: 0.72rem; color: var(--accent); margin-top: 1px; }
|
||||
.ped-titles { font-size: 0.66rem; color: var(--accent); margin-top: 2px; line-height: 1.2; }
|
||||
.ped-reg {
|
||||
font-size: 0.72rem;
|
||||
font-size: 0.66rem;
|
||||
color: var(--muted);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/* lineage spine: children indented under the card */
|
||||
.ped-children {
|
||||
margin-left: 16px;
|
||||
padding-left: 14px;
|
||||
border-left: 2px solid var(--border);
|
||||
}
|
||||
.ped-node.collapsed > .ped-children { display: none; }
|
||||
|
||||
/* sire ♂ (blue) above dam ♀ (purple) markers on the parent-role cards */
|
||||
.ped-sire > .ped-card { border-left: 3px solid var(--sleep); }
|
||||
.ped-dam > .ped-card { border-left: 3px solid var(--training); }
|
||||
/* sire ♂ (blue) / dam ♀ (purple) accents on the parent cards */
|
||||
.ped-sire > .ped-card { border-top: 3px solid var(--sleep); }
|
||||
.ped-dam > .ped-card { border-top: 3px solid var(--training); }
|
||||
.ped-sire > .ped-card::after,
|
||||
.ped-dam > .ped-card::after {
|
||||
position: absolute;
|
||||
right: 7px;
|
||||
top: 6px;
|
||||
font-size: 0.75rem;
|
||||
right: 6px;
|
||||
top: 5px;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.ped-sire > .ped-card::after { content: "♂"; color: var(--sleep); }
|
||||
.ped-dam > .ped-card::after { content: "♀"; color: var(--training); }
|
||||
|
||||
/* expand/collapse toggle */
|
||||
/* pedigree collapse: a dog filling more than one position gets a ×N badge, a
|
||||
stable hue, and lights up (with every copy) when tapped. */
|
||||
.ped-card.ped-repeat { cursor: pointer; }
|
||||
.ped-repeat-badge {
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
bottom: 5px;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
padding: 2px 5px;
|
||||
border-radius: 999px;
|
||||
color: #fff;
|
||||
background: hsl(var(--repeat-hue, 0), 58%, 48%);
|
||||
}
|
||||
.ped-card.ped-lit {
|
||||
border-color: hsl(var(--repeat-hue, 0), 70%, 50%);
|
||||
box-shadow: 0 0 0 2px hsl(var(--repeat-hue, 0), 70%, 50%), var(--shadow);
|
||||
}
|
||||
.pedigree-repeat-note { margin: 0 0 10px; }
|
||||
|
||||
/* expand/collapse toggle (top-left of the card) */
|
||||
.ped-toggle {
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
left: 5px;
|
||||
top: 5px;
|
||||
width: 18px; height: 18px;
|
||||
padding: 0;
|
||||
font-size: 0.9rem;
|
||||
|
||||
Reference in New Issue
Block a user