3v324v23 commited on
Commit
fa2b4bc
·
1 Parent(s): b6579f5

feat(activity): implement scoped activity monitoring and fallback mechanism

Browse files
app/auth.py CHANGED
@@ -69,6 +69,7 @@ import urllib.error
69
  import urllib.parse
70
  import urllib.request
71
  from datetime import datetime, timezone
 
72
 
73
  log = logging.getLogger("ihki.auth")
74
  TIMEOUT = 10
@@ -891,6 +892,49 @@ class SupabaseAuth:
891
  raise
892
  return {"buckets": buckets, "summary": summary}
893
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
894
  def log_event(self, kind: str, user: dict | None, label: str = "", details: dict | None = None) -> None:
895
  """Best-effort append to the management activity feed; a missing audit table never blocks the action."""
896
  try:
 
69
  import urllib.parse
70
  import urllib.request
71
  from datetime import datetime, timezone
72
+ from zoneinfo import ZoneInfo
73
 
74
  log = logging.getLogger("ihki.auth")
75
  TIMEOUT = 10
 
892
  raise
893
  return {"buckets": buckets, "summary": summary}
894
 
895
+ def scoped_activity(self, user_ids: set[str], since: datetime, bucket_minutes: int = 15) -> dict:
896
+ """Aggregated monitoring data for one admin's students, calculated in Postgres without downloading attempts."""
897
+ if not user_ids:
898
+ return {"buckets": [], "summary": {"attempts": 0, "active_students": 0,
899
+ "returning_students": 0, "active_days": 0}}
900
+ body = {"p_user_ids": sorted(user_ids), "p_since": since.isoformat()}
901
+ try:
902
+ buckets = self._admin("/rest/v1/rpc/activity_buckets_scoped", "POST",
903
+ {**body, "p_bucket_minutes": bucket_minutes})
904
+ rows = self._admin("/rest/v1/rpc/activity_summary_scoped", "POST", body) or []
905
+ return {"buckets": buckets or [], "summary": rows[0] if rows else None}
906
+ except AuthUnavailable as e:
907
+ if "answered 404" not in str(e):
908
+ raise
909
+ log.warning("scoped activity RPCs are missing; using the paged timestamp fallback")
910
+ return self._scoped_activity_fallback(user_ids, since, bucket_minutes)
911
+
912
+ def _scoped_activity_fallback(self, user_ids: set[str], since: datetime, bucket_minutes: int) -> dict:
913
+ """Accurate compatibility path until the scoped aggregate RPC migration is installed."""
914
+ rows: list[dict] = []
915
+ for offset in range(0, MAX_CLASS_ATTEMPTS, PAGE):
916
+ query = urllib.parse.urlencode({"select": "user_id,created_at", "user_id": f"in.({','.join(sorted(user_ids))})",
917
+ "created_at": f"gte.{since.isoformat()}", "order": "created_at.desc",
918
+ "limit": PAGE, "offset": offset})
919
+ batch = self._admin(f"/rest/v1/attempts?{query}") or []
920
+ rows += batch
921
+ if len(batch) < PAGE:
922
+ break
923
+ seconds, buckets, per_user = max(1, min(bucket_minutes, 60)) * 60, {}, {}
924
+ local = ZoneInfo("Asia/Jerusalem")
925
+ for row in rows:
926
+ stamp = datetime.strptime(row["created_at"][:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc)
927
+ slot = int(stamp.timestamp()) // seconds * seconds
928
+ item = buckets.setdefault(slot, {"users": set(), "attempts": 0})
929
+ item["attempts"] += 1; item["users"].add(row["user_id"])
930
+ per_user.setdefault(row["user_id"], set()).add(stamp.astimezone(local).date())
931
+ return {"buckets": [{"bucket": datetime.fromtimestamp(slot, timezone.utc).isoformat(),
932
+ "attempts": item["attempts"], "active_students": len(item["users"])}
933
+ for slot, item in sorted(buckets.items())],
934
+ "summary": {"attempts": len(rows), "active_students": len(per_user),
935
+ "returning_students": sum(len(days) >= 2 for days in per_user.values()),
936
+ "active_days": len({day for days in per_user.values() for day in days})}}
937
+
938
  def log_event(self, kind: str, user: dict | None, label: str = "", details: dict | None = None) -> None:
939
  """Best-effort append to the management activity feed; a missing audit table never blocks the action."""
940
  try:
app/main.py CHANGED
@@ -914,13 +914,16 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
914
  """Anonymous, aggregated practice activity for the teacher's monitoring graph."""
915
  try:
916
  scoped = bool(actor and "developer" not in actor.get("roles", ()) and hasattr(a, "access_for"))
917
- if scoped or window_minutes is not None:
918
  ids = None
919
- if scoped:
920
- classes, users = managed_classes(a, actor, fresh=True), a.users()
921
- emails = {email for c in classes for email in c.get("emails", ())}
922
- ids = {u["id"] for u in users if u["email"].lower() in emails}
923
  start = datetime.now(timezone.utc) - (timedelta(minutes=window_minutes) if window_minutes else timedelta(hours=hours))
 
 
 
 
924
  rows = [r for r in a.all_attempts(limit=MAX_MONITOR_ATTEMPTS)
925
  if (ids is None or r.get("user_id") in ids) and utc(r["created_at"]) >= start]
926
  seconds, buckets = bucket_minutes * 60, {}
 
914
  """Anonymous, aggregated practice activity for the teacher's monitoring graph."""
915
  try:
916
  scoped = bool(actor and "developer" not in actor.get("roles", ()) and hasattr(a, "access_for"))
917
+ if scoped:
918
  ids = None
919
+ classes, users = managed_classes(a, actor, fresh=True), a.users()
920
+ emails = {email for c in classes for email in c.get("emails", ())}
921
+ ids = {u["id"] for u in users if u["email"].lower() in emails}
 
922
  start = datetime.now(timezone.utc) - (timedelta(minutes=window_minutes) if window_minutes else timedelta(hours=hours))
923
+ data = a.scoped_activity(ids, start, bucket_minutes)
924
+ elif window_minutes is not None:
925
+ ids = None
926
+ start = datetime.now(timezone.utc) - timedelta(minutes=window_minutes)
927
  rows = [r for r in a.all_attempts(limit=MAX_MONITOR_ATTEMPTS)
928
  if (ids is None or r.get("user_id") in ids) and utc(r["created_at"]) >= start]
929
  seconds, buckets = bucket_minutes * 60, {}
docs/mathly-supabase.sql CHANGED
@@ -58,6 +58,40 @@ $$;
58
  revoke execute on function public.activity_summary(integer) from public, anon, authenticated;
59
  grant execute on function public.activity_summary(integer) to service_role;
60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  -- Recent system activity shown only in the management panel. The backend writes with the service-role key;
62
  -- no browser role gets direct table access.
63
  create table public.system_events (
 
58
  revoke execute on function public.activity_summary(integer) from public, anon, authenticated;
59
  grant execute on function public.activity_summary(integer) to service_role;
60
 
61
+ -- Subject-scoped monitoring aggregates. These keep busy classes accurate without downloading individual attempts.
62
+ create or replace function public.activity_buckets_scoped(
63
+ p_user_ids uuid[], p_since timestamptz, p_bucket_minutes integer default 15
64
+ )
65
+ returns table (bucket timestamptz, attempts bigint, active_students bigint)
66
+ language sql stable security invoker set search_path = '' as $$
67
+ select date_bin(make_interval(mins => greatest(1, least(p_bucket_minutes, 60))), a.created_at,
68
+ timestamptz '2000-01-01 00:00:00+00'),
69
+ count(*)::bigint, count(distinct a.user_id)::bigint
70
+ from public.attempts a
71
+ where a.created_at >= p_since and a.user_id = any(p_user_ids)
72
+ group by 1 order by 1
73
+ $$;
74
+ revoke execute on function public.activity_buckets_scoped(uuid[], timestamptz, integer) from public, anon, authenticated;
75
+ grant execute on function public.activity_buckets_scoped(uuid[], timestamptz, integer) to service_role;
76
+
77
+ create or replace function public.activity_summary_scoped(p_user_ids uuid[], p_since timestamptz)
78
+ returns table (attempts bigint, active_students bigint, returning_students bigint, active_days bigint)
79
+ language sql stable security invoker set search_path = '' as $$
80
+ with windowed as (
81
+ select a.user_id, (a.created_at at time zone 'Asia/Jerusalem')::date as practice_day
82
+ from public.attempts a
83
+ where a.created_at >= p_since and a.user_id = any(p_user_ids)
84
+ ), per_user as (
85
+ select user_id, count(distinct practice_day) as days from windowed group by user_id
86
+ )
87
+ select (select count(*) from windowed)::bigint,
88
+ (select count(*) from per_user)::bigint,
89
+ (select count(*) from per_user where days >= 2)::bigint,
90
+ (select count(distinct practice_day) from windowed)::bigint
91
+ $$;
92
+ revoke execute on function public.activity_summary_scoped(uuid[], timestamptz) from public, anon, authenticated;
93
+ grant execute on function public.activity_summary_scoped(uuid[], timestamptz) to service_role;
94
+
95
  -- Recent system activity shown only in the management panel. The backend writes with the service-role key;
96
  -- no browser role gets direct table access.
97
  create table public.system_events (
docs/scoped-activity-migration.sql ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- Accurate, subject-scoped monitoring aggregates. Safe to run more than once.
2
+ create or replace function public.activity_buckets_scoped(
3
+ p_user_ids uuid[], p_since timestamptz, p_bucket_minutes integer default 15
4
+ )
5
+ returns table (bucket timestamptz, attempts bigint, active_students bigint)
6
+ language sql stable security invoker set search_path = '' as $$
7
+ select date_bin(make_interval(mins => greatest(1, least(p_bucket_minutes, 60))), a.created_at,
8
+ timestamptz '2000-01-01 00:00:00+00'),
9
+ count(*)::bigint, count(distinct a.user_id)::bigint
10
+ from public.attempts a
11
+ where a.created_at >= p_since and a.user_id = any(p_user_ids)
12
+ group by 1 order by 1
13
+ $$;
14
+ revoke execute on function public.activity_buckets_scoped(uuid[], timestamptz, integer)
15
+ from public, anon, authenticated;
16
+ grant execute on function public.activity_buckets_scoped(uuid[], timestamptz, integer) to service_role;
17
+
18
+ create or replace function public.activity_summary_scoped(p_user_ids uuid[], p_since timestamptz)
19
+ returns table (attempts bigint, active_students bigint, returning_students bigint, active_days bigint)
20
+ language sql stable security invoker set search_path = '' as $$
21
+ with windowed as (
22
+ select a.user_id, (a.created_at at time zone 'Asia/Jerusalem')::date as practice_day
23
+ from public.attempts a
24
+ where a.created_at >= p_since and a.user_id = any(p_user_ids)
25
+ ), per_user as (
26
+ select user_id, count(distinct practice_day) as days from windowed group by user_id
27
+ )
28
+ select (select count(*) from windowed)::bigint,
29
+ (select count(*) from per_user)::bigint,
30
+ (select count(*) from per_user where days >= 2)::bigint,
31
+ (select count(distinct practice_day) from windowed)::bigint
32
+ $$;
33
+ revoke execute on function public.activity_summary_scoped(uuid[], timestamptz)
34
+ from public, anon, authenticated;
35
+ grant execute on function public.activity_summary_scoped(uuid[], timestamptz) to service_role;
static/index.html CHANGED
@@ -373,7 +373,8 @@ body:not(.home) .reward-shelf,body.manage .reward-shelf,body[data-view] .reward-
373
  .label{position:absolute;top:50%;width:var(--w);transform:translateY(-50%);display:flex;flex-direction:column;gap:2px}
374
  .sat.R .label{left:calc(100% + 10px);text-align:left;align-items:flex-end}
375
  .sat.L .label{right:calc(100% + 10px);text-align:right;align-items:flex-start}
376
- .label small{font:600 12px/1.2 var(--f-body);color:var(--muted)}
 
377
  .way{padding:1px 7px;border-radius:999px;background:var(--olive-soft);color:var(--olive);font:700 11px/1.3 var(--f-body);white-space:nowrap}
378
  .way.read{background:var(--surface);color:var(--ink);box-shadow:inset 0 0 0 1px var(--track)}
379
  .sat.now .label b{font-weight:700}
@@ -747,6 +748,8 @@ body.manage #manage{width:100%;max-width:none}
747
  .access-actions{display:flex;justify-content:flex-end;margin:8px 0}.access-scroll{overflow:auto}.access-table{width:100%;border-collapse:collapse;min-width:1180px}.access-table :is(th,td){padding:9px 10px;border-bottom:1px solid var(--line);text-align:center}.access-table th{position:sticky;top:0;background:var(--surface);font-size:13px;white-space:nowrap;z-index:1}.access-table th>span{display:block;margin-bottom:4px}.access-table .access-all{display:inline-flex;align-items:center;gap:4px;color:var(--muted);font-weight:600;cursor:pointer}.access-table .access-all small{font-size:11px}.access-table :is(th,td):first-child{text-align:start;position:sticky;right:0;background:var(--surface);z-index:2;min-width:260px}.access-table td:first-child{font-weight:700}.access-table td:first-child small{display:block;color:var(--muted);font-weight:400;direction:ltr;overflow:hidden;text-overflow:ellipsis}.access-table input{width:18px;height:18px}.access-table .save-cell{min-width:96px}
748
  @media(max-width:700px){.modes{grid-template-columns:repeat(2,1fr)}}
749
  .flag-review{gap:10px}.flag-review h2{margin:0}.flag-review .meta{color:var(--muted);font-size:13px}.flag-review fieldset{border:0;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:7px}
 
 
750
  .flag-review fieldset label{display:flex;align-items:center;gap:5px;padding:6px 8px;border:1px solid var(--line);border-radius:9px}.flag-review select,.flag-review textarea{font:inherit;border:1px solid var(--line);border-radius:9px;padding:7px;background:var(--surface);color:var(--ink)}
751
  .flag-review textarea{width:100%;resize:vertical}
752
  .scoring-rules summary{cursor:pointer;font-size:17px}.scoring-rules details[open] summary{margin-bottom:8px}
@@ -1236,7 +1239,7 @@ html[data-subject="math"] #bootErr{color:#F08A8A}
1236
  #wrap{opacity:1;transition:opacity .65s ease .1s}
1237
  #wrap[inert]{opacity:0}
1238
  /* Accessibility toolbar: available on the splash screen and throughout every app view. */
1239
- .a11y-launch{position:fixed;top:max(12px,env(safe-area-inset-top));left:12px;z-index:100;display:grid;place-items:center;width:46px;height:46px;padding:0;border:2px solid var(--olive);border-radius:50%;background:var(--surface);color:var(--olive);box-shadow:0 3px 12px rgb(0 0 0/.2);font:700 24px/1 var(--f-body);cursor:pointer}
1240
  .a11y-launch:hover,.a11y-launch:focus-visible{background:var(--olive-soft);outline:3px solid var(--surface);outline-offset:2px}
1241
  .a11y-panel{position:fixed;top:max(66px,calc(env(safe-area-inset-top) + 66px));left:12px;z-index:101;width:min(310px,calc(100vw - 24px));padding:14px;border:1px solid var(--line);border-radius:14px;background:var(--surface);color:var(--ink);box-shadow:0 8px 28px rgb(0 0 0/.28)}
1242
  .a11y-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:10px}.a11y-head strong{font-size:19px}.a11y-close{width:36px;height:36px;border:0;border-radius:50%;background:transparent;color:var(--ink);font:700 18px/1 var(--f-body);cursor:pointer}.a11y-close:hover,.a11y-close:focus-visible{background:var(--olive-soft);outline:2px solid var(--olive)}
@@ -4747,14 +4750,19 @@ function drawFlags(){
4747
  if(!pending.length){ head.append(mark("meta","אין כרגע דיווחים שממתינים לבדיקה.","p")); return; }
4748
  const byUser=new Map; for(const f of pending){ const who=f.email||""; if(!byUser.has(who)) byUser.set(who,[]); byUser.get(who).push(f); }
4749
  head.append(mark("meta",`${pending.length} ${pending.length===1?"דיווח":"דיווחים"} מ־${byUser.size} ${byUser.size===1?"מדווח/ת":"מדווחים"}, מקובצים לפי המדווח/ת.`,"p"));
4750
- for(const [who,flags] of [...byUser].sort((a,b)=>b[1].length-a[1].length||a[0].localeCompare(b[0]))){ // the most reports first
 
 
4751
  const group=document.createElement("details"), summary=document.createElement("summary");
4752
  group.className="flag-group"; group.open=flagGroupOpen[who]===true; group.ontoggle=()=>{ flagGroupOpen[who]=group.open; };
4753
- summary.append(mark("",who||"ללא משתמש","b"),mark("meta",` · ${flags.length} ${flags.length===1?"דיווח":"דיווחים"}`)); group.append(summary); box.append(group);
4754
- for(const f of flags){
4755
- const card=mark("card flag-review",""), title=mark("",f.he||f.phrase,"h2"), result=f.result||{}, issues=flagIssues(f);
 
4756
  const ar=mark("",f.ar||"","p"); ar.dir="rtl";
4757
- card.append(title,ar,mark("meta",`ציון ${f.score} · המודל שמע: ${f.heard_ipa||"–"}`,"p"));
 
 
4758
  if(f.note) card.append(mark("",`הערת המדווח/ת: ${f.note}`,"p"));
4759
  if(f.audio){
4760
  const audioRow=mark("row",""), play=mark("btn","▶ השמעת ההקלטה","button"), audio=document.createElement("audio"), audioMsg=mark("msg","");
 
373
  .label{position:absolute;top:50%;width:var(--w);transform:translateY(-50%);display:flex;flex-direction:column;gap:2px}
374
  .sat.R .label{left:calc(100% + 10px);text-align:left;align-items:flex-end}
375
  .sat.L .label{right:calc(100% + 10px);text-align:right;align-items:flex-start}
376
+ .label b{color:#F7FAEF;text-shadow:0 1px 2px #000,0 0 5px rgb(0 0 0/.8)}
377
+ .label small{font:600 12px/1.2 var(--f-body);color:#D5DDCE;text-shadow:0 1px 2px #000,0 0 5px rgb(0 0 0/.8)}
378
  .way{padding:1px 7px;border-radius:999px;background:var(--olive-soft);color:var(--olive);font:700 11px/1.3 var(--f-body);white-space:nowrap}
379
  .way.read{background:var(--surface);color:var(--ink);box-shadow:inset 0 0 0 1px var(--track)}
380
  .sat.now .label b{font-weight:700}
 
748
  .access-actions{display:flex;justify-content:flex-end;margin:8px 0}.access-scroll{overflow:auto}.access-table{width:100%;border-collapse:collapse;min-width:1180px}.access-table :is(th,td){padding:9px 10px;border-bottom:1px solid var(--line);text-align:center}.access-table th{position:sticky;top:0;background:var(--surface);font-size:13px;white-space:nowrap;z-index:1}.access-table th>span{display:block;margin-bottom:4px}.access-table .access-all{display:inline-flex;align-items:center;gap:4px;color:var(--muted);font-weight:600;cursor:pointer}.access-table .access-all small{font-size:11px}.access-table :is(th,td):first-child{text-align:start;position:sticky;right:0;background:var(--surface);z-index:2;min-width:260px}.access-table td:first-child{font-weight:700}.access-table td:first-child small{display:block;color:var(--muted);font-weight:400;direction:ltr;overflow:hidden;text-overflow:ellipsis}.access-table input{width:18px;height:18px}.access-table .save-cell{min-width:96px}
749
  @media(max-width:700px){.modes{grid-template-columns:repeat(2,1fr)}}
750
  .flag-review{gap:10px}.flag-review h2{margin:0}.flag-review .meta{color:var(--muted);font-size:13px}.flag-review fieldset{border:0;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:7px}
751
+ .flag-review.flag-urgent{border-inline-start:4px solid var(--sumac)}.flag-review.flag-lower{border-inline-start:4px solid var(--line)}
752
+ .flag-priority{display:inline-block;margin-inline-start:6px;padding:2px 7px;border-radius:999px;font-size:12px;font-weight:700}.flag-priority.urgent{background:var(--sumac-bg);color:var(--sumac)}.flag-priority.lower{background:var(--bg);color:var(--muted)}
753
  .flag-review fieldset label{display:flex;align-items:center;gap:5px;padding:6px 8px;border:1px solid var(--line);border-radius:9px}.flag-review select,.flag-review textarea{font:inherit;border:1px solid var(--line);border-radius:9px;padding:7px;background:var(--surface);color:var(--ink)}
754
  .flag-review textarea{width:100%;resize:vertical}
755
  .scoring-rules summary{cursor:pointer;font-size:17px}.scoring-rules details[open] summary{margin-bottom:8px}
 
1239
  #wrap{opacity:1;transition:opacity .65s ease .1s}
1240
  #wrap[inert]{opacity:0}
1241
  /* Accessibility toolbar: available on the splash screen and throughout every app view. */
1242
+ .a11y-launch{position:fixed;top:max(12px,env(safe-area-inset-top));left:12px;z-index:100;display:none;place-items:center;width:46px;height:46px;padding:0;border:2px solid var(--olive);border-radius:50%;background:var(--surface);color:var(--olive);box-shadow:0 3px 12px rgb(0 0 0/.2);font:700 24px/1 var(--f-body);cursor:pointer}
1243
  .a11y-launch:hover,.a11y-launch:focus-visible{background:var(--olive-soft);outline:3px solid var(--surface);outline-offset:2px}
1244
  .a11y-panel{position:fixed;top:max(66px,calc(env(safe-area-inset-top) + 66px));left:12px;z-index:101;width:min(310px,calc(100vw - 24px));padding:14px;border:1px solid var(--line);border-radius:14px;background:var(--surface);color:var(--ink);box-shadow:0 8px 28px rgb(0 0 0/.28)}
1245
  .a11y-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:10px}.a11y-head strong{font-size:19px}.a11y-close{width:36px;height:36px;border:0;border-radius:50%;background:transparent;color:var(--ink);font:700 18px/1 var(--f-body);cursor:pointer}.a11y-close:hover,.a11y-close:focus-visible{background:var(--olive-soft);outline:2px solid var(--olive)}
 
4750
  if(!pending.length){ head.append(mark("meta","אין כרגע דיווחים שממתינים לבדיקה.","p")); return; }
4751
  const byUser=new Map; for(const f of pending){ const who=f.email||""; if(!byUser.has(who)) byUser.set(who,[]); byUser.get(who).push(f); }
4752
  head.append(mark("meta",`${pending.length} ${pending.length===1?"דיווח":"דיווחים"} מ־${byUser.size} ${byUser.size===1?"מדווח/ת":"מדווחים"}, מקובצים לפי המדווח/ת.`,"p"));
4753
+ const flagScore=f=>Number.isFinite(Number(f.score))?Number(f.score):101;
4754
+ const groupPriority=flags=>Math.min(...flags.map(flagScore));
4755
+ for(const [who,flags] of [...byUser].sort((a,b)=>groupPriority(a[1])-groupPriority(b[1])||b[1].length-a[1].length||a[0].localeCompare(b[0]))){
4756
  const group=document.createElement("details"), summary=document.createElement("summary");
4757
  group.className="flag-group"; group.open=flagGroupOpen[who]===true; group.ontoggle=()=>{ flagGroupOpen[who]=group.open; };
4758
+ const urgent=flags.filter(f=>flagScore(f)<75).length;
4759
+ summary.append(mark("",who||"ללא משתמש","b"),mark("meta",` · ${flags.length} ${flags.length===1?"דיווח":"דיווחים"}${urgent?` · ${urgent} מתחת ל־75`:" · כולם בחומרה נמוכה"}`)); group.append(summary); box.append(group);
4760
+ for(const f of [...flags].sort((a,b)=>flagScore(a)-flagScore(b))){
4761
+ const lower=flagScore(f)>=75, card=mark(`card flag-review ${lower?"flag-lower":"flag-urgent"}`,""), title=mark("",f.he||f.phrase,"h2"), result=f.result||{}, issues=flagIssues(f);
4762
  const ar=mark("",f.ar||"","p"); ar.dir="rtl";
4763
+ const scoreLine=mark("meta",`ציון ${f.score} · המודל שמע: ${f.heard_ipa||"–"}`,"p");
4764
+ scoreLine.append(mark(`flag-priority ${lower?"lower":"urgent"}`,lower?"חומרה נמוכה":"עדיפות לבדיקה"));
4765
+ card.append(title,ar,scoreLine);
4766
  if(f.note) card.append(mark("",`הערת המדווח/ת: ${f.note}`,"p"));
4767
  if(f.audio){
4768
  const audioRow=mark("row",""), play=mark("btn","▶ השמעת ההקלטה","button"), audio=document.createElement("audio"), audioMsg=mark("msg","");
tests/test_activity.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timezone
2
+
3
+ from app.auth import AuthUnavailable, SupabaseAuth
4
+
5
+
6
+ def test_scoped_activity_uses_database_aggregates():
7
+ auth = SupabaseAuth("https://example.supabase.co", "public", secret="secret")
8
+ calls = []
9
+
10
+ def admin(path, method="GET", body=None, prefer="return=representation", timeout=10):
11
+ calls.append((path, body))
12
+ if path.endswith("activity_buckets_scoped"):
13
+ return [{"bucket": "2026-10-22T10:00:00+00:00", "attempts": 4, "active_students": 2}]
14
+ return [{"attempts": 4, "active_students": 2, "returning_students": 1, "active_days": 2}]
15
+
16
+ auth._admin = admin
17
+ data = auth.scoped_activity({"u2", "u1"}, datetime(2026, 10, 15, tzinfo=timezone.utc), 60)
18
+ assert data["summary"]["returning_students"] == 1
19
+ assert calls[0][1]["p_user_ids"] == ["u1", "u2"]
20
+ assert calls[0][1]["p_bucket_minutes"] == 60
21
+
22
+
23
+ def test_scoped_activity_fallback_is_not_limited_to_the_newest_day():
24
+ auth = SupabaseAuth("https://example.supabase.co", "public", secret="secret")
25
+
26
+ def admin(path, method="GET", body=None, prefer="return=representation", timeout=10):
27
+ if "/rpc/" in path:
28
+ raise AuthUnavailable("supabase answered 404: function missing")
29
+ return [
30
+ {"user_id": "u1", "created_at": "2026-10-22T08:00:00.12345+00:00"},
31
+ {"user_id": "u1", "created_at": "2026-10-21T08:00:00+00:00"},
32
+ {"user_id": "u2", "created_at": "2026-10-22T09:00:00+00:00"},
33
+ ]
34
+
35
+ auth._admin = admin
36
+ data = auth.scoped_activity({"u1", "u2"}, datetime(2026, 10, 15, tzinfo=timezone.utc), 60)
37
+ assert data["summary"] == {"attempts": 3, "active_students": 2, "returning_students": 1, "active_days": 2}
38
+ assert sum(bucket["attempts"] for bucket in data["buckets"]) == 3