{"id":253,"date":"2026-04-29T14:41:17","date_gmt":"2026-04-29T11:41:17","guid":{"rendered":"https:\/\/sites.utu.fi\/mivevi\/?page_id=253"},"modified":"2026-04-29T19:34:08","modified_gmt":"2026-04-29T16:34:08","slug":"counter-app","status":"publish","type":"page","link":"https:\/\/sites.utu.fi\/mivevi\/counter-app\/","title":{"rendered":"Counter app"},"content":{"rendered":"\n<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"UTF-8\" \/>\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" \/>\n  <title>Teaching Workload Calculator<\/title>\n  <script src=\"https:\/\/cdnjs.cloudflare.com\/ajax\/libs\/react\/18.2.0\/umd\/react.production.min.js\"><\/script>\n  <script src=\"https:\/\/cdnjs.cloudflare.com\/ajax\/libs\/react-dom\/18.2.0\/umd\/react-dom.production.min.js\"><\/script>\n  <script src=\"https:\/\/cdnjs.cloudflare.com\/ajax\/libs\/babel-standalone\/7.23.2\/babel.min.js\"><\/script>\n  <script src=\"https:\/\/cdnjs.cloudflare.com\/ajax\/libs\/prop-types\/15.8.1\/prop-types.min.js\"><\/script>\n  <script src=\"https:\/\/cdnjs.cloudflare.com\/ajax\/libs\/recharts\/2.12.7\/Recharts.min.js\"><\/script>\n  <script src=\"https:\/\/cdn.tailwindcss.com\"><\/script>\n<\/head>\n<body class=\"bg-gray-100\">\n<div id=\"root\"><\/div>\n<script type=\"text\/babel\">\nconst { useState, useRef } = React\nconst { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } = Recharts\n\n\/\/ \u2500\u2500 helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nconst makeId = () => Math.random().toString(36).substr(2, 9)\nconst n = (v) => parseFloat(v) || 0\nconst minToH = (min) => min \/ 60\n\nfunction deepSet(obj, path, val) {\n  const parts = path.split(\".\")\n  const clone = { ...obj }\n  let cur = clone\n  for (let i = 0; i < parts.length - 1; i++) {\n    cur[parts[i]] = Array.isArray(cur[parts[i]]) ? [...cur[parts[i]]] : { ...cur[parts[i]] }\n    cur = cur[parts[i]]\n  }\n  cur[parts[parts.length - 1]] = val\n  return clone\n}\n\n\/\/ \u2500\u2500 defaults \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nconst SECTION_DEFS = [\n  { key: \"luennot\",             label: \"Lectures\",                  group: \"contact\" },\n  { key: \"tyopajat\",            label: \"Workshop teaching\",         group: \"contact\" },\n  { key: \"henkiloOhjaus\",       label: \"Individual supervision\",    group: \"contact\" },\n  { key: \"seminaari\",           label: \"Seminar\",                   group: \"contact\" },\n  { key: \"muuKontakti\",         label: \"Other contact teaching\",    group: \"contact\" },\n  { key: \"harjoitustehtavat\",   label: \"Assignments\",               group: \"assessment\" },\n  { key: \"kuulustelut\",         label: \"Examinations\",              group: \"assessment\" },\n  { key: \"opintosuoritteet\",    label: \"Other coursework\",          group: \"assessment\" },\n  { key: \"oppimisalusta\",       label: \"Learning platform\",         group: \"other\" },\n  { key: \"opiskelijaviestinta\", label: \"Student communication\",     group: \"other\" },\n  { key: \"muuMuu\",              label: \"Other course-related work\", group: \"other\" },\n]\n\nconst TYYPPI_LABELS = {\n  seminaarikirjoitus: \"Seminar paper\", tutkielma: \"Thesis\", ontyo: \"LLB paper\", tutkimussuunnitelma: \"Research proposal\", muuSuorite: \"Other coursework\",\n}\n\nconst KUULUSTELU_LABELS = {\n  oikeustapaus: \"Case examination\",\n  essee: \"Essay examination\",\n  monivalinta: \"Multiple choice examination\",\n}\n\n\/\/ course type \u2192 allowed other coursework types\nconst SUORITE_FILTER = {\n  oikeudenalaopinnot: [\"muuSuorite\"],\n  erikoistumisjakso: [\"seminaarikirjoitus\", \"ontyo\", \"muuSuorite\"],\n  syventavat: [\"tutkimussuunnitelma\", \"tutkielma\", \"muuSuorite\"],\n  laajentavat: [\"muuSuorite\"],\n  muut: [\"seminaarikirjoitus\", \"muuSuorite\"],\n}\n\nconst defaultKuulustelu = (tyyppi = \"oikeustapaus\") => ({\n  id: makeId(), tyyppi,\n  uusiaTapauksia: \"\", hPerTapaus: \"\",\n  suorituksiaYht: \"\", minPerSuoritus: \"\",\n  uusiaKysymyksia: \"\", minPerKysymys: \"\",\n})\n\nconst defaultSuorite = (tyyppi = \"muuSuorite\") => ({\n  id: makeId(), tyyppi, kierratetaan: false,\n  uusiaTapauksia: \"\", hPerTapaus: \"\",\n  suorituksiaYht: \"\", minPerSuoritus: \"\",\n  uusiaKysymyksia: \"\", minPerKysymys: \"\",\n  opiskelijoita: \"\", arviointiMinPerOpisk: \"\",\n  tunnit: \"\", kuvausSuorite: \"\",\n})\n\nconst OPINTOJAKSO_TYYPIT = [\n  { key: \"oikeudenalaopinnot\", label: \"Core legal studies\",   color: \"blue\" },\n  { key: \"erikoistumisjakso\", label: \"Specialisation course\", color: \"green\" },\n  { key: \"syventavat\", label: \"Advanced studies\",             color: \"violet\" },\n  { key: \"laajentavat\", label: \"Expanding studies\",           color: \"orange\" },\n  { key: \"muut\", label: \"Other courses\",                      color: \"slate\" },\n]\n\nconst TYYPPI_BORDER = {\n  oikeudenalaopinnot: \"border-blue-300\",\n  erikoistumisjakso: \"border-green-300\",\n  syventavat: \"border-violet-300\",\n  laajentavat: \"border-orange-300\",\n  muut: \"border-slate-300\",\n}\n\nconst TYYPPI_BADGE = {\n  oikeudenalaopinnot: \"bg-blue-100 text-blue-700\",\n  erikoistumisjakso: \"bg-green-100 text-green-700\",\n  syventavat: \"bg-violet-100 text-violet-700\",\n  laajentavat: \"bg-orange-100 text-orange-700\",\n  muut: \"bg-slate-100 text-slate-700\",\n}\n\nconst defaultCourse = () => ({\n  id: makeId(),\n  nimi: \"\",\n  tyyppi: \"oikeudenalaopinnot\",\n  sections: {\n    luennot: false, tyopajat: false, henkiloOhjaus: false, seminaari: false, muuKontakti: false,\n    harjoitustehtavat: false, kuulustelut: false, opintosuoritteet: false,\n    oppimisalusta: false, opiskelijaviestinta: false, muuMuu: false,\n  },\n  luennot: { kuvaus: \"\", luentoH: \"\", valmisteluH: \"\" },\n  tyopajat: { kuvaus: \"\", kontaktiH: \"\", valmisteluH: \"\" },\n  henkiloOhjaus: { kuvaus: \"\", opiskelijoita: \"\", kontaktiMinPerOpisk: \"\", valmisteluMinPerOpisk: \"\" },\n  seminaari: { kuvaus: \"\", opiskelijoita: \"\", kontaktiMinPerOpisk: \"\", valmisteluMinPerOpisk: \"\" },\n  muuKontakti: [],\n  harjoitustehtavat: { kuvaus: \"\", laadinta: { tehtavia: \"\", hPerTehtava: \"\" }, arviointi: { suorituksia: \"\", minPerSuoritus: \"\" } },\n  kuulustelut: [],\n  kuulusteluKuvaus: \"\",\n  opintosuoritteet: [],\n  opintosuoritteetKuvaus: \"\",\n  oppimisalusta: { kuvaus: \"\", tunnit: \"\" },\n  opiskelijaviestinta: { kuvaus: \"\", tunnit: \"\" },\n  muuMuu: { kuvaus: \"\", tunnit: \"\" },\n})\n\n\n\/\/ \u2500\u2500 calculations \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nconst laskeKuulustelut = (ks) =>\n  ks.reduce((sum, k) => {\n    if (k.tyyppi === \"monivalinta\")\n      return sum + (n(k.uusiaKysymyksia) * n(k.minPerKysymys)) \/ 60\n    return sum + n(k.uusiaTapauksia) * n(k.hPerTapaus) + minToH(n(k.minPerSuoritus)) * n(k.suorituksiaYht)\n  }, 0)\n\nconst laskeOpintosuoritteet = (ss) =>\n  ss.reduce((sum, s) => {\n    if (s.tyyppi === \"seminaarikirjoitus\" || s.tyyppi === \"tutkielma\" || s.tyyppi === \"ontyo\" || s.tyyppi === \"tutkimussuunnitelma\")\n      return sum + minToH(n(s.arviointiMinPerOpisk)) * n(s.opiskelijoita)\n    if (s.tyyppi === \"muuSuorite\")\n      return sum + n(s.tunnit)\n    return sum\n  }, 0)\n\nfunction laskeCourse(c) {\n  const luennot = c.sections.luennot ? n(c.luennot.luentoH) + n(c.luennot.valmisteluH) : 0\n  const tyopajat = c.sections.tyopajat ? n(c.tyopajat.kontaktiH) + n(c.tyopajat.valmisteluH) : 0\n  const ohjOpisk = n(c.henkiloOhjaus.opiskelijoita)\n  const henkiloOhjaus = c.sections.henkiloOhjaus ? minToH(n(c.henkiloOhjaus.kontaktiMinPerOpisk) + n(c.henkiloOhjaus.valmisteluMinPerOpisk)) * ohjOpisk : 0\n  const semOpisk = n(c.seminaari.opiskelijoita)\n  const seminaari = c.sections.seminaari ? minToH(n(c.seminaari.kontaktiMinPerOpisk) + n(c.seminaari.valmisteluMinPerOpisk)) * semOpisk : 0\n  const muuKontakti = c.sections.muuKontakti ? c.muuKontakti.reduce((s, m) => s + n(m.opetusH) + n(m.valmisteluH), 0) : 0\n  const harjLaadinta = c.sections.harjoitustehtavat ? n(c.harjoitustehtavat.laadinta.tehtavia) * n(c.harjoitustehtavat.laadinta.hPerTehtava) : 0\n  const harjArviointi = c.sections.harjoitustehtavat ? minToH(n(c.harjoitustehtavat.arviointi.minPerSuoritus)) * n(c.harjoitustehtavat.arviointi.suorituksia) : 0\n  const harjoitustehtavat = harjLaadinta + harjArviointi\n  const kuulustelut = c.sections.kuulustelut ? laskeKuulustelut(c.kuulustelut) : 0\n  const opintosuoritteet = c.sections.opintosuoritteet ? laskeOpintosuoritteet(c.opintosuoritteet) : 0\n  const oppimisalusta = c.sections.oppimisalusta ? n(c.oppimisalusta.tunnit) : 0\n  const opiskelijaviestinta = c.sections.opiskelijaviestinta ? n(c.opiskelijaviestinta.tunnit) : 0\n  const muuMuu = c.sections.muuMuu ? n(c.muuMuu.tunnit) : 0\n  const kontaktiYht = luennot + tyopajat + henkiloOhjaus + seminaari + muuKontakti\n  const arviointiYht = harjoitustehtavat + kuulustelut + opintosuoritteet\n  const muuYht = oppimisalusta + opiskelijaviestinta + muuMuu\n  const yhteensa = kontaktiYht + arviointiYht + muuYht\n  return { luennot, tyopajat, henkiloOhjaus, seminaari, muuKontakti, kontaktiYht, harjoitustehtavat, harjLaadinta, harjArviointi, kuulustelut, opintosuoritteet, arviointiYht, oppimisalusta, opiskelijaviestinta, muuMuu, muuYht, yhteensa }\n}\n\n\/\/ \u2500\u2500 UI primitives \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfunction Lbl({ children }) {\n  return <label className=\"block text-xs font-medium text-gray-500 mb-1 uppercase tracking-wide\">{children}<\/label>\n}\nfunction Num({ value, onChange, placeholder = \"\", step = \"1\", min = \"0\" }) {\n  return (\n    <input type=\"number\" value={value} onChange={(e) => onChange(e.target.value)}\n      placeholder={placeholder} step={step} min={min}\n      className=\"border border-gray-300 rounded px-2 py-1 text-sm w-full focus:outline-none focus:ring-1 focus:ring-blue-400\" \/>\n  )\n}\nfunction Txt({ value, onChange, placeholder = \"\" }) {\n  return (\n    <input type=\"text\" value={value} onChange={(e) => onChange(e.target.value)}\n      placeholder={placeholder}\n      className=\"border border-gray-300 rounded px-2 py-1 text-sm w-full focus:outline-none focus:ring-1 focus:ring-blue-400\" \/>\n  )\n}\nfunction SectionHeader({ label, color, hours }) {\n  return (\n    <div className=\"flex items-center justify-between px-4 py-2 rounded-t-lg\" style={{ backgroundColor: color }}>\n      <span className=\"text-white font-semibold text-sm\">{label}<\/span>\n      <span className=\"text-white font-mono text-sm\">{hours.toFixed(1)} h<\/span>\n    <\/div>\n  )\n}\nfunction Hint({ children }) { return <p className=\"text-xs text-gray-400 mt-1\">{children}<\/p> }\n\n\/\/ \u2500\u2500 SuoriteForm \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfunction SuoriteForm({ s, onChange, onRemove }) {\n  const set = (f, v) => onChange({ ...s, [f]: v })\n\n  if (s.tyyppi === \"seminaarikirjoitus\" || s.tyyppi === \"tutkielma\" || s.tyyppi === \"ontyo\" || s.tyyppi === \"tutkimussuunnitelma\") {\n    const opisk = n(s.opiskelijoita)\n    const arviointiH = minToH(n(s.arviointiMinPerOpisk)) * opisk\n    return (\n      <div className=\"border border-gray-200 rounded-lg p-3 mb-3 bg-white\">\n        <div className=\"flex items-center justify-between mb-3\">\n          <span className=\"px-2 py-1 rounded text-xs font-medium bg-red-500 text-white\">{TYYPPI_LABELS[s.tyyppi]}<\/span>\n          <div className=\"flex items-center gap-3\">\n            <span className=\"text-sm font-semibold text-red-600\">{arviointiH.toFixed(1)} h<\/span>\n            <button onClick={onRemove} className=\"text-gray-300 hover:text-red-400 text-base leading-none\">\u2715<\/button>\n          <\/div>\n        <\/div>\n        <div className=\"grid grid-cols-2 gap-3\">\n          <div>\n            <Lbl>Students<\/Lbl>\n            <Num value={s.opiskelijoita} onChange={(v) => set(\"opiskelijoita\", v)} placeholder=\"no.\" \/>\n          <\/div>\n          <div>\n            <Lbl>Assessment (min \/ student)<\/Lbl>\n            <Num value={s.arviointiMinPerOpisk} onChange={(v) => set(\"arviointiMinPerOpisk\", v)} placeholder=\"min\" step=\"5\" \/>\n            <Hint>{n(s.arviointiMinPerOpisk)} min \u00d7 {opisk} = {arviointiH.toFixed(1)} h<\/Hint>\n          <\/div>\n        <\/div>\n      <\/div>\n    )\n  }\n\n  if (s.tyyppi === \"monivalinta\") {\n    const laadintaH = (n(s.uusiaKysymyksia) * n(s.minPerKysymys)) \/ 60\n    return (\n      <div className=\"border border-gray-200 rounded-lg p-3 mb-3 bg-white\">\n        <div className=\"flex items-center justify-between mb-3\">\n          <span className=\"px-2 py-1 rounded text-xs font-medium bg-red-500 text-white\">Multiple choice<\/span>\n          <div className=\"flex items-center gap-3\">\n            <span className=\"text-sm font-semibold text-red-600\">{laadintaH.toFixed(1)} h<\/span>\n            <button onClick={onRemove} className=\"text-gray-300 hover:text-red-400 text-base leading-none\">\u2715<\/button>\n          <\/div>\n        <\/div>\n        <div className=\"grid grid-cols-2 gap-3\">\n          <div><Lbl>New questions \/ yr<\/Lbl><Num value={s.uusiaKysymyksia} onChange={(v) => set(\"uusiaKysymyksia\", v)} placeholder=\"no.\" \/><\/div>\n          <div>\n            <Lbl>min \/ question<\/Lbl>\n            <Num value={s.minPerKysymys} onChange={(v) => set(\"minPerKysymys\", v)} placeholder=\"min\" \/>\n            <Hint>Drafting: {laadintaH.toFixed(1)} h<\/Hint>\n          <\/div>\n        <\/div>\n        <p className=\"text-xs text-gray-400 mt-2\">Assessment: 0 h (automated)<\/p>\n      <\/div>\n    )\n  }\n\n  if (s.tyyppi === \"muuSuorite\") {\n    return (\n      <div className=\"border border-gray-200 rounded-lg p-3 mb-3 bg-white\">\n        <div className=\"flex items-center justify-between mb-3\">\n          <span className=\"px-2 py-1 rounded text-xs font-medium bg-red-500 text-white\">Other coursework<\/span>\n          <div className=\"flex items-center gap-3\">\n            <span className=\"text-sm font-semibold text-red-600\">{n(s.tunnit).toFixed(1)} h<\/span>\n            <button onClick={onRemove} className=\"text-gray-300 hover:text-red-400 text-base leading-none\">\u2715<\/button>\n          <\/div>\n        <\/div>\n        <div className=\"grid grid-cols-2 gap-3\">\n          <div><Lbl>Description<\/Lbl><Txt value={s.kuvausSuorite} onChange={(v) => set(\"kuvausSuorite\", v)} placeholder=\"what coursework?\" \/><\/div>\n          <div><Lbl>Workload (h)<\/Lbl><Num value={s.tunnit} onChange={(v) => set(\"tunnit\", v)} placeholder=\"h\" step=\"0.5\" \/><\/div>\n        <\/div>\n      <\/div>\n    )\n  }\n\n  \/\/ case \/ essay\n  const laadintaH = n(s.uusiaTapauksia) * n(s.hPerTapaus)\n  const suorituksia = n(s.suorituksiaYht)\n  const arviointiH = minToH(n(s.minPerSuoritus)) * suorituksia\n  const totalH = laadintaH + arviointiH\n\n  return (\n    <div className=\"border border-gray-200 rounded-lg p-3 mb-3 bg-white\">\n      <div className=\"flex items-center justify-between mb-3\">\n        <div className=\"flex gap-1\">\n          {[\"oikeustapaus\", \"essee\"].map((t) => (\n            <button key={t} onClick={() => set(\"tyyppi\", t)}\n              className={`px-2 py-1 rounded text-xs font-medium transition-colors ${s.tyyppi === t ? \"bg-red-500 text-white\" : \"bg-gray-100 text-gray-600 hover:bg-gray-200\"}`}>\n              {TYYPPI_LABELS[t]}\n            <\/button>\n          ))}\n        <\/div>\n        <div className=\"flex items-center gap-3\">\n          <span className=\"text-sm font-semibold text-red-600\">{totalH.toFixed(1)} h<\/span>\n          <button onClick={onRemove} className=\"text-gray-300 hover:text-red-400 text-base leading-none\">\u2715<\/button>\n        <\/div>\n      <\/div>\n      <div className=\"space-y-3\">\n        <div className=\"grid grid-cols-2 gap-3\">\n          <div><Lbl>New tasks \/ yr<\/Lbl><Num value={s.uusiaTapauksia} onChange={(v) => set(\"uusiaTapauksia\", v)} placeholder=\"no.\" \/><\/div>\n          <div>\n            <Lbl>h \/ task (drafting)<\/Lbl>\n            <Num value={s.hPerTapaus} onChange={(v) => set(\"hPerTapaus\", v)} placeholder=\"h\" step=\"0.5\" \/>\n            <Hint>Drafting: {laadintaH.toFixed(1)} h<\/Hint>\n          <\/div>\n        <\/div>\n        <div className=\"grid grid-cols-2 gap-3\">\n          <div>\n            <Lbl>Submissions total (incl. resits)<\/Lbl>\n            <Num value={s.suorituksiaYht} onChange={(v) => set(\"suorituksiaYht\", v)} placeholder=\"no.\" \/>\n          <\/div>\n          <div>\n            <Lbl>Assessment (min \/ submission)<\/Lbl>\n            <Num value={s.minPerSuoritus} onChange={(v) => set(\"minPerSuoritus\", v)} placeholder=\"min\" step=\"5\" \/>\n            <Hint>{n(s.minPerSuoritus)} min \u00d7 {suorituksia} = {arviointiH.toFixed(1)} h<\/Hint>\n          <\/div>\n        <\/div>\n      <\/div>\n    <\/div>\n  )\n}\n\n\/\/ \u2500\u2500 Section components \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfunction LuennotSection({ data, hours, onChange }) {\n  return (\n    <div className=\"rounded-lg overflow-hidden border border-gray-200\">\n      <SectionHeader label=\"Lectures + preparation\" color=\"#3b82f6\" hours={hours} \/>\n      <div className=\"p-3 bg-white space-y-3\">\n        <div><Lbl>Description<\/Lbl><Txt value={data.kuvaus} onChange={(v) => onChange(\"kuvaus\", v)} placeholder=\"e.g. structure and topics of the lecture series...\" \/><\/div>\n        <div className=\"grid grid-cols-2 gap-3 max-w-sm\">\n          <div>\n            <Lbl>Lectures (h)<\/Lbl>\n            <Num value={data.luentoH} onChange={(v) => onChange(\"luentoH\", v)} placeholder=\"h\" step=\"0.5\" \/>\n          <\/div>\n          <div>\n            <Lbl>Preparation (h)<\/Lbl>\n            <Num value={data.valmisteluH} onChange={(v) => onChange(\"valmisteluH\", v)} placeholder=\"h\" step=\"0.5\" \/>\n          <\/div>\n        <\/div>\n        {hours > 0 && <Hint>{n(data.luentoH).toFixed(1)} h + {n(data.valmisteluH).toFixed(1)} h = {hours.toFixed(1)} h<\/Hint>}\n      <\/div>\n    <\/div>\n  )\n}\n\nfunction TyopajaSection({ data, hours, onChange }) {\n  return (\n    <div className=\"rounded-lg overflow-hidden border border-gray-200\">\n      <SectionHeader label=\"Workshop teaching\" color=\"#7c3aed\" hours={hours} \/>\n      <div className=\"p-3 bg-white space-y-3\">\n        <div><Lbl>Description<\/Lbl><Txt value={data.kuvaus} onChange={(v) => onChange(\"kuvaus\", v)} placeholder=\"e.g. how the workshops are organised...\" \/><\/div>\n        <div className=\"grid grid-cols-2 gap-3 max-w-sm\">\n          <div>\n            <Lbl>Contact (h)<\/Lbl>\n            <Num value={data.kontaktiH} onChange={(v) => onChange(\"kontaktiH\", v)} placeholder=\"h\" step=\"0.5\" \/>\n          <\/div>\n          <div>\n            <Lbl>Preparation (h)<\/Lbl>\n            <Num value={data.valmisteluH} onChange={(v) => onChange(\"valmisteluH\", v)} placeholder=\"h\" step=\"0.5\" \/>\n          <\/div>\n        <\/div>\n      <\/div>\n    <\/div>\n  )\n}\n\nfunction HenkiloOhjausSection({ data, hours, onChange }) {\n  const opisk = n(data.opiskelijoita)\n  const kontaktiH = minToH(n(data.kontaktiMinPerOpisk)) * opisk\n  const valmisteluH = minToH(n(data.valmisteluMinPerOpisk)) * opisk\n  return (\n    <div className=\"rounded-lg overflow-hidden border border-gray-200\">\n      <SectionHeader label=\"Individual supervision\" color=\"#6366f1\" hours={hours} \/>\n      <div className=\"p-3 bg-white space-y-3\">\n        <div><Lbl>Description<\/Lbl><Txt value={data.kuvaus} onChange={(v) => onChange(\"kuvaus\", v)} placeholder=\"e.g. how supervision is arranged...\" \/><\/div>\n        <div className=\"grid grid-cols-3 gap-3\">\n          <div>\n            <Lbl>Students<\/Lbl>\n            <Num value={data.opiskelijoita} onChange={(v) => onChange(\"opiskelijoita\", v)} placeholder=\"no.\" \/>\n          <\/div>\n          <div>\n            <Lbl>Contact (min \/ student)<\/Lbl>\n            <Num value={data.kontaktiMinPerOpisk} onChange={(v) => onChange(\"kontaktiMinPerOpisk\", v)} placeholder=\"min\" step=\"5\" \/>\n            <Hint>{n(data.kontaktiMinPerOpisk)} min \u00d7 {opisk} = {kontaktiH.toFixed(1)} h<\/Hint>\n          <\/div>\n          <div>\n            <Lbl>Preparation (min \/ student)<\/Lbl>\n            <Num value={data.valmisteluMinPerOpisk} onChange={(v) => onChange(\"valmisteluMinPerOpisk\", v)} placeholder=\"min\" step=\"5\" \/>\n            <Hint>{n(data.valmisteluMinPerOpisk)} min \u00d7 {opisk} = {valmisteluH.toFixed(1)} h<\/Hint>\n          <\/div>\n        <\/div>\n      <\/div>\n    <\/div>\n  )\n}\n\nfunction SeminaariSection({ data, hours, onChange }) {\n  const opisk = n(data.opiskelijoita)\n  const kontaktiH = minToH(n(data.kontaktiMinPerOpisk)) * opisk\n  const valmisteluH = minToH(n(data.valmisteluMinPerOpisk)) * opisk\n  return (\n    <div className=\"rounded-lg overflow-hidden border border-gray-200\">\n      <SectionHeader label=\"Seminar\" color=\"#0891b2\" hours={hours} \/>\n      <div className=\"p-3 bg-white space-y-3\">\n        <div><Lbl>Description<\/Lbl><Txt value={data.kuvaus} onChange={(v) => onChange(\"kuvaus\", v)} placeholder=\"e.g. how the seminar is organised...\" \/><\/div>\n        <div className=\"grid grid-cols-3 gap-3\">\n          <div>\n            <Lbl>Students<\/Lbl>\n            <Num value={data.opiskelijoita} onChange={(v) => onChange(\"opiskelijoita\", v)} placeholder=\"no.\" \/>\n          <\/div>\n          <div>\n            <Lbl>Contact (min \/ student)<\/Lbl>\n            <Num value={data.kontaktiMinPerOpisk} onChange={(v) => onChange(\"kontaktiMinPerOpisk\", v)} placeholder=\"min\" step=\"5\" \/>\n            <Hint>{n(data.kontaktiMinPerOpisk)} min \u00d7 {opisk} = {kontaktiH.toFixed(1)} h<\/Hint>\n          <\/div>\n          <div>\n            <Lbl>Preparation (min \/ student)<\/Lbl>\n            <Num value={data.valmisteluMinPerOpisk} onChange={(v) => onChange(\"valmisteluMinPerOpisk\", v)} placeholder=\"min\" step=\"5\" \/>\n            <Hint>{n(data.valmisteluMinPerOpisk)} min \u00d7 {opisk} = {valmisteluH.toFixed(1)} h<\/Hint>\n          <\/div>\n        <\/div>\n      <\/div>\n    <\/div>\n  )\n}\n\nfunction MuuKontaktiRivi({ m, onChange, onRemove }) {\n  const set = (f, v) => onChange({ ...m, [f]: v })\n  const riviH = n(m.opetusH) + n(m.valmisteluH)\n  return (\n    <div className=\"border border-gray-200 rounded-lg p-3 mb-2 bg-white\">\n      <div className=\"grid grid-cols-12 gap-2 items-end\">\n        <div className=\"col-span-4\"><Lbl>Description<\/Lbl><Txt value={m.kuvaus} onChange={(v) => set(\"kuvaus\", v)} placeholder=\"e.g. reading group, small group...\" \/><\/div>\n        <div className=\"col-span-3\"><Lbl>Basis of calculation<\/Lbl><Txt value={m.laskentaperusteet} onChange={(v) => set(\"laskentaperusteet\", v)} placeholder=\"e.g. 4 \u00d7 2 h\" \/><\/div>\n        <div className=\"col-span-2\"><Lbl>Teaching h<\/Lbl><Num value={m.opetusH} onChange={(v) => set(\"opetusH\", v)} placeholder=\"h\" step=\"0.5\" \/><\/div>\n        <div className=\"col-span-1\"><Lbl>Prep. h<\/Lbl><Num value={m.valmisteluH} onChange={(v) => set(\"valmisteluH\", v)} placeholder=\"h\" step=\"0.5\" \/><\/div>\n        <div className=\"col-span-1 text-right pb-1\"><span className=\"text-sm font-semibold text-purple-600\">{riviH.toFixed(1)} h<\/span><\/div>\n        <div className=\"col-span-1 text-right pb-1\"><button onClick={onRemove} className=\"text-gray-300 hover:text-red-400 text-base leading-none\">\u2715<\/button><\/div>\n      <\/div>\n    <\/div>\n  )\n}\n\nfunction MuuKontaktiSection({ rows, hours, onAdd, onUpdate, onRemove }) {\n  return (\n    <div className=\"rounded-lg overflow-hidden border border-gray-200\">\n      <SectionHeader label=\"Other contact teaching\" color=\"#8b5cf6\" hours={hours} \/>\n      <div className=\"p-3 bg-gray-50\">\n        {rows.map((m) => <MuuKontaktiRivi key={m.id} m={m} onChange={(u) => onUpdate(m.id, u)} onRemove={() => onRemove(m.id)} \/>)}\n        <button onClick={onAdd}\n          className=\"px-3 py-1 border border-dashed border-gray-400 rounded text-sm text-gray-500 hover:border-purple-400 hover:text-purple-500 bg-white transition-colors mt-1\">\n          + Add row\n        <\/button>\n      <\/div>\n    <\/div>\n  )\n}\n\nfunction HarjoitustehtavatSection({ data, hours, harjLaadinta, harjArviointi, onChange }) {\n  const suorituksia = n(data.arviointi.suorituksia)\n  return (\n    <div className=\"rounded-lg overflow-hidden border border-gray-200\">\n      <SectionHeader label=\"Assignments\" color=\"#f59e0b\" hours={hours} \/>\n      <div className=\"p-3 bg-white space-y-3\">\n        <div><Lbl>Description<\/Lbl><Txt value={data.kuvaus} onChange={(v) => onChange(\"kuvaus\", v)} placeholder=\"e.g. case analysis, weekly essays...\" \/><\/div>\n        <div className=\"grid grid-cols-2 gap-3\">\n          <div><Lbl>Tasks to draft \/ yr<\/Lbl><Num value={data.laadinta.tehtavia} onChange={(v) => onChange(\"laadinta.tehtavia\", v)} placeholder=\"no.\" \/><\/div>\n          <div>\n            <Lbl>h \/ task (drafting)<\/Lbl>\n            <Num value={data.laadinta.hPerTehtava} onChange={(v) => onChange(\"laadinta.hPerTehtava\", v)} placeholder=\"h\" step=\"0.5\" \/>\n            <Hint>Drafting total: {harjLaadinta.toFixed(1)} h<\/Hint>\n          <\/div>\n        <\/div>\n        <div className=\"grid grid-cols-2 gap-3\">\n          <div>\n            <Lbl>Submissions total (incl. resits)<\/Lbl>\n            <Num value={data.arviointi.suorituksia} onChange={(v) => onChange(\"arviointi.suorituksia\", v)} placeholder=\"no.\" \/>\n          <\/div>\n          <div>\n            <Lbl>Assessment (min \/ submission)<\/Lbl>\n            <Num value={data.arviointi.minPerSuoritus} onChange={(v) => onChange(\"arviointi.minPerSuoritus\", v)} placeholder=\"min\" step=\"5\" \/>\n            <Hint>{n(data.arviointi.minPerSuoritus)} min \u00d7 {suorituksia} = {harjArviointi.toFixed(1)} h<\/Hint>\n          <\/div>\n        <\/div>\n      <\/div>\n    <\/div>\n  )\n}\n\nfunction KuulusteluForm({ s, onChange, onRemove }) {\n  const set = (f, v) => onChange({ ...s, [f]: v })\n\n  if (s.tyyppi === \"monivalinta\") {\n    const laadintaH = (n(s.uusiaKysymyksia) * n(s.minPerKysymys)) \/ 60\n    return (\n      <div className=\"border border-gray-200 rounded-lg p-3 mb-3 bg-white\">\n        <div className=\"flex items-center justify-between mb-3\">\n          <span className=\"px-2 py-1 rounded text-xs font-medium bg-orange-500 text-white\">Multiple choice examination<\/span>\n          <div className=\"flex items-center gap-3\">\n            <span className=\"text-sm font-semibold text-orange-600\">{laadintaH.toFixed(1)} h<\/span>\n            <button onClick={onRemove} className=\"text-gray-300 hover:text-red-400 text-base leading-none\">\u2715<\/button>\n          <\/div>\n        <\/div>\n        <div className=\"grid grid-cols-2 gap-3\">\n          <div><Lbl>New questions \/ yr<\/Lbl><Num value={s.uusiaKysymyksia} onChange={(v) => set(\"uusiaKysymyksia\", v)} placeholder=\"no.\" \/><\/div>\n          <div>\n            <Lbl>min \/ question<\/Lbl>\n            <Num value={s.minPerKysymys} onChange={(v) => set(\"minPerKysymys\", v)} placeholder=\"min\" \/>\n            <Hint>Drafting: {laadintaH.toFixed(1)} h<\/Hint>\n          <\/div>\n        <\/div>\n        <p className=\"text-xs text-gray-400 mt-2\">Assessment: 0 h (automated)<\/p>\n      <\/div>\n    )\n  }\n\n  \/\/ case \/ essay\n  const laadintaH = n(s.uusiaTapauksia) * n(s.hPerTapaus)\n  const suorituksia = n(s.suorituksiaYht)\n  const arviointiH = minToH(n(s.minPerSuoritus)) * suorituksia\n  const totalH = laadintaH + arviointiH\n  return (\n    <div className=\"border border-gray-200 rounded-lg p-3 mb-3 bg-white\">\n      <div className=\"flex items-center justify-between mb-3\">\n        <div className=\"flex gap-1\">\n          {[\"oikeustapaus\", \"essee\"].map((t) => (\n            <button key={t} onClick={() => set(\"tyyppi\", t)}\n              className={`px-2 py-1 rounded text-xs font-medium transition-colors ${s.tyyppi === t ? \"bg-orange-500 text-white\" : \"bg-gray-100 text-gray-600 hover:bg-gray-200\"}`}>\n              {KUULUSTELU_LABELS[t]}\n            <\/button>\n          ))}\n        <\/div>\n        <div className=\"flex items-center gap-3\">\n          <span className=\"text-sm font-semibold text-orange-600\">{totalH.toFixed(1)} h<\/span>\n          <button onClick={onRemove} className=\"text-gray-300 hover:text-red-400 text-base leading-none\">\u2715<\/button>\n        <\/div>\n      <\/div>\n      <div className=\"space-y-3\">\n        <div className=\"grid grid-cols-2 gap-3\">\n          <div><Lbl>New tasks \/ yr<\/Lbl><Num value={s.uusiaTapauksia} onChange={(v) => set(\"uusiaTapauksia\", v)} placeholder=\"no.\" \/><\/div>\n          <div>\n            <Lbl>h \/ task (drafting)<\/Lbl>\n            <Num value={s.hPerTapaus} onChange={(v) => set(\"hPerTapaus\", v)} placeholder=\"h\" step=\"0.5\" \/>\n            <Hint>Drafting: {laadintaH.toFixed(1)} h<\/Hint>\n          <\/div>\n        <\/div>\n        <div className=\"grid grid-cols-2 gap-3\">\n          <div>\n            <Lbl>Submissions total (incl. resits)<\/Lbl>\n            <Num value={s.suorituksiaYht} onChange={(v) => set(\"suorituksiaYht\", v)} placeholder=\"no.\" \/>\n          <\/div>\n          <div>\n            <Lbl>Assessment (min \/ submission)<\/Lbl>\n            <Num value={s.minPerSuoritus} onChange={(v) => set(\"minPerSuoritus\", v)} placeholder=\"min\" step=\"5\" \/>\n            <Hint>{n(s.minPerSuoritus)} min \u00d7 {suorituksia} = {arviointiH.toFixed(1)} h<\/Hint>\n          <\/div>\n        <\/div>\n      <\/div>\n    <\/div>\n  )\n}\n\nfunction KuulustelutSection({ course, hours, onChange }) {\n  const addK = (tyyppi) => onChange({ ...course, kuulustelut: [...course.kuulustelut, defaultKuulustelu(tyyppi)] })\n  const updK = (id, u) => onChange({ ...course, kuulustelut: course.kuulustelut.map((k) => k.id === id ? u : k) })\n  const remK = (id) => onChange({ ...course, kuulustelut: course.kuulustelut.filter((k) => k.id !== id) })\n  return (\n    <div className=\"rounded-lg overflow-hidden border border-gray-200\">\n      <SectionHeader label=\"Examinations\" color=\"#ea580c\" hours={hours} \/>\n      <div className=\"p-3 bg-gray-50 space-y-3\">\n        <div className=\"bg-white rounded-lg p-3\"><Lbl>Description<\/Lbl><Txt value={course.kuulusteluKuvaus || \"\"} onChange={(v) => onChange({ ...course, kuulusteluKuvaus: v })} placeholder=\"e.g. how examinations are organised...\" \/><\/div>\n        {course.kuulustelut.map((k) => (\n          <KuulusteluForm key={k.id} s={k} onChange={(u) => updK(k.id, u)} onRemove={() => remK(k.id)} \/>\n        ))}\n        <div className=\"flex gap-2 flex-wrap mt-1\">\n          {[\"oikeustapaus\", \"essee\", \"monivalinta\"].map((tyyppi) => (\n            <button key={tyyppi} onClick={() => addK(tyyppi)}\n              className=\"px-3 py-1 border border-dashed border-gray-400 rounded text-sm text-gray-500 hover:border-orange-400 hover:text-orange-500 bg-white transition-colors\">\n              + {KUULUSTELU_LABELS[tyyppi]}\n            <\/button>\n          ))}\n        <\/div>\n      <\/div>\n    <\/div>\n  )\n}\n\nfunction OpintosuoritteetSection({ course, hours, onChange }) {\n  const addS = (tyyppi) => onChange({ ...course, opintosuoritteet: [...course.opintosuoritteet, defaultSuorite(tyyppi)] })\n  const updS = (id, u) => onChange({ ...course, opintosuoritteet: course.opintosuoritteet.map((s) => s.id === id ? u : s) })\n  const remS = (id) => onChange({ ...course, opintosuoritteet: course.opintosuoritteet.filter((s) => s.id !== id) })\n  return (\n    <div className=\"rounded-lg overflow-hidden border border-gray-200\">\n      <SectionHeader label=\"Other coursework\" color=\"#ef4444\" hours={hours} \/>\n      <div className=\"p-3 bg-gray-50 space-y-3\">\n        <div className=\"bg-white rounded-lg p-3\"><Lbl>Description<\/Lbl><Txt value={course.opintosuoritteetKuvaus || \"\"} onChange={(v) => onChange({ ...course, opintosuoritteetKuvaus: v })} placeholder=\"e.g. how coursework is organised...\" \/><\/div>\n        {course.opintosuoritteet.map((s) => (\n          <SuoriteForm key={s.id} s={s} onChange={(u) => updS(s.id, u)} onRemove={() => remS(s.id)} \/>\n        ))}\n        <div className=\"flex gap-2 flex-wrap mt-1\">\n          {(SUORITE_FILTER[course.tyyppi] || []).map((tyyppi) => (\n            <button key={tyyppi} onClick={() => addS(tyyppi)}\n              className=\"px-3 py-1 border border-dashed border-gray-400 rounded text-sm text-gray-500 hover:border-red-400 hover:text-red-500 bg-white transition-colors\">\n              + {TYYPPI_LABELS[tyyppi]}\n            <\/button>\n          ))}\n        <\/div>\n      <\/div>\n    <\/div>\n  )\n}\n\nfunction OppimisalustaSection({ data, hours, onChange }) {\n  return (\n    <div className=\"rounded-lg overflow-hidden border border-gray-200\">\n      <SectionHeader label=\"Learning platform\" color=\"#10b981\" hours={hours} \/>\n      <div className=\"p-3 bg-white space-y-3\">\n        <div><Lbl>Description<\/Lbl><Txt value={data.kuvaus} onChange={(v) => onChange(\"kuvaus\", v)} placeholder=\"e.g. what is done on the learning platform...\" \/><\/div>\n        <div className=\"max-w-xs\"><Lbl>Hours \/ year<\/Lbl><Num value={data.tunnit} onChange={(v) => onChange(\"tunnit\", v)} placeholder=\"h\" \/><\/div>\n      <\/div>\n    <\/div>\n  )\n}\n\nfunction OpiskelijaviestintaSection({ data, hours, onChange }) {\n  return (\n    <div className=\"rounded-lg overflow-hidden border border-gray-200\">\n      <SectionHeader label=\"Student communication\" color=\"#0d9488\" hours={hours} \/>\n      <div className=\"p-3 bg-white space-y-3\">\n        <div><Lbl>Description<\/Lbl><Txt value={data.kuvaus} onChange={(v) => onChange(\"kuvaus\", v)} placeholder=\"e.g. email, announcements, chat...\" \/><\/div>\n        <div className=\"max-w-xs\"><Lbl>Hours \/ year<\/Lbl><Num value={data.tunnit} onChange={(v) => onChange(\"tunnit\", v)} placeholder=\"h\" \/><\/div>\n      <\/div>\n    <\/div>\n  )\n}\n\nfunction MuuMuuSection({ data, hours, onChange }) {\n  return (\n    <div className=\"rounded-lg overflow-hidden border border-gray-200\">\n      <SectionHeader label=\"Other course-related work\" color=\"#6b7280\" hours={hours} \/>\n      <div className=\"p-3 bg-white\">\n        <div className=\"grid grid-cols-2 gap-3 max-w-sm\">\n          <div>\n            <Lbl>Description<\/Lbl>\n            <Txt value={data.kuvaus} onChange={(v) => onChange(\"kuvaus\", v)} placeholder=\"what?\" \/>\n          <\/div>\n          <div>\n            <Lbl>Hours \/ year<\/Lbl>\n            <Num value={data.tunnit} onChange={(v) => onChange(\"tunnit\", v)} placeholder=\"h\" \/>\n          <\/div>\n        <\/div>\n      <\/div>\n    <\/div>\n  )\n}\n\n\/\/ \u2500\u2500 CourseCard \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nconst SECTION_COLORS = {\n  luennot: \"bg-blue-50 border-blue-200 text-blue-700\",\n  tyopajat: \"bg-violet-50 border-violet-200 text-violet-700\",\n  henkiloOhjaus: \"bg-indigo-50 border-indigo-200 text-indigo-700\",\n  seminaari: \"bg-cyan-50 border-cyan-200 text-cyan-700\",\n  muuKontakti: \"bg-purple-50 border-purple-200 text-purple-700\",\n  harjoitustehtavat: \"bg-amber-50 border-amber-200 text-amber-700\",\n  kuulustelut: \"bg-orange-50 border-orange-200 text-orange-700\",\n  opintosuoritteet: \"bg-red-50 border-red-200 text-red-700\",\n  oppimisalusta: \"bg-emerald-50 border-emerald-200 text-emerald-700\",\n  opiskelijaviestinta: \"bg-teal-50 border-teal-200 text-teal-700\",\n  muuMuu: \"bg-gray-50 border-gray-300 text-gray-600\",\n}\n\nfunction CourseCard({ course, onChange, onRemove }) {\n  const set = (path, val) => onChange(deepSet(course, path, val))\n  const toggle = (key) => set(`sections.${key}`, !course.sections[key])\n  const t = laskeCourse(course)\n\n  const addMuu = () => onChange({ ...course, muuKontakti: [...course.muuKontakti, { id: makeId(), kuvaus: \"\", laskentaperusteet: \"\", opetusH: \"\", valmisteluH: \"\" }] })\n  const updMuu = (id, u) => onChange({ ...course, muuKontakti: course.muuKontakti.map((m) => m.id === id ? u : m) })\n  const remMuu = (id) => onChange({ ...course, muuKontakti: course.muuKontakti.filter((m) => m.id !== id) })\n\n  const anyActive = Object.values(course.sections).some(Boolean)\n\n  const borderColor = TYYPPI_BORDER[course.tyyppi] || \"border-gray-200\"\n  const badgeColor = TYYPPI_BADGE[course.tyyppi] || \"bg-gray-100 text-gray-700\"\n\n  return (\n    <div className={`border-2 ${borderColor} rounded-xl mb-6 overflow-hidden bg-gray-50 shadow-sm`}>\n      {\/* Header *\/}\n      <div className=\"flex items-center gap-3 px-4 py-3 bg-white border-b border-gray-200\">\n        <select value={course.tyyppi || \"oikeudenalaopinnot\"} onChange={(e) => set(\"tyyppi\", e.target.value)}\n          className={`text-xs font-semibold px-2 py-1 rounded shrink-0 border-0 cursor-pointer ${badgeColor}`}>\n          {OPINTOJAKSO_TYYPIT.map(({ key, label }) => <option key={key} value={key}>{label}<\/option>)}\n        <\/select>\n        <input type=\"text\" value={course.nimi} onChange={(e) => set(\"nimi\", e.target.value)} placeholder=\"Course name\"\n          className=\"text-lg font-bold border-b-2 border-blue-400 bg-transparent flex-1 focus:outline-none\" \/>\n        <div className=\"shrink-0 text-right\">\n          <span className=\"text-2xl font-bold text-blue-700\">{t.yhteensa.toFixed(0)}<\/span>\n          <span className=\"text-gray-400 text-sm ml-1\">h\/yr<\/span>\n        <\/div>\n        <button onClick={onRemove} className=\"text-gray-300 hover:text-red-400 shrink-0 text-lg\">\u2715<\/button>\n      <\/div>\n\n      {\/* Checkboxes *\/}\n      <div className=\"px-4 py-3 bg-white border-b border-gray-100\">\n        <p className=\"text-xs font-semibold text-gray-400 uppercase tracking-wide mb-2\">Contact teaching<\/p>\n        <div className=\"flex flex-wrap gap-2 mb-3\">\n          {SECTION_DEFS.filter(d => d.group === \"contact\").map(({ key, label }) => (\n            <label key={key} className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-sm cursor-pointer select-none transition-all ${\n              course.sections[key] ? SECTION_COLORS[key] + \" border-current font-medium\" : \"bg-white border-gray-200 text-gray-400 hover:border-gray-300\"\n            }`}>\n              <input type=\"checkbox\" checked={course.sections[key]} onChange={() => toggle(key)} className=\"sr-only\" \/>\n              <span className={`w-3 h-3 rounded-sm border flex items-center justify-center ${course.sections[key] ? \"bg-current border-current\" : \"border-gray-300\"}`}>\n                {course.sections[key] && <svg className=\"w-2.5 h-2.5 text-white\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" strokeWidth=\"3\"><path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M5 13l4 4L19 7\" \/><\/svg>}\n              <\/span>\n              {label}\n            <\/label>\n          ))}\n        <\/div>\n        <p className=\"text-xs font-semibold text-gray-400 uppercase tracking-wide mb-2 mt-3\">Assessment<\/p>\n        <div className=\"flex flex-wrap gap-2 mb-3\">\n          {SECTION_DEFS.filter(d => d.group === \"assessment\").map(({ key, label }) => (\n            <label key={key} className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-sm cursor-pointer select-none transition-all ${\n              course.sections[key] ? SECTION_COLORS[key] + \" border-current font-medium\" : \"bg-white border-gray-200 text-gray-400 hover:border-gray-300\"\n            }`}>\n              <input type=\"checkbox\" checked={course.sections[key]} onChange={() => toggle(key)} className=\"sr-only\" \/>\n              <span className={`w-3 h-3 rounded-sm border flex items-center justify-center ${course.sections[key] ? \"bg-current border-current\" : \"border-gray-300\"}`}>\n                {course.sections[key] && <svg className=\"w-2.5 h-2.5 text-white\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" strokeWidth=\"3\"><path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M5 13l4 4L19 7\" \/><\/svg>}\n              <\/span>\n              {label}\n            <\/label>\n          ))}\n        <\/div>\n        <p className=\"text-xs font-semibold text-gray-400 uppercase tracking-wide mb-2\">Other work<\/p>\n        <div className=\"flex flex-wrap gap-2\">\n          {SECTION_DEFS.filter(d => d.group === \"other\").map(({ key, label }) => (\n            <label key={key} className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-sm cursor-pointer select-none transition-all ${\n              course.sections[key] ? SECTION_COLORS[key] + \" border-current font-medium\" : \"bg-white border-gray-200 text-gray-400 hover:border-gray-300\"\n            }`}>\n              <input type=\"checkbox\" checked={course.sections[key]} onChange={() => toggle(key)} className=\"sr-only\" \/>\n              <span className={`w-3 h-3 rounded-sm border flex items-center justify-center ${course.sections[key] ? \"bg-current border-current\" : \"border-gray-300\"}`}>\n                {course.sections[key] && <svg className=\"w-2.5 h-2.5 text-white\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" strokeWidth=\"3\"><path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M5 13l4 4L19 7\" \/><\/svg>}\n              <\/span>\n              {label}\n            <\/label>\n          ))}\n        <\/div>\n      <\/div>\n\n      {\/* Active sections *\/}\n      {anyActive && (\n        <div className=\"p-4 space-y-4\">\n          {course.sections.luennot && <LuennotSection data={course.luennot} hours={t.luennot} onChange={(f, v) => set(`luennot.${f}`, v)} \/>}\n          {course.sections.tyopajat && <TyopajaSection data={course.tyopajat} hours={t.tyopajat} onChange={(f, v) => set(`tyopajat.${f}`, v)} \/>}\n          {course.sections.henkiloOhjaus && <HenkiloOhjausSection data={course.henkiloOhjaus} hours={t.henkiloOhjaus} onChange={(f, v) => set(`henkiloOhjaus.${f}`, v)} \/>}\n          {course.sections.seminaari && <SeminaariSection data={course.seminaari} hours={t.seminaari} onChange={(f, v) => set(`seminaari.${f}`, v)} \/>}\n          {course.sections.muuKontakti && <MuuKontaktiSection rows={course.muuKontakti} hours={t.muuKontakti} onAdd={addMuu} onUpdate={updMuu} onRemove={remMuu} \/>}\n          {course.sections.harjoitustehtavat && <HarjoitustehtavatSection data={course.harjoitustehtavat} hours={t.harjoitustehtavat} harjLaadinta={t.harjLaadinta} harjArviointi={t.harjArviointi} onChange={(f, v) => set(`harjoitustehtavat.${f}`, v)} \/>}\n          {course.sections.kuulustelut && <KuulustelutSection course={course} hours={t.kuulustelut} onChange={onChange} \/>}\n          {course.sections.opintosuoritteet && <OpintosuoritteetSection course={course} hours={t.opintosuoritteet} onChange={onChange} \/>}\n          {course.sections.oppimisalusta && <OppimisalustaSection data={course.oppimisalusta} hours={t.oppimisalusta} onChange={(f, v) => set(`oppimisalusta.${f}`, v)} \/>}\n          {course.sections.opiskelijaviestinta && <OpiskelijaviestintaSection data={course.opiskelijaviestinta} hours={t.opiskelijaviestinta} onChange={(f, v) => set(`opiskelijaviestinta.${f}`, v)} \/>}\n          {course.sections.muuMuu && <MuuMuuSection data={course.muuMuu} hours={t.muuMuu} onChange={(f, v) => set(`muuMuu.${f}`, v)} \/>}\n        <\/div>\n      )}\n    <\/div>\n  )\n}\n\n\/\/ \u2500\u2500 App \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nconst CHART_COLORS = {\n  Lectures: \"#3b82f6\", Workshops: \"#7c3aed\", \"Indiv. supervision\": \"#6366f1\",\n  Seminar: \"#0891b2\", \"Other contact\": \"#8b5cf6\", Assignments: \"#f59e0b\",\n  Examinations: \"#ea580c\", \"Other coursework\": \"#ef4444\", \"Lrn. platform\": \"#10b981\",\n  \"Student comm.\": \"#0d9488\", \"Other work\": \"#6b7280\",\n}\n\nfunction App() {\n  const [courses, setCourses] = useState(() => [\n    { ...defaultCourse(), tyyppi: \"oikeudenalaopinnot\" },\n    { ...defaultCourse(), tyyppi: \"erikoistumisjakso\" },\n    { ...defaultCourse(), tyyppi: \"syventavat\" },\n    { ...defaultCourse(), tyyppi: \"laajentavat\" },\n  ])\n  const [professori, setProfessori] = useState(\"\")\n  const [savedMsg, setSavedMsg] = useState(\"\")\n  const fileInputRef = useRef(null)\n\n  const update = (id, u) => setCourses(courses.map((c) => c.id === id ? u : c))\n  const remove = (id) => setCourses(courses.filter((c) => c.id !== id))\n\n  const totals = courses.map((c) => laskeCourse(c))\n\n  const grand = totals.reduce(\n    (s, t) => ({\n      luennot: s.luennot + t.luennot,\n      tyopajat: s.tyopajat + t.tyopajat,\n      henkiloOhjaus: s.henkiloOhjaus + t.henkiloOhjaus,\n      seminaari: s.seminaari + t.seminaari,\n      muuKontakti: s.muuKontakti + t.muuKontakti,\n      harjoitustehtavat: s.harjoitustehtavat + t.harjoitustehtavat,\n      kuulustelut: s.kuulustelut + t.kuulustelut,\n      opintosuoritteet: s.opintosuoritteet + t.opintosuoritteet,\n      oppimisalusta: s.oppimisalusta + t.oppimisalusta,\n      opiskelijaviestinta: s.opiskelijaviestinta + t.opiskelijaviestinta,\n      muuMuu: s.muuMuu + t.muuMuu,\n      yhteensa: s.yhteensa + t.yhteensa,\n    }),\n    { luennot: 0, tyopajat: 0, henkiloOhjaus: 0, seminaari: 0, muuKontakti: 0, harjoitustehtavat: 0, kuulustelut: 0, opintosuoritteet: 0, oppimisalusta: 0, opiskelijaviestinta: 0, muuMuu: 0, yhteensa: 0 }\n  )\n\n  const chartData = courses.map((c, i) => {\n    const t = totals[i]\n    return {\n      name: c.nimi || \"Unnamed\",\n      Lectures: +t.luennot.toFixed(1),\n      Workshops: +t.tyopajat.toFixed(1),\n      \"Indiv. supervision\": +t.henkiloOhjaus.toFixed(1),\n      Seminar: +t.seminaari.toFixed(1),\n      \"Other contact\": +t.muuKontakti.toFixed(1),\n      Assignments: +t.harjoitustehtavat.toFixed(1),\n      Examinations: +t.kuulustelut.toFixed(1),\n      \"Other coursework\": +t.opintosuoritteet.toFixed(1),\n      \"Lrn. platform\": +t.oppimisalusta.toFixed(1),\n      \"Student comm.\": +t.opiskelijaviestinta.toFixed(1),\n      \"Other work\": +t.muuMuu.toFixed(1),\n    }\n  })\n\n  const summaryItems = [\n    { key: \"luennot\", label: \"Lectures\", color: \"#3b82f6\", val: grand.luennot },\n    { key: \"tyopajat\", label: \"Workshops\", color: \"#7c3aed\", val: grand.tyopajat },\n    { key: \"henkiloOhjaus\", label: \"Indiv. supervision\", color: \"#6366f1\", val: grand.henkiloOhjaus },\n    { key: \"seminaari\", label: \"Seminar\", color: \"#0891b2\", val: grand.seminaari },\n    { key: \"muuKontakti\", label: \"Other contact\", color: \"#8b5cf6\", val: grand.muuKontakti },\n    { key: \"harjoitustehtavat\", label: \"Assignments\", color: \"#f59e0b\", val: grand.harjoitustehtavat },\n    { key: \"kuulustelut\", label: \"Examinations\", color: \"#ea580c\", val: grand.kuulustelut },\n    { key: \"opintosuoritteet\", label: \"Other coursework\", color: \"#ef4444\", val: grand.opintosuoritteet },\n    { key: \"oppimisalusta\", label: \"Lrn. platform\", color: \"#10b981\", val: grand.oppimisalusta },\n    { key: \"opiskelijaviestinta\", label: \"Student comm.\", color: \"#0d9488\", val: grand.opiskelijaviestinta },\n    { key: \"muuMuu\", label: \"Other work\", color: \"#6b7280\", val: grand.muuMuu },\n  ]\n\n  function tallenna(nimiOverride) {\n    const nimi = nimiOverride || professori\n    const yhteenveto = {\n      ...grand,\n      kurssit: courses.map((c, i) => ({ nimi: c.nimi, tyyppi: c.tyyppi, ...totals[i] })),\n    }\n    const blob = new Blob([JSON.stringify({ name: nimi, exported: new Date().toISOString(), courses, summary: yhteenveto }, null, 2)], { type: \"application\/json\" })\n    const url = URL.createObjectURL(blob)\n    const a = document.createElement(\"a\")\n    const safeName = (nimi || \"staff\").replace(\/[^a-zA-Z\u00e4\u00f6\u00e5\u00c4\u00d6\u00c50-9_-]\/g, \"_\")\n    a.href = url\n    a.download = `workload_${safeName}.json`\n    a.click()\n    URL.revokeObjectURL(url)\n    setSavedMsg(\"Saved!\")\n    setTimeout(() => setSavedMsg(\"\"), 3000)\n  }\n\n  function lataa(e) {\n    const file = e.target.files[0]\n    if (!file) return\n    const reader = new FileReader()\n    reader.onload = (ev) => {\n      try {\n        const data = JSON.parse(ev.target.result)\n        if (data.courses) setCourses(data.courses)\n        else if (data.kurssit) setCourses(data.kurssit)\n        if (data.name !== undefined) setProfessori(data.name)\n        else if (data.professori !== undefined) setProfessori(data.professori)\n        setSavedMsg(\"Loaded!\")\n        setTimeout(() => setSavedMsg(\"\"), 3000)\n      } catch {\n        alert(\"Failed to read file.\")\n      }\n    }\n    reader.readAsText(file)\n    e.target.value = \"\"\n  }\n\n  const [modal, setModal] = useState(false)\n  const [lahetaNimi, setLahetaNimi] = useState(\"\")\n  const [helpModal, setHelpModal] = useState(false)\n\n  function laheta() {\n    const nimi = professori.trim() || window.prompt(\"Your name (appears in the filename):\")\n    if (!nimi) return\n    if (!professori.trim()) setProfessori(nimi)\n    tallenna(nimi)\n    setLahetaNimi(nimi)\n    setModal(true)\n  }\n\n  const safeName = (professori || \"staff\").replace(\/[^a-zA-Z\u00e4\u00f6\u00e5\u00c4\u00d6\u00c50-9_-]\/g, \"_\")\n  const emailSubject = encodeURIComponent(`Teaching workload \u2013 ${professori || \"staff\"}`)\n  const emailBody = encodeURIComponent(`Hi,\\n\\nPlease find my teaching workload data attached as workload_${safeName}.json.\\n\\nBest regards\\n${professori || \"\"}`)\n  const mailtoLink = `mailto:mivevi@utu.fi?subject=${emailSubject}&body=${emailBody}`\n\n  return (\n    <div className=\"min-h-screen bg-gray-100\">\n\n      {\/* Help modal *\/}\n      {helpModal && (\n        <div className=\"fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-40\" onClick={() => setHelpModal(false)}>\n          <div className=\"bg-white rounded-2xl shadow-2xl max-w-lg w-full mx-4 p-6 max-h-[90vh] overflow-y-auto\" onClick={(e) => e.stopPropagation()}>\n            <div className=\"flex items-center justify-between mb-4\">\n              <h2 className=\"text-lg font-bold text-gray-800\">How to use the calculator<\/h2>\n              <button onClick={() => setHelpModal(false)} className=\"text-gray-300 hover:text-gray-500 text-xl leading-none\">\u2715<\/button>\n            <\/div>\n            <ol className=\"space-y-4 mb-6\">\n              <li className=\"flex gap-3\">\n                <span className=\"w-7 h-7 rounded-full bg-blue-600 text-white text-sm font-bold flex items-center justify-center shrink-0 mt-0.5\">1<\/span>\n                <div>\n                  <p className=\"text-sm font-semibold text-gray-800\">Add your courses<\/p>\n                  <p className=\"text-sm text-gray-600 mt-0.5\">Four blank cards are ready \u2014 one for each course type. Change the type using the dropdown and enter the course name. Add more cards with the + button at the bottom.<\/p>\n                <\/div>\n              <\/li>\n              <li className=\"flex gap-3\">\n                <span className=\"w-7 h-7 rounded-full bg-blue-600 text-white text-sm font-bold flex items-center justify-center shrink-0 mt-0.5\">2<\/span>\n                <div>\n                  <p className=\"text-sm font-semibold text-gray-800\">Select what each course involves<\/p>\n                  <p className=\"text-sm text-gray-600 mt-0.5\">Tick the elements that apply to each course \u2014 e.g. lectures, seminar, assignments. Each tick opens a form for that element.<\/p>\n                <\/div>\n              <\/li>\n              <li className=\"flex gap-3\">\n                <span className=\"w-7 h-7 rounded-full bg-blue-600 text-white text-sm font-bold flex items-center justify-center shrink-0 mt-0.5\">3<\/span>\n                <div>\n                  <p className=\"text-sm font-semibold text-gray-800\">Fill in the details<\/p>\n                  <p className=\"text-sm text-gray-600 mt-0.5\">Enter hours and student numbers. Per-student times are entered in <strong>minutes<\/strong> \u2014 the calculator converts them to hours automatically. Use the description field for a brief note on how you organise that element.<\/p>\n                <\/div>\n              <\/li>\n              <li className=\"flex gap-3\">\n                <span className=\"w-7 h-7 rounded-full bg-blue-600 text-white text-sm font-bold flex items-center justify-center shrink-0 mt-0.5\">4<\/span>\n                <div>\n                  <p className=\"text-sm font-semibold text-gray-800\">Send<\/p>\n                  <p className=\"text-sm text-gray-600 mt-0.5\">Click <strong>Send<\/strong>. A file downloads to your computer and an email opens pre-addressed. Attach the downloaded file to the email and send.<\/p>\n                <\/div>\n              <\/li>\n            <\/ol>\n            <div className=\"bg-gray-50 rounded-lg p-4 space-y-1.5\">\n              <p className=\"text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2\">Tips<\/p>\n              <p className=\"text-sm text-gray-600\">\ud83d\udcbe <strong>Save<\/strong> downloads the file \u2014 you can continue later using <strong>Load<\/strong>.<\/p>\n              <p className=\"text-sm text-gray-600\">\ud83d\udcca The summary updates in real time at the top of the page.<\/p>\n              <p className=\"text-sm text-gray-600\">\u2753 Questions? Contact: <a href=\"mailto:mivevi@utu.fi\" className=\"text-blue-600 hover:underline\">mivevi@utu.fi<\/a><\/p>\n            <\/div>\n          <\/div>\n        <\/div>\n      )}\n\n      {\/* Send modal *\/}\n      {modal && (\n        <div className=\"fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-40\" onClick={() => setModal(false)}>\n          <div className=\"bg-white rounded-2xl shadow-2xl max-w-md w-full mx-4 p-6\" onClick={(e) => e.stopPropagation()}>\n            <h2 className=\"text-lg font-bold text-gray-800 mb-1\">File downloaded<\/h2>\n            <p className=\"text-sm text-gray-500 mb-5\">How to send your data:<\/p>\n            <ol className=\"space-y-3 mb-6\">\n              <li className=\"flex gap-3\">\n                <span className=\"w-6 h-6 rounded-full bg-blue-100 text-blue-700 text-xs font-bold flex items-center justify-center shrink-0 mt-0.5\">1<\/span>\n                <span className=\"text-sm text-gray-700\">Your JSON file has been downloaded \u2014 you'll usually find it in your Downloads folder as <code className=\"bg-gray-100 px-1 rounded\">workload_{safeName}.json<\/code>.<\/span>\n              <\/li>\n              <li className=\"flex gap-3\">\n                <span className=\"w-6 h-6 rounded-full bg-blue-100 text-blue-700 text-xs font-bold flex items-center justify-center shrink-0 mt-0.5\">2<\/span>\n                <span className=\"text-sm text-gray-700\">Open the email using the button below. The address and subject are pre-filled.<\/span>\n              <\/li>\n              <li className=\"flex gap-3\">\n                <span className=\"w-6 h-6 rounded-full bg-blue-100 text-blue-700 text-xs font-bold flex items-center justify-center shrink-0 mt-0.5\">3<\/span>\n                <span className=\"text-sm text-gray-700\">Attach the JSON file and send.<\/span>\n              <\/li>\n            <\/ol>\n            <div className=\"flex gap-3\">\n              <a href={mailtoLink}\n                className=\"flex-1 text-center px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors\">\n                Open email\n              <\/a>\n              <button onClick={() => setModal(false)}\n                className=\"px-4 py-2 bg-gray-100 text-gray-600 text-sm font-medium rounded-lg hover:bg-gray-200 transition-colors\">\n                Close\n              <\/button>\n            <\/div>\n          <\/div>\n        <\/div>\n      )}\n\n      {\/* Top bar *\/}\n      <div className=\"bg-white border-b border-gray-200 shadow-sm sticky top-0 z-10\">\n        <div className=\"max-w-3xl mx-auto px-4 py-3 flex items-center gap-3\">\n          <h1 className=\"text-lg font-bold text-gray-800 shrink-0\">Teaching Workload Calculator<\/h1>\n          <button onClick={() => setHelpModal(true)}\n            className=\"w-6 h-6 rounded-full bg-gray-100 border border-gray-300 text-gray-500 text-xs font-bold hover:bg-blue-50 hover:border-blue-300 hover:text-blue-600 transition-colors shrink-0\"\n            title=\"Help\">?<\/button>\n          <div className=\"flex items-center gap-2 shrink-0 ml-auto\">\n            {savedMsg && <span className=\"text-sm text-green-600 font-medium\">{savedMsg}<\/span>}\n            <button onClick={tallenna}\n              className=\"px-4 py-1.5 bg-gray-100 text-gray-700 text-sm font-medium rounded hover:bg-gray-200 border border-gray-300 transition-colors\">\n              Save\n            <\/button>\n            <button onClick={() => fileInputRef.current.click()}\n              className=\"px-4 py-1.5 bg-gray-100 text-gray-700 text-sm font-medium rounded hover:bg-gray-200 border border-gray-300 transition-colors\">\n              Load\n            <\/button>\n            <input ref={fileInputRef} type=\"file\" accept=\".json\" onChange={lataa} className=\"hidden\" \/>\n            <button onClick={laheta}\n              className=\"px-4 py-1.5 bg-green-600 text-white text-sm font-medium rounded hover:bg-green-700 transition-colors\">\n              Send\n            <\/button>\n          <\/div>\n        <\/div>\n      <\/div>\n\n      <div className=\"max-w-3xl mx-auto px-4 py-8\">\n        {\/* Summary *\/}\n        <div className=\"bg-white rounded-xl shadow-sm p-5 mb-6\">\n          <div className=\"flex items-start gap-6\">\n            <div className=\"text-center shrink-0\">\n              <div className=\"text-4xl font-bold text-blue-700\">{grand.yhteensa.toFixed(0)}<\/div>\n              <div className=\"text-xs text-gray-400 mt-1\">h \/ year<\/div>\n            <\/div>\n            <div className=\"flex-1 grid grid-cols-2 sm:grid-cols-3 gap-2\">\n              {summaryItems.filter(i => i.val > 0).map(({ key, label, color, val }) => (\n                <div key={key} className=\"flex items-center gap-2\">\n                  <div className=\"w-3 h-3 rounded-sm shrink-0\" style={{ backgroundColor: color }} \/>\n                  <span className=\"text-xs text-gray-600\">{label}: <strong className=\"text-gray-800\">{val.toFixed(0)} h<\/strong><\/span>\n                <\/div>\n              ))}\n            <\/div>\n          <\/div>\n          {grand.yhteensa > 0 && courses.length > 0 && (\n            <div className=\"mt-5\">\n              <ResponsiveContainer width=\"100%\" height={160}>\n                <BarChart data={chartData} margin={{ top: 0, right: 0, left: -10, bottom: 0 }}>\n                  <XAxis dataKey=\"name\" tick={{ fontSize: 11 }} \/>\n                  <YAxis tick={{ fontSize: 11 }} unit=\"h\" width={40} \/>\n                  <Tooltip formatter={(v) => `${v} h`} \/>\n                  {Object.entries(CHART_COLORS).map(([key, color]) => <Bar key={key} dataKey={key} stackId=\"a\" fill={color} \/>)}\n                <\/BarChart>\n              <\/ResponsiveContainer>\n            <\/div>\n          )}\n        <\/div>\n\n        {\/* Course cards *\/}\n        {courses.map((course) => (\n          <CourseCard key={course.id} course={course} onChange={(u) => update(course.id, u)} onRemove={() => remove(course.id)} \/>\n        ))}\n\n        {\/* Add course *\/}\n        <button onClick={() => setCourses([...courses, defaultCourse()])}\n          className=\"w-full py-5 border-2 border-dashed border-blue-300 rounded-xl text-blue-500 hover:border-blue-500 hover:text-blue-700 transition-colors text-base font-medium mb-6\">\n          + Add course\n        <\/button>\n\n      <\/div>\n    <\/div>\n  )\n}\n\nReactDOM.createRoot(document.getElementById(\"root\")).render(<App \/>)\n<\/script>\n<\/body>\n<\/html>\n\n","protected":false},"excerpt":{"rendered":"<p>Teaching Workload Calculator<\/p>\n","protected":false},"author":4378,"featured_media":0,"parent":0,"menu_order":0,"comment_status":"closed","ping_status":"closed","template":"","meta":{"_acf_changed":false,"_kad_blocks_custom_css":"","_kad_blocks_head_custom_js":"","_kad_blocks_body_custom_js":"","_kad_blocks_footer_custom_js":"","_kad_post_transparent":"","_kad_post_title":"","_kad_post_layout":"","_kad_post_sidebar_id":"","_kad_post_content_style":"","_kad_post_vertical_padding":"","_kad_post_feature":"","_kad_post_feature_position":"","_kad_post_header":false,"_kad_post_footer":false,"_kad_post_classname":"","footnotes":""},"class_list":["post-253","page","type-page","status-publish","hentry"],"acf":[],"lang":"fi","translations":{"fi":253},"taxonomy_info":[],"featured_image_src_large":false,"author_info":{"display_name":"miveviutufi","author_link":"https:\/\/sites.utu.fi\/mivevi\/author\/miveviutufi\/"},"comment_info":0,"pll_sync_post":[],"_links":{"self":[{"href":"https:\/\/sites.utu.fi\/mivevi\/wp-json\/wp\/v2\/pages\/253","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/sites.utu.fi\/mivevi\/wp-json\/wp\/v2\/pages"}],"about":[{"href":"https:\/\/sites.utu.fi\/mivevi\/wp-json\/wp\/v2\/types\/page"}],"author":[{"embeddable":true,"href":"https:\/\/sites.utu.fi\/mivevi\/wp-json\/wp\/v2\/users\/4378"}],"replies":[{"embeddable":true,"href":"https:\/\/sites.utu.fi\/mivevi\/wp-json\/wp\/v2\/comments?post=253"}],"version-history":[{"count":2,"href":"https:\/\/sites.utu.fi\/mivevi\/wp-json\/wp\/v2\/pages\/253\/revisions"}],"predecessor-version":[{"id":258,"href":"https:\/\/sites.utu.fi\/mivevi\/wp-json\/wp\/v2\/pages\/253\/revisions\/258"}],"wp:attachment":[{"href":"https:\/\/sites.utu.fi\/mivevi\/wp-json\/wp\/v2\/media?parent=253"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}