designpaca/apps/site/public/work/gaon-lms/calc.js
Yun Chan 14cb138e1a feat(site,skill): 모바일 앱 수준 UX — 바텀내비·백 키 히스토리·바텀 시트·safe area·PWA 와 L6 시나리오 E2E
- HIG(탭바 5~6·시트)·M3(바텀내비 3~5·48dp)·NN/g·CSS-Tricks 조사(research/mobile-app-ux-v1.md) 후 Nielsen 10 감사로 위반 도출
- 바텀 내비 5탭 + 더보기 바텀 시트(그룹 헤더), 뷰·다이얼로그 pushState/popstate — 안드로이드 백 키가 앱 내 뒤로 작동
- safe area(env+max)·입력 16px(iOS 확대 방지)·coarse 44px 타깃·tap-highlight·overscroll 위생, 인쇄에서 모바일 크롬 제거
- PWA: manifest + sharp 생성 아이콘(가온=장부 문항, 두레=시간표 블록)
- tools/e2e.mjs L6 시나리오: RED 1,742 단언 318 실패 → GREEN 1,762/0, verify 체인 7계층 통과
- 실기기(안드로이드 에뮬레이터·Chrome) 텍스트 근거 검증 — 백 키·시트·탭 전환, iOS 는 WebKit(L4) 대체
- 스킬 내재화: mobile-app-ux.md, audit-gate 7계층·하니스 12~14, design-gate 터치타깃 검사, SKILL.md 규칙 7
- 도그푸드: 게이트가 죽은 선택자 8종·thresholds 병합 버그 적발 — CSS v22(양앱) 범프 후 전체 재검증
2026-08-23 14:33:15 +09:00

67 lines
2.6 KiB
JavaScript

/* 가온 학적부 — 순수 계산 로직. 브라우저와 Node 테스트가 같은 함수를 공유한다. */
(function (global) {
"use strict";
const attSummary = (row) => {
const days = (row || []).filter((v) => v !== null && v !== undefined);
if (!days.length) return "휴학";
const att = days.filter((v) => v === 2).length;
return `${Math.round((att / days.length) * 100)}%`;
};
// 일별 합계 — marks 는 '행 배열'의 목록이다. v11 사고(행 자체를 세어 0/3) 방지가 이 함수의 존재 이유.
const dayTotal = (marks, d) =>
marks.filter((row) => row && row[d] !== null && row[d] !== undefined).length;
const dayPresent = (marks, d) =>
marks.filter((row) => row && row[d] === 2).length;
const counts = (marks) => {
const flat = (marks || []).filter(Boolean).flat();
const att = flat.filter((v) => v === 2).length;
const late = flat.filter((v) => v === 1).length;
const abs = flat.filter((v) => v === 0).length;
return {
att, late, abs, total: flat.length,
rate: flat.length ? Math.round((att / flat.length) * 100) : 0,
};
};
// 명단 통계 — 0명 나눗셈 가드 포함
const rosterStats = (students) => {
const list = students || [];
const active = list.filter((s) => s.status === "재학");
const avgAtt = active.length
? Math.round(active.reduce((a, s) => a + (s.att || 0), 0) / active.length)
: 0;
const hw = active.reduce(
(a, s) => {
const [done, total] = String(s.hw || "0/0").split("/").map(Number);
return { done: a.done + (done || 0), total: a.total + (total || 0) };
},
{ done: 0, total: 0 }
);
return {
total: list.length,
active: active.length,
off: list.length - active.length,
avgAtt,
hwDone: hw.done,
hwTotal: hw.total,
hwRate: hw.total ? Math.round((hw.done / hw.total) * 100) : 0,
};
};
// 채점 입력 검증 — 빈 칸·숫자 아님·범위 밖을 모두 걸러낸다. 모달과 단위 테스트가 같은 판정을 공유한다.
const validateScore = (raw) => {
const s = String(raw ?? "").trim();
if (!s) return { ok: false, reason: "empty" };
const v = Number(s);
if (Number.isNaN(v)) return { ok: false, reason: "nan" };
if (v < 0 || v > 100) return { ok: false, reason: "range" };
return { ok: true, value: v };
};
const api = { attSummary, dayTotal, dayPresent, counts, rosterStats, validateScore };
global.GAON = api;
if (typeof module !== "undefined") module.exports = api;
})(typeof window !== "undefined" ? window : globalThis);