a bunch of frontend vibecoding. I hate that this is so much fun. No we have a graph view of all contacts.

This commit is contained in:
2026-09-04 01:01:38 +02:00
parent 230c150356
commit ebcb6a64dc
5 changed files with 169 additions and 3 deletions
+1
View File
@@ -34,6 +34,7 @@ async function select_module(name, btn) {
// workaround to execute script tags in loaded html
content.querySelectorAll('script').forEach(oldScript => {
const newScript = document.createElement('script');
newScript.async = false;
if (oldScript.src) newScript.src = oldScript.src;
else newScript.textContent = oldScript.textContent;
document.body.appendChild(newScript);
+25
View File
@@ -0,0 +1,25 @@
# how many events per week will result in an perfect event score (float)
FREQUENCY_TARGET_WEEKLY=
# how many different people per month will result in an perfect diversity score (float)
DIVERSITY_TARGET_MONTHLY=
# full path to one carddav addressbook
CARDDAV_URL=https://
CARDDAV_USER=
CARDDAV_PASSWORD=
# adding this to a contacts "notes" section excludes them
CARDDAV_IGNORE_STR=<ignore>
# full path to one specific calendar
CALDAV_URL=https://
CALDAV_USER=
CALDAV_PASSWORD=
# prefix to mark event participants in calendar event description like "@max mustermann\n"
CALDAV_PARTICIPANT_PREFIX=@
# delay between attempts to sync data from servers (int)
REFETCH_SECONDS=300
+2 -1
View File
@@ -3,6 +3,7 @@
<link href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@500;600;700&family=IBM+Plex+Sans:wght@400;500&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/modules/socialwatch/static/_style.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.9.0/d3.min.js"></script>
<div class="sw">
<div class="wrap">
@@ -56,7 +57,7 @@
<div class="stat-num" id="social-score"></div>
<div class="stat-sub">social score</div>
</div>
<div class="card">
<div class="card clickable" id="events-card">
<div class="stat-num" id="events-month"></div>
<div class="stat-sub">events / mo</div>
</div>
+121 -2
View File
@@ -3,7 +3,9 @@ AW.define(() => {
const now = new Date();
$('#dateline').textContent = now.toLocaleDateString('en-GB',{weekday:'short',day:'2-digit',month:'short',year:'numeric'});
let people = [], events = [], parseErrors = [];
let people = [], events = [], parseErrors = [], stats = {};
let graphSimulation = null;
let graphResizeObserver = null;
async function loadData(){
try{
@@ -271,6 +273,9 @@ AW.define(() => {
}
function closeModal(){
overlay.classList.remove('open');
$('#modal').classList.remove('graph-modal');
if(graphResizeObserver){ graphResizeObserver.disconnect(); graphResizeObserver = null; }
if(graphSimulation){ graphSimulation.stop(); graphSimulation = null; }
}
$('#modal-close').addEventListener('click', closeModal);
overlay.addEventListener('click', e => { if(e.target === overlay) closeModal(); });
@@ -319,7 +324,6 @@ AW.define(() => {
`);
if(person && person.photo){
console.log("loading picture")
loadAvatar(uid).then(src => {
const el = $('#modal-avatar');
if(el) el.style.backgroundImage = `url(${src})`;
@@ -397,8 +401,123 @@ AW.define(() => {
`);
}
// ---- graph modal ----
function openGraphModal(){
$('#modal').classList.add('graph-modal');
openModal(`
<div class="modal-kicker">Social Graph</div>
<div class="modal-title"><i class="fa-solid fa-diagram-project"></i> SocialGraph </div>
<div id="graph-wrap"><svg id="graph-svg"></svg></div>
`);
const wrap = document.getElementById('graph-wrap');
const svg = d3.select('#graph-svg');
const g = svg.append('g');
svg.call(d3.zoom().scaleExtent([0.3, 4]).filter(event => !event.target.closest('g')).on('zoom', e => g.attr('transform', e.transform)));
// build nodes + edges from shared events
const nodesMap = new Map();
const edgeMap = new Map();
events.forEach(ev => {
const parts = (ev.participants || []).map(participantUid);
parts.forEach(uid => {
if(!nodesMap.has(uid)) nodesMap.set(uid, { id: uid, name: participantName(uid), count: 0 });
nodesMap.get(uid).count++;
});
for(let i = 0; i < parts.length; i++)
for(let j = i+1; j < parts.length; j++){
const [a,b] = [parts[i], parts[j]].sort();
const key = a + '|' + b;
if(!edgeMap.has(key)) edgeMap.set(key, { source: a, target: b, count: 0 });
edgeMap.get(key).count++;
}
});
const nodes = Array.from(nodesMap.values());
const links = Array.from(edgeMap.values());
const seedW = wrap.clientWidth || 600, seedH = wrap.clientHeight || 400;
nodes.forEach(n => {
n.x = seedW / 2 + (Math.random() - 0.5) * 100;
n.y = seedH / 2 + (Math.random() - 0.5) * 100;
});
const linkSel = g.append('g').selectAll('line').data(links).join('line')
.attr('stroke', 'var(--border)')
.attr('stroke-width', d => 1 + Math.sqrt(d.count));
const nodeSel = g.append('g').selectAll('g').data(nodes).join('g')
.call(drag());
nodeSel.each(function(d, i){
const person = findPerson(d.id);
const r = 5 + Math.min(14, Math.sqrt(d.count) * 3);
const sel = d3.select(this);
// fallback circle always drawn first (shows while photo loads / if no photo)
sel.append('circle')
.attr('r', r)
.attr('fill', 'var(--primary)');
if(person && person.photo){
const clipId = `node-clip-${i}`;
sel.append('clipPath').attr('id', clipId)
.append('circle').attr('r', r);
const img = sel.append('image')
.attr('x', -r).attr('y', -r)
.attr('width', r * 2).attr('height', r * 2)
.attr('clip-path', `url(#${clipId})`)
.attr('preserveAspectRatio', 'xMidYMid slice');
loadAvatar(d.id).then(src => { if(src) img.attr('href', src); });
}
sel.append('text')
.attr('x', r + 5).attr('y', 4)
.attr('fill', 'var(--text)')
.style('font-size', '11px')
.text(d.name);
});
function drag(){
return d3.drag()
.on('start', (event, d) => { if(!event.active) graphSimulation.alphaTarget(0.25).restart(); d.fx = d.x; d.fy = d.y; })
.on('drag', (event, d) => { d.fx = event.x; d.fy = event.y; })
.on('end', (event, d) => { if(!event.active) graphSimulation.alphaTarget(0); d.fx = null; d.fy = null; });
}
function tick(){
linkSel.attr('x1', d => d.source.x).attr('y1', d => d.source.y)
.attr('x2', d => d.target.x).attr('y2', d => d.target.y);
nodeSel.attr('transform', d => `translate(${d.x},${d.y})`);
}
// more shared events -> shorter link distance -> nodes pulled closer
graphResizeObserver = new ResizeObserver(() => {
const w = wrap.clientWidth, h = wrap.clientHeight;
svg.attr('viewBox', [0, 0, w, h]);
if(!graphSimulation){
graphSimulation = d3.forceSimulation(nodes)
.force('link', d3.forceLink(links).id(d => d.id).distance(d => 140 - Math.min(110, d.count * 20)))
.force('charge', d3.forceManyBody().strength(-180))
.force('center', d3.forceCenter(w/2, h/2))
.force('collide', d3.forceCollide().radius(24))
.force('x', d3.forceX(w/2).strength(0.05))
.force('y', d3.forceY(h/2).strength(0.05))
.on('tick', tick);
} else {
graphSimulation.force('center', d3.forceCenter(w/2, h/2))
.force('x', d3.forceX(w/2).strength(0.05))
.force('y', d3.forceY(h/2).strength(0.05))
.alpha(0.3).restart();
}
});
graphResizeObserver.observe(wrap);
}
$('#parse-fab').addEventListener('click', openParseErrorsModal);
$('#score-card').addEventListener('click', openScoreModal);
$('#events-card').addEventListener('click', openGraphModal);
// event delegation for the whole page + modal content
document.addEventListener('click', e => {
+20
View File
@@ -196,6 +196,26 @@
color: var(--border);
}
/* graph modal — #modal-body itself must be flex so #graph-wrap can flex:1 */
.sw .modal.graph-modal{
max-width:90vw; width:90vw;
height:80vh; max-height:80vh;
display:flex; flex-direction:column;
overflow:hidden;
}
.sw .modal.graph-modal #modal-body{
display:flex; flex-direction:column;
flex:1; min-height:0;
}
.sw .modal.graph-modal #graph-wrap{
flex:1; min-height:0;
height:auto; width:100%;
}
.sw #graph-svg{ width:100%; height:100%; display:block; cursor:grab; }
.sw #graph-svg:active{ cursor:grabbing; }
.sw #graph-svg .node{ cursor: grab; }
.sw #graph-svg .node:active{ cursor: grabbing; }
@media (max-width:980px){
.sw{height:auto;overflow-y:auto;overflow-x:hidden;padding:1rem}
.sw main.grid{grid-template-columns:1fr;display:flex;flex-direction:column}