| <!doctype html> |
| <html lang="en"> |
| <head> |
| <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#0b1424"> |
| <title>ReviewFlow | Systematic Review Screening Studio</title> |
| <style> |
| :root{color-scheme:dark;--bg:#0a111e;--surface:#101c2d;--surface2:#152339;--surface3:#1d304a;--border:#2a3c55;--fg:#eaf2fa;--muted:#9baec4;--teal:#44dbba;--blue:#78a9ff;--amber:#f4c56b;--red:#ff8b93;--r:14px}*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:radial-gradient(ellipse at 70% -16%,#1a3555 0,transparent 42%),var(--bg);font:14px/1.5 Inter,ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--fg)}button,input,select,textarea{font:inherit}button{cursor:pointer}button:disabled{cursor:not-allowed;opacity:.55}a{color:var(--teal)}.shell{max-width:1540px;min-height:100vh;margin:auto;display:grid;grid-template-columns:236px minmax(0,1fr)}aside{border-right:1px solid var(--border);padding:25px 15px;position:sticky;top:0;height:100vh;overflow:auto;background:#0c1626dd}.brand{display:flex;align-items:center;gap:11px;margin:0 7px 30px}.brand-icon{background:linear-gradient(135deg,#3edbbb,#4b83ed);color:#0a1721;padding:10px;border-radius:13px;font-weight:900;font-size:22px}.brand strong{font-size:19px;letter-spacing:-.6px;display:block}.brand small{font-size:10px;color:var(--muted);letter-spacing:2px}.side-label{font-size:10px;color:#7391b1;text-transform:uppercase;letter-spacing:1.6px;margin:16px 12px 9px}.nav{width:100%;display:flex;gap:10px;align-items:center;text-align:left;padding:12px 13px;border:1px solid transparent;border-radius:10px;background:transparent;color:#9eb3cd;margin-bottom:5px;font-weight:600}.nav span{font-size:17px;opacity:.85}.nav.active{color:var(--teal);background:#163d4c;border-color:#2d6973}.nav:hover:not(.active){background:#1c2d44;color:white}.side-bottom{padding:14px;margin-top:35px;border:1px solid var(--border);border-radius:12px;background:#142036}.side-bottom strong{font-size:12px;color:var(--teal)}.side-bottom p{margin:6px 0 0;color:var(--muted);font-size:11px}main{min-width:0;padding:27px clamp(16px,3.5vw,45px) 55px}.topbar{display:flex;justify-content:space-between;gap:16px;align-items:center;margin-bottom:24px}.eyebrow{font-size:11px;letter-spacing:2px;color:var(--teal);text-transform:uppercase;font-weight:800}.topbar h1{font-size:clamp(26px,3.1vw,39px);letter-spacing:-1.2px;line-height:1.2;margin:6px 0 7px}.subtitle{color:var(--muted);margin:0;max-width:750px}.tools{display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end}.input,.select,.textarea{background:#0e1a2b;border:1px solid #38506b;color:var(--fg);padding:10px 11px;border-radius:9px;min-width:0;max-width:100%}.textarea{resize:vertical;min-height:78px;width:100%}.input:focus,.select:focus,.textarea:focus{outline:2px solid #367f85;outline-offset:1px}.btn{background:linear-gradient(120deg,#50e0c0,#2caeb3);border:0;color:#062529;padding:10px 14px;border-radius:9px;font-weight:800;white-space:normal}.btn.secondary{background:#1b324d;color:#dceafb;border:1px solid #3a536d}.btn.danger{background:#4a2633;color:#ffabb4;border:1px solid #82505a}.btn:focus-visible,.nav:focus-visible{outline:2px solid #e4b963;outline-offset:2px}.grid{display:grid;gap:14px}.kpis{grid-template-columns:repeat(4,minmax(0,1fr));margin:21px 0}.panel{background:linear-gradient(155deg,#142238,#101c2c);border:1px solid #2b405a;border-radius:var(--r);padding:20px;min-width:0;box-shadow:0 9px 27px #00000015}.kpi .label{font-size:12px;color:var(--muted);font-weight:600}.kpi strong{display:block;font-size:clamp(23px,2.6vw,34px);letter-spacing:-.8px;margin:7px 0 2px}.kpi .detail{color:#aabbd1;font-size:11px}.accent{color:var(--teal)}.warning{color:var(--amber)}.muted{color:var(--muted)}.danger-text{color:var(--red)}.section-title{margin:0 0 5px;font-size:17px;letter-spacing:-.25px}.desc{color:var(--muted);font-size:12px;margin:0 0 15px}.twocol{grid-template-columns:minmax(0,1.5fr) minmax(280px,1fr)}.equal{grid-template-columns:repeat(2,minmax(0,1fr))}.headrow{display:flex;align-items:center;justify-content:space-between;gap:14px;flex-wrap:wrap;margin-bottom:12px}.pill{display:inline-flex;align-items:center;font-size:11px;font-weight:800;padding:4px 9px;border-radius:999px;background:#20413f;color:var(--teal)}.pill.gold{background:#3d3522;color:var(--amber)}.pill.red{background:#462b36;color:var(--red)}.stack{display:grid;gap:10px}.barrow{display:grid;grid-template-columns:minmax(96px,1.1fr) minmax(0,3fr) 44px;align-items:center;gap:10px;margin:9px 0;color:var(--muted);font-size:12px}.track{height:10px;background:#223850;border-radius:30px;overflow:hidden}.fill{height:100%;border-radius:30px;background:linear-gradient(90deg,#2fb9af,#6a94f7);min-width:0}.fill.gold{background:#eab965}.fill.red{background:#de747f}.flowchart{display:grid;grid-template-columns:1fr 1fr;gap:9px;align-items:center;max-width:650px;margin:auto}.flowbox{border:1px solid #3a536d;border-radius:10px;padding:13px;background:#1b2f47;text-align:center;min-height:70px;display:flex;flex-direction:column;justify-content:center}.flowbox strong{font-size:23px;color:var(--teal);line-height:1.2}.flowbox small{font-size:11px;color:#c5d5e7}.flowbox.note strong{color:var(--amber)}.flowbox.final{background:#173c42;border-color:#328b85}.flowline{text-align:center;color:#7793b0;font-size:16px;grid-column:1 / -1;line-height:10px}.flowwide{grid-column:1 / -1}.tablewrap{overflow:auto;border:1px solid #29415b;border-radius:10px}.table{border-collapse:collapse;min-width:550px;width:100%;text-align:left}.table th{font-size:11px;text-transform:uppercase;letter-spacing:.75px;color:#a9bdd2;background:#1a2d42;padding:11px;white-space:nowrap}.table td{border-top:1px solid #293d53;padding:10px 11px;font-size:12px;vertical-align:top}.table tr:hover td{background:#1a2b41}.table .active-row td{background:#143c47}.clickrow{cursor:pointer}.tag{border:1px solid #36536b;color:#a6c7d9;border-radius:7px;padding:2px 6px;font-size:11px;white-space:nowrap}.actions{display:flex;gap:8px;flex-wrap:wrap;align-items:center}.formgrid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.formfield{display:flex;flex-direction:column;gap:5px;min-width:0}.formfield label{font-size:11px;color:#b1c3d6;font-weight:700}.record-title{font-size:18px;line-height:1.4;margin:3px 0 10px}.metadata{color:var(--muted);font-size:12px;margin-bottom:11px;overflow-wrap:anywhere}.abstract{line-height:1.65;color:#c6d7e7;font-size:13px;max-height:160px;overflow:auto}.alert{border:1px solid #856b3c;background:#3c31213d;border-radius:10px;color:#f1d29b;padding:11px 13px;font-size:12px}.notice{border:1px solid #2f6468;background:#14323b;border-radius:10px;padding:11px 13px;color:#c4f3e5;font-size:12px}.download-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:9px}.export{background:#1b3047;color:var(--fg);border:1px solid #36516b;border-radius:9px;padding:12px 10px;text-align:left;font-weight:700}.export:hover{border-color:var(--teal)}.mini{font-size:11px;color:var(--muted)}.footer{font-size:11px;color:#7992aa;text-align:center;padding-top:26px}.hidden{display:none!important}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}#toast{position:fixed;z-index:60;right:24px;bottom:20px;background:#16423f;color:#e8fff8;border:1px solid #55bda5;box-shadow:0 12px 36px #0008;max-width:420px;padding:13px 16px;border-radius:11px;display:none}#toast.error{background:#4b2833;border-color:#e58693}.statusline{display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;margin:9px 0;color:var(--muted);font-size:12px}.doc-separator{height:1px;background:#2d425b;margin:18px 0}code{color:#a6e8d9;font-size:12px}.scenario-note{max-width:230px}.chart-svg{display:block;width:100%;height:auto}.hand{cursor:pointer}.navstrip{display:none}.flowhint{color:var(--muted);text-align:center;font-size:11px;margin-top:9px} |
| @media(max-width:1120px){.shell{grid-template-columns:205px minmax(0,1fr)}.kpis{grid-template-columns:repeat(2,minmax(0,1fr))}.twocol{grid-template-columns:1fr}} |
| @media(max-width:800px){.shell{display:block}aside{position:static;height:auto;border-right:0;border-bottom:1px solid var(--border);padding:12px 15px}.brand{margin:0 0 12px}.brand small{letter-spacing:1px}.side-label,.side-bottom{display:none}.side-nav{display:flex;gap:8px;overflow-x:auto;padding:0 0 7px;scrollbar-width:thin}.nav{width:auto;flex:0 0 auto;white-space:nowrap;margin:0;padding:9px 11px}.nav span{font-size:14px}main{padding:19px 14px 35px}.topbar{flex-direction:column;align-items:flex-start}.tools{justify-content:flex-start}.equal{grid-template-columns:1fr}.panel{padding:15px}} |
| @media(max-width:490px){.kpis{gap:9px}.kpi strong{font-size:25px}.panel{padding:12px}.flowbox strong{font-size:19px}.flowbox{padding:9px;min-height:66px}.formgrid,.download-grid{grid-template-columns:1fr}.topbar h1{font-size:29px}.input,.select{width:100%}} |
| </style> |
| </head><body> |
| <div class="shell"> |
| <aside><div class="brand"><span class="brand-icon">R↗</span><div><strong>ReviewFlow</strong><small>RESEARCH INTELLIGENCE</small></div></div> |
| <div class="side-label">Research workspace</div><div id="sideNav" class="side-nav"> |
| <button type="button" class="nav active" data-page="overview"><span>◫</span> Overview</button> |
| <button type="button" class="nav" data-page="screening"><span>▤</span> Screening Studio</button> |
| <button type="button" class="nav" data-page="duplicates"><span>⧉</span> Duplicate Audit</button> |
| <button type="button" class="nav" data-page="agreement"><span>◎</span> Reviewer Agreement</button> |
| <button type="button" class="nav" data-page="prisma"><span>⬡</span> Review Flow</button> |
| <button type="button" class="nav" data-page="data"><span>▣</span> Data & Exports</button> |
| </div><div class="side-bottom"><strong>LOCAL-FIRST RESEARCH</strong><p>Your imported records stay in this browser session. Download your work before refreshing. No account, API or server processing.</p></div></aside> |
| <main><div class="topbar"><div><div class="eyebrow">PROJECT 69 / EVIDENCE SYNTHESIS</div><h1 id="pageTitle">Literature review command center</h1><p class="subtitle" id="pageSubtitle">Trace every screening decision, disagreement and duplicate in one reproducible workflow.</p></div><div class="tools"><label for="scenario" class="sr-only">Demonstration study</label><select id="scenario" class="select" aria-label="Select a demonstration study"><option value="ai">Academic writing & AI</option><option value="mobility">Border education & mobility</option><option value="climate">Climate adaptation & policy</option></select><button id="reset" class="btn secondary" type="button">Reset demo</button></div></div> |
| <div class="notice" id="demoNotice">SYNTHETIC EXAMPLES: All references and records are invented for demonstration. ReviewFlow is a title/abstract screening tool, not a database search, full-text review or certified PRISMA workflow.</div> |
| <div id="pageContent"></div><div class="footer">REVIEWFLOW v1.0 · OFFLINE TITLE & ABSTRACT SCREENING · MIT LICENSE · NO AUTOMATED ELIGIBILITY DECISIONS</div></main> |
| </div><input class="hidden" type="file" accept=".csv,text/csv" id="csvFile" aria-label="Import CSV dataset"><div id="toast" role="status" aria-live="polite"></div> |
| <script id="engine"> |
| const Engine=(()=>{ |
| const decisions=['pending','include','exclude','unsure']; |
| const norm=s=>String(s??'').normalize('NFKD').replace(/[\u0300-\u036f]/g,'').toLowerCase().replace(/[^a-z0-9]+/g,' ').trim().replace(/\s+/g,' '); |
| const safe=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); |
| function dedupe(rows){const known=new Map();return rows.map(row=>{let doi=norm(row.doi).replace(/\s+/g,'');let key=doi?'doi:'+doi:'title:'+norm(row.title)+'|'+row.year;let parent=known.get(key);if(!parent)known.set(key,row.id);return {...row,duplicateOf:parent||'',matchReason:parent?(doi?'identifier':'title + year'):''};});} |
| function finalDecision(row){if(row.resolution==='include'||row.resolution==='exclude')return row.resolution;if(['include','exclude'].includes(row.reviewer_a)&&row.reviewer_a===row.reviewer_b)return row.reviewer_a;return 'pending';} |
| function stats(rows){let base=dedupe(rows), unique=base.filter(x=>!x.duplicateOf),dup=base.length-unique.length;let included=unique.filter(x=>finalDecision(x)==='include').length,excluded=unique.filter(x=>finalDecision(x)==='exclude').length,pending=unique.length-included-excluded;let compared=unique.filter(x=>['include','exclude'].includes(x.reviewer_a)&&['include','exclude'].includes(x.reviewer_b));let both=compared.length, aInc=compared.filter(x=>x.reviewer_a==='include').length,bInc=compared.filter(x=>x.reviewer_b==='include').length, agreed=compared.filter(x=>x.reviewer_a===x.reviewer_b).length;let p0=both?agreed/both:null,pe=both?(aInc*bInc+(both-aInc)*(both-bInc))/(both*both):null;let k=both&&Math.abs(1-pe)>1e-12?(p0-pe)/(1-pe):null;return {base,unique,total:base.length,duplicates:dup,screened:unique.length,included,excluded,pending,compared:both,agreed,agreement:p0,kappa:k};} |
| function matrix(rows){let s=stats(rows);return ['include','exclude'].map(a=>['include','exclude'].map(b=>s.unique.filter(x=>x.reviewer_a===a&&x.reviewer_b===b).length));} |
| const headers=['id','title','authors','year','doi','source','abstract','reviewer_a','reviewer_b','resolution','reason','note']; |
| function parseCSV(text){if(typeof text!=='string'||text.length>3000000)throw Error('CSV must be smaller than 3 MB.');text=text.replace(/^\uFEFF/,'');let rows=[],row=[],cell='',quoted=false,closed=false;for(let i=0;i<text.length;i++){let ch=text[i];if(quoted){if(ch==='"'&&text[i+1]==='"'){cell+='"';i++}else if(ch==='"'){quoted=false;closed=true}else cell+=ch;continue}if(closed){if(ch===','){row.push(cell);cell='';closed=false;continue}if(ch==='\n'||ch==='\r'){row.push(cell);rows.push(row);row=[];cell='';closed=false;if(ch==='\r'&&text[i+1]==='\n')i++;continue}throw Error('Unexpected text after a quoted field, at character '+(i+1));}if(ch==='"'){if(cell.length)throw Error('Unexpected quotation mark inside an unquoted field.');quoted=true}else if(ch===','){row.push(cell);cell=''}else if(ch==='\n'||ch==='\r'){row.push(cell);rows.push(row);row=[];cell='';if(ch==='\r'&&text[i+1]==='\n')i++}else cell+=ch;}if(quoted)throw Error('Unterminated quoted field.');if(cell!==''||row.length){row.push(cell);rows.push(row)}rows=rows.filter(r=>r.some(c=>String(c).trim()!==''));if(rows.length<2)throw Error('CSV must contain headers and at least one record.');let head=rows.shift().map(x=>x.trim().toLowerCase());if(head.length!==new Set(head).size)throw Error('Duplicate CSV column names.');for(let k of ['id','title','year'])if(!head.includes(k))throw Error('Missing required CSV column: '+k);if(rows.length>5000)throw Error('Maximum 5,000 records per import.');let out=[],ids=new Set();for(let i=0;i<rows.length;i++){let vals=rows[i];if(vals.length!==head.length)throw Error('Row '+(i+2)+' has '+vals.length+' values, expected '+head.length);let data={};head.forEach((key,j)=>data[key]=String(vals[j]||'').trim());let obj={};headers.forEach(key=>obj[key]=data[key]??'');obj.id=obj.id.trim();obj.title=obj.title.trim();if(!obj.id||!obj.title)throw Error('Row '+(i+2)+' is missing an ID or title.');if(ids.has(obj.id))throw Error('Duplicate ID: '+obj.id);ids.add(obj.id);if(!/^\d{4}$/.test(obj.year)||+obj.year<1800||+obj.year>2100)throw Error('Invalid year in row '+(i+2));obj.year=+obj.year;for(let key of ['reviewer_a','reviewer_b']){obj[key]=(obj[key]||'pending').toLowerCase();if(!decisions.includes(obj[key]))throw Error('Invalid '+key+' in row '+(i+2));}obj.resolution=(obj.resolution||'pending').toLowerCase();if(!['pending','include','exclude'].includes(obj.resolution))throw Error('Invalid resolution in row '+(i+2));if(obj.resolution==='exclude'&&!obj.reason)throw Error('An excluded record needs an exclusion reason in row '+(i+2));out.push(obj)}return out;} |
| function csvProtect(v){let s=String(v??'');if(/^[\s]*[=+@\-]/.test(s)&&!/^\s*-?\d+(\.\d+)?$/.test(s))s="'"+s;return '"'+s.replace(/"/g,'""')+'"';} |
| function csvExport(rows,columns=headers){return '\uFEFF'+columns.join(',')+'\r\n'+rows.map(r=>columns.map(c=>csvProtect(r[c]??'')).join(',')).join('\r\n')+'\r\n';} |
| function charts(rows){let arr=dedupe(rows).filter(x=>!x.duplicateOf);let years=new Map(),sources=new Map();for(let r of arr){years.set(r.year,(years.get(r.year)||0)+1);sources.set(r.source||'Unspecified',(sources.get(r.source||'Unspecified')||0)+1)}return {years:[...years].sort((a,b)=>a[0]-b[0]),sources:[...sources].sort((a,b)=>b[1]-a[1])};} |
| function demo(type){const packs={ai:{prefix:'AI',name:'Academic writing and generative AI',titles:['Feedback literacy in AI-assisted academic writing','Student agency and automated writing feedback','Ethical disclosure practices in university composition','Generative systems and scholarly argumentation','Revision strategies in multilingual graduate writing','Faculty perceptions of automated text assessment','Transparent citation practices in machine assisted writing','Writing anxiety and digital feedback in higher education','Peer review protocols for AI-supported manuscripts','Evidence appraisal in automated literature summaries','Language transfer in bilingual scientific writing','Hallucination awareness in academic editing'],tags:['feedback','writing','integrity','revision']},mobility:{prefix:'BE',name:'Border education and mobility',titles:['Cross-border university mobility and student belonging','Commuting constraints in regional tertiary education','Credential recognition across border institutions','Language practices among mobile university students','Scholarship access in frontier communities','Academic identities in cross-border classrooms','Institutional partnerships and student transitions','Digital learning for geographically mobile students','Family networks in transnational education','Rural inequality in student mobility systems','Higher education governance in border regions','Mobility barriers in bilingual degree programmes'],tags:['access','mobility','recognition','education']},climate:{prefix:'CL',name:'Climate adaptation and regional policy',titles:['Local climate adaptation and municipal governance','Risk perception in highland farming communities','Participatory resilience planning for regional authorities','Early warning communication in vulnerable districts','Public investment in climate risk infrastructure','Climate literacy and citizen engagement','Household adaptation under rainfall uncertainty','Institutional coordination for drought response','Green infrastructure and urban heat exposure','Disaster preparedness in community organisations','Participatory monitoring for ecosystem restoration','Policy learning across environmental agencies'],tags:['climate','resilience','policy','adaptation']}};let p=packs[type]||packs.ai;let authors=['M. Rivera, T. Solis','A. Campos, J. Silva','L. Romero, S. Vega','P. Garcia, N. Ortega','C. Navarro, D. Torres','R. Molina, F. Vidal'];let src=['Database A','Database B','Hand-search','Repository'];let r=[];for(let i=0;i<36;i++){let k=i%12,decA=(i%7===0?'unsure':i%4===0?'exclude':'include'),decB=(i%5===0?'exclude':i%8===0?'unsure':decA),resolution=i%9===0?'exclude':i%9===4?'include':'pending';if(i%6===0){decA='pending';decB='pending';resolution='pending'}let subject=p.titles[k],title=subject+(Math.floor(i/12)===1?' : a comparative study':Math.floor(i/12)===2?' : methodological evidence':'');r.push({id:p.prefix+'-'+String(i+1).padStart(3,'0'),title,authors:authors[i%6],year:2018+(i%9),doi:i%4===0?'DEMO-'+p.prefix+'-'+i:'',source:src[i%4],abstract:'Synthetic demonstration record about '+subject.toLowerCase()+'. This illustrative abstract examines '+p.tags[i%4]+' with a transparent research protocol. It does not describe a real publication.',reviewer_a:decA,reviewer_b:decB,resolution,reason:resolution==='exclude'?'Outside review scope':'',note:''});}for(let k=0;k<5;k++){let orig=r[k*5+1];r.push({...orig,id:p.prefix+'-D'+String(k+1).padStart(2,'0'),source:'Database B',reviewer_a:'pending',reviewer_b:'pending',resolution:'pending',reason:'',note:''})}return r;} |
| function report(rows,study){let s=stats(rows),byReasons={};for(let r of s.unique){if(finalDecision(r)==='exclude'){let reason=r.reason||'Not recorded';byReasons[reason]=(byReasons[reason]||0)+1;}}return {app:'ReviewFlow',version:'1.0',study,disclaimer:'Synthetic demonstrations or user-provided data. Title/abstract stage only, not a complete PRISMA review.',generated_at:new Date().toISOString(),summary:{records_identified:s.total,duplicate_records:s.duplicates,unique_records:s.screened,retained_for_full_text:s.included,excluded_at_title_abstract:s.excluded,awaiting_final_decision:s.pending,compared_records:s.compared,raw_agreement:s.agreement,cohens_kappa:s.kappa},exclusion_reasons:byReasons,records:s.base.map(r=>({...r,final:!r.duplicateOf?finalDecision(r):'duplicate'}))};} |
| return {norm,safe,finalDecision,dedupe,stats,matrix,headers,parseCSV,csvProtect,csvExport,charts,demo,report}; |
| })(); |
| </script> |
| <script id="app"> |
| (()=>{'use strict';const $=s=>document.querySelector(s),$$=s=>[...document.querySelectorAll(s)],E=Engine;let page='overview',dataset='ai',rows=E.demo('ai'),selected='',filter='',statusFilter='all',dirty=false,pageIndex=0;let titleMap={overview:['Literature review command center','A traceable approach to title and abstract screening.'],screening:['Screening Studio','Review evidence independently, then document an explicit decision.'],duplicates:['Duplicate Audit','Identify identical records without silently deleting source entries.'],agreement:['Reviewer Agreement','Understand agreement, disagreement and missing reviewer decisions.'],prisma:['Screening Flow','A PRISMA-inspired view of the title and abstract stage only.'],data:['Data & Audit Exports','Import your records, review methods and download your work.']};let toastTimer; |
| function msg(message,error=false){let el=$('#toast');el.textContent=message;el.className=error?'error':'';el.style.display='block';clearTimeout(toastTimer);toastTimer=setTimeout(()=>el.style.display='none',4400)} |
| function fmt(n,d=0){return Number.isFinite(n)?n.toLocaleString('en-US',{maximumFractionDigits:d,minimumFractionDigits:d}):'N/A'} |
| function percent(x){return x===null||x===undefined?'N/A':(x*100).toFixed(1)+'%'} |
| function badge(s){let cls=s==='include'?'':s==='exclude'?'red':'gold';return `<span class="pill ${cls}">${E.safe(s)}</span>`} |
| function boxTitle(name,subtitle){return `<h2 class="section-title">${name}</h2><p class="desc">${subtitle}</p>`} |
| function kpis(data){let k=[['Records identified',data.total,'Across source imports'],['Duplicate records',data.duplicates,'Flagged and excluded from counts'],['Retained for full text',data.included,'After title/abstract review'],['Awaiting decision',data.pending,'Unresolved or incomplete']];return `<section class="grid kpis">${k.map((v,i)=>`<div class="panel kpi"><div class="label">${v[0]}</div><strong class="${i===2?'accent':i===3?'warning':''}">${v[1]}</strong><div class="detail">${v[2]}</div></div>`).join('')}</section>`} |
| function bars(items,max=0){max=max||Math.max(...items.map(x=>x[1]),1);return items.map(([name,n],i)=>`<div class="barrow"><div title="${E.safe(name)}">${E.safe(String(name))}</div><div class="track"><div class="fill ${i%3===2?'gold':''}" style="width:${Math.max(0,100*n/max)}%"></div></div><strong style="color:#e1f0f7;text-align:right">${n}</strong></div>`).join('')||'<p class="muted">No records</p>'} |
| function flowChart(s){return `<div class="flowchart"><div class="flowbox flowwide"><small>Records identified across sources</small><strong>${s.total}</strong></div><div class="flowline">↓</div><div class="flowbox note"><small>Duplicate records flagged</small><strong>${s.duplicates}</strong></div><div class="flowbox"><small>Unique records for screening</small><strong>${s.screened}</strong></div><div class="flowline">↓</div><div class="flowbox"><small>Excluded at title / abstract</small><strong>${s.excluded}</strong></div><div class="flowbox note"><small>Pending final decision</small><strong>${s.pending}</strong></div><div class="flowline">↓</div><div class="flowbox final flowwide"><small>Retained for full-text assessment</small><strong>${s.included}</strong></div></div><p class="flowhint">No full-text assessments, database retrieval or final study inclusions are claimed.</p>`} |
| function overview(s){let charts=E.charts(rows);return kpis(s)+`<section class="grid twocol"><div class="panel">${boxTitle('Screening flow','Automatically reconciled counts, from identification through provisional full-text retention.')}${flowChart(s)}</div><div class="stack"><div class="panel">${boxTitle('Screening status','Decisions are derived from manual resolution or matching independent reviews.')}${bars([['Retained',s.included],['Excluded',s.excluded],['Pending',s.pending]])}</div><div class="panel">${boxTitle('Sources represented','Counts of unique records, grouped by the imported source field.')}${bars(charts.sources,Math.max(...charts.sources.map(a=>a[1]),1))}</div></div></section><section class="grid equal" style="margin-top:14px"><div class="panel">${boxTitle('Publication years','Distribution of the unique demonstration records.')}${bars(charts.years.map(x=>[String(x[0]),x[1]]))}</div><div class="panel">${boxTitle('Integrity & boundaries','Screening decisions should always be traceable and reviewed by humans.')}<div class="stack"><div class="notice">${s.duplicates} duplicates remain in the source dataset but do not enter unique-record screening totals.</div><div class="alert">Unresolved or unsure reviewer decisions never count as included studies.</div><div class="mini">The diagram is PRISMA-inspired, not an official completed PRISMA 2020 flow diagram. Full-text retrieval and exclusions are not tracked.</div></div></div></section>`} |
| function option(val,cur,txt){return `<option value="${val}" ${val===cur?'selected':''}>${txt||val}</option>`} |
| function recordEditor(row){if(!row)return `<div class="panel">${boxTitle('Selected reference','Choose a record to view and edit its two independent screening decisions.')}</div>`;let duplicate=Boolean(row.duplicateOf);return `<div class="panel"><div class="headrow">${boxTitle('Record detail','Title/abstract evaluation with an auditable note field.')} ${badge(duplicate?'duplicate':E.finalDecision(row))}</div><div class="tag">${E.safe(row.id)}</div><h3 class="record-title">${E.safe(row.title)}</h3><div class="metadata">${E.safe(row.authors)} · ${E.safe(row.year)} · ${E.safe(row.source)} · ID ${E.safe(row.doi||'not provided')}</div><p class="abstract">${E.safe(row.abstract||'No abstract supplied.')}</p>${duplicate?`<div class="alert">This is a probable duplicate of <strong>${E.safe(row.duplicateOf)}</strong> by ${E.safe(row.matchReason)}. Decisions belong to the canonical record. No source records have been deleted.</div>`:`<div class="formgrid" style="margin-top:16px"><div class="formfield"><label for="rvA">Reviewer A</label><select id="rvA" class="select">${['pending','include','exclude','unsure'].map(x=>option(x,row.reviewer_a)).join('')}</select></div><div class="formfield"><label for="rvB">Reviewer B</label><select id="rvB" class="select">${['pending','include','exclude','unsure'].map(x=>option(x,row.reviewer_b)).join('')}</select></div><div class="formfield"><label for="resolution">Consensus decision</label><select id="resolution" class="select">${['pending','include','exclude'].map(x=>option(x,row.resolution)).join('')}</select></div><div class="formfield"><label for="reason">Exclusion reason</label><select id="reason" class="select">${['','Outside review scope','Wrong population','Wrong intervention','Wrong outcome','Not empirical','Wrong study design','Other documented reason'].map(x=>option(x,row.reason,x||'Not selected')).join('')}</select></div></div><div class="formfield" style="margin-top:10px"><label for="note">Review note</label><textarea id="note" class="textarea" maxlength="2000" placeholder="Why was this decision made?">${E.safe(row.note)}</textarea></div><div class="actions" style="margin-top:12px"><button class="btn" data-action="save-record" type="button">Save screening decisions</button><span class="mini">Export before refreshing your browser.</span></div>`}</div>`} |
| function screening(s){let list=s.base.filter(x=>(!filter||[x.title,x.authors,x.abstract,x.id,x.doi].some(v=>E.norm(v).includes(E.norm(filter))))&&(statusFilter==='all'||(statusFilter==='duplicate'?!!x.duplicateOf:!x.duplicateOf&&E.finalDecision(x)===statusFilter)));const size=12,pages=Math.max(1,Math.ceil(list.length/size));pageIndex=Math.min(pageIndex,pages-1);const visible=list.slice(pageIndex*size,(pageIndex+1)*size);if(!visible.some(x=>x.id===selected))selected=(visible.find(x=>!x.duplicateOf)||visible[0]||{}).id;let sel=s.base.find(x=>x.id===selected);return `<div class="grid twocol"><div class="panel">${boxTitle('Screening queue','Search references and choose a record. Duplicate rows are visibly marked.')}<div class="actions" style="margin-bottom:12px"><input id="searchRecords" class="input" style="flex:1;min-width:130px" placeholder="Search title, abstract or ID" value="${E.safe(filter)}" aria-label="Search references"><select id="filterStatus" class="select" aria-label="Filter by status">${['all','include','exclude','pending','duplicate'].map(x=>option(x,statusFilter,x==='all'?'All statuses':x)).join('')}</select></div><div class="statusline"><span>${list.length} matching records</span><span>${s.screened} unique references</span></div><div class="tablewrap"><table class="table"><thead><tr><th>Reference</th><th>Year</th><th>Status</th></tr></thead><tbody>${visible.map(r=>`<tr class="clickrow ${r.id===selected?'active-row':''}" data-action="select-record" data-id="${E.safe(r.id)}" tabindex="0" role="button" aria-label="Select ${E.safe(r.title)}"><td><strong>${E.safe(r.title)}</strong><div class="mini">${E.safe(r.id)} · ${E.safe(r.source)}</div></td><td>${E.safe(r.year)}</td><td>${badge(r.duplicateOf?'duplicate':E.finalDecision(r))}</td></tr>`).join('')}</tbody></table></div><div class="headrow" style="margin:13px 0 0"><span class="mini">Page ${pageIndex+1} of ${pages} · 12 records per page</span><div class="actions"><button class="btn secondary" type="button" data-action="prev-page" ${pageIndex===0?'disabled':''}>← Previous</button><button class="btn secondary" type="button" data-action="next-page" ${pageIndex>=pages-1?'disabled':''}>Next →</button></div></div></div>${recordEditor(sel)}</div>`} |
| function duplicates(s){let groups=s.base.filter(x=>x.duplicateOf);return kpis(s)+`<div class="grid equal"><div class="panel">${boxTitle('Duplicate groups','Exact normalized identifier, or exact normalized title and year. Not a fuzzy-match engine.')}<div class="stack">${groups.length?groups.map(x=>`<div style="border-bottom:1px solid #2a425b;padding-bottom:9px"><strong>${E.safe(x.id)}</strong> <span class="muted">duplicates</span> <strong class="accent">${E.safe(x.duplicateOf)}</strong><div class="mini">${E.safe(x.title)} · ${E.safe(x.matchReason)}</div></div>`).join(''):'<p class="muted">No deterministic duplicates identified.</p>'}</div></div><div class="panel">${boxTitle('Deduplication safeguards','Conservative matching to avoid merging separate studies incorrectly.')}<div class="stack"><div class="notice">Every imported record is preserved. Only the first record per match key is used in screening statistics.</div><div class="mini">Priority 1: normalized DOI / identifier when present. Otherwise: normalized title together with publication year.</div><div class="mini">Similarity-only or near-duplicate titles are not merged automatically. Review any apparent missed matches manually outside this demonstration.</div><div class="alert">Records with ambiguous identifiers may need human deduplication in a reference manager before importing.</div></div></div></div>`} |
| function agreement(s){let m=E.matrix(rows),discord=m[0][1]+m[1][0];return kpis(s)+`<div class="grid equal"><div class="panel">${boxTitle('Inter-reviewer reliability','Cohen’s kappa is calculated only on unique records with two definite decisions (include/exclude).')}<div class="grid equal"><div class="panel kpi"><div class="label">Cohen’s kappa</div><strong class="accent">${s.kappa===null?'N/A':fmt(s.kappa,3)}</strong><div class="detail">Not computable for degenerate agreement.</div></div><div class="panel kpi"><div class="label">Raw agreement</div><strong>${percent(s.agreement)}</strong><div class="detail">${s.agreed} agreements in ${s.compared} pairs.</div></div></div><div class="doc-separator"></div><p class="desc">Confusion matrix of decisions for records where both reviewers selected include or exclude.</p><div class="tablewrap"><table class="table"><thead><tr><th>Reviewer A / B</th><th>B: include</th><th>B: exclude</th></tr></thead><tbody><tr><td>A: include</td><td>${m[0][0]}</td><td>${m[0][1]}</td></tr><tr><td>A: exclude</td><td>${m[1][0]}</td><td>${m[1][1]}</td></tr></tbody></table></div></div><div class="panel">${boxTitle('Resolution queue','Open disagreements and uncertain ratings need a deliberate human decision.')}<div class="stack">${bars([['Definite paired ratings',s.compared],['Paired disagreements',discord],['Not definitively paired',s.screened-s.compared]],s.screened)}<div class="notice">Manual resolutions are tracked separately from the two reviewer fields. They do not retroactively modify reviewer agreement.</div><div class="alert">Kappa is descriptive and depends on prevalence, decision thresholds and the set of reviewed records. It is not a measure of review validity.</div></div></div></div>`} |
| function prismaSVG(s){let lines=[['IDENTIFICATION',`Records identified: ${s.total}`],['DEDUPLICATION',`Duplicates identified: ${s.duplicates}`],['SCREENING',`Unique records: ${s.screened}`],['REVIEW DECISIONS',`Excluded: ${s.excluded} | Pending: ${s.pending}`],['RETAINED',`For full-text review: ${s.included}`]];return `<svg xmlns="http://www.w3.org/2000/svg" width="780" height="610" viewBox="0 0 780 610"><rect width="780" height="610" rx="16" fill="#101d2e"/><text x="390" y="41" text-anchor="middle" fill="#eef5fb" font-family="Arial" font-size="24" font-weight="bold">ReviewFlow Screening Flow</text><text x="390" y="66" text-anchor="middle" fill="#b2c6da" font-family="Arial" font-size="13">PRISMA-inspired · Title / abstract stage only</text>${lines.map(([name,detail],i)=>`<rect x="125" y="${89+i*102}" width="530" height="72" rx="9" fill="${i===4?'#18433f':'#213650'}" stroke="${i===4?'#5ec9b5':'#46708b'}"/><text x="390" y="${113+i*102}" text-anchor="middle" font-family="Arial" font-size="12" fill="#a5c5d8">${name}</text><text x="390" y="${144+i*102}" text-anchor="middle" font-family="Arial" font-size="21" fill="#f0fbff" font-weight="bold">${detail.replace(/&/g,'&')}</text>${i<4?`<path d="M390 ${161+i*102} v24" stroke="#5ca5b6" stroke-width="2"/><path d="M385 ${178+i*102} l5 8 l5 -8" fill="none" stroke="#5ca5b6" stroke-width="2"/>`:''}`).join('')}<text x="390" y="594" text-anchor="middle" fill="#9db5c9" font-family="Arial" font-size="11">Not a complete PRISMA 2020 diagram; no full-text assessment tracked.</text></svg>`} |
| function prisma(s){return `<div class="grid twocol"><div class="panel">${boxTitle('Title / abstract flow','Counts always reconcile: identified minus duplicates equals unique; unique equals retained + excluded + pending.')}${flowChart(s)}<div class="actions"><button class="btn secondary" data-export="flow-svg">Download flow diagram (SVG)</button></div></div><div class="panel">${boxTitle('Flow audit','The stages below describe only what this application directly records.')}<div class="tablewrap"><table class="table"><thead><tr><th>Stage</th><th>Count</th></tr></thead><tbody>${[['Identified',s.total],['Duplicate entries',s.duplicates],['Unique records',s.screened],['Title/abstract excluded',s.excluded],['Pending resolution',s.pending],['Retained for full-text review',s.included]].map(x=>`<tr><td>${x[0]}</td><td><strong>${x[1]}</strong></td></tr>`).join('')}</tbody></table></div><div class="doc-separator"></div><div class="alert">For a formal systematic review, additional steps are required: database search logs, screening of actual full texts, reasons for full-text exclusion, complete PRISMA 2020 reporting and protocol documentation.</div></div></div>`} |
| function data(s){return `<div class="grid equal"><div class="panel">${boxTitle('Import reference records','Upload a CSV file in your browser. Import replaces the current dataset only after every record passes validation.')}<div class="stack"><div class="notice">No data is uploaded to a server. Changes remain in memory until you export them.</div><div class="mini">Required columns: <code>id, title, year</code>. Optional: <code>authors, doi, source, abstract, reviewer_a, reviewer_b, resolution, reason, note</code>.</div><div class="mini">Reviewers: pending, include, exclude, unsure. Resolution: pending, include, exclude. Excluded resolutions require a reason.</div><div class="actions"><button class="btn" data-action="import" type="button">Choose CSV to import</button><button class="btn secondary" data-export="template" type="button">CSV template</button></div><div class="alert">Importing a valid file replaces the current in-memory study. Download your current data first. Maximum 5,000 rows, 3 MB.</div></div></div><div class="panel">${boxTitle('Download research artifacts','Export all screening actions for external review and preservation.')}<div class="download-grid">${[['records','Full dataset CSV'],['decisions','Decisions CSV'],['reasons','Exclusion reasons CSV'],['json','Complete audit JSON'],['markdown','Methods report Markdown'],['flow-svg','Screening flow SVG']].map(([id,name])=>`<button class="export" data-export="${id}" type="button">↓ ${name}</button>`).join('')}</div><div class="doc-separator"></div><div class="mini">CSV cells starting with formula characters are escaped to reduce spreadsheet-formula injection risk. Audit JSON preserves raw text fields.</div></div></div><div class="panel" style="margin-top:14px">${boxTitle('Methods & limitations','Implementation choices and transparent scope of this tool.')}<div class="grid equal"><div><p><strong>Decision states.</strong> Matching include/exclude decisions count as screened. A separate manual resolution overrides the two ratings but preserves them in the audit trail. Undecided and unsure remain pending.</p><p><strong>Duplicates.</strong> Exact normalized DOI/identifier, otherwise normalized title and publication year. This is intentionally conservative.</p><p><strong>Agreement.</strong> Cohen’s kappa for complete, definite independent ratings only. The result may be undefined.</p></div><div><p><strong>Not PRISMA certification.</strong> The diagram summarizes title/abstract screening only. It is not a complete PRISMA 2020 workflow.</p><p><strong>Privacy.</strong> The application is client-side only; avoid uploading confidential documents to a public repository. CSV content is held only in the current browser session.</p><p><strong>Scientific integrity.</strong> Three demo studies are explicitly synthetic. No real citations, published results or automated inclusion assessments are claimed.</p></div></div></div>`} |
| function render(){let s=E.stats(rows),p=titleMap[page];$('#pageTitle').textContent=p[0];$('#pageSubtitle').textContent=p[1];$$('.nav').forEach(b=>{b.classList.toggle('active',b.dataset.page===page);b.setAttribute('aria-current',b.dataset.page===page?'page':'false')});let html=page==='overview'?overview(s):page==='screening'?screening(s):page==='duplicates'?duplicates(s):page==='agreement'?agreement(s):page==='prisma'?prisma(s):data(s);$('#pageContent').innerHTML=html;} |
| function download(filename,content,type){let b=new Blob([content],{type}),u=URL.createObjectURL(b),a=document.createElement('a');a.href=u;a.download=filename;document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(u),1000)} |
| function exportFile(which){let s=E.stats(rows),prefix='reviewflow-'+dataset;switch(which){case 'records':download(prefix+'-records.csv',E.csvExport(rows),'text/csv;charset=utf-8');break;case 'template':download('reviewflow-import-template.csv',E.csvExport([{id:'R-001',title:'A sample record',authors:'Example Author',year:2024,doi:'',source:'Example database',abstract:'This is a synthetic test abstract.',reviewer_a:'pending',reviewer_b:'pending',resolution:'pending',reason:'',note:''}]),'text/csv;charset=utf-8');break;case 'decisions':download(prefix+'-decisions.csv',E.csvExport(s.base.map(r=>({...r,final_decision:r.duplicateOf?'duplicate':E.finalDecision(r)})),['id','title','reviewer_a','reviewer_b','resolution','final_decision','reason','note','duplicateOf']),'text/csv;charset=utf-8');break;case 'reasons':download(prefix+'-exclusion-reasons.csv',E.csvExport(s.unique.filter(r=>E.finalDecision(r)==='exclude').map(r=>({id:r.id,title:r.title,reason:r.reason||'Not recorded'})),['id','title','reason']),'text/csv;charset=utf-8');break;case 'json':download(prefix+'-audit.json',JSON.stringify(E.report(rows,dataset),null,2),'application/json');break;case 'flow-svg':download(prefix+'-screening-flow.svg',prismaSVG(s),'image/svg+xml');break;case 'markdown':{let r=E.report(rows,dataset),t=`# ReviewFlow screening audit\n\nGenerated: ${r.generated_at}\n\nStudy: ${dataset}\n\n## Title / abstract screening\n\n- Records identified: ${s.total}\n- Duplicates: ${s.duplicates}\n- Unique records: ${s.screened}\n- Retained for full-text assessment: ${s.included}\n- Excluded during title/abstract stage: ${s.excluded}\n- Pending decisions: ${s.pending}\n- Reviewer pairs with definite decisions: ${s.compared}\n- Raw agreement: ${percent(s.agreement)}\n- Cohen's kappa: ${s.kappa===null?'Not estimable':fmt(s.kappa,3)}\n\n## Exclusion reasons\n\n${Object.entries(r.exclusion_reasons).map(([a,b])=>'- '+a+': '+b).join('\n')||'- None recorded'}\n\n## Methodological scope\n\nThis is a title/abstract screening audit, not a complete PRISMA 2020 review. Synthetic demonstrations are fictional. Check all decisions and full-text screening independently.\n`;download(prefix+'-audit.md',t,'text/markdown');break;}default:msg('Unknown export format.',true);return}msg('File prepared: '+which)} |
| function save(){let r=rows.find(x=>x.id===selected);if(!r||E.dedupe(rows).find(x=>x.id===selected).duplicateOf)return msg('Select a canonical record first.',true);let a=$('#rvA').value,b=$('#rvB').value,c=$('#resolution').value,reason=$('#reason').value,note=$('#note').value;if(c==='exclude'&&!reason)return msg('Select an exclusion reason before saving.',true);Object.assign(r,{reviewer_a:a,reviewer_b:b,resolution:c,reason,note});dirty=true;msg('Screening record saved in this browser session.');render()} |
| $('#sideNav').addEventListener('click',e=>{let b=e.target.closest('[data-page]');if(!b)return;page=b.dataset.page;render();window.scrollTo({top:0,behavior:'smooth'})});$('#scenario').addEventListener('change',e=>{if(dirty&&!confirm('Changing the demonstration will discard unexported edits or imported data. Export first if needed. Continue?')){e.target.value=dataset;return;}dataset=e.target.value;rows=E.demo(dataset);dirty=false;selected='';filter='';statusFilter='all';pageIndex=0;render();msg('Loaded synthetic demonstration: '+dataset)});$('#reset').addEventListener('click',()=>{if(!confirm('Reset all in-session edits to the demonstration dataset? Export first if needed.'))return;rows=E.demo(dataset);dirty=false;selected='';filter='';statusFilter='all';pageIndex=0;render();msg('Demonstration reset.')});$('#pageContent').addEventListener('click',e=>{let exp=e.target.closest('[data-export]');if(exp){exportFile(exp.dataset.export);return}let b=e.target.closest('[data-action]');if(!b)return;let action=b.dataset.action;if(action==='select-record'){selected=b.dataset.id;render()}if(action==='prev-page'){pageIndex=Math.max(0,pageIndex-1);selected='';render()}if(action==='next-page'){pageIndex++;selected='';render()}if(action==='save-record')save();if(action==='import')$('#csvFile').click()});$('#pageContent').addEventListener('keydown',e=>{if((e.key==='Enter'||e.key===' ')&&e.target.matches('[data-action="select-record"]')){e.preventDefault();selected=e.target.dataset.id;render()}});$('#pageContent').addEventListener('input',e=>{if(e.target.id==='searchRecords'){let start=e.target.selectionStart;filter=e.target.value;pageIndex=0;render();let next=$('#searchRecords');if(next){next.focus();next.setSelectionRange(start,start)}}});$('#pageContent').addEventListener('change',e=>{if(e.target.id==='filterStatus'){statusFilter=e.target.value;pageIndex=0;render()}});$('#csvFile').addEventListener('change',async e=>{let file=e.target.files&&e.target.files[0];e.target.value='';if(!file)return;try{if(file.size>3000000)throw Error('File exceeds 3 MB.');let parsed=E.parseCSV(await file.text());if(!confirm('Replace your current in-memory dataset with '+parsed.length+' imported records? Export your work first.'))return;rows=parsed;dirty=true;selected='';filter='';statusFilter='all';pageIndex=0;page='overview';render();msg('Imported '+parsed.length+' records. No data left this browser.')}catch(err){msg('Import rejected: '+err.message,true)}});render(); |
| })(); |
| </script> |
| </body></html> |
|
|