started porting socialwatch into an module. Copied code over and wrote main module file for now
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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.")
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
@@ -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>
|
||||
@@ -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(); });
|
||||
@@ -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 +0,0 @@
|
||||
from .module import TestModule
|
||||
|
||||
Reference in New Issue
Block a user