started porting socialwatch into an module. Copied code over and wrote main module file for now

This commit is contained in:
2026-09-03 12:53:17 +02:00
parent d0c8ea3450
commit f3f506bd26
13 changed files with 1230 additions and 1 deletions
View File
+90
View File
@@ -0,0 +1,90 @@
import os, sys
from typing import override
from flask import Blueprint, send_from_directory, jsonify, app
from base.basemodule import BaseModule
from src.fetch_job import BGFetchJob
from src.score import get_social_scores_dict
from src.person import Person
from src.addressbook import Addressbook
from src import config
class SocialWatchModule(BaseModule):
def __init__(self, app: app):
self.addrbook = Addressbook()
self.addrbook.update_cross_refs()
name = "socialwatch"
faIcon = "fa-users"
blueprint = Blueprint(
"socialwatch", __name__,
static_folder="static",
static_url_path=f"/static" # prefix /module/<name> comes from BaseModule
)
blueprint.add_url_rule("/api/people", view_func=self.api_people)
blueprint.add_url_rule("/api/people/<id>", view_func=self.api_person_by_id)
blueprint.add_url_rule("/api/people/<id>/photo", view_func=self.api_photo_by_id)
blueprint.add_url_rule("/api/events", view_func=self.api_events)
blueprint.add_url_rule("/api/events/<id>", view_func=self.api_event_by_id)
blueprint.add_url_rule("/api/parse_errors", view_func=self.api_parse_errors)
blueprint.add_url_rule("/api/scores", view_func=self.api_scores)
super().__init__(app=app, name=name, faIcon=faIcon, blueprint=blueprint)
@override
def sync(self):
pass
# ROUTES ===
@override
def get_page(self):
return send_from_directory(
os.path.join(self.blueprint.root_path, "static"),
"_page.html"
)
# people
def api_people():
return jsonify([p.as_dict() for p in addrbook.people])
def api_person_by_id(id):
for person in addrbook.people:
if person.uid == id:
return jsonify(person.as_dict())
return jsonify({"error": "not found"}), 404
def api_photo_by_id(id):
for person in addrbook.people:
if person.uid == id:
return jsonify({
"photo":person.photo,
"format":person.photo_fmt,
})
return jsonify({"error": "not found"}), 404
# events
def api_events():
return jsonify([e.as_dict() for e in addrbook.events])
def api_event_by_id(id):
for event in addrbook.events:
if event.uid == id:
return jsonify(event.as_dict())
return jsonify({"error": "not found"}), 404
# parse fails
def api_parse_errors():
return jsonify([{
"event_uid":m["event"].uid,
"name":str(m["name"])
} for m in addrbook.missing_contacts])
def api_scores():
return jsonify(get_social_scores_dict(addrbook))
MODULE_CLASS = SocialWatchModule
+34
View File
@@ -0,0 +1,34 @@
blinker==1.9.0
caldav==3.2.1
certifi==2026.7.22
charset-normalizer==3.5.1
click==8.4.2
dnspython==2.8.0
dotenv==0.9.9
Flask==3.1.3
h11==0.16.0
icalendar==7.2.2
icalendar-searcher==1.0.6
idna==3.18
itsdangerous==2.2.0
jh2==5.0.13
Jinja2==3.1.6
logging==0.4.9.6
lxml==6.1.1
MarkupSafe==3.0.3
niquests==3.21.0
python-dateutil==2.9.0.post0
python-dotenv==1.2.2
pytz==2026.3.post1
PyYAML==6.0.3
qh3==1.9.4
recurring-ical-events==3.8.2
requests==2.34.2
six==1.17.0
tzdata==2026.3
urllib3==2.7.0
urllib3-future==2.24.901
vobject==0.9.9
wassima==2.1.3
Werkzeug==3.1.8
x-wr-timezone==2.0.1
+144
View File
@@ -0,0 +1,144 @@
import vobject, requests, logging, sys, caldav
from datetime import date, datetime
from src.score import event_date
from src.person import Person
from src.event import Event
from src.config import *
def normalize_date(dt):
return dt.date() if isinstance(dt, datetime) else dt
class CardDAVSyncError(Exception):
pass
class CalDAVSyncError(Exception):
pass
class Addressbook:
def __init__(self):
self.people: list[Person] = []
self.events: list[Event] = []
# events with names in summary that could not be found in addressbook
self.missing_contacts: list = []
self.calendar = caldav.Calendar(client=caldav.DAVClient(url=CALDAV_URL, username=CALDAV_USER, password=CALDAV_PASSWORD), url=CALDAV_URL)
# init cardDAV
logger = logging.getLogger(__name__)
try:
self.sync_with_carddav()
except Exception:
logger.exception("Failed initial sync with cardDAV server. Failure is not allowed!")
sys.exit(1)
logger.info(f"{len(self.people)} Contacts synced from cardDAV server!")
# init calDAV
try:
self.sync_with_caldav()
except Exception:
logger.exception("Failed initial sync with calDAV server. Failure is not allowed!")
sys.exit(1)
logger.info(f"{len(self.events)} Events synced from calDAV server!")
def update(self):
logger = logging.getLogger(__name__)
try:
self.sync_with_carddav()
logger.info(f"{len(self.people)} Contacts synced from cardDAV server!")
except Exception:logger.exception("Failed to sync contacts from cardDAV server!")
try:
self.sync_with_caldav()
logger.info(f"{len(self.events)} Events synced from calDAV server!")
except Exception: logger.exception("Failed to sync events from calDAV server!")
self.update_cross_refs()
logger.info("Sync complete.")
def clear_cross_refs(self):
for person in self.people:
person.seen_at = []
for event in self.events:
event.participants = []
def update_cross_refs(self):
self.clear_cross_refs()
for event in self.events:
description = event.ical.get("description")
if description:
# string name list of participants from event
participants = [ line[1:].strip().lower() for line in description.split("\n") if line.startswith("@") ]
for part in participants:
match_found = False
for person in self.people:
if person.name == part:
# found a match
event.participants.append(person)
person.seen_at.append(event)
match_found = True
break
if not match_found:
if event not in self.missing_contacts:
self.missing_contacts.append({
"event":event,
"name":part,
})
logging.getLogger(__name__).warn(f"{part} from event summary not found in addressbook.")
else:
continue
logging.getLogger(__name__).info("Updated cross references.")
self.update_people_last_seen()
pass
def update_people_last_seen(self):
for person in self.people:
person.update_last_seen()
def sync_with_carddav(self):
try:
resp = requests.get(CARDDAV_URL, auth=(CARDDAV_USER, CARDDAV_PASSWORD))
resp.raise_for_status()
vcards = list(vobject.readComponents(resp.text))
people = [ Person(vc) for vc in vcards ]
people_cleaned = [ person for person in people if person.is_valid()]
self.people = people_cleaned
except Exception as e:
raise CardDAVSyncError(f"Failed to sync with CardDAV server: {e}")
def sync_with_caldav(self):
try:
events = self.calendar.events()
past_events = [
Event(event.icalendar_component)
for event in events
if normalize_date(event.icalendar_component.get("dtstart").dt) < date.today()
]
self.events = sorted(past_events, key=lambda e: normalize_date(e.ical.get('dtstart').dt))
except Exception as e:
raise CalDAVSyncError(f"Failed to sync with CalDAV server: {e}")
def print_events(self):
for event in self.events:
print(event.ical.get("summary"))
for part in event.participants:
print("> ", part)
def print_people(self):
for person in self.people:
print(person)
for event in person.seen_at:
print(event)
+37
View File
@@ -0,0 +1,37 @@
import sys, os, logging
from dotenv import load_dotenv
_logger = logging.getLogger(__name__)
def _env(name, type=str):
val = os.environ[name]
if not val:
raise Exception(f"Empty option in .env: {name}")
try:
return type(val)
except:
raise ValueError(f"Value of false type given for {name}: expected {type.__name__}")
try:
load_dotenv()
FREQUENCY_TARGET_WEEKLY = _env("FREQUENCY_TARGET_WEEKLY", float)
DIVERSITY_TARGET_MONTHLY = _env("DIVERSITY_TARGET_MONTHLY", float)
CARDDAV_URL = _env("CARDDAV_URL", str)
CARDDAV_USER = _env("CARDDAV_USER", str)
CARDDAV_PASSWORD = _env("CARDDAV_PASSWORD", str)
CARDDAV_IGNORE_STR = _env("CARDDAV_IGNORE_STR", str)
CALDAV_URL = _env("CALDAV_URL", str)
CALDAV_USER = _env("CALDAV_USER", str)
CALDAV_PASSWORD = _env("CALDAV_PASSWORD", str)
CALDAV_PARTICIPANT_PREFIX = _env("CALDAV_PARTICIPANT_PREFIX", str)
REFETCH_SECONDS = _env("REFETCH_SECONDS", int)
_logger.info("Config loaded!")
except Exception:
_logger.exception("Failed to load config from .env!")
sys.exit(1)
if FREQUENCY_TARGET_WEEKLY <= 0 or DIVERSITY_TARGET_MONTHLY <= 0 or REFETCH_SECONDS <= 0:
raise ValueError("Floatingpoint and integer options must be > 0.")
+16
View File
@@ -0,0 +1,16 @@
class Event:
def __init__(self, ical, participants=None):
self.ical = ical
self.uid = str(self.ical.get("uid"))
self.participants = participants or []
def __str__(self):
return f"{self.ical.get("summary")} at {str(self.ical.get("dtstart").dt)}"
def as_dict(self):
return {
"participants": [ p.uid for p in self.participants ],
"summary": self.ical.get("summary"),
"date": self.ical.get("dtstart").dt if self.ical.get("dtstart").dt else None,
"uid": self.uid,
}
+30
View File
@@ -0,0 +1,30 @@
import time, logging, threading
from src import config
from src.addressbook import Addressbook
logger = logging.getLogger(__name__)
def background_fetch_job(addressbook):
logging.getLogger(__name__).info("Daemon up!")
while True:
time.sleep(config.REFETCH_SECONDS)
addressbook.update()
class BGFetchJob:
def __init__(self):
self.thread = None
def run(self, addressbook):
logger.info("Summoning fetch job daemon")
self.thread = threading.Thread(target=background_fetch_job, args=(addressbook,), daemon=True)
self.thread.start()
def is_alive(self):
if self.thread:
return self.thread.is_alive()
else:
return None
+93
View File
@@ -0,0 +1,93 @@
import vobject, base64
from datetime import date, datetime
import uuid
from src import config
class Person:
def __init__(self, vcard):
self.vcard = vcard
self.name = None
self.birthday = None
self.photo = None
self.numbers = []
self.seen_at = []
self.last_seen: date = None
self.uid = str(uuid.uuid4())
self.parse_name()
self.parse_numbers()
self.parse_photo()
self.parse_birthday()
def as_dict(self):
return {
"name": self.name,
"meetups": [ e.uid for e in self.seen_at ],
"last_seen": self.last_seen.isoformat() if self.last_seen else None,
"birthday": self.birthday.isoformat() if self.birthday else None,
"uid": self.uid,
"photo": bool(self.photo),
"numbers": self.numbers
}
def parse_name(self):
if hasattr(self.vcard, "nickname"):
self.name = self.vcard.nickname.value.strip().lower()
return
if hasattr(self.vcard, "fn"):
self.name = self.vcard.fn.value.strip().lower()
return
else:
return
def parse_numbers(self):
if not hasattr(self.vcard, "tel_list"):
return
for t in self.vcard.tel_list:
self.numbers.append(t.value)
def parse_birthday(self):
if not hasattr(self.vcard, "bday"):
return
self.birthday = date.fromisoformat(self.vcard.bday.value.strip())
def parse_photo(self):
if not hasattr(self.vcard, "photo"):
return
self.photo_fmt = self.vcard.photo.params.get('type', ['JPEG'])[0].lower()
self.photo = base64.b64encode(self.vcard.photo.value).decode()
def __str__(self):
return f"[name: {self.name}, birthday: {self.birthday}, seen: {str(self.last_seen)}]"
def update_last_seen(self):
if self.seen_at == []:
self.last_seen = None
return
self.last_seen = max((e.ical.get("dtstart").dt for e in self.seen_at), default=None)
def is_valid(self):
# check if we should ignore this contact
# ignore when no name was found or an explicit tag is found in its notes
if hasattr(self.vcard, "note") and (config.CARDDAV_IGNORE_STR in self.vcard.note.value):
return False
if (not self.name):
return False
return True
+51
View File
@@ -0,0 +1,51 @@
from datetime import date, datetime, timedelta
from src.config import *
def get_social_scores_dict(ab: Addressbook):
from src.addressbook import Addressbook
f_score = get_frequency_score(ab)
d_score = get_diversity_score(ab)
t_score = f_score * d_score
return {
"total" : t_score,
"diversity" : d_score,
"frequency" : f_score
}
# all-day events are dates, others datetime. We may need to convert to dates
def event_date(e):
dt = e.ical.get("dtstart").dt
return dt.date() if isinstance(dt, datetime) else dt
def get_frequency_score(ab: Addressbook):
target = FREQUENCY_TARGET_WEEKLY
scope_in_days = 21
scope_start = date.today() - timedelta(days=scope_in_days)
scope_events = [e for e in ab.events if event_date(e) >= scope_start]
num_evs = len(scope_events)
ev_per_w = num_evs / (scope_in_days / 7)
score = ev_per_w / target
return min(1.0, max(0.0, score))
def get_diversity_score(ab: Addressbook):
target = DIVERSITY_TARGET_MONTHLY
scope_in_days = 63
scope_start = date.today() - timedelta(days=scope_in_days)
scope_events = [e for e in ab.events if event_date(e) >= scope_start]
unique_parts = set()
for event in scope_events:
for part in event.participants:
unique_parts.add(part.uid)
num_unique_parts = len(unique_parts)
parts_per_m = num_unique_parts / (scope_in_days / 28)
score = parts_per_m / target
return min(1.0, max(0.0, score))
+93
View File
@@ -0,0 +1,93 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SocialWatch</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<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="static/style.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
</head>
<body>
<div class="wrap">
<header>
<div>
<div class="logo">Social<span class="accent">Watch</span></div>
<div class="tagline">track your friendships like a nerd</div>
</div>
<div class="today" id="dateline"></div>
</header>
<main class="grid">
<div class="col">
<div class="card grow">
<h2>Last Meetups</h2>
<div class="scroll" id="meetups"><div class="loading">loading…</div></div>
</div>
</div>
<div class="col">
<div class="card fixed">
<h2>This Year</h2>
<div class="heatmap-wrap">
<div id="heatmap-months" class="heatmap-months"></div>
<div id="heatmap" class="heatmap"></div>
</div>
<div class="heat-legend">
no meetup
<span class="cell" style="background:var(--border)"></span>
<span class="cell" style="background:var(--fresh)"></span>
meetup
</div>
</div>
<div class="card grow">
<h2>Top Contacts</h2>
<div class="scroll" id="topcontacts"><div class="loading">loading…</div></div>
</div>
<!-- <div class="card fixed" style="display:flex;gap:.8rem"> -->
<!-- <div class="empty-panel">empty</div> -->
<!-- <div class="empty-panel">empty</div> -->
<!-- </div> -->
</div>
<div class="col">
<div class="stat-row">
<div class="card clickable" id="score-card">
<div class="stat-num" id="social-score"></div>
<div class="stat-sub">social score</div>
</div>
<div class="card">
<div class="stat-num" id="events-month"></div>
<div class="stat-sub">events / mo</div>
</div>
</div>
<div class="card grow">
<h2>Catch Up Soon <span id="warn-badge-holder"></span></h2>
<div class="scroll" id="decay"><div class="loading">loading…</div></div>
</div>
</div>
</main>
</div>
<button class="parse-fab" id="parse-fab" style="display:none" aria-label="Unresolved participants">
<span class="parse-fab-badge" id="parse-fab-badge">0</span>
</button>
<div class="modal-overlay" id="modal-overlay">
<div class="modal" id="modal" role="dialog" aria-modal="true">
<button class="modal-close" id="modal-close" aria-label="Close"></button>
<div id="modal-body"></div>
</div>
</div>
<script src="static/script.js"></script>
</body>
</html>
+421
View File
@@ -0,0 +1,421 @@
const $ = sel => document.querySelector(sel);
const now = new Date();
$('#dateline').textContent = now.toLocaleDateString('en-GB',{weekday:'short',day:'2-digit',month:'short',year:'numeric'});
let people = [], events = [], parseErrors = [];
async function loadData(){
try{
const [pRes, eRes, mRes, sRes] = await Promise.all([
fetch('/api/people'),
fetch('/api/events'),
fetch('/api/parse_errors'),
fetch('/api/scores')
]);
people = await pRes.json();
events = await eRes.json();
parseErrors = await mRes.json();
stats = await sRes.json();
} catch(e){
people = []; events = []; parseErrors = []; stats = {}
}
renderMeetups();
renderHeatmap();
renderTopContacts();
renderStats();
renderDecay();
renderParseFab();
}
function renderParseFab(){
const fab = $('#parse-fab');
const badge = $('#parse-fab-badge');
if(!parseErrors.length){
fab.style.display = 'none';
return;
}
fab.style.display = 'flex';
badge.textContent = parseErrors.length;
}
function findPerson(uid){ return people.find(p => p.uid === uid); }
function findEvent(uid){ return events.find(e => e.uid === uid); }
function toTitleCase(str){
return str.replace(/\w\S*/g, w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase());
}
function participantName(p){
if(typeof p === 'object' && p !== null) return toTitleCase(p.name || p.uid);
const match = findPerson(p);
return toTitleCase(match ? match.name : p);
}
function participantUid(p){
return (typeof p === 'object' && p !== null) ? p.uid : p;
}
function daysBetween(a,b){ return Math.floor((a-b)/86400000); }
function fmtDate(d){ return new Date(d).toLocaleDateString('en-GB',{day:'2-digit',month:'2-digit',year:'numeric'}); }
function fmtDateLong(d){ return new Date(d).toLocaleDateString('en-GB',{weekday:'long',day:'2-digit',month:'long',year:'numeric'}); }
function eventsForPerson(uid){
return events.filter(ev => (ev.participants||[]).some(p => participantUid(p) === uid))
.sort((a,b) => new Date(b.date) - new Date(a.date));
}
function eventsForDateKey(key){
return events.filter(ev => new Date(ev.date).toISOString().slice(0,10) === key)
.sort((a,b) => new Date(a.date) - new Date(b.date));
}
// ---- rendering ----
function renderMeetups(){
const el = $('#meetups');
const sorted = [...events]
.filter(ev => new Date(ev.date) <= now)
.sort((a,b) => new Date(b.date) - new Date(a.date));
if(!sorted.length){ el.innerHTML = '<div class="empty-note">No meetups logged yet.</div>'; return; }
el.innerHTML = sorted.map(ev => {
const whoSpans = (ev.participants||[]).map(p =>
`<span data-person-uid="${participantUid(p)}">${participantName(p)}</span>`).join(', ');
return `<div class="meetup clickable" data-event-uid="${ev.uid}">
<div class="what">${ev.summary}</div>
<div class="when">${fmtDate(ev.date)}</div>
${whoSpans ? `<div class="who">${whoSpans}</div>` : ''}
</div>`;
}).join('');
}
function renderHeatmap(){
const el = $('#heatmap');
const monthsEl = $('#heatmap-months');
const year = now.getFullYear();
const start = new Date(year,0,1);
const counts = {};
events.forEach(ev => {
const d = new Date(ev.date);
if(d.getFullYear() === year){
const key = d.toISOString().slice(0,10);
counts[key] = (counts[key]||0) + 1;
}
});
const firstDow = (start.getDay() + 6) % 7;
const dayEntries = [];
const endOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
for(let d = new Date(start); d.getFullYear() === year && d <= endOfToday; d.setDate(d.getDate()+1)){
dayEntries.push(new Date(d));
}
const numWeeks = Math.ceil((firstDow + dayEntries.length) / 7);
const isMobile = window.matchMedia('(max-width:980px)').matches;
const colTemplate = isMobile ? `repeat(${numWeeks}, 12px)` : `repeat(${numWeeks}, 1fr)`;
const gridWidth = isMobile ? 'max-content' : '100%';
el.style.gridTemplateColumns = colTemplate;
el.style.width = gridWidth;
monthsEl.style.gridTemplateColumns = colTemplate;
monthsEl.style.width = gridWidth;
// month initials, placed above the week-column where each month's 1st falls
const monthNames = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
const monthCols = {};
dayEntries.forEach((d, i) => {
if(d.getDate() === 1){
const slot = firstDow + i;
const col = Math.floor(slot / 7) + 1;
monthCols[col] = monthNames[d.getMonth()];
}
});
if(dayEntries.length && !(1 in monthCols)){
monthCols[1] = monthNames[dayEntries[0].getMonth()];
}
let monthsHtml = '';
for(let c = 1; c <= numWeeks; c++){
monthsHtml += `<span style="grid-column:${c}">${monthCols[c] || ''}</span>`;
}
monthsEl.innerHTML = monthsHtml;
el.innerHTML = dayEntries.map((d, i) => {
const slot = firstDow + i;
const col = Math.floor(slot / 7) + 1;
const row = (slot % 7) + 1;
const key = d.toISOString().slice(0,10);
const c = counts[key] || 0;
const bg = c > 0 ? 'var(--fresh)' : 'var(--border)';
return `<div class="cell clickable" data-date-key="${key}" style="grid-column:${col};grid-row:${row};background:${bg}" title="${key}: ${c} event(s)"></div>`;
}).join('');
if(isMobile){
const wrap = el.parentElement;
wrap.scrollLeft = wrap.scrollWidth;
}
}
function meetupCounts(){
const counts = {};
events.forEach(ev => {
(ev.participants||[]).forEach(p => {
const uid = participantUid(p);
counts[uid] = (counts[uid]||0) + 1;
});
});
return counts;
}
function renderTopContacts(){
const el = $('#topcontacts');
const counts = meetupCounts();
const ranked = Object.entries(counts)
.map(([uid,c]) => ({ uid, count:c, name: participantName(uid) }))
.sort((a,b) => b.count - a.count);
if(!ranked.length){ el.innerHTML = '<div class="empty-note">No participants recorded on any event yet.</div>'; return; }
const max = ranked[0].count;
el.innerHTML = ranked.map((r,i) => `
<div class="rank-item clickable" data-person-uid="${r.uid}">
<div class="rank-num">${i+1}</div>
<div class="rank-name">${r.name}</div>
<div class="rank-bar-track"><div class="rank-bar-fill" style="width:${(r.count/max*100).toFixed(0)}%"></div></div>
<div class="rank-count">${r.count}×</div>
</div>`).join('');
}
function renderStats(){
const monthEvents = events.filter(ev => {
const d = new Date(ev.date);
return d.getMonth() === now.getMonth() && d.getFullYear() === now.getFullYear() && d <= now;
});
$('#events-month').textContent = monthEvents.length;
const cutoff = new Date(now); cutoff.setDate(cutoff.getDate()-90);
const recent = events.filter(ev => new Date(ev.date) >= cutoff && new Date(ev.date) <= now);
const uniqueContacts = new Set();
recent.forEach(ev => (ev.participants||[]).forEach(p => uniqueContacts.add(participantUid(p))));
const score = stats.total ?? 0;;
$('#social-score').textContent = score.toFixed(2);
}
function renderDecay(){
const el = $('#decay');
const lastSeenMap = {};
events.forEach(ev => {
(ev.participants||[]).forEach(p => {
const uid = participantUid(p);
const d = new Date(ev.date);
if(!lastSeenMap[uid] || d > lastSeenMap[uid]) lastSeenMap[uid] = d;
});
});
people.forEach(p => {
if(p.last_seen){
const d = new Date(p.last_seen);
if(!lastSeenMap[p.uid] || d > lastSeenMap[p.uid]) lastSeenMap[p.uid] = d;
}
});
const rows = Object.entries(lastSeenMap)
.map(([uid,d]) => ({ uid, name: participantName(uid), days: daysBetween(now, d) }))
.sort((a,b) => b.days - a.days);
if(!rows.length){ el.innerHTML = '<div class="empty-note">No contact history yet.</div>'; return; }
let overdue = 0;
el.innerHTML = rows.map(r => {
let cls = 'fresh';
if(r.days > 63){ cls = 'critical'; overdue++; }
else if(r.days > 21){ cls = 'warn'; }
const width = Math.max(6, 100 - Math.min(r.days, 100));
const label = r.days <= 0 ? 'today' : r.days === 1 ? '1d' : `${r.days}d`;
return `<div class="decay-row clickable" data-person-uid="${r.uid}">
<div class="decay-top">
<div class="decay-name"><span class="flag ${cls}"></span>${r.name}</div>
<div class="decay-days ${cls}">${label}</div>
</div>
<div class="decay-track"><div class="decay-fill ${cls}" style="width:${width}%"></div></div>
</div>`;
}).join('');
const badgeHolder = $('#warn-badge-holder');
badgeHolder.innerHTML = overdue ? `<span class="warn-badge">${overdue}</span>` : '';
}
// ---- avatar photo ----
const photoCache = new Map();
async function loadAvatar(uid){
if(photoCache.has(uid)) return photoCache.get(uid);
try{
const res = await fetch(`/api/people/${uid}/photo`);
if(!res.ok) throw new Error(`${res.status} ${res.statusText}`);
const { format, photo } = await res.json();
const dataUri = `data:image/${format};base64,${photo}`;
photoCache.set(uid, dataUri);
return dataUri;
} catch(err){
console.error('avatar load failed for', uid, err);
return null;
}
}
function avatarHtml(person){
if(!person || !person.photo){
return ``;
}
return `<div class="modal-avatar" id="modal-avatar"></div>`;
}
// ---- modal ----
const overlay = $('#modal-overlay');
const modalBody = $('#modal-body');
function openModal(html){
modalBody.innerHTML = html;
overlay.classList.add('open');
}
function closeModal(){
overlay.classList.remove('open');
}
$('#modal-close').addEventListener('click', closeModal);
overlay.addEventListener('click', e => { if(e.target === overlay) closeModal(); });
document.addEventListener('keydown', e => { if(e.key === 'Escape') closeModal(); });
function fmtBirthday(b){
if(!b) return null;
const d = new Date(b);
if(isNaN(d)) return b; // fallback if not a parseable date
const mm = String(d.getMonth()+1).padStart(2,'0');
const dd = String(d.getDate()).padStart(2,'0');
if(d.getFullYear() === 1604){
return `${mm}.${dd}`;
}
return `${mm}.${dd}.${d.getFullYear()}`;
}
function openPersonModal(uid){
const person = findPerson(uid);
const name = person ? toTitleCase(person.name) : uid;
const meetups = eventsForPerson(uid);
const lastSeenDate = meetups.length ? new Date(meetups[0].date) : (person && person.last_seen ? new Date(person.last_seen) : null);
const daysSince = lastSeenDate ? daysBetween(now, lastSeenDate) : null;
const rows = [
['Birthday', person && person.birthday ? fmtBirthday(person.birthday) : '—'],
['Last seen', daysSince !== null ? `${daysSince}d ago` : '—'],
['Meetups together', meetups.length],
['Numbers', person.numbers.join(",\n") ?? "no number"]
];
const meetupChips = meetups.length
? `<div class="modal-link-list">${meetups.map(ev =>
`<button class="modal-chip" data-event-uid="${ev.uid}"><i class="fa-solid fa-calendar-days"></i> ${ev.summary} · ${fmtDate(ev.date)}</button>`).join('')}</div>`
: `<div class="modal-empty">No shared meetups yet.</div>`;
openModal(`
<div class="modal-kicker">Person</div>
${avatarHtml(person)}
<div class="modal-title"><i class="fa-solid fa-circle-user"></i> ${name}</div>
${rows.map(([k,v]) => `<div class="modal-row"><span class="k">${k}</span><span class="v">${v}</span></div>`).join('')}
<div class="modal-section">
<h3>Meetups</h3>
${meetupChips}
</div>
`);
if(person && person.photo){
console.log("loading picture")
loadAvatar(uid).then(src => {
const el = $('#modal-avatar');
if(el) el.style.backgroundImage = `url(${src})`;
});
}
}
function openScoreModal(){
const rows = [
['Total', stats.total],
['Event frequency', stats.frequency],
['Diversity', stats.diversity],
];
openModal(`
<div class="modal-kicker">Social Score</div>
<div class="modal-title"><i class="fa-solid fa-arrow-trend-up"></i> Score breakdown</div>
${rows.map(([k,v]) => `<div class="modal-row"><span class="k">${k}</span><span class="v">${(v ?? 0).toFixed(2)}</span></div>`).join('')}
`);
}
function openEventModal(uid){
const ev = findEvent(uid);
if(!ev){ openModal(`<div class="modal-title">Not found</div>`); return; }
const participants = ev.participants || [];
const chips = participants.length
? `<div class="modal-link-list">${participants.map(p =>
`<button class="modal-chip" data-person-uid="${participantUid(p)}"><i class="fa-solid fa-user"></i> ${participantName(p)}</button>`).join('')}</div>`
: `<div class="modal-empty">No participants recorded.</div>`;
openModal(`
<div class="modal-kicker">Event</div>
<div class="modal-title"><i class="fa-solid fa-calendar-days"></i> ${ev.summary}</div>
<div class="modal-row"><span class="k">Date</span><span class="v">${fmtDate(ev.date)}</span></div>
<div class="modal-row"><span class="k">Attendees</span><span class="v">${participants.length}</span></div>
<div class="modal-section">
<h3>Who was there</h3>
${chips}
</div>
`);
}
function openDayModal(key){
const dayEvents = eventsForDateKey(key);
const chips = dayEvents.length
? `<div class="modal-link-list">${dayEvents.map(ev =>
`<button class="modal-chip" data-event-uid="${ev.uid}"><i class="fa-solid fa-calendar-days"></i> ${ev.summary}</button>`).join('')}</div>`
: `<div class="modal-empty">Nothing logged this day.</div>`;
openModal(`
<div class="modal-kicker">Day</div>
<div class="modal-title"><i class="fa-solid fa-map-pin"></i> ${fmtDateLong(key + 'T00:00:00')}</div>
<div class="modal-row"><span class="k">Events</span><span class="v">${dayEvents.length}</span></div>
<div class="modal-section">
<h3>What happened</h3>
${chips}
</div>
`);
}
function openParseErrorsModal(){
const rows = parseErrors.map(m => {
const ev = findEvent(m.event_uid);
const evLabel = ev ? `${ev.summary} · ${fmtDate(ev.date)}` : m.event_uid;
return `<div class="modal-row">
<span class="k">"${m.name}"</span>
<span class="v"><button class="modal-chip" data-event-uid="${m.event_uid}">${evLabel}</button></span>
</div>`;
}).join('');
openModal(`
<div class="modal-kicker">Unresolved participants</div>
<div class="modal-title">Couldn't match ${parseErrors.length} name${parseErrors.length === 1 ? '' : 's'}</div>
${rows || '<div class="modal-empty">Nothing to fix.</div>'}
`);
}
$('#parse-fab').addEventListener('click', openParseErrorsModal);
$('#score-card').addEventListener('click', openScoreModal);
// event delegation for the whole page + modal content
document.addEventListener('click', e => {
const personEl = e.target.closest('[data-person-uid]');
const eventEl = e.target.closest('[data-event-uid]');
const dayEl = e.target.closest('[data-date-key]');
if(personEl){
openPersonModal(personEl.dataset.personUid);
return;
}
if(eventEl){
openEventModal(eventEl.dataset.eventUid);
return;
}
if(dayEl){
openDayModal(dayEl.dataset.dateKey);
}
});
loadData();
window.addEventListener('resize', () => { if(events.length || people.length) renderHeatmap(); });
+221
View File
@@ -0,0 +1,221 @@
:root{
--bg:#181c17;
--panel:#212820;
--panel-2:#262e25;
--border:#37402e;
--text:#e9ede2;
--dim:#a2ab95;
--dimmer:#67705e;
--primary:#a3c98a;
--primary-bg:#2a3524;
--fresh:#a3c98a;
--fresh-bg:#2c3a26;
--warn:#d9a35c;
--warn-bg:#3a2f1c;
--critical:#cf7a6f;
--critical-bg:#3a2620;
}
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0;
background:var(--bg);
color:var(--text);
font-family:'IBM Plex Sans', sans-serif;
display:flex;
flex-direction:column;
height:100vh;
overflow:hidden;
padding:1.1rem 1.4rem;
}
.wrap{display:flex;flex-direction:column;height:100%;min-height:0}
header{display:flex;align-items:flex-end;justify-content:space-between;flex:0 0 auto;margin-bottom:1rem}
.logo{font-family:'Baloo 2',sans-serif;font-size:1.4rem;font-weight:700;letter-spacing:-.01em;color:var(--text)}
.logo .accent{color:var(--primary)}
.tagline{font-size:.75rem;color:var(--dim);margin-top:.1rem}
.today{font-size:.72rem;color:var(--dim);text-align:right;font-family:'JetBrains Mono',monospace}
main.grid{
flex:1;
min-height:0;
display:grid;
grid-template-columns:250px 1fr 280px;
gap:1rem;
}
.col{display:flex;flex-direction:column;min-height:0;gap:.9rem}
.card{
background:var(--panel);
border:1px solid var(--border);
border-radius:16px;
padding:.9rem 1.1rem;
min-height:0;
box-shadow:0 2px 10px rgba(0,0,0,.25);
}
.card.grow{flex:1;display:flex;flex-direction:column}
.card.fixed{flex:0 0 auto}
.card h2{
margin:0 0 .7rem;
font-family:'Baloo 2',sans-serif;
font-size:.85rem;
font-weight:600;
color:var(--text);
display:flex;
align-items:center;
justify-content:space-between;
}
.scroll{overflow-y:auto;overflow-x:hidden;flex:1;min-height:0;scrollbar-width:none;-ms-overflow-style:none}
.scroll::-webkit-scrollbar{display:none}
.clickable{cursor:pointer;border-radius:8px;transition:background .12s ease}
.clickable:hover{background:var(--panel-2)}
/* meetups */
.meetup{padding:.55rem .5rem;margin:0 -0.5rem;border-bottom:1px solid var(--border)}
.meetup:last-child{border-bottom:none}
.meetup .what{font-size:.85rem;color:var(--text);font-weight:500}
.meetup .when{font-family:'JetBrains Mono',monospace;font-size:.68rem;color:var(--primary);margin-top:.2rem}
.meetup .who{font-size:.7rem;color:var(--dim);margin-top:.15rem}
.meetup .who span{cursor:pointer;text-decoration:underline;text-decoration-color:var(--border)}
.meetup .who span:hover{color:var(--primary)}
.empty-note{color:var(--dimmer);font-size:.78rem;font-style:italic}
/* stat row */
.stat-row{display:flex;gap:.7rem;flex:0 0 auto}
.stat-row .card{flex:1;text-align:center;padding:.8rem}
.stat-num{font-family:'Baloo 2',sans-serif;font-size:1.9rem;font-weight:700;line-height:1;color:var(--primary)}
.stat-sub{font-size:.65rem;color:var(--dim);margin-top:.3rem}
/* heatmap */
.heatmap-wrap{overflow-x:auto;overflow-y:hidden;scrollbar-width:none;-ms-overflow-style:none}
.heatmap-wrap::-webkit-scrollbar{display:none}
.heatmap-months{display:grid;gap:2px;margin-bottom:4px;font-family:'JetBrains Mono',monospace;font-size:.6rem;color:var(--dim)}
.heatmap-months span{white-space:nowrap}
.heatmap{display:grid;grid-template-rows:repeat(7,1fr);gap:2px;width:100%}
/* made a tad shorter: cells are wider than tall instead of square */
.heatmap .cell{width:70%;aspect-ratio:1.0;border-radius:3px;background:var(--border)}
.heatmap .cell.clickable{cursor:pointer}
.heatmap .cell.clickable:hover{outline:1.5px solid var(--primary);outline-offset:1px}
.heat-legend{display:flex;align-items:center;gap:.3rem;justify-content:flex-end;margin-top:.5rem;font-size:.62rem;color:var(--dim)}
.heat-legend .cell{width:8px;height:8px;border-radius:3px}
/* top contacts */
.rank-item{display:flex;align-items:center;gap:.55rem;padding:.34rem .5rem;margin:0 -0.5rem}
.rank-num{font-family:'JetBrains Mono',monospace;font-size:.68rem;color:var(--dimmer);width:1rem}
.rank-bar-track{flex:1;height:6px;border-radius:4px;background:var(--border);overflow:hidden}
.rank-bar-fill{height:100%;background:var(--primary);border-radius:4px}
.rank-name{font-size:.78rem;width:7.5rem;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.rank-count{font-family:'JetBrains Mono',monospace;font-size:.66rem;color:var(--dim);width:2rem;text-align:right}
/* empty bottom panels */
.empty-panel{flex:1;display:flex;align-items:center;justify-content:center;border:1.5px dashed var(--border);border-radius:12px;color:var(--dimmer);font-size:.68rem;min-height:56px}
/* catch-up list */
.decay-row{padding:.5rem .5rem;margin:0 -0.5rem;border-bottom:1px solid var(--border)}
.decay-row:last-child{border-bottom:none}
.decay-top{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:.28rem}
.decay-name{font-size:.8rem;color:var(--text);font-weight:500;display:flex;align-items:center;gap:.4rem}
.decay-name .flag{width:7px;height:7px;border-radius:50%;flex-shrink:0}
.decay-name .flag.fresh{background:var(--fresh)}
.decay-name .flag.warn{background:var(--warn)}
.decay-name .flag.critical{background:var(--critical)}
.decay-days{font-family:'JetBrains Mono',monospace;font-size:.68rem;font-weight:600}
.decay-days.fresh{color:var(--fresh)}
.decay-days.warn{color:var(--warn)}
.decay-days.critical{color:var(--critical)}
.decay-track{height:5px;border-radius:4px;background:var(--border);overflow:hidden}
.decay-fill{height:100%;border-radius:4px}
.decay-fill.fresh{background:var(--fresh)}
.decay-fill.warn{background:var(--warn)}
.decay-fill.critical{background:var(--critical)}
.warn-badge{display:inline-flex;align-items:center;justify-content:center;background:var(--critical);color:#241615;
font-family:'JetBrains Mono',monospace;font-size:.6rem;font-weight:700;border-radius:999px;width:1.15rem;height:1.15rem}
.loading{color:var(--dimmer);font-size:.75rem}
/* parse-error fab */
.parse-fab{
position:fixed;right:1.4rem;bottom:1.4rem;
width:52px;height:52px;
border-radius:16px;
background:var(--panel);
border:1px solid var(--border);
color:var(--warn);
font-size:1.3rem;
display:flex;align-items:center;justify-content:center;
cursor:pointer;
box-shadow:0 6px 18px rgba(0,0,0,.4);
z-index:40;
transition:transform .12s ease, background .12s ease;
}
.parse-fab:hover{transform:translateY(-2px);background:var(--panel-2)}
.parse-fab-badge{
position:absolute;top:-6px;right:-6px;
min-width:20px;height:20px;padding:0 4px;
border-radius:999px;
background:var(--critical);
color:#241615;
font-family:'JetBrains Mono',monospace;
font-size:.68rem;font-weight:700;
display:flex;align-items:center;justify-content:center;
border:2px solid var(--bg);
}
/* modal */
.modal-overlay{
position:fixed;inset:0;background:rgba(0,0,0,.55);
display:none;align-items:center;justify-content:center;z-index:50;padding:1rem;
}
.modal-overlay.open{display:flex}
.modal{
background:var(--panel);border-radius:18px;border:1px solid var(--border);
max-width:380px;width:100%;max-height:80vh;overflow-y:auto;
padding:1.4rem 1.5rem;box-shadow:0 16px 40px rgba(0,0,0,.5);
position:relative;
}
.modal-close{
position:absolute;top:.9rem;right:1rem;background:none;border:none;
font-size:1.1rem;color:var(--dim);cursor:pointer;line-height:1;padding:.2rem;
}
.modal-close:hover{color:var(--text)}
.modal-kicker{font-size:.65rem;text-transform:uppercase;letter-spacing:.08em;color:var(--dim);margin-bottom:.3rem}
.modal-title{font-family:'Baloo 2',sans-serif;font-size:1.25rem;font-weight:700;margin-bottom:.8rem;padding-right:1.5rem}
.modal-row{display:flex;justify-content:space-between;gap:1rem;padding:.5rem 0;border-bottom:1px solid var(--border);font-size:.82rem}
.modal-row:last-child{border-bottom:none}
.modal-row .k{color:var(--dim)}
.modal-row .v{text-align:right;font-family:'JetBrains Mono',monospace;font-size:.78rem}
.modal-section{margin-top:1rem}
.modal-section h3{font-size:.68rem;text-transform:uppercase;letter-spacing:.07em;color:var(--dim);margin:0 0 .4rem}
.modal-link-list{display:flex;flex-wrap:wrap;gap:.4rem}
.modal-chip{
background:var(--primary-bg);color:var(--primary);border-radius:999px;padding:.25rem .65rem;
font-size:.72rem;cursor:pointer;border:none;
}
.modal-chip:hover{filter:brightness(1.2)}
.modal-empty{color:var(--dimmer);font-size:.78rem;font-style:italic}
.modal-avatar {
width: 90px;
height: 90px;
border-radius: 50%;
margin: 4px auto 12px;
background-size: 120%;
background-position: center;
background-color: var(--border);
}
.modal-avatar.placeholder {
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
color: var(--border);
}
@media (max-width:980px){
body{height:auto;overflow-y:auto;overflow-x:hidden;padding:1rem}
main.grid{grid-template-columns:1fr;display:flex;flex-direction:column}
.card.grow{flex:none}
.scroll{max-height:220px}
.stat-row{flex-direction:row}
}
-1
View File
@@ -1 +0,0 @@
from .module import TestModule