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/
// ==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();
}
})();