Web Digital Wellbeing Script

You can measure and analyze web usage time, and limit web usage. (Currently, the web usage restriction features contain many bugs, and the UI currently supports only Korean.), Usage: You can configure the settings by visiting https://aajkrvv.github.io/web-digital-wellbeing-script/

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name Web Digital Wellbeing Script
// @namespace    aajkrvv
// @version      1.1.2
// @description  You can measure and analyze web usage time, and limit web usage. (Currently, the web usage restriction features contain many bugs, and the UI currently supports only Korean.), Usage: You can configure the settings by visiting https://aajkrvv.github.io/web-digital-wellbeing-script/
// @description:ko 웹 사용 시간을 측정하고 통계화하며 웹 사용을 제한할 수 있습니다.(현재 웹 사용 제한 관련 기능에는 오류가 많습니다.), 사용 방법: https://aajkrvv.github.io/web-digital-wellbeing-script/ 주소에 접속하여 설정을 조작 할 수 있습니다.
// @author       aajkrvv
// @match        *://*/*
// @run-at       document-end
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @homepageURL  https://aajkrvv.github.io/web-digital-wellbeing-script/
// @supportURL   https://github.com/aajkrvv/web-digital-wellbeing-script
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @grant        GM_listValues
// ==/UserScript==
(function () {
    'use strict';

    /* ─────────── 상수! ─────────── */
    const KEY_PREFIX   = 'wbt_d_';
    const MAX_DAYS     = 14;
    const VIEWER_URL   = 'https://aajkrvv.github.io/web-digital-wellbeing-script/';
    const COMMIT_EVERY = 5000;
    const MAX_TICK_SEC = 30;

    /* ─────────── 유틸 ─────────── */
    function getLocalDate(off=0){
        const d=new Date();d.setDate(d.getDate()-off);
        return [d.getFullYear(),String(d.getMonth()+1).padStart(2,'0'),String(d.getDate()).padStart(2,'0')].join('-');
    }
    function getDomain(){ return location.hostname.replace(/^www\./,'')||'unknown'; }
    function readSec(date,dom){ return parseInt(GM_getValue(KEY_PREFIX+date+'_'+dom,'0'),10)||0; }
    function writeSec(date,dom,s){ GM_setValue(KEY_PREFIX+date+'_'+dom,String(s)); }
    function loadAll(){
        const r={};
        try{ GM_listValues().forEach(k=>{
            if(!k.startsWith(KEY_PREFIX))return;
            const rest=k.slice(KEY_PREFIX.length),date=rest.slice(0,10),dom=rest.slice(11);
            if(!/^\d{4}-\d{2}-\d{2}$/.test(date)||!dom)return;
            if(!r[date])r[date]={};
            r[date][dom]=parseInt(GM_getValue(k,'0'),10)||0;
        });}catch(e){}
        return r;
    }
    function cleanup(){
        try{
            const c=getLocalDate(MAX_DAYS);
            GM_listValues().forEach(k=>{
                if(k.startsWith(KEY_PREFIX)&&k.slice(KEY_PREFIX.length,KEY_PREFIX.length+10)<c)GM_deleteValue(k);
            });
        }catch(e){}
    }
    function genId(){ return Math.random().toString(36).slice(2,10); }
    function fmt(s){
        if(!s||s<1)return '0초';
        if(s<60)return `${s}초`;
        if(s<3600){const m=Math.floor(s/60),r=s%60;return r?`${m}분 ${r}초`:`${m}분`;}
        const h=Math.floor(s/3600),m=Math.floor((s%3600)/60);
        return m?`${h}시간 ${m}분`:`${h}시간`;
    }
    function fmtS(s){
        if(!s||s<1)return '-';
        if(s<60)return `${s}s`;
        if(s<3600)return `${Math.floor(s/60)}m`;
        const h=Math.floor(s/3600),m=Math.floor((s%3600)/60);
        return m?`${h}h ${m}m`:`${h}h`;
    }
    function fmtRemain(sec){
        if(sec<=0)return '0초 남음';
        if(sec<60)return `${sec}초 남음`;
        const m=Math.floor(sec/60);
        if(m<60)return `${m}분 남음`;
        const h=Math.floor(m/60);
        return m%60?`${h}시간 ${m%60}분 남음`:`${h}시간 남음`;
    }

    /* ─────────── 큐 ─────────── */
    let _busy=false;const _q=[];
    function enqueue(fn){
        if(_busy)return new Promise(r=>_q.push({fn,r}));
        _busy=true;let res;
        try{res=fn();}finally{
            _busy=false;
            if(_q.length){const nx=_q.shift();Promise.resolve().then(()=>enqueue(nx.fn).then(nx.r));}
        }
        return Promise.resolve(res);
    }

    /* ═══════════════════════════════════════════════
       규칙·그룹  (GM storage)
       규칙: {id,domain,groupId,delaySec,freeMins,limitMins,days,notify:{min1,min5,min10}}
       그룹: {id,name,limitMins}
    ═══════════════════════════════════════════════ */
    function getRules(){
        try{return JSON.parse(GM_getValue('wbt_rules','[]'));}catch{return [];}
    }
    function saveRules(r){GM_setValue('wbt_rules',JSON.stringify(r));}
    function getGroups(){
        try{return JSON.parse(GM_getValue('wbt_groups','[]'));}catch{return [];}
    }
    function saveGroups(g){GM_setValue('wbt_groups',JSON.stringify(g));}

    /* www.naver.com == naver.com, cafe.naver.com != naver.com */
    function stripWWW(d){return d.replace(/^www\./,'');}
    function domainMatches(current,ruleDomain){
        return stripWWW(current)===stripWWW(ruleDomain);
    }

    function findRule(domain){
        const today=new Date().getDay();
        return getRules().find(r=>{
            if(!domainMatches(domain,r.domain))return false;
            if(r.days&&r.days.length>0&&!r.days.includes(today))return false;
            return true;
        })||null;
    }

    function getGroupUsage(groupId){
        const td=getLocalDate();
        return getRules().filter(r=>r.groupId===groupId).reduce((s,r)=>s+readSec(td,r.domain),0);
    }

    /* ═══════════════════════════════════════════════
       오버레이 (트래커 측)
    ═══════════════════════════════════════════════ */
    const OV_CSS=`
    #wbt-ov{position:fixed;inset:0;z-index:2147483647;background:#F8F9FA;color:#202124;
      font-family:-apple-system,"Apple SD Gothic Neo","Noto Sans KR",sans-serif;
      display:flex;flex-direction:column;align-items:center;padding:12vh 24px 5vh;
      box-sizing:border-box;user-select:none;-webkit-user-select:none}
    .wbt-ob{flex:1;display:flex;flex-direction:column;align-items:center;
            justify-content:center;text-align:center}
    #wbt-ot{font-size:28px;font-weight:500;line-height:1.4;margin-bottom:20px}
    #wbt-on{font-size:52px;font-weight:700;color:#4C80F1;margin-bottom:8px}
    #wbt-od{font-size:15px;color:#5F6368;line-height:1.7;max-width:300px;margin-top:8px}
    .wbt-bw{width:100%;display:flex;flex-direction:column;align-items:center;gap:12px;margin-bottom:60px}
    .wbt-b{width:100%;max-width:260px;height:52px;background:#E8E8E8;color:#000;border:none;
      border-radius:26px;font-size:15px;font-weight:700;cursor:pointer;
      -webkit-tap-highlight-color:transparent;transition:transform .08s,opacity .08s}
    .wbt-b.off{opacity:.4;cursor:not-allowed}
    .wbt-b:active:not(.off){transform:scale(0.93);opacity:.65}
    `;

    let _toastTimer=null;
    function showNotifToast(msg){
        let el=document.getElementById('wbt-toast');
        if(!el){
            const s=document.createElement('style');
            s.textContent=`#wbt-toast{position:fixed;top:-60px;left:50%;transform:translateX(-50%);
                background:rgba(20,20,20,.88);color:#fff;padding:9px 18px;border-radius:20px;
                font-size:13px;z-index:2147483646;transition:top .3s cubic-bezier(.34,1.56,.64,1);
                white-space:nowrap;pointer-events:none;
                font-family:-apple-system,"Apple SD Gothic Neo","Noto Sans KR",sans-serif}`;
            document.head.appendChild(s);
            el=document.createElement('div');el.id='wbt-toast';
            document.body.appendChild(el);
        }
        el.textContent=msg;
        el.style.top='16px';
        clearTimeout(_toastTimer);
        _toastTimer=setTimeout(()=>{el.style.top='-60px';},3500);
    }

    function _injectOvStyle(){
        if(document.getElementById('wbt-ov-st'))return;
        const s=document.createElement('style');s.id='wbt-ov-st';s.textContent=OV_CSS;
        (document.head||document.documentElement).appendChild(s);
    }

    function showLockScreen(domain,rule,isGroup){
        if(document.getElementById('wbt-ov'))return;
        _injectOvStyle();
        const el=document.createElement('div');el.id='wbt-ov';
        const descGroup=isGroup?'그룹 사용 시간 제한에 도달했습니다.':'오늘의 사용 시간 제한에 도달했습니다.';
        el.innerHTML=`
            <div class="wbt-ob">
                <div id="wbt-ot">오늘은 여기까지!</div>
                <div id="wbt-od">${descGroup}<br>내일 다시 사용할 수 있습니다.</div>
            </div>
            <div class="wbt-bw">
                <button class="wbt-b" id="wbt-close">닫기</button>
            </div>`;
        (document.body||document.documentElement).appendChild(el);
        document.getElementById('wbt-close').addEventListener('click',()=>{
            /* 닫기: 차단된 사이트가 아닌 곳으로 이동 */
            el.remove();
            document.getElementById('wbt-ov-st')?.remove();
            history.back();
        });
    }

    function showDelayScreen(domain,rule,onPass){
        if(document.getElementById('wbt-ov'))return;
        _injectOvStyle();
        const freeLbl=rule.freeMins>0?`${rule.freeMins}분동안 잠금 해제`:'잠금 해제';
        /* 시스템 시간 기반 카운트다운 (드리프트 방지) */
        let _targetTime=Date.now()+rule.delaySec*1000;
        let _raf=null;
        const el=document.createElement('div');el.id='wbt-ov';
        el.innerHTML=`
            <div class="wbt-ob">
                <div id="wbt-ot">숨을 크게 들이 마시고<br>길게 내쉬어 봅시다</div>
                <div id="wbt-on">${rule.delaySec}</div>
            </div>
            <div class="wbt-bw">
                <button class="wbt-b off" id="wbt-open">${freeLbl}</button>
                <button class="wbt-b" id="wbt-close">닫기</button>
            </div>`;
        (document.body||document.documentElement).appendChild(el);
        const numEl=document.getElementById('wbt-on');
        const openBtn=document.getElementById('wbt-open');
        const closeBtn=document.getElementById('wbt-close');

        function resetCountdown(){
            _targetTime=Date.now()+rule.delaySec*1000;
            numEl.style.display='';
            numEl.textContent=rule.delaySec;
            openBtn.classList.add('off');
        }
        function unlock(){
            clearTimeout(_raf);
            numEl.style.display='none';
            openBtn.classList.remove('off');
        }
        function tick(){
            const rem=Math.max(0,Math.ceil((_targetTime-Date.now())/1000));
            if(rem<=0){unlock();return;}
            numEl.textContent=rem;
            _raf=setTimeout(tick,100); /* 100ms마다 시스템 시간 확인 → 정확도 보장 */
        }
        tick();

        /* 탭 전환 시 카운트다운 리셋 */
        const onBlur=()=>{ clearTimeout(_raf);_raf=null;resetCountdown(); };
        const onFocus=()=>{ if(openBtn.classList.contains('off')){resetCountdown();tick();} };
        const onPageShow=(e)=>{ if(openBtn.classList.contains('off')){resetCountdown();tick();} };
        window.addEventListener('blur',onBlur);
        window.addEventListener('focus',onFocus);
        window.addEventListener('pageshow',onPageShow);

        openBtn.addEventListener('click',()=>{
            if(openBtn.classList.contains('off'))return;
            clearTimeout(_raf);
            window.removeEventListener('blur',onBlur);
            window.removeEventListener('focus',onFocus);
            window.removeEventListener('pageshow',onPageShow);
            el.remove();
            document.getElementById('wbt-ov-st')?.remove();
            onPass?.();
        });
        closeBtn.addEventListener('click',()=>{
            clearTimeout(_raf);
            window.removeEventListener('blur',onBlur);
            window.removeEventListener('focus',onFocus);
            window.removeEventListener('pageshow',onPageShow);
            el.remove();
            document.getElementById('wbt-ov-st')?.remove();
            history.back();
        });
    }

    function initLockMonitor(domain,rule){
        const hasIndivLim=rule.limitMins>0;
        const grp=rule.groupId?getGroups().find(g=>g.id===rule.groupId):null;
        const hasGrpLim=grp&&grp.limitMins>0;
        if(!hasIndivLim&&!hasGrpLim)return;
        setInterval(()=>{
            if(document.getElementById('wbt-ov'))return;
            if(hasIndivLim&&readSec(getLocalDate(),domain)>=rule.limitMins*60){
                showLockScreen(domain,rule,false);return;
            }
            if(hasGrpLim&&getGroupUsage(rule.groupId)>=grp.limitMins*60){
                showLockScreen(domain,rule,true);
            }
        },10000);
    }

    /* 알림 체크에 쓰이는 이미 표시된 임계값 추적 */
    const _notifShown={};

    function checkNotifs(domain,rule,usedSec){
        if(!rule.notify||!rule.limitMins)return;
        const limSec=rule.limitMins*60;
        const remSec=limSec-usedSec;
        [[1,'min1','1분'],[5,'min5','5분'],[10,'min10','10분']].forEach(([mins,key,label])=>{
            if(!rule.notify[key])return;
            const k=`${domain}_${mins}_${getLocalDate()}`;
            if(_notifShown[k])return;
            const th=mins*60;
            if(remSec>0&&remSec<=th+COMMIT_EVERY/1000+1){
                _notifShown[k]=1;
                showNotifToast(`${domain} — ${label} 후 차단됩니다`);
            }
        });
    }

    function checkAndShowOverlay(){
        if(location.href.startsWith(VIEWER_URL))return;
        const domain=getDomain();
        const rule=findRule(domain);
        if(!rule)return;

        /* 1. 제한 초과 여부 (개인·그룹 포함) — 우선순위 최고 */
        let isLocked=false;
        if(rule.limitMins>0&&readSec(getLocalDate(),domain)>=rule.limitMins*60)isLocked=true;
        if(!isLocked&&rule.groupId){
            const grp=getGroups().find(g=>g.id===rule.groupId);
            if(grp?.limitMins>0&&getGroupUsage(rule.groupId)>=grp.limitMins*60)isLocked=true;
        }
        if(isLocked){
            if(!document.getElementById('wbt-ov'))showLockScreen(domain,rule,!!rule.groupId);
            return;
        }

        /* 2. 대기 화면 */
        if(rule.delaySec>0){
            const fk='wbt_free_'+domain;
            const gk=rule.groupId?'wbt_free_grp_'+rule.groupId:null;
            const fe=sessionStorage.getItem(fk);
            const ge=gk?sessionStorage.getItem(gk):null;
            const inFree=(fe&&Date.now()<+fe)||(ge&&Date.now()<+ge);
            if(inFree){initLockMonitor(domain,rule);return;}
            sessionStorage.removeItem(fk);
            if(gk)sessionStorage.removeItem(gk);
            if(!document.getElementById('wbt-ov')){
                showDelayScreen(domain,rule,()=>{
                    const exp=Date.now()+(rule.freeMins>0?rule.freeMins*60000:86400000);
                    sessionStorage.setItem(fk,String(exp));
                    if(gk)sessionStorage.setItem(gk,String(exp));
                    initLockMonitor(domain,rule);
                });
            }
        } else {
            initLockMonitor(domain,rule);
        }
    }

    function initOverlay(){
        /* 여러 경로로 진입 시 지속적으로 체크 */
        const check=()=>setTimeout(checkAndShowOverlay,50);
        if(document.body)check();
        else document.addEventListener('DOMContentLoaded',check);
        window.addEventListener('pageshow',check);               /* BFCache 복원 */
        window.addEventListener('visibilitychange',()=>{if(!document.hidden)check();}); /* 탭 전환 */
        window.addEventListener('popstate',check);               /* SPA 네비게이션 */
    }

    /* ─────────── 트래커 ─────────── */
    function initTracker(){
        const dom=getDomain();let acc=0,lt=Date.now();
        /* 오버레이 표시 중에는 시간 측정 안 함 */
        const active=()=>!document.hidden&&document.hasFocus()&&!document.getElementById('wbt-ov');
        setInterval(()=>{
            const n=Date.now();
            if(active()){const e=Math.min(Math.round((n-lt)/1000),MAX_TICK_SEC);if(e>0)acc+=e;}
            lt=n;
        },1000);
        setInterval(()=>{
            if(acc<1)return;
            const d=acc,td=getLocalDate();acc=0;
            enqueue(()=>{
                writeSec(td,dom,readSec(td,dom)+d);
                /* 알림 체크 */
                const rule=findRule(dom);
                if(rule)checkNotifs(dom,rule,readSec(td,dom));
            });
        },COMMIT_EVERY);
        window.addEventListener('beforeunload',()=>{
            if(acc<1)return;
            const td=getLocalDate();
            writeSec(td,dom,readSec(td,dom)+acc);acc=0;
        });
        if(Math.random()<0.05)cleanup();
    }

    /* ─────────── 뷰어 상태 ─────────── */
    let _D=null,_dd='',_wo=0,_editRuleId=null,_editGroupId=null;

    /* ─────────── 테마 ─────────── */
    const getTheme=()=>GM_getValue('wbt_theme','auto');
    function setTheme(t){GM_setValue('wbt_theme',t);applyTheme();}
    function applyTheme(){document.documentElement.setAttribute('data-theme',getTheme());}

    /* ─────────── 네비게이션 ─────────── */
    function nav(view,p={}){
        if(p.date)_dd=p.date;
        if(p.wo!==undefined)_wo=p.wo;
        if(p.ruleId!==undefined)_editRuleId=p.ruleId;
        if(p.groupId!==undefined)_editGroupId=p.groupId;
        window.scrollTo(0,0);
        const app=document.getElementById('app');
        app.innerHTML=
            view==='main'       ?renderMain()      :
            view==='dash'       ?renderDash()      :
            view==='weekly'     ?renderWeekly()    :
            view==='settings'   ?renderSettings()  :
            view==='rules'      ?renderRules()     :
            view==='rule-form'  ?renderRuleForm()  :
            view==='group-form' ?renderGroupForm() :'';
        attachEv(view);
    }

    /* ─────────── 탭 피드백 ─────────── */
    function initTapFeedback(appEl){
        appEl.addEventListener('pointerdown',e=>{
            const s=e.target.closest('.ico,.dpill,.wk-tab,.day-btn,.tog');
            if(s)s.classList.add('tap-sc');
            const d=e.target.closest('.card-tap,.s-row,.r-item');
            if(d)d.classList.add('tap-dm');
        },{capture:true,passive:true});
        const clr=()=>setTimeout(()=>{
            appEl.querySelectorAll('.tap-sc,.tap-dm').forEach(el=>el.classList.remove('tap-sc','tap-dm'));
        },80);
        ['pointerup','pointercancel','pointerleave'].forEach(ev=>
            appEl.addEventListener(ev,clr,{capture:true,passive:true}));
    }

    /* ─────────── 페이지 셋업 ─────────── */
    function initViewer(){
        _D=loadAll();_dd=getLocalDate();_wo=0;_editRuleId=null;_editGroupId=null;
        document.head.innerHTML=`
            <meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
            <title>디지털 웰빙</title>
            <link rel="preconnect" href="https://fonts.googleapis.com">
            <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
            <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
        `;
        document.body.innerHTML='<div id="app"></div>';
        document.body.removeAttribute('style');document.body.removeAttribute('class');
        const s=document.createElement('style');s.textContent=CSS;document.head.appendChild(s);
        applyTheme();
        initTapFeedback(document.getElementById('app'));
        nav('main');
    }

    /* ═══════════════ CSS ═══════════════ */
    const CSS=`
    *,*::before,*::after{margin:0;padding:0;box-sizing:border-box}
    :root{
        --bg:#EFEFEF;--sf:#FFF;--sf2:#EFEFEF;
        --t1:#1A1A1A;--t2:#8E8E93;--t3:#C7C7CC;
        --pri:#007AFF;--div:rgba(60,60,67,.12);
        --empty:#E5E5EA;--sh:0 1px 3px rgba(0,0,0,.06);
    }
    [data-theme="dark"]{
        --bg:#000;--sf:#1C1C1E;--sf2:#2C2C2E;
        --t1:#FFF;--t2:#AEAEB2;--t3:#48484A;
        --pri:#0A84FF;--div:rgba(255,255,255,.1);
        --empty:#2C2C2E;--sh:none;
    }
    @media(prefers-color-scheme:dark){
        :root:not([data-theme="light"]){
            --bg:#000;--sf:#1C1C1E;--sf2:#2C2C2E;
            --t1:#FFF;--t2:#AEAEB2;--t3:#48484A;
            --pri:#0A84FF;--div:rgba(255,255,255,.1);
            --empty:#2C2C2E;--sh:none;
        }
    }
    html,body{background:var(--bg);min-height:100vh}
    body{font-family:'Noto Sans KR',-apple-system,'Apple SD Gothic Neo',sans-serif;
         color:var(--t1);-webkit-font-smoothing:antialiased}
    #app{max-width:480px;margin:0 auto;padding-bottom:48px}

    .tap-sc{transform:scale(0.84)!important;opacity:.5!important;
            transition:transform .05s,opacity .05s!important}
    .tap-dm{opacity:.6!important;background:var(--sf2)!important;
            transition:opacity .05s,background .05s!important}

    /* 헤더 */
    .hdr{display:flex;align-items:center;padding:18px 16px 10px;gap:4px}
    .hdr-title{font-size:22px;font-weight:700;flex:1;color:var(--t1);line-height:1}
    .ico{width:36px;height:36px;background:none;border:none;color:var(--t2);
         cursor:pointer;border-radius:10px;display:flex;align-items:center;
         justify-content:center;flex-shrink:0;font-family:inherit;
         -webkit-tap-highlight-color:transparent;user-select:none}
    .ico:hover{background:rgba(128,128,128,.1)}
    .ico-back{color:var(--t2);margin-right:2px}

    /* 레이블 */
    .slbl{font-size:13px;font-weight:500;color:var(--t2);padding:14px 20px 6px}
    .slbl-row{display:flex;align-items:center;justify-content:space-between;padding:14px 20px 6px}
    .slbl-txt{font-size:13px;font-weight:500;color:var(--t2)}
    .badge{font-size:13px;color:var(--pri)}

    /* 카드 */
    .card{background:var(--sf);border-radius:18px;margin:0 16px 4px;box-shadow:var(--sh)}
    .card-tap{cursor:pointer;-webkit-tap-highlight-color:transparent;user-select:none}

    /* 오늘 카드 */
    .today-lbl{font-size:13px;color:var(--t2);padding:16px 18px 0}
    .today-body{display:flex;align-items:center;padding:8px 18px 12px;gap:8px}
    .today-left{flex:1;min-width:0}
    .big{font-size:34px;font-weight:800;letter-spacing:-1.5px;line-height:1.05;color:var(--t1)}
    .today-right{flex-shrink:0}
    .app-rows{padding:0 18px 16px;border-top:.5px solid var(--div);padding-top:12px}
    .app-row{display:flex;align-items:center;gap:8px;margin-bottom:9px}
    .app-row:last-child{margin-bottom:0}
    .a-dot{width:9px;height:9px;border-radius:50%;flex-shrink:0}
    .a-name{flex:1;font-size:14px;font-weight:500;color:var(--t1);
            overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
    .a-time{font-size:14px;color:var(--t2);white-space:nowrap}

    /* 미니 카드 */
    .mini-row{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin:4px 16px 4px}
    .mc{background:var(--sf);border-radius:14px;padding:13px 11px;box-shadow:var(--sh)}
    .mc-lbl{font-size:11px;font-weight:500;color:var(--t2);margin-bottom:5px}
    .mc-val{font-size:19px;font-weight:800;letter-spacing:-.4px;line-height:1.1;color:var(--t1)}
    .mc-sub{font-size:10px;color:var(--t3);margin-top:2px}

    /* 리스트 행 */
    .lrow{display:flex;align-items:center;padding:12px 18px;gap:12px;
          border-bottom:.5px solid var(--div)}
    .lrow:last-child{border-bottom:none}
    .l-icon{width:32px;height:32px;border-radius:8px;flex-shrink:0;object-fit:cover}
    .l-icon-fb{width:32px;height:32px;border-radius:8px;flex-shrink:0;
               display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:700}
    .l-rank{width:22px;text-align:center;font-size:15px;font-weight:700;color:var(--t3);flex-shrink:0}
    .l-name{flex:1;font-size:15px;font-weight:500;color:var(--t1);
            overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
    .l-time{font-size:14px;color:var(--t2);white-space:nowrap;flex-shrink:0}
    .empty-msg{padding:28px;text-align:center;font-size:14px;color:var(--t3)}

    /* 날짜 스크롤러 */
    .date-wrap{overflow-x:auto;scrollbar-width:none;background:var(--sf);
               border-radius:18px;margin:0 16px 4px;box-shadow:var(--sh)}
    .date-wrap::-webkit-scrollbar{display:none}
    .date-scr{display:flex;padding:10px 0;gap:4px}
    .dpill{display:flex;flex-direction:column;align-items:center;min-width:42px;
           padding:8px 4px;border-radius:21px;cursor:pointer;flex-shrink:0;
           -webkit-tap-highlight-color:transparent;user-select:none}
    .dpill-dow{font-size:11px;color:var(--t3);margin-bottom:2px;font-weight:500}
    .dpill-num{font-size:19px;font-weight:600;color:var(--t1)}
    .dpill.today .dpill-num{color:var(--pri)}
    .dpill.sel{background:var(--t1)}
    .dpill.sel .dpill-dow,.dpill.sel .dpill-num{color:var(--sf)}

    /* 스택 차트 */
    .schart{display:flex;align-items:flex-end;gap:3px;height:110px;cursor:pointer;user-select:none}
    .scol{flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;height:100%;justify-content:flex-end}
    .scol:hover .sbar{opacity:.72}
    .sbar{width:100%;border-radius:3px 3px 0 0;overflow:hidden;display:flex;flex-direction:column-reverse}
    .slb{font-size:9px;color:var(--t3);white-space:nowrap;height:16px;line-height:16px}
    .slb.today{color:var(--pri);font-weight:700}
    .slb.sel{color:var(--t1);font-weight:700}
    .scol.sel .sbar{outline:2px solid var(--t1);outline-offset:-1px}
    .s-legend{display:flex;flex-direction:column;gap:6px;padding:8px 0 2px}
    .sl-item{display:flex;align-items:center;gap:5px;font-size:12px;color:var(--t2)}
    .sl-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}

    /* 주간 리포트 */
    .wk-sub{font-size:13px;color:var(--t2);padding:4px 20px 10px}
    .wk-tabs{display:flex;padding:4px 16px}
    .wk-tab{flex:1;text-align:center;padding:10px 4px;font-size:15px;font-weight:600;
            color:var(--t3);cursor:pointer;border-radius:30px;border:none;
            background:none;font-family:inherit;
            -webkit-tap-highlight-color:transparent;user-select:none}
    .wk-tab.active{background:rgba(128,128,128,.18);color:var(--t1)}
    .wk-chart-outer{position:relative;padding:0 4px}
    .wk-avg-line{position:absolute;left:4px;right:4px;height:1px;
                 background:rgba(0,122,255,.5);pointer-events:none}
    .wk-avg-lbl{position:absolute;right:0;font-size:10px;color:var(--pri);
                font-weight:600;white-space:nowrap;transform:translateY(-14px)}
    .wk-bars{display:flex;align-items:flex-end;gap:4px;height:90px}
    .wk-col{flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;height:100%;justify-content:flex-end}
    .wk-bar{width:100%;border-radius:4px 4px 0 0;min-height:3px;overflow:hidden}
    .wk-bar.zero{background:var(--empty)}
    .wk-lbl{font-size:11px;color:var(--t3);height:16px;line-height:16px}
    .wk-lbl.today{color:var(--pri);font-weight:700}
    .wk-legend{display:flex;flex-direction:column;gap:6px;padding:10px 0 6px}
    .wl-item{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--t2)}
    .wl-dot{width:9px;height:9px;border-radius:50%}
    .wl-dash{width:16px;height:0;border-top:2px dashed rgba(0,122,255,.6)}

    /* 설정 */
    .setting-section{font-size:12px;color:var(--t2);padding:14px 20px 6px;font-weight:500}
    .s-row{display:flex;align-items:center;justify-content:space-between;
           padding:14px 18px;border-bottom:.5px solid var(--div);cursor:pointer;
           -webkit-tap-highlight-color:transparent;user-select:none}
    .s-row:last-child{border-bottom:none}
    .s-lbl{font-size:16px;font-weight:500;color:var(--t1)}
    .s-sub{font-size:13px;color:var(--t2);margin-top:3px}
    .s-chk{font-size:16px;color:var(--pri);width:20px;text-align:center;flex-shrink:0}
    .s-arr{font-size:16px;color:var(--t3);line-height:1}
    .raw-pre{background:var(--sf);border-radius:12px;margin:4px 16px;padding:14px;
             font-family:'Courier New',monospace;font-size:10px;color:var(--t2);
             white-space:pre-wrap;word-break:break-all;max-height:260px;
             overflow-y:auto;line-height:1.6;display:none}

    /* 규칙 목록 (Samsung 스타일) */
    .rules-desc{font-size:13px;color:var(--t2);padding:8px 20px 12px;line-height:1.6}
    .r-item{padding:14px 18px;border-bottom:.5px solid var(--div)}
    .r-item:last-child{border-bottom:none}
    .r-item-hdr{display:flex;align-items:center;justify-content:space-between;margin-bottom:4px}
    .r-item-name{font-size:15px;font-weight:600;color:var(--t1);
                 display:flex;align-items:center;gap:8px}
    .r-item-favicon{width:20px;height:20px;border-radius:4px}
    .r-item-badge{min-width:22px;height:22px;padding:0 6px;border-radius:11px;
                  background:var(--sf2);display:flex;align-items:center;justify-content:center;
                  font-size:12px;font-weight:600;color:var(--t2)}
    .r-item-status{font-size:14px;font-weight:700;color:var(--t1);margin-bottom:6px}
    .r-item-status.over{color:#EA4335}
    .r-item-status.off{color:var(--t3);font-weight:500}
    .r-bar-bg{height:4px;background:var(--empty);border-radius:2px}
    .r-bar-fill{height:100%;border-radius:2px}
    .r-grp-tag{font-size:11px;color:var(--pri);margin-top:4px}
    .r-days-tag{font-size:11px;color:var(--t3);margin-top:3px}
    .edit-btn{font-size:12px;color:var(--pri);background:none;border:none;cursor:pointer;
              font-family:inherit;padding:2px 0;-webkit-tap-highlight-color:transparent}

    /* 그룹 섹션 */
    .grp-item{padding:12px 18px;border-bottom:.5px solid var(--div)}
    .grp-item:last-child{border-bottom:none}
    .grp-item-hdr{display:flex;align-items:center;justify-content:space-between;margin-bottom:3px}
    .grp-item-name{font-size:15px;font-weight:600;color:var(--t1)}
    .grp-members{font-size:12px;color:var(--t2);margin-bottom:5px}
    .grp-status{font-size:13px;font-weight:700;color:var(--t1)}
    .grp-status.over{color:#EA4335}

    /* 규칙 폼 */
    .form-card{background:var(--sf);border-radius:18px;margin:0 16px 4px;
               padding:18px;box-shadow:var(--sh)}
    .form-lbl{font-size:12px;color:var(--t2);margin-bottom:6px;font-weight:500}
    .form-input{width:100%;padding:10px 12px;border:1.5px solid var(--div);
                border-radius:10px;background:var(--sf2);color:var(--t1);
                font-size:15px;font-family:inherit;outline:none;
                -webkit-appearance:none;appearance:none}
    .form-input:focus{border-color:var(--pri)}
    .num-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}
    .num-cell{text-align:center}
    .num-cell .form-lbl{text-align:center}
    .num-input{width:100%;padding:8px 6px;border:1.5px solid var(--div);
               border-radius:8px;background:var(--sf2);color:var(--t1);
               font-size:14px;font-family:inherit;outline:none;text-align:center;
               -webkit-appearance:none;appearance:none}
    .num-input:focus{border-color:var(--pri)}
    .form-sep{height:.5px;background:var(--div);margin:14px 0}
    /* 요일 버튼 */
    .day-row{display:flex;gap:8px;padding:2px 0;flex-wrap:wrap}
    .day-btn{width:36px;height:36px;border-radius:50%;border:1.5px solid var(--div);
             background:none;font-size:13px;font-weight:600;color:var(--t2);
             cursor:pointer;display:flex;align-items:center;justify-content:center;
             font-family:inherit;flex-shrink:0;-webkit-tap-highlight-color:transparent}
    .day-btn.on{background:var(--pri);border-color:var(--pri);color:#fff}
    /* 토글 스위치 */
    .tog-row{display:flex;align-items:center;justify-content:space-between;
             padding:10px 0;border-bottom:.5px solid var(--div)}
    .tog-row:last-child{border-bottom:none}
    .tog-lbl{font-size:15px;color:var(--t1)}
    .tog{width:44px;height:26px;border-radius:13px;background:var(--empty);
         cursor:pointer;position:relative;transition:background .2s;flex-shrink:0;
         -webkit-tap-highlight-color:transparent;border:none}
    .tog.on{background:var(--pri)}
    .tog::after{content:'';position:absolute;width:22px;height:22px;border-radius:50%;
                background:white;top:2px;left:2px;transition:transform .2s;
                box-shadow:0 1px 3px rgba(0,0,0,.2)}
    .tog.on::after{transform:translateX(18px)}
    /* 그룹 셀렉트 */
    .form-select{width:100%;padding:10px 12px;border:1.5px solid var(--div);
                 border-radius:10px;background:var(--sf2);color:var(--t1);
                 font-size:15px;font-family:inherit;outline:none;
                 -webkit-appearance:none;appearance:none;
                 background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' fill='none' stroke='%238E8E93' stroke-width='1.5'%3E%3Cpath d='M1 1l5 5 5-5'/%3E%3C/svg%3E");
                 background-repeat:no-repeat;background-position:right 12px center;
                 padding-right:36px}
    /* 저장 버튼 */
    .save-btn{width:100%;padding:14px;background:var(--pri);color:#fff;border:none;
              border-radius:12px;font-size:16px;font-weight:700;cursor:pointer;
              font-family:inherit;-webkit-tap-highlight-color:transparent;margin-top:4px}
    .save-btn:active{opacity:.8}
    .hint{font-size:11px;color:var(--t3);line-height:1.7;padding:8px 0 0}

    /* 메인 타이머 목록 */
    .t-row{padding:13px 18px;border-bottom:.5px solid var(--div)}
    .t-row:last-child{border-bottom:none}
    .t-top{display:flex;align-items:center;justify-content:space-between;margin-bottom:4px}
    .t-domain{font-size:14px;font-weight:600;color:var(--t1)}
    .t-stat{font-size:11px;color:var(--t2)}
    .t-stat.over{color:#EA4335;font-weight:600}
    .t-bar-bg{height:3px;background:var(--empty);border-radius:2px;margin-bottom:3px}
    .t-bar-fill{height:100%;border-radius:2px}
    .t-cfg{font-size:10px;color:var(--t3)}

    @media(max-width:360px){.big{font-size:28px}.mc-val{font-size:15px}}
    `;

    /* ─────────── 헬퍼 ─────────── */
    const PAL=['#4285F4','#00BCD4','#34A853','#FF6D00','#9C27B0',
               '#00ACC1','#F4511E','#0B8043','#3F51B5','#8E24AA'];
    const DOW_KOR=['일','월','화','수','목','금','토'];

    const ICO={
        back:`<svg width="16" height="16" viewBox="0 0 16 16" fill="none"
               stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
             <path d="M10 3L5 8L10 13"/></svg>`,
        refresh:`<svg width="16" height="16" viewBox="0 0 16 16" fill="none"
                   stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
                 <path d="M14 8a6 6 0 1 1-1.6-4.1"/><polyline points="14 2 14 6 10 6"/></svg>`,
        weekly:`<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
                <rect x="0" y="9" width="4" height="7" rx="1"/>
                <rect x="6" y="5" width="4" height="11" rx="1"/>
                <rect x="12" y="0" width="4" height="16" rx="1"/></svg>`,
        settings:`<svg width="16" height="16" viewBox="0 0 16 16" fill="none"
                    stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
                  <circle cx="6" cy="5" r="2"/>
                  <path d="M2 5h2m4 0h6m-12 6h6m4 0h2"/>
                  <circle cx="10" cy="11" r="2"/></svg>`,
        add:`<svg width="16" height="16" viewBox="0 0 16 16" fill="none"
              stroke="currentColor" stroke-width="2" stroke-linecap="round">
             <line x1="8" y1="2" x2="8" y2="14"/><line x1="2" y1="8" x2="14" y2="8"/></svg>`,
        trash:`<svg width="16" height="16" viewBox="0 0 16 16" fill="none"
                stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
               <polyline points="2 4 14 4"/>
               <path d="M5 4V3h6v1m1 0v10a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V4"/>
               <line x1="6" y1="7" x2="6" y2="11"/><line x1="10" y1="7" x2="10" y2="11"/></svg>`,
    };

    function iconLI(domain,idx){
        const c=PAL[idx%PAL.length],i=domain.replace(/\..+$/,'').charAt(0).toUpperCase();
        return `<img class="l-icon"
                     src="https://www.google.com/s2/favicons?domain=${domain}&sz=64"
                     loading="lazy" alt=""
                     onerror="this.style.display='none';this.nextElementSibling.style.display='flex'">
                <div class="l-icon-fb" style="display:none;background:${c}22;color:${c}">${i}</div>`;
    }

    function donut(segs){
        const r=36,cx=50,cy=50,C=2*Math.PI*r;
        const total=segs.reduce((s,x)=>s+(x.v||0),0);
        if(!total)return `<svg width="90" height="90" viewBox="0 0 100 100">
            <circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="var(--empty)" stroke-width="13"/></svg>`;
        let off=0;
        const cs=segs.filter(x=>x.v>0).map(x=>{
            const len=(x.v/total)*C;
            const el=`<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${x.c}"
                        stroke-width="13" stroke-linecap="butt"
                        stroke-dasharray="${len.toFixed(2)} ${C.toFixed(2)}"
                        stroke-dashoffset="${(-off).toFixed(2)}"
                        transform="rotate(-90 ${cx} ${cy})"/>`;
            off+=len;return el;
        });
        return `<svg width="90" height="90" viewBox="0 0 100 100">${cs.join('')}</svg>`;
    }

    function renderTimerList(D,td){
        const rules=getRules();const groups=getGroups();
        if(!rules.length&&!groups.length){
            return `<div style="padding:22px 18px;text-align:center;color:var(--t3);font-size:14px;line-height:1.7">
                설정된 타이머가 없습니다<br>
                <span style="font-size:12px">'관리'를 눌러 사이트를 추가하세요</span>
            </div>`;
        }
        const todayDow=new Date().getDay();
        return rules.map(r=>{
            const activeToday=!r.days||r.days.length===0||r.days.includes(todayDow);
            const used=(D[td]||{})[r.domain]||0;
            const lim=r.limitMins||0;
            const pct=lim>0?Math.min(Math.round(used/(lim*60)*100),100):0;
            const rem=lim>0?Math.max(lim*60-used,0):-1;
            const over=lim>0&&used>=lim*60;
            const bc=over?'#EA4335':pct>75?'#FF9500':'var(--pri)';
            const cfg=[];
            if(r.delaySec>0)cfg.push(`${r.delaySec}초 대기`);
            if(r.freeMins>0)cfg.push(`${r.freeMins}분 자유`);
            if(lim>0)cfg.push(`${lim}분 제한`);
            const statusTxt=!activeToday?'오늘은 타이머 꺼짐':over?'오늘 제한 초과':lim>0?fmtRemain(rem):`${Math.floor(used/60)}분 사용`;
            return `<div class="t-row">
                <div class="t-top">
                    <span class="t-domain">${r.domain}</span>
                    <span class="t-stat${over?' over':''}">${statusTxt}</span>
                </div>
                ${lim>0&&activeToday?`<div class="t-bar-bg"><div class="t-bar-fill" style="width:${pct}%;background:${bc}"></div></div>`:''}
                ${cfg.length?`<div class="t-cfg">${cfg.join(' · ')}</div>`:''}
            </div>`;
        }).join('');
    }

    /* ═══════════════════ 메인 ═══════════════════ */
    function renderMain(){
        const D=_D,td=getLocalDate();
        const sorted=Object.entries(D[td]||{}).sort((a,b)=>b[1]-a[1]);
        const total=sorted.reduce((s,[,v])=>s+v,0);
        const top3=sorted.slice(0,3),rest=sorted.slice(3).reduce((s,[,v])=>s+v,0);
        const COLS=['#4285F4','#00BCD4','#34A853'];
        const dSegs=[...top3.map(([,v],i)=>({v,c:COLS[i]})),...(rest>0?[{v:rest,c:'#C7C7CC'}]:[])];
        const w7=Array.from({length:7},(_,i)=>getLocalDate(6-i));
        const wTot=w7.reduce((s,d)=>s+Object.values(D[d]||{}).reduce((a,b)=>a+b,0),0);
        const wAct=w7.filter(d=>Object.values(D[d]||{}).reduce((a,b)=>a+b,0)>0).length;
        const wAvg=wAct?Math.round(wTot/wAct):0;
        const wS={};
        for(const d of w7)for(const[s,v]of Object.entries(D[d]||{}))wS[s]=(wS[s]||0)+v;
        const wTop3=Object.entries(wS).sort((a,b)=>b[1]-a[1]).slice(0,3);
        return `
        <div class="hdr">
            <span class="hdr-title">디지털 웰빙</span>
            <button class="ico" id="btn-refresh" type="button">${ICO.refresh}</button>
            <button class="ico" id="btn-weekly"  type="button">${ICO.weekly}</button>
            <button class="ico" id="btn-settings" type="button">${ICO.settings}</button>
        </div>
        <div class="card card-tap" style="margin-top:8px" id="today-card">
            <div class="today-lbl">오늘의 사이트 사용 시간</div>
            <div class="today-body">
                <div class="today-left"><div class="big">${fmt(total)}</div></div>
                <div class="today-right">${donut(dSegs)}</div>
            </div>
            <div class="app-rows">
                ${top3.map(([site,secs],i)=>`
                    <div class="app-row">
                        <span class="a-dot" style="background:${COLS[i]}"></span>
                        <span class="a-name">${site}</span>
                        <span class="a-time">${fmt(secs)}</span>
                    </div>`).join('')}
                ${!total?`<div style="text-align:center;font-size:13px;color:var(--t3);padding:8px 0">데이터 없음</div>`:''}
            </div>
        </div>
        <div class="mini-row">
            <div class="mc"><div class="mc-lbl">최근 7일</div>
                <div class="mc-val">${fmtS(wTot)}</div><div class="mc-sub">누적</div></div>
            <div class="mc"><div class="mc-lbl">일 평균</div>
                <div class="mc-val">${fmtS(wAvg)}</div><div class="mc-sub">활동일 기준</div></div>
            <div class="mc"><div class="mc-lbl">방문 사이트</div>
                <div class="mc-val">${sorted.length}</div><div class="mc-sub">개</div></div>
        </div>
        <div class="slbl-row">
            <span class="slbl-txt">TOP 3</span>
            <span class="badge">이번 주</span>
        </div>
        <div class="card">
            ${wTop3.length?wTop3.map(([s,v],i)=>`
                <div class="lrow">
                    <span class="l-rank">${i+1}</span>
                    ${iconLI(s,i)}
                    <span class="l-name">${s}</span>
                    <span class="l-time">${fmt(v)}</span>
                </div>`).join('')
            :'<div class="empty-msg">데이터 없음</div>'}
        </div>
        <div class="slbl-row">
            <span class="slbl-txt">사이트 타이머</span>
            <button id="btn-manage" type="button"
                    style="background:none;border:none;cursor:pointer;font:inherit;
                           color:var(--pri);font-size:13px;padding:0">관리</button>
        </div>
        <div class="card">${renderTimerList(D,td)}</div>`;
    }

    /* ═══════════════════ 대시보드 ═══════════════════ */
    function renderDash(){
        const D=_D,td=getLocalDate();
        const days=Array.from({length:14},(_,i)=>getLocalDate(13-i));
        const tots=days.map(d=>Object.values(D[d]||{}).reduce((a,b)=>a+b,0));
        const maxT=Math.max(...tots,1);
        const allS={};
        for(const d of days)for(const[s,v]of Object.entries(D[d]||{}))allS[s]=(allS[s]||0)+v;
        const top3=Object.entries(allS).sort((a,b)=>b[1]-a[1]).slice(0,3).map(([s])=>s);
        const C3=['#4285F4','#00BCD4','#34A853'];
        const bc=s=>{const i=top3.indexOf(s);return i>=0?C3[i]:'#C7C7CC';};
        const selD=D[_dd]||{},selT=Object.values(selD).reduce((a,b)=>a+b,0);
        const selS=Object.entries(selD).sort((a,b)=>b[1]-a[1]);
        const sdt=new Date(_dd+'T00:00:00');
        const sLbl=_dd===td?'오늘':`${sdt.getMonth()+1}월 ${sdt.getDate()}일 (${DOW_KOR[sdt.getDay()]})`;
        return `
        <div class="hdr">
            <button class="ico ico-back" id="btn-back" type="button">${ICO.back}</button>
            <span class="hdr-title">대시보드</span>
        </div>
        <div class="date-wrap"><div class="date-scr" id="dscr">
            <div style="min-width:8px;flex-shrink:0"></div>
            ${days.map(d=>{
                const dt=new Date(d+'T00:00:00');
                const isT=d===td,isSel=d===_dd;
                return `<div class="dpill${isSel?' sel':''}${isT&&!isSel?' today':''}" data-date="${d}">
                    <div class="dpill-dow">${DOW_KOR[dt.getDay()]}</div>
                    <div class="dpill-num">${dt.getDate()}</div>
                </div>`;
            }).join('')}
            <div style="min-width:8px;flex-shrink:0"></div>
        </div></div>
        <div class="card" style="padding:20px">
            <div class="big">${fmt(selT)}</div>
            <div style="font-size:13px;color:var(--t2);margin-top:4px">${sLbl} · ${selS.length}개 사이트</div>
        </div>
        <div class="card" style="padding:16px 12px 8px">
            <div class="schart" id="schart">
                ${days.map((d,i)=>{
                    const dD=D[d]||{},dT=tots[i];
                    const bH=dT>0?Math.max(Math.round(dT/maxT*90),4):0;
                    const isSel=d===_dd,isT=d===td;
                    const dt=new Date(d+'T00:00:00');
                    const t3s=top3.map(s=>[s,dD[s]||0]).filter(([,v])=>v>0);
                    const rv=Object.entries(dD).filter(([s])=>!top3.includes(s)).reduce((s,[,v])=>s+v,0);
                    const segs=[...t3s,...(rv>0?[['__',rv]]:[])];
                    const segH=segs.map(([s,v])=>`<div style="flex:${v};background:${s==='__'?'#C7C7CC':bc(s)}"></div>`).join('');
                    return `<div class="scol${isSel?' sel':''}" data-date="${d}" title="${fmt(dT)}">
                        <div class="sbar" style="height:${bH}px">${segH}</div>
                        <div class="slb${isT?' today':''}${isSel?' sel':''}">${dt.getMonth()+1}/${dt.getDate()}</div>
                    </div>`;
                }).join('')}
            </div>
            <div class="s-legend">
                ${top3.map((s,i)=>`<div class="sl-item">
                    <span class="sl-dot" style="background:${C3[i]}"></span><span>${s}</span>
                </div>`).join('')}
            </div>
        </div>
        <div class="slbl-row">
            <span class="slbl-txt">사용 시간</span>
            <span class="badge">${sLbl}</span>
        </div>
        <div class="card">
            ${selS.length?selS.map(([s,v],i)=>`
                <div class="lrow">${iconLI(s,i)}
                    <span class="l-name">${s}</span>
                    <span class="l-time">${fmt(v)}</span>
                </div>`).join('')
            :'<div class="empty-msg">데이터 없음</div>'}
        </div>
        <div style="height:20px"></div>`;
    }

    /* ═══════════════════ 주간 리포트 ═══════════════════ */
    function renderWeekly(){
        const D=_D,td=getLocalDate();
        function wDays(off){
            const now=new Date(),dow=now.getDay();
            const sun=new Date(now);sun.setDate(now.getDate()-dow-(off*7));
            return Array.from({length:7},(_,i)=>{
                const d=new Date(sun);d.setDate(sun.getDate()+i);
                return [d.getFullYear(),String(d.getMonth()+1).padStart(2,'0'),String(d.getDate()).padStart(2,'0')].join('-');
            });
        }
        const wd=wDays(_wo);
        const dT=wd.map(d=>Object.values(D[d]||{}).reduce((a,b)=>a+b,0));
        const mxT=Math.max(...dT,1);
        const wTot=dT.reduce((a,b)=>a+b,0);
        const act=dT.filter(v=>v>0).length;
        const wAvg=act?Math.round(wTot/act):0;
        const avgPx=mxT>0?Math.min(Math.round(wAvg/mxT*90),85):0;
        const showAvg=wTot>0&&avgPx>0;
        const wS={};
        for(const d of wd)for(const[s,v]of Object.entries(D[d]||{}))wS[s]=(wS[s]||0)+v;
        const wTop5=Object.entries(wS).sort((a,b)=>b[1]-a[1]).slice(0,5);
        const wTop3Sites=wTop5.slice(0,3).map(([s])=>s);
        const C3=['#4285F4','#00BCD4','#34A853'];
        const bc=s=>{const i=wTop3Sites.indexOf(s);return i>=0?C3[i]:'#C7C7CC';};
        const ws=new Date(wd[0]+'T00:00:00'),we=new Date(wd[6]+'T00:00:00');
        const range=`${ws.getMonth()+1}월 ${ws.getDate()}일-${we.getMonth()+1}월 ${we.getDate()}일`;
        return `
        <div class="hdr">
            <button class="ico ico-back" id="btn-back" type="button">${ICO.back}</button>
            <span class="hdr-title">주간 리포트</span>
        </div>
        <div class="wk-sub">${range}</div>
        <div class="wk-tabs">
            <button class="wk-tab${_wo===1?' active':''}" type="button" data-wo="1">지난 주</button>
            <button class="wk-tab${_wo===0?' active':''}" type="button" data-wo="0">이번 주</button>
        </div>
        <div class="slbl">주별 사용 시간</div>
        <div class="card" style="padding:20px 18px 16px">
            <div class="big">${fmt(wAvg)}</div>
            <div style="font-size:14px;color:var(--t2);margin-top:4px;margin-bottom:20px">하루 평균 사용 시간</div>
            <div class="wk-chart-outer">
                ${showAvg?`<div class="wk-avg-line" style="bottom:calc(20px + ${avgPx}px)">
                    <span class="wk-avg-lbl">평균</span></div>`:''}
                <div class="wk-bars">
                    ${wd.map((d,i)=>{
                        const dD=D[d]||{};
                        const pct=dT[i]>0?Math.max(Math.round(dT[i]/mxT*100),4):0;
                        const isT=d===td;
                        const t3s=wTop3Sites.map(s=>[s,dD[s]||0]).filter(([,v])=>v>0);
                        const rv=Object.entries(dD).filter(([s])=>!wTop3Sites.includes(s)).reduce((s,[,v])=>s+v,0);
                        const segs=[...t3s,...(rv>0?[['__',rv]]:[])];
                        const segH=segs.map(([s,v])=>`<div style="flex:${v};background:${s==='__'?'#C7C7CC':bc(s)}"></div>`).join('');
                        return `<div class="wk-col">
                            <div class="wk-bar${!pct?' zero':''}" style="height:${pct}%${pct?';display:flex;flex-direction:column-reverse':''}">${pct?segH:''}</div>
                            <div class="wk-lbl${isT?' today':''}">${DOW_KOR[i]}</div>
                        </div>`;
                    }).join('')}
                </div>
            </div>
            <div class="wk-legend">
                ${wTop3Sites.map((s,i)=>`<div class="wl-item">
                    <span class="wl-dot" style="background:${C3[i]}"></span><span>${s}</span>
                </div>`).join('')}
                ${showAvg?`<div class="wl-item"><span class="wl-dash"></span><span>주간 평균</span></div>`:''}
            </div>
        </div>
        <div class="mini-row">
            <div class="mc"><div class="mc-lbl">주 합계</div>
                <div class="mc-val">${fmtS(wTot)}</div><div class="mc-sub">누적</div></div>
            <div class="mc"><div class="mc-lbl">일 평균</div>
                <div class="mc-val">${fmtS(wAvg)}</div><div class="mc-sub">활동일 기준</div></div>
            <div class="mc"><div class="mc-lbl">방문 사이트</div>
                <div class="mc-val">${Object.keys(wS).length}</div><div class="mc-sub">개</div></div>
        </div>
        ${wTop5.length?`
        <div class="slbl">사용 시간</div>
        <div class="card">
            ${wTop5.map(([s,v],i)=>`
                <div class="lrow"><span class="l-rank">${i+1}</span>
                    ${iconLI(s,i)}
                    <span class="l-name">${s}</span>
                    <span class="l-time">${fmt(v)}</span>
                </div>`).join('')}
        </div>`:''}
        <div style="height:20px"></div>`;
    }

    /* ═══════════════════ 설정 ═══════════════════ */
    function renderSettings(){
        const t=getTheme();const chk=v=>t===v?'✓':'';
        return `
        <div class="hdr">
            <button class="ico ico-back" id="btn-back" type="button">${ICO.back}</button>
            <span class="hdr-title">설정</span>
        </div>
        <div class="setting-section">화면 테마</div>
        <div class="card">
            <div class="s-row" id="th-auto"><div><div class="s-lbl">자동</div>
                <div class="s-sub">시스템 설정을 따릅니다</div></div>
                <span class="s-chk">${chk('auto')}</span></div>
            <div class="s-row" id="th-light"><div class="s-lbl">라이트 모드</div>
                <span class="s-chk">${chk('light')}</span></div>
            <div class="s-row" id="th-dark"><div class="s-lbl">다크 모드</div>
                <span class="s-chk">${chk('dark')}</span></div>
        </div>
        <div class="setting-section">데이터</div>
        <div class="card">
            <div class="s-row" id="raw-toggle">
                <div class="s-lbl">데이터 원본 보기</div>
                <span class="s-arr" id="raw-arr">▸</span>
            </div>
        </div>
        <pre class="raw-pre" id="raw-pre"></pre>
        <div style="height:20px"></div>`;
    }

    /* ═══════════════════ 규칙 목록 (Samsung 스타일) ═══════════════════ */
    function renderRules(){
        const rules=getRules(),groups=getGroups();
        const D=_D,td=getLocalDate(),todayDow=new Date().getDay();
        return `
        <div class="hdr">
            <button class="ico ico-back" id="btn-back" type="button">${ICO.back}</button>
            <span class="hdr-title">사이트 타이머</span>
            <button class="ico" id="btn-add-rule" type="button" title="규칙 추가">${ICO.add}</button>
            <button class="ico" id="btn-add-group" type="button" title="그룹 추가" style="font-size:12px;font-weight:700">G+</button>
        </div>
        <div class="rules-desc">타이머는 매일 자정에 초기화됩니다. 그룹에 포함된 사이트는 한 곳에서 대기 완료 시 그룹 전체가 허용됩니다.</div>

        ${rules.length?`
        <div class="card">
            ${rules.map(r=>{
                const activeToday=!r.days||r.days.length===0||r.days.includes(todayDow);
                const used=(D[td]||{})[r.domain]||0;
                const lim=r.limitMins||0;
                const pct=lim>0?Math.min(Math.round(used/(lim*60)*100),100):0;
                const rem=lim>0?Math.max(lim*60-used,0):-1;
                const over=lim>0&&used>=lim*60;
                const bc=over?'#EA4335':pct>75?'#FF9500':'var(--pri)';
                const statusTxt=!activeToday?'오늘은 타이머 꺼짐':over?'오늘 제한 초과':lim>0?fmtRemain(rem):`오늘 ${Math.floor(used/60)}분 사용`;
                const grp=r.groupId?groups.find(g=>g.id===r.groupId):null;
                const daysLabel=r.days&&r.days.length>0&&r.days.length<7?
                    r.days.map(d=>DOW_KOR[d]).join('·')+' 활성':'매일';
                const notifParts=[];
                if(r.notify?.min10)notifParts.push('10분 전');
                if(r.notify?.min5)notifParts.push('5분 전');
                if(r.notify?.min1)notifParts.push('1분 전');
                return `<div class="r-item">
                    <div class="r-item-hdr">
                        <div class="r-item-name">
                            <img class="r-item-favicon"
                                 src="https://www.google.com/s2/favicons?domain=${r.domain}&sz=32"
                                 onerror="this.style.display='none'" alt="">
                            ${r.domain}
                        </div>
                        <div style="display:flex;align-items:center;gap:6px">
                            ${r.delaySec>0?`<span class="r-item-badge">${r.delaySec}초</span>`:''}
                            <button class="edit-btn" data-rule-id="${r.id}">편집</button>
                        </div>
                    </div>
                    <div class="r-item-status${over?' over':''}${!activeToday?' off':''}">${statusTxt}</div>
                    <div class="r-bar-bg"><div class="r-bar-fill" style="width:${activeToday?pct:0}%;background:${bc}"></div></div>
                    ${grp?`<div class="r-grp-tag">그룹: ${grp.name}</div>`:''}
                    <div class="r-days-tag">${daysLabel}${notifParts.length?' · 알림 '+notifParts.join(', '):''}</div>
                </div>`;
            }).join('')}
        </div>`
        :'<div class="empty-msg" style="margin:0 16px;background:var(--sf);border-radius:18px;box-shadow:var(--sh)">등록된 규칙 없음<br><span style="font-size:12px">상단 + 버튼으로 추가하세요</span></div>'}

        ${groups.length?`
        <div class="slbl">그룹</div>
        <div class="card">
            ${groups.map(g=>{
                const members=rules.filter(r=>r.groupId===g.id);
                const grpUsed=getGroupUsage(g.id);
                const grpLim=g.limitMins||0;
                const grpPct=grpLim>0?Math.min(Math.round(grpUsed/(grpLim*60)*100),100):0;
                const grpOver=grpLim>0&&grpUsed>=grpLim*60;
                return `<div class="grp-item">
                    <div class="grp-item-hdr">
                        <span class="grp-item-name">${g.name}</span>
                        <div style="display:flex;gap:8px;align-items:center">
                            <span style="font-size:12px;color:var(--t2)">${members.length}개 사이트</span>
                            <button class="edit-btn" data-group-id="${g.id}">편집</button>
                        </div>
                    </div>
                    <div class="grp-members">${members.map(r=>r.domain).join(', ')||'사이트 없음'}</div>
                    ${grpLim>0?`
                    <div class="grp-status${grpOver?' over':''}">${grpOver?'그룹 제한 초과':fmtRemain(Math.max(grpLim*60-grpUsed,0))}</div>
                    <div class="r-bar-bg"><div class="r-bar-fill" style="width:${grpPct}%;background:${grpOver?'#EA4335':'var(--pri)'}"></div></div>
                    `:`<div class="grp-status">하루 ${Math.floor(grpUsed/60)}분 사용</div>`}
                </div>`;
            }).join('')}
        </div>`:''}
        <div style="height:24px"></div>`;
    }

    /* ═══════════════════ 규칙 폼 (추가/편집) ═══════════════════ */
    function renderRuleForm(){
        const isEdit=!!_editRuleId;
        const r=isEdit?getRules().find(x=>x.id===_editRuleId)||{}:{};
        const groups=getGroups();
        const days=r.days||[];
        const notify=r.notify||{min1:false,min5:false,min10:false};
        const mkTog=(key,label)=>`
            <div class="tog-row">
                <span class="tog-lbl">${label}</span>
                <button class="tog${notify[key]?' on':''}" type="button" data-tog="${key}"></button>
            </div>`;
        return `
        <div class="hdr">
            <button class="ico ico-back" id="btn-back" type="button">${ICO.back}</button>
            <span class="hdr-title">${isEdit?'규칙 편집':'새 규칙'}</span>
            ${isEdit?`<button class="ico" id="btn-del-rule" type="button" style="color:#EA4335">${ICO.trash}</button>`:'<div style="width:36px"></div>'}
        </div>

        <div class="form-card">
            <div class="form-lbl">도메인</div>
            <input id="r-domain" class="form-input" placeholder="예: youtube.com"
                   autocomplete="off" autocapitalize="none" spellcheck="false"
                   value="${r.domain||''}"${isEdit?' readonly':''}>
            <div class="form-sep"></div>
            <div class="form-lbl">활성 요일 (비우면 매일)</div>
            <div class="day-row">
                ${DOW_KOR.map((d,i)=>`
                    <button class="day-btn${days.includes(i)?' on':''}" type="button" data-day="${i}">${d}</button>`).join('')}
            </div>
        </div>

        <div class="form-card">
            <div class="num-grid">
                <div class="num-cell">
                    <div class="form-lbl">대기 (초)</div>
                    <input id="r-delay" class="num-input" type="number" min="0" max="600"
                           value="${r.delaySec!==undefined?r.delaySec:30}">
                </div>
                <div class="num-cell">
                    <div class="form-lbl">자유 (분)</div>
                    <input id="r-free" class="num-input" type="number" min="0" max="1440"
                           value="${r.freeMins!==undefined?r.freeMins:15}">
                </div>
                <div class="num-cell">
                    <div class="form-lbl">제한 (분)</div>
                    <input id="r-limit" class="num-input" type="number" min="0" max="1440"
                           value="${r.limitMins!==undefined?r.limitMins:0}">
                </div>
            </div>
            <div class="hint">· 대기: 접속 시 카운트다운 · 자유: 대기 후 허용 시간 · 제한: 하루 최대 (0=제한 없음)</div>
        </div>

        <div class="form-card">
            <div class="form-lbl">잠금 전 알림</div>
            ${mkTog('min10','10분 전 알림')}
            ${mkTog('min5','5분 전 알림')}
            ${mkTog('min1','1분 전 알림')}
        </div>

        <div class="form-card">
            <div class="form-lbl">그룹 (선택)</div>
            <select id="r-group" class="form-select">
                <option value="">없음</option>
                ${groups.map(g=>`<option value="${g.id}"${r.groupId===g.id?' selected':''}>${g.name}</option>`).join('')}
            </select>
        </div>

        <div style="margin:0 16px">
            <button id="btn-save-rule" class="save-btn" type="button">${isEdit?'저장':'추가'}</button>
        </div>
        <div style="height:24px"></div>`;
    }

    /* ═══════════════════ 그룹 폼 ═══════════════════ */
    function renderGroupForm(){
        const isEdit=!!_editGroupId;
        const g=isEdit?getGroups().find(x=>x.id===_editGroupId)||{}:{};
        const allRules=getRules();
        const memberIds=allRules.filter(r=>r.groupId===_editGroupId).map(r=>r.id);
        return `
        <div class="hdr">
            <button class="ico ico-back" id="btn-back" type="button">${ICO.back}</button>
            <span class="hdr-title">${isEdit?'그룹 편집':'새 그룹'}</span>
            ${isEdit?`<button class="ico" id="btn-del-group" type="button" style="color:#EA4335">${ICO.trash}</button>`:'<div style="width:36px"></div>'}
        </div>

        <div class="form-card">
            <div class="form-lbl">그룹 이름</div>
            <input id="g-name" class="form-input" placeholder="예: SNS" value="${g.name||''}">
            <div class="form-sep"></div>
            <div class="form-lbl">그룹 총 일일 제한 (분, 0=없음)</div>
            <input id="g-limit" class="num-input" type="number" min="0" max="1440"
                   style="width:120px" value="${g.limitMins||0}">
        </div>

        ${allRules.length?`
        <div class="form-card">
            <div class="form-lbl">포함할 사이트</div>
            ${allRules.map(r=>`
                <div class="tog-row">
                    <span class="tog-lbl">${r.domain}</span>
                    <button class="tog${memberIds.includes(r.id)?' on':''}" type="button" data-member="${r.id}"></button>
                </div>`).join('')}
        </div>`:''}

        <div style="margin:0 16px">
            <button id="btn-save-group" class="save-btn" type="button">${isEdit?'저장':'추가'}</button>
        </div>
        <div style="height:24px"></div>`;
    }

    /* ═══════════════════ 이벤트 ═══════════════════ */
    function attachEv(view){
        const on=(id,fn)=>{const el=document.getElementById(id);if(el)el.addEventListener('click',fn);};

        if(view==='main'){
            on('btn-refresh',()=>location.reload());
            on('btn-weekly', ()=>{_wo=0;nav('weekly');});
            on('btn-settings',()=>nav('settings'));
            on('today-card',  ()=>nav('dash'));
            on('btn-manage',  ()=>nav('rules'));
        }

        if(view==='dash'){
            on('btn-back',()=>nav('main'));
            const sc=document.getElementById('dscr');
            if(sc){
                sc.addEventListener('click',e=>{
                    const p=e.target.closest('.dpill');
                    if(p)nav('dash',{date:p.dataset.date});
                });
                sc.querySelector('.dpill.sel')?.scrollIntoView({behavior:'auto',inline:'center',block:'nearest'});
            }
            document.getElementById('schart')?.addEventListener('click',e=>{
                const c=e.target.closest('.scol');if(c)nav('dash',{date:c.dataset.date});
            });
        }

        if(view==='weekly'){
            on('btn-back',()=>nav('main'));
            document.querySelectorAll('.wk-tab').forEach(b=>{
                b.addEventListener('click',()=>nav('weekly',{wo:parseInt(b.dataset.wo)}));
            });
        }

        if(view==='settings'){
            on('btn-back',()=>nav('main'));
            on('th-auto', ()=>{setTheme('auto'); nav('settings');});
            on('th-light',()=>{setTheme('light');nav('settings');});
            on('th-dark', ()=>{setTheme('dark'); nav('settings');});
            on('raw-toggle',()=>{
                const pre=document.getElementById('raw-pre');
                const arr=document.getElementById('raw-arr');
                if(!pre)return;
                if(pre.style.display==='block'){pre.style.display='none';if(arr)arr.textContent='▸';}
                else{
                    const raw={};
                    GM_listValues().filter(k=>k.startsWith(KEY_PREFIX)).sort()
                        .forEach(k=>{raw[k]=GM_getValue(k);});
                    pre.textContent=JSON.stringify(raw,null,2);
                    pre.style.display='block';if(arr)arr.textContent='▾';
                }
            });
        }

        if(view==='rules'){
            on('btn-back',()=>nav('main'));
            on('btn-add-rule', ()=>{_editRuleId=null;nav('rule-form');});
            on('btn-add-group',()=>{_editGroupId=null;nav('group-form');});
            document.querySelectorAll('.edit-btn[data-rule-id]').forEach(b=>{
                b.addEventListener('click',()=>nav('rule-form',{ruleId:b.dataset.ruleId}));
            });
            document.querySelectorAll('.edit-btn[data-group-id]').forEach(b=>{
                b.addEventListener('click',()=>nav('group-form',{groupId:b.dataset.groupId}));
            });
        }

        if(view==='rule-form'){
            on('btn-back',()=>nav('rules'));
            /* 요일 토글 */
            const days=new Set((_editRuleId?getRules().find(r=>r.id===_editRuleId)?.days||[]:[]));
            document.querySelectorAll('.day-btn').forEach(b=>{
                b.addEventListener('click',()=>{
                    const d=parseInt(b.dataset.day);
                    if(days.has(d)){days.delete(d);b.classList.remove('on');}
                    else{days.add(d);b.classList.add('on');}
                });
            });
            /* 알림 토글 */
            const notify={
                min1: !!(_editRuleId&&getRules().find(r=>r.id===_editRuleId)?.notify?.min1),
                min5: !!(_editRuleId&&getRules().find(r=>r.id===_editRuleId)?.notify?.min5),
                min10:!!(_editRuleId&&getRules().find(r=>r.id===_editRuleId)?.notify?.min10),
            };
            document.querySelectorAll('.tog[data-tog]').forEach(b=>{
                b.addEventListener('click',()=>{
                    const k=b.dataset.tog;
                    notify[k]=!notify[k];
                    b.classList.toggle('on',notify[k]);
                });
            });
            /* 삭제 */
            on('btn-del-rule',()=>{
                if(!confirm('이 규칙을 삭제할까요?'))return;
                saveRules(getRules().filter(r=>r.id!==_editRuleId));
                _editRuleId=null;nav('rules');
            });
            /* 저장 */
            on('btn-save-rule',()=>{
                const domEl=document.getElementById('r-domain');
                const domain=(domEl?.value||'').trim().replace(/^www\./i,'').toLowerCase();
                const delaySec=Math.max(0,parseInt(document.getElementById('r-delay')?.value||'0',10)||0);
                const freeMins=Math.max(0,parseInt(document.getElementById('r-free')?.value||'0',10)||0);
                const limitMins=Math.max(0,parseInt(document.getElementById('r-limit')?.value||'0',10)||0);
                const groupId=document.getElementById('r-group')?.value||'';
                if(!domain||!/^[a-z0-9][a-z0-9\-\.]+\.[a-z]{2,}$/i.test(domain)){
                    alert('올바른 도메인을 입력해주세요\n예: youtube.com');return;
                }
                const rules=getRules();
                if(_editRuleId){
                    const idx=rules.findIndex(r=>r.id===_editRuleId);
                    if(idx>=0)rules[idx]={...rules[idx],delaySec,freeMins,limitMins,
                        groupId:groupId||null,days:[...days],notify:{...notify}};
                } else {
                    if(rules.find(r=>domainMatches(r.domain,domain))){
                        alert('이미 등록된 도메인입니다');return;
                    }
                    rules.push({id:genId(),domain,delaySec,freeMins,limitMins,
                        groupId:groupId||null,days:[...days],notify:{...notify}});
                }
                saveRules(rules);_editRuleId=null;nav('rules');
            });
        }

        if(view==='group-form'){
            on('btn-back',()=>nav('rules'));
            /* 멤버 토글 */
            const members=new Set(getRules().filter(r=>r.groupId===_editGroupId).map(r=>r.id));
            document.querySelectorAll('.tog[data-member]').forEach(b=>{
                b.addEventListener('click',()=>{
                    const id=b.dataset.member;
                    if(members.has(id)){members.delete(id);b.classList.remove('on');}
                    else{members.add(id);b.classList.add('on');}
                });
            });
            /* 삭제 */
            on('btn-del-group',()=>{
                if(!confirm('이 그룹을 삭제할까요?\n(포함된 규칙의 그룹 연결도 해제됩니다)'))return;
                saveGroups(getGroups().filter(g=>g.id!==_editGroupId));
                const rules=getRules().map(r=>r.groupId===_editGroupId?{...r,groupId:null}:r);
                saveRules(rules);_editGroupId=null;nav('rules');
            });
            /* 저장 */
            on('btn-save-group',()=>{
                const name=(document.getElementById('g-name')?.value||'').trim();
                const limitMins=Math.max(0,parseInt(document.getElementById('g-limit')?.value||'0',10)||0);
                if(!name){alert('그룹 이름을 입력해주세요');return;}
                const groups=getGroups();
                let gid=_editGroupId;
                if(gid){
                    const idx=groups.findIndex(g=>g.id===gid);
                    if(idx>=0)groups[idx]={...groups[idx],name,limitMins};
                } else {
                    gid=genId();groups.push({id:gid,name,limitMins});
                }
                saveGroups(groups);
                /* 멤버 업데이트 */
                const rules=getRules().map(r=>{
                    if(members.has(r.id))return{...r,groupId:gid};
                    if(r.groupId===gid)return{...r,groupId:null};
                    return r;
                });
                saveRules(rules);_editGroupId=null;nav('rules');
            });
        }
    }

    /* ═════════════════════ 진입점 ════════════════════ */
    if(location.href.startsWith(VIEWER_URL)){
        initViewer();
    } else {
        initTracker();
        initOverlay();
    }

})();