Untitled

 avatar
unknown
plain_text
10 months ago
22 kB
15
Indexable
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Academic Performance Tracker</title>
<style>
  :root{
    --bg:#0f172a; --panel:#111827; --muted:#94a3b8; --text:#e5e7eb;
    --accent:#22c55e; --warn:#f59e0b; --danger:#ef4444; --card:#0b1220;
    --border:#1f2937; --blue:#3b82f6; --purple:#8b5cf6;
  }
  *{box-sizing:border-box}
  body{margin:0; background:linear-gradient(180deg,#0b1020, #0f172a 40%, #0b1020);
       color:var(--text); font-family:system-ui,-apple-system,Segoe UI,Roboto,Inter,Arial,sans-serif}
  header{padding:20px; border-bottom:1px solid var(--border); background:rgba(17,24,39,.6); backdrop-filter: blur(8px); position:sticky; top:0; z-index:10}
  header h1{margin:0; font-size:20px}
  main{max-width:1100px; margin:20px auto; padding:0 16px 40px}
  .grid{display:grid; gap:16px}
  @media(min-width:900px){ .grid{grid-template-columns: 1.1fr 1fr} }
  .card{background:linear-gradient(180deg, #0d1528, #0a1120); border:1px solid var(--border); border-radius:14px; padding:16px}
  .card h2{margin:0 0 12px; font-size:16px; color:#e2e8f0}
  label{display:block; font-size:12px; color:var(--muted); margin-bottom:6px}
  input, select, button, textarea{
    width:100%; padding:10px 12px; border-radius:10px; border:1px solid var(--border); background:#0a0f1e; color:var(--text);
  }
  input:focus, select:focus, button:focus{outline:1px solid var(--blue)}
  .row{display:grid; gap:10px; grid-template-columns:1fr}
  @media(min-width:640px){ .row.two{grid-template-columns:1fr 1fr} .row.three{grid-template-columns:1fr 1fr 1fr} }
  button{cursor:pointer; border:1px solid #1f2b46; background:linear-gradient(180deg,#14213d,#0f1b34); color:#e5e7eb}
  button.primary{background:linear-gradient(180deg,#1e40af,#1b235a); border-color:#243b7b}
  button.success{background:linear-gradient(180deg,#166534,#064e3b); border-color:#115e38}
  button.warn{background:linear-gradient(180deg,#92400e,#7c2d12); border-color:#4a1d09}
  .flex{display:flex; gap:10px; align-items:center; flex-wrap:wrap}
  .badge{font-size:11px; padding:3px 8px; border-radius:999px; border:1px solid var(--border); background:#0a0f1e; color:var(--muted)}
  .kpi{display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:10px}
  .kpi .box{background:#0a0f1e; border:1px solid var(--border); border-radius:12px; padding:12px}
  .kpi .value{font-size:22px; font-weight:600}
  table{width:100%; border-collapse:collapse; font-size:14px; margin-top:8px}
  th, td{padding:10px; border-bottom:1px solid var(--border); vertical-align:middle}
  th{text-align:left; color:#cbd5e1}
  tr:hover td{background:#0b1326}
  .progress{height:10px; background:#0b1020; border:1px solid var(--border); border-radius:999px; overflow:hidden}
  .bar{height:100%; background:linear-gradient(90deg, #22c55e, #3b82f6); width:0%}
  .status{font-size:12px}
  .status.ok{color:var(--accent)}
  .status.warn{color:var(--warn)}
  .status.bad{color:var(--danger)}
  .pill{padding:2px 8px; border-radius:999px; border:1px solid var(--border); color:#cbd5e1; font-size:12px}
  .right{margin-left:auto}
  .muted{color:var(--muted)}
  .small{font-size:12px}
  .spacer{height:8px}
  .divider{border-top:1px solid var(--border); margin:10px 0}
  .footer{color:var(--muted); font-size:12px; text-align:center; margin-top:20px}
</style>
</head>
<body>
<header>
  <div class="flex">
    <h1>Academic Performance Tracker</h1>
    <span class="pill">Local save</span>
    <span class="pill">GPA 4.0 scale</span>
    <span class="pill">Weighted assessments</span>
    <span class="right small muted">Tip: export a backup regularly</span>
  </div>
</header>

<main>
  <div class="grid">
    <section class="card">
      <h2>Setup: semesters and courses</h2>
      <div class="row two">
        <div>
          <label>Semester name</label>
          <input id="semesterName" placeholder="e.g., 2025 S1" />
        </div>
        <div>
          <label>&nbsp;</label>
          <button class="primary" id="addSemesterBtn">Add semester</button>
        </div>
      </div>
      <div class="divider"></div>
      <div class="row three">
        <div>
          <label>Semester</label>
          <select id="semesterSelect"></select>
        </div>
        <div>
          <label>Course name</label>
          <input id="courseName" placeholder="e.g., General Chemistry" />
        </div>
        <div>
          <label>Course code</label>
          <input id="courseCode" placeholder="e.g., CHM-101" />
        </div>
      </div>
      <div class="row three">
        <div>
          <label>Credits</label>
          <input id="courseCredits" type="number" min="0" step="0.5" placeholder="e.g., 3" />
        </div>
        <div>
          <label>Target grade (percent)</label>
          <input id="targetPercent" type="number" min="0" max="100" step="1" placeholder="e.g., 75" />
        </div>
        <div>
          <label>&nbsp;</label>
          <button class="success" id="addCourseBtn">Add course</button>
        </div>
      </div>
      <div class="divider"></div>
      <div class="flex">
        <button id="exportBtn">Export backup (JSON)</button>
        <input id="importFile" type="file" accept="application/json" />
        <button class="warn" id="importBtn">Import</button>
        <span class="small muted">Import replaces current data after confirmation.</span>
      </div>
    </section>

    <section class="card">
      <h2>GPA and progress</h2>
      <div class="kpi">
        <div class="box">
          <div class="small muted">Semester GPA</div>
          <div class="value" id="semesterGpa">—</div>
          <div class="small muted" id="semesterGpaNote"></div>
        </div>
        <div class="box">
          <div class="small muted">Cumulative GPA</div>
          <div class="value" id="cumulativeGpa">—</div>
          <div class="small muted" id="cumulativeGpaNote"></div>
        </div>
        <div class="box">
          <div class="small muted">On-track courses</div>
          <div class="value" id="onTrackCount">—</div>
          <div class="small muted" id="onTrackNote"></div>
        </div>
      </div>
      <div class="spacer"></div>
      <div class="small muted">GPA mapping (editable in code): A=85+ → 4.0, A-=80 → 3.7, B+=75 → 3.3, B=70 → 3.0, B-=65 → 2.7, C+=60 → 2.3, C=55 → 2.0, D=50 → 1.0, F&lt;50 → 0.0</div>
    </section>
  </div>

  <section class="card">
    <h2>Add assessments</h2>
    <div class="row three">
      <div>
        <label>Course</label>
        <select id="courseSelect"></select>
      </div>
      <div>
        <label>Assessment type</label>
        <select id="assessType">
          <option>Mid-sem</option>
          <option>Continuous assessment</option>
          <option>Assignment</option>
          <option>Quiz</option>
          <option>End-sem</option>
          <option>Other</option>
        </select>
      </div>
      <div>
        <label>Weight (%) of course</label>
        <input id="assessWeight" type="number" min="0" max="100" step="0.5" placeholder="e.g., 30" />
      </div>
    </div>
    <div class="row three">
      <div>
        <label>Score achieved</label>
        <input id="assessScore" type="number" min="0" step="0.1" placeholder="e.g., 24" />
      </div>
      <div>
        <label>Out of</label>
        <input id="assessOutOf" type="number" min="1" step="0.1" placeholder="e.g., 30" />
      </div>
      <div>
        <label>&nbsp;</label>
        <button class="primary" id="addAssessBtn">Add assessment</button>
      </div>
    </div>
    <div class="small muted">Weights should sum to 100% across all assessments for each course. The tracker will warn you if not.</div>
  </section>

  <section class="card">
    <h2>Courses dashboard</h2>
    <div id="coursesContainer"></div>
  </section>

  <div class="footer">Built for clarity and honest feedback — keep learning sharp.</div>
</main>

<script>
  // Data model in localStorage
  const storeKey = 'apt-data-v1';
  let data = JSON.parse(localStorage.getItem(storeKey) || '{"semesters":[]}');

  const $ = (id)=>document.getElementById(id);

  function save(){ localStorage.setItem(storeKey, JSON.stringify(data)); }

  // GPA mapping: percentage -> grade points (editable)
  function pctToGpa(p){
    if (p >= 85) return 4.0;
    if (p >= 80) return 3.7;
    if (p >= 75) return 3.3;
    if (p >= 70) return 3.0;
    if (p >= 65) return 2.7;
    if (p >= 60) return 2.3;
    if (p >= 55) return 2.0;
    if (p >= 50) return 1.0;
    return 0.0;
  }

  function number(v, d=2){ return isFinite(v) ? Number(v).toFixed(d) : '—'; }

  function ensureSelections(){
    // Semester dropdowns
    const semSel = $('semesterSelect');
    const courseSel = $('courseSelect');
    semSel.innerHTML = '';
    courseSel.innerHTML = '';
    data.semesters.forEach((s, i)=>{
      const o = document.createElement('option'); o.value=i; o.textContent=s.name; semSel.appendChild(o);
    });
    // Course dropdown uses current semester (first by default)
    const si = semSel.value || 0;
    const semester = data.semesters[si] || null;
    if (semester){
      semester.courses.forEach((c, j)=>{
        const o = document.createElement('option');
        o.value = `${si}:${j}`;
        o.textContent = `${c.code} — ${c.name}`;
        courseSel.appendChild(o);
      });
    }
  }

  function renderCourses(){
    const container = $('coursesContainer');
    container.innerHTML = '';
    data.semesters.forEach((sem, si)=>{
      const semDiv = document.createElement('div');
      semDiv.className='card';
      semDiv.innerHTML = `
        <div class="flex">
          <h2>${sem.name}</h2>
          <span class="pill right">${sem.courses.length} course(s)</span>
          <button class="warn" data-del-sem="${si}">Delete semester</button>
        </div>
        <div class="spacer"></div>
        <table>
          <thead>
            <tr>
              <th>Course</th>
              <th>Credits</th>
              <th>Weights</th>
              <th>Current %</th>
              <th>GPA</th>
              <th>Status</th>
              <th></th>
            </tr>
          </thead>
          <tbody id="tbody-${si}"></tbody>
        </table>
      `;
      container.appendChild(semDiv);

      const tbody = semDiv.querySelector(`#tbody-${si}`);
      sem.courses.forEach((c, ci)=>{
        const { percent, weightSum } = coursePercent(c);
        const gpa = pctToGpa(percent);
        const onTrack = (c.targetPercent ?? 0) <= percent;
        const statusClass = onTrack ? 'ok' : (Math.abs((c.targetPercent||0)-percent) <= 5 ? 'warn' : 'bad');
        const statusText = onTrack ? 'On track' : (statusClass==='warn' ? 'Slightly behind' : 'At risk');

        const tr = document.createElement('tr');
        tr.innerHTML = `
          <td>
            <div>${c.code} — ${c.name}</div>
            <div class="small muted">Target: ${c.targetPercent || 0}%</div>
            <div class="progress" style="margin-top:6px"><div class="bar" style="width:${Math.min(100, percent)}%"></div></div>
          </td>
          <td>${c.credits}</td>
          <td>${number(weightSum,0)}%</td>
          <td>${number(percent)}%</td>
          <td>${number(gpa,2)}</td>
          <td><span class="status ${statusClass}">${statusText}</span></td>
          <td>
            <div class="flex">
              <button data-view="${si}:${ci}">View</button>
              <button data-del="${si}:${ci}" class="warn">Delete</button>
            </div>
          </td>
        `;
        tbody.appendChild(tr);
      });

      // Semester summary GPA
      const { semGpa, credits } = semesterGpa(sem);
      // Update top KPI if this is the currently selected semester
      const currentIndex = Number(($('semesterSelect').value || 0));
      if (currentIndex === si){
        $('semesterGpa').textContent = number(semGpa,2);
        $('semesterGpaNote').textContent = credits ? `${credits} total credits` : '';
      }
    });

    // Cumulative GPA
    const { cumGpa, credits } = cumulativeGpa();
    $('cumulativeGpa').textContent = number(cumGpa,2);
    $('cumulativeGpaNote').textContent = credits ? `${credits} total credits` : '';

    // On-track count
    const { onTrack, total } = onTrackSummary();
    $('onTrackCount').textContent = `${onTrack}/${total}`;
    $('onTrackNote').textContent = total ? `${Math.round(onTrack*100/total)}% on track` : '';
  }

  function coursePercent(course){
    // Weighted percentage = sum( (score/outOf)*100 * weight/100 )
    let total = 0, wsum = 0;
    (course.assessments||[]).forEach(a=>{
      if (a.outOf > 0 && a.weight > 0){
        total += (a.score / a.outOf) * 100 * (a.weight/100);
        wsum += a.weight;
      }
    });
    return { percent: wsum>0 ? total : 0, weightSum: wsum };
  }

  function semesterGpa(sem){
    let totalPts = 0, totalCred = 0;
    sem.courses.forEach(c=>{
      const { percent, weightSum } = coursePercent(c);
      // If weights < 100, still compute current standing; GPA uses current percent estimate
      const gp = pctToGpa(percent);
      totalPts += gp * Number(c.credits || 0);
      totalCred += Number(c.credits || 0);
    });
    return { semGpa: totalCred ? totalPts/totalCred : 0, credits: totalCred };
  }

  function cumulativeGpa(){
    let totalPts = 0, totalCred = 0;
    data.semesters.forEach(sem=>{
      const s = semesterGpa(sem);
      totalPts += s.semGpa * s.credits;
      totalCred += s.credits;
    });
    return { cumGpa: totalCred ? totalPts/totalCred : 0, credits: totalCred };
  }

  function onTrackSummary(){
    let on = 0, total = 0;
    data.semesters.forEach(sem=>{
      sem.courses.forEach(c=>{
        const { percent } = coursePercent(c);
        total++;
        if ((c.targetPercent||0) <= percent) on++;
      });
    });
    return { onTrack:on, total };
  }

  function refreshAll(){
    ensureSelections();
    renderCourses();
  }

  // Events
  $('addSemesterBtn').addEventListener('click', ()=>{
    const name = $('semesterName').value.trim();
    if (!name) return alert('Enter a semester name.');
    data.semesters.push({ name, courses: [] });
    save(); $('semesterName').value=''; refreshAll();
  });

  $('addCourseBtn').addEventListener('click', ()=>{
    const si = Number($('semesterSelect').value || 0);
    if (!data.semesters[si]) return alert('Add a semester first.');
    const name = $('courseName').value.trim();
    const code = $('courseCode').value.trim();
    const credits = Number($('courseCredits').value || 0);
    const targetPercent = Number($('targetPercent').value || 0);
    if (!name || !code || credits<=0) return alert('Provide course name, code, and positive credits.');
    data.semesters[si].courses.push({
      name, code, credits, targetPercent, assessments:[]
    });
    save();
    $('courseName').value=''; $('courseCode').value=''; $('courseCredits').value=''; $('targetPercent').value='';
    refreshAll();
  });

  $('addAssessBtn').addEventListener('click', ()=>{
    const sel = $('courseSelect').value;
    if (!sel) return alert('Add a course first.');
    const [si, ci] = sel.split(':').map(Number);
    const type = $('assessType').value;
    const weight = Number($('assessWeight').value || 0);
    const score = Number($('assessScore').value || 0);
    const outOf = Number($('assessOutOf').value || 0);
    if (weight<=0 || outOf<=0) return alert('Enter positive weight and out-of.');
    const course = data.semesters[si].courses[ci];
    course.assessments.push({ type, weight, score, outOf, ts: Date.now() });

    // Warn if weights exceed 100
    const { weightSum } = coursePercent(course);
    if (weightSum > 100 + 1e-6){
      if (!confirm(`Weights now sum to ${weightSum}%. Continue?`)){
        course.assessments.pop(); // revert
        return;
      }
    }

    save();
    $('assessWeight').value=''; $('assessScore').value=''; $('assessOutOf').value='';
    refreshAll();
  });

  // Delegated actions: delete semester/course, view course detail
  document.body.addEventListener('click', (e)=>{
    const delSem = e.target.getAttribute('data-del-sem');
    if (delSem !== null){
      const si = Number(delSem);
      if (confirm(`Delete semester "${data.semesters[si].name}"?`)){
        data.semesters.splice(si,1); save(); refreshAll();
      }
      return;
    }
    const del = e.target.getAttribute('data-del');
    if (del){
      const [si, ci] = del.split(':').map(Number);
      const c = data.semesters[si].courses[ci];
      if (confirm(`Delete course "${c.code} — ${c.name}"?`)){
        data.semesters[si].courses.splice(ci,1); save(); refreshAll();
      }
      return;
    }
    const view = e.target.getAttribute('data-view');
    if (view){
      const [si, ci] = view.split(':').map(Number);
      openCourseModal(si, ci);
    }
  });

  // Export/Import
  $('exportBtn').addEventListener('click', ()=>{
    const blob = new Blob([JSON.stringify(data,null,2)], {type:'application/json'});
    const a = document.createElement('a');
    a.href = URL.createObjectURL(blob);
    a.download = 'apt-backup.json';
    a.click();
    URL.revokeObjectURL(a.href);
  });
  $('importBtn').addEventListener('click', ()=>{
    const f = $('importFile').files[0];
    if (!f) return alert('Choose a file.');
    const reader = new FileReader();
    reader.onload = ()=>{
      if (!confirm('Import will replace current data. Continue?')) return;
      try{
        const obj = JSON.parse(reader.result);
        if (!obj || !Array.isArray(obj.semesters)) throw new Error('Invalid file.');
        data = obj; save(); refreshAll();
      }catch(err){ alert('Invalid JSON file.'); }
    };
    reader.readAsText(f);
  });

  // Course detail modal (simple inline panel)
  function openCourseModal(si, ci){
    const sem = data.semesters[si];
    const c = sem.courses[ci];
    const { percent, weightSum } = coursePercent(c);
    const container = document.createElement('div');
    container.style.position='fixed';
    container.style.inset='0';
    container.style.background='rgba(0,0,0,.6)';
    container.style.zIndex='999';
    container.innerHTML = `
      <div style="max-width:900px;margin:60px auto;background:#0b1220;border:1px solid #1f2937;border-radius:14px;padding:16px">
        <div class="flex">
          <h2>${c.code} — ${c.name}</h2>
          <span class="pill">Credits: ${c.credits}</span>
          <span class="pill">Target: ${c.targetPercent || 0}%</span>
          <button class="right" id="closeModalBtn">Close</button>
        </div>
        <div class="divider"></div>
        <div class="row two">
          <div class="box" style="background:#0a0f1e;border:1px solid #1f2937;border-radius:12px;padding:12px">
            <div class="small muted">Current percentage</div>
            <div class="value">${number(percent)}%</div>
            <div class="small muted">Weights used: ${number(weightSum,0)}%</div>
          </div>
          <div class="box" style="background:#0a0f1e;border:1px solid #1f2937;border-radius:12px;padding:12px">
            <div class="small muted">Estimated GPA</div>
            <div class="value">${number(pctToGpa(percent),2)}</div>
            <div class="small muted">Mapping editable in code</div>
          </div>
        </div>
        <div class="spacer"></div>
        <table>
          <thead>
            <tr>
              <th>Type</th><th>Weight %</th><th>Score</th><th>%</th><th>Added</th><th></th>
            </tr>
          </thead>
          <tbody id="alist"></tbody>
        </table>
      </div>
    `;
    document.body.appendChild(container);
    const tbody = container.querySelector('#alist');
    c.assessments.forEach((a, idx)=>{
      const pct = a.outOf>0 ? (a.score/a.outOf)*100 : 0;
      const tr = document.createElement('tr');
      tr.innerHTML = `
        <td>${a.type}</td>
        <td>${number(a.weight,1)}</td>
        <td>${a.score}/${a.outOf}</td>
        <td>${number(pct,1)}%</td>
        <td>${new Date(a.ts).toLocaleDateString()}</td>
        <td><button data-del-ass="${si}:${ci}:${idx}" class="warn">Delete</button></td>
      `;
      tbody.appendChild(tr);
    });
    container.addEventListener('click', (e)=>{
      if (e.target.id==='closeModalBtn' || e.target===container) container.remove();
      const delA = e.target.getAttribute('data-del-ass');
      if (delA){
        const [s, cidx, aidx] = delA.split(':').map(Number);
        if (confirm('Delete this assessment?')){
          data.semesters[s].courses[cidx].assessments.splice(aidx,1);
          save();
          container.remove();
          refreshAll();
        }
      }
    });
  }

  // Initial render with a helpful default semester if empty
  if (data.semesters.length===0){
    data.semesters.push({
      name:'Sample Semester',
      courses:[
        { name:'General Chemistry', code:'CHM-101', credits:3, targetPercent:75, assessments:[
          {type:'Mid-sem', weight:30, score:24, outOf:30, ts: Date.now()-86400000*30},
          {type:'Continuous assessment', weight:20, score:16, outOf:20, ts: Date.now()-86400000*20},
        ]},
        { name:'Calculus I', code:'MTH-111', credits:4, targetPercent:70, assessments:[
          {type:'Quiz', weight:10, score:8, outOf:10, ts: Date.now()-86400000*15},
          {type:'Assignment', weight:10, score:7.5, outOf:10, ts: Date.now()-86400000*10},
        ]},
      ]
    });
    save();
  }

  refreshAll();
</script>
</body>
</html>
Editor is loading...
Leave a Comment