diff --git a/web/core/views.py b/web/core/views.py index 3c4748f..0b1b58e 100644 --- a/web/core/views.py +++ b/web/core/views.py @@ -663,4 +663,150 @@ def api_get_recent_views(request): @login_required def settings_home(request): - return render(request, "settings/home.html") \ No newline at end of file + return render(request, "settings/home.html") + +@login_required +def stats_page(request): + from collections import Counter, OrderedDict + from calendar import month_abbr + + total = Entry.objects.count() + today = date.today() + last30 = Entry.objects.filter(date_added__gte=today - timedelta(days=30)).count() + last365 = Entry.objects.filter(date_added__gte=today - timedelta(days=365)).count() + + # ---- Sparkline (last 12 months) ---- + months = [] + y = today.year + m = today.month + for i in range(12): + mm = m - i + yy = y + while mm <= 0: + mm += 12 + yy -= 1 + from datetime import date as _d + start = _d(yy, mm, 1) + end = _d(yy + 1, 1, 1) if mm == 12 else _d(yy, mm + 1, 1) + label = f"{month_abbr[mm]} {str(yy)[2:]}" + months.append((label, start, end)) + months = list(reversed(months)) + + series = [ + (label, Entry.objects.filter(date_added__gte=start, date_added__lt=end).count()) + for label, start, end in months + ] + peak = max((v for _, v in series), default=1) + heights = [ + (label, value, 8 + int((value / peak) * 100) if peak else 8) + for label, value in series + ] + + # ---- Top subjects (existing) ---- + subject_counts = Counter() + for subj in Entry.objects.exclude(subject="").values_list("subject", flat=True): + for tag in [t.strip() for t in subj.split(",") if t.strip()]: + subject_counts[tag.lower()] += 1 + top_subjects = [{"name": n.title(), "count": c} for n, c in subject_counts.most_common(10)] + + # =============================== + # Scripture analytics (from scripture_raw) + # =============================== + # A light normalizer so common abbreviations map to canonical book names. + BOOK_MAP = { + # OT (examples; extend as needed) + "gen":"Genesis","ge":"Genesis","gn":"Genesis", + "ex":"Exodus","exo":"Exodus", + "lev":"Leviticus","le":"Leviticus", + "num":"Numbers","nu":"Numbers", + "de":"Deuteronomy","deut":"Deuteronomy", + "jos":"Joshua","josh":"Joshua", + "jdg":"Judges","judg":"Judges", + "ru":"Ruth","rut":"Ruth", + "ps":"Psalms","psalm":"Psalms","psalms":"Psalms", + "pr":"Proverbs","pro":"Proverbs", + "ec":"Ecclesiastes","ecc":"Ecclesiastes", + "isa":"Isaiah","is":"Isaiah", + "jer":"Jeremiah","je":"Jeremiah", + "eze":"Ezekiel","ez":"Ezekiel", + "da":"Daniel","dan":"Daniel", + "ho":"Hosea","hos":"Hosea", + # NT (examples; extend as needed) + "mt":"Matthew","matt":"Matthew", + "mr":"Mark","mk":"Mark", + "lu":"Luke","lk":"Luke", + "joh":"John","john":"John","jn":"John", + "ac":"Acts","acts":"Acts", + "rom":"Romans","ro":"Romans", + "1cor":"1 Corinthians","1 co":"1 Corinthians","1 cor":"1 Corinthians", + "2cor":"2 Corinthians","2 co":"2 Corinthians","2 cor":"2 Corinthians", + } + + BOOK_RE = re.compile(r""" + ^\s* + (?P(?:[1-3]\s*)?[A-Za-z\.]+) # optional 1/2/3 prefix + word + [\s\.]+ + (?P\d+[:\.]\d+.*)? # 3:16 or 3.16 etc (optional tail) + """, re.X) + + def normalize_book(raw): + b = raw.strip().lower().replace('.', '') + b = re.sub(r'\s+', '', b) # "1 john" -> "1john" + return BOOK_MAP.get(b, raw.strip().title()) + + def split_refs(text): + if not text: + return [] + # Entries are typically separated by semicolons; allow commas too. + parts = re.split(r'[;]+', text) + return [p.strip() for p in parts if p.strip()] + + def parse_piece(piece): + m = BOOK_RE.match(piece) + if not m: + return None, None + book = normalize_book(m.group('book')) + ref = (m.group('ref') or '').strip() + return book, (f"{book} {ref}" if ref else book) + + book_counts = Counter() + ref_counts = Counter() + refs_per_entry = [] + + entries_with_script = (Entry.objects + .exclude(scripture_raw__isnull=True) + .exclude(scripture_raw__exact="")) + for e in entries_with_script.iterator(): + pieces = split_refs(e.scripture_raw) + entry_ref_count = 0 + for piece in pieces: + book, full = parse_piece(piece) + if not book: + continue + book_counts[book] += 1 + if full and full != book: + ref_counts[full] += 1 + entry_ref_count += 1 + if entry_ref_count: + refs_per_entry.append(entry_ref_count) + + avg_refs_per_entry = round(sum(refs_per_entry) / len(refs_per_entry), 2) if refs_per_entry else 0 + top_books = list(book_counts.most_common(10)) + top_refs = list(ref_counts.most_common(10)) + + return render( + request, + "stats.html", + { + "total": total, + "last30": last30, + "last365": last365, + "series": series, + "heights": heights, + "top_subjects": top_subjects, + # NEW: + "avg_refs_per_entry": avg_refs_per_entry, + "top_books": top_books, # iterable of (book, count) + "top_refs": top_refs, # iterable of (ref, count) + }, + ) \ No newline at end of file