<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>zoink</title>
    <link>https://zoink.cc/</link>
    <description>Recent content on zoink</description>
    <generator>Hugo</generator>
    <language>en-us</language>
    <lastBuildDate>Sun, 01 Mar 2026 00:00:00 +0000</lastBuildDate>
    <atom:link href="https://zoink.cc/feed.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Going around the sun</title>
      <link>https://zoink.cc/blog/2026/03/going-around-the-sun/</link>
      <pubDate>Sun, 01 Mar 2026 00:00:00 +0000</pubDate>
      <guid>https://zoink.cc/blog/2026/03/going-around-the-sun/</guid>
      <description>&lt;p&gt;This year marks my 10th journey around the sun as a graduated software engineer. It hasn&amp;rsquo;t been smooth sailing, and a lot of things have changed since takeoff.&lt;/p&gt;&#xA;&lt;p&gt;I started out as a gung-ho sponge, absorbing every bit of information and opportunity I could. I quickly surrounded myself with people who believed in me and created a safe environment for experimentation. This allowed me to work on a broad range of fringe projects, which gave me a fresh perspective on problems that others hadn&amp;rsquo;t thought to question.&lt;/p&gt;</description>
    </item>
    <item>
      <title>about</title>
      <link>https://zoink.cc/about/</link>
      <pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate>
      <guid>https://zoink.cc/about/</guid>
      <description>&lt;section&gt;&#xA;&lt;div class=&#34;label&#34;&gt;About&lt;/div&gt;&#xA;&lt;div class=&#34;prose&#34;&gt;&#xA;&lt;p&gt;I&#39;m a software developer who builds things, writes occasionally, and maintains a healthy backlog of half-finished projects. This site is where I share the things I finish and the things I have thoughts about.&lt;/p&gt;&#xA;&lt;p&gt;I&#39;m interested in small tools boosting development workflow, plaintext, and distributed systems. By day I work on scaling startups by building cloud-native solutions. By night I add features to things that don&#39;t need them.&lt;/p&gt;&#xA;&lt;/div&gt;&#xA;&lt;/section&gt;&#xA;&lt;section&gt;&#xA;&lt;div class=&#34;label&#34;&gt;Contact&lt;/div&gt;&#xA;&lt;p&gt;The best way to reach me is by &lt;a href=&#34;mailto:prichrd.dev@gmail.com&#34;&gt;email&lt;/a&gt;. You can also find me on &lt;a target=&#34;_blank&#34; href=&#34;https://github.com/prichrd&#34;&gt;GitHub&lt;/a&gt;.&lt;/p&gt;</description>
    </item>
    <item>
      <title>netrw.nvim</title>
      <link>https://zoink.cc/playground/netrw-nvim/</link>
      <pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate>
      <guid>https://zoink.cc/playground/netrw-nvim/</guid>
      <description>&lt;p&gt;&lt;a target=&#34;_blank&#34; href=&#34;https://github.com/prichrd/netrw.nvim&#34;&gt;netrw.nvim&lt;/a&gt; adds a layer of bling to netrw, the builtin file explorer shipped with Vim. It adds file icons to all list types, making it easier to visually parse the file explorer.&lt;/p&gt;&#xA;&lt;p&gt;It started as a fun experiment to see if I could get rid of some of the notorious file explorers in the Neovim ecosystem (e.g. &lt;a target=&#34;_blank&#34; href=&#34;https://github.com/preservim/nerdtree&#34;&gt;NERDTree&lt;/a&gt;). The lighter my plugin list is, the happier I am. I whipped up a quick proof of concept using &lt;a target=&#34;_blank&#34; href=&#34;https://github.com/nvim-tree/nvim-web-devicons&#34;&gt;nvim-web-devicons&lt;/a&gt; and &lt;a target=&#34;_blank&#34; href=&#34;https://neovim.io/doc/user/sign.html&#34;&gt;signs&lt;/a&gt;, and it worked surprisingly well. I cleaned it up, added some configuration options, and here we are.&lt;/p&gt;</description>
    </item>
    <item>
      <title>ZWO Builder</title>
      <link>https://zoink.cc/playground/zwo-builder/</link>
      <pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate>
      <guid>https://zoink.cc/playground/zwo-builder/</guid>
      <description>&lt;p&gt;Write a structured bike workout in plain text and export it as a &lt;code&gt;.zwo&lt;/code&gt; file for Zwift or any compatible trainer app.&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;span style=&#34;color:#5b8dd9&#34;&gt;■&lt;/span&gt; Recovery — below 55% FTP&lt;/li&gt;&#xA;&lt;li&gt;&lt;span style=&#34;color:#52a46a&#34;&gt;■&lt;/span&gt; Endurance — 55–75%&lt;/li&gt;&#xA;&lt;li&gt;&lt;span style=&#34;color:#d4a017&#34;&gt;■&lt;/span&gt; Tempo — 75–90%&lt;/li&gt;&#xA;&lt;li&gt;&lt;span style=&#34;color:#e07b30&#34;&gt;■&lt;/span&gt; Threshold — 90–105%&lt;/li&gt;&#xA;&lt;li&gt;&lt;span style=&#34;color:#c93d3d&#34;&gt;■&lt;/span&gt; VO2max — 105–120%&lt;/li&gt;&#xA;&lt;li&gt;&lt;span style=&#34;color:#9535b5&#34;&gt;■&lt;/span&gt; Anaerobic — above 120%&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;style&gt;&#xA;#zwo {&#xA;  margin-top: 1.5rem;&#xA;  display: flex;&#xA;  flex-direction: column;&#xA;  gap: 0;&#xA;  border: 1px solid var(--border);&#xA;}&#xA;#zwo-row1 {&#xA;  display: flex;&#xA;  align-items: stretch;&#xA;  border-bottom: 1px solid var(--border);&#xA;}&#xA;#workout-name {&#xA;  flex: 1;&#xA;  font-family: var(--font);&#xA;  font-size: 13px;&#xA;  background: var(--code-bg);&#xA;  color: var(--fg);&#xA;  border: none;&#xA;  border-right: 1px solid var(--border);&#xA;  padding: 0.5rem 0.75rem;&#xA;  outline: none;&#xA;  min-width: 0;&#xA;}&#xA;#workout-name:focus { background: var(--bg); }&#xA;#ftp-row {&#xA;  display: flex;&#xA;  align-items: center;&#xA;  gap: 0.4rem;&#xA;  padding: 0.5rem 0.75rem;&#xA;  background: var(--code-bg);&#xA;}&#xA;#ftp-label {&#xA;  font-size: 11px;&#xA;  font-weight: 700;&#xA;  letter-spacing: 0.08em;&#xA;  text-transform: uppercase;&#xA;  color: var(--muted);&#xA;}&#xA;#ftp {&#xA;  font-family: var(--font);&#xA;  font-size: 13px;&#xA;  background: var(--bg);&#xA;  color: var(--fg);&#xA;  border: 1px solid var(--border);&#xA;  padding: 0.3rem 0.5rem;&#xA;  width: 64px;&#xA;  outline: none;&#xA;}&#xA;#ftp:focus { border-color: var(--fg); }&#xA;#ftp-unit {&#xA;  font-size: 12px;&#xA;  color: var(--muted);&#xA;}&#xA;#workout-input-wrap {&#xA;  position: relative;&#xA;  display: flex;&#xA;  flex-direction: column;&#xA;  border-bottom: 1px solid var(--border);&#xA;}&#xA;#workout-input {&#xA;  width: 100%;&#xA;  font-family: var(--font);&#xA;  font-size: 13px;&#xA;  background: var(--code-bg);&#xA;  color: var(--fg);&#xA;  border: none;&#xA;  padding: 0.75rem;&#xA;  resize: none;&#xA;  overflow: hidden;&#xA;  outline: none;&#xA;  line-height: 1.7;&#xA;  tab-size: 2;&#xA;  box-sizing: border-box;&#xA;  min-height: 120px;&#xA;}&#xA;#watts-overlay {&#xA;  position: absolute;&#xA;  top: 0;&#xA;  left: 0;&#xA;  right: 0;&#xA;  bottom: 0;&#xA;  pointer-events: none;&#xA;  font-family: var(--font);&#xA;  font-size: 13px;&#xA;  line-height: 1.7;&#xA;  padding: 0.75rem;&#xA;  box-sizing: border-box;&#xA;  overflow: hidden;&#xA;}&#xA;.overlay-line {&#xA;  display: flex;&#xA;  justify-content: flex-end;&#xA;  align-items: center;&#xA;  height: calc(13px * 1.7);&#xA;}&#xA;.overlay-line.is-error {&#xA;  background: rgba(224, 64, 64, 0.1);&#xA;  margin: 0 -0.75rem;&#xA;  padding: 0 0.75rem;&#xA;}&#xA;.overlay-watts {&#xA;  font-size: 11px;&#xA;  font-weight: 700;&#xA;  letter-spacing: 0.02em;&#xA;}&#xA;#zwo-row3 {&#xA;  display: flex;&#xA;  align-items: stretch;&#xA;  background: var(--code-bg);&#xA;}&#xA;.sf-btn {&#xA;  font-family: var(--font);&#xA;  font-size: 12px;&#xA;  padding: 0.45rem 0.75rem;&#xA;  cursor: pointer;&#xA;  border: none;&#xA;  background: transparent;&#xA;  color: var(--fg);&#xA;  white-space: nowrap;&#xA;}&#xA;.sf-btn-primary {&#xA;  background: var(--fg);&#xA;  color: var(--bg);&#xA;}&#xA;.sf-btn-ghost {&#xA;  color: var(--muted);&#xA;}&#xA;.sf-btn:hover:not(:disabled) { opacity: 0.7; }&#xA;.sf-btn:disabled { opacity: 0.3; cursor: default; }&#xA;#zwo-export {&#xA;  display: flex;&#xA;  gap: 0;&#xA;  border-right: 1px solid var(--border);&#xA;}&#xA;#export-zwo-btn {&#xA;  border-right: 1px solid var(--border);&#xA;}&#xA;#zwo-import {&#xA;  display: flex;&#xA;  gap: 0;&#xA;  margin-left: auto;&#xA;}&#xA;#import-txt-btn {&#xA;  border-left: 1px solid var(--border);&#xA;}&#xA;#parse-error {&#xA;  font-size: 11px;&#xA;  color: #e04040;&#xA;  line-height: 1.4;&#xA;  word-break: break-word;&#xA;  padding: 0 0.75rem;&#xA;  display: flex;&#xA;  align-items: center;&#xA;}&#xA;&lt;/style&gt;&#xA;&lt;div id=&#34;zwo&#34;&gt;&#xA;  &lt;div id=&#34;zwo-row1&#34;&gt;&#xA;    &lt;input id=&#34;workout-name&#34; type=&#34;text&#34; value=&#34;My Workout&#34; placeholder=&#34;Workout name&#34; /&gt;&#xA;    &lt;div id=&#34;ftp-row&#34;&gt;&#xA;      &lt;span id=&#34;ftp-label&#34;&gt;FTP&lt;/span&gt;&#xA;      &lt;input id=&#34;ftp&#34; type=&#34;number&#34; value=&#34;250&#34; min=&#34;50&#34; max=&#34;600&#34; /&gt;&#xA;      &lt;span id=&#34;ftp-unit&#34;&gt;w&lt;/span&gt;&#xA;    &lt;/div&gt;&#xA;  &lt;/div&gt;&#xA;  &lt;div id=&#34;workout-input-wrap&#34;&gt;&#xA;    &lt;textarea id=&#34;workout-input&#34; spellcheck=&#34;false&#34;&gt;10m60%&#xA;5m200w&#xA;3x&#xA;  5m104%&#xA;  3m52%&#xA;10m60%&lt;/textarea&gt;&#xA;    &lt;div id=&#34;watts-overlay&#34;&gt;&lt;/div&gt;&#xA;  &lt;/div&gt;&#xA;  &lt;div id=&#34;zwo-row3&#34;&gt;&#xA;    &lt;div id=&#34;zwo-export&#34;&gt;&#xA;      &lt;button id=&#34;export-zwo-btn&#34; class=&#34;sf-btn sf-btn-primary&#34; disabled&gt;Export .zwo&lt;/button&gt;&#xA;      &lt;button id=&#34;export-txt-btn&#34; class=&#34;sf-btn&#34; disabled&gt;Export .txt&lt;/button&gt;&#xA;    &lt;/div&gt;&#xA;    &lt;div id=&#34;parse-error&#34;&gt;&lt;/div&gt;&#xA;    &lt;div id=&#34;zwo-import&#34;&gt;&#xA;      &lt;button id=&#34;import-txt-btn&#34; class=&#34;sf-btn sf-btn-ghost&#34;&gt;Import .txt&lt;/button&gt;&#xA;      &lt;button id=&#34;import-zwo-btn&#34; class=&#34;sf-btn sf-btn-ghost&#34;&gt;Import .zwo&lt;/button&gt;&#xA;    &lt;/div&gt;&#xA;  &lt;/div&gt;&#xA;  &lt;input id=&#34;import-txt-file&#34; type=&#34;file&#34; accept=&#34;.txt&#34; style=&#34;display:none&#34;&gt;&#xA;  &lt;input id=&#34;import-zwo-file&#34; type=&#34;file&#34; accept=&#34;.zwo,.xml&#34; style=&#34;display:none&#34;&gt;&#xA;&lt;/div&gt;&#xA;&lt;script&gt;&#xA;(function () {&#xA;  // ── Parser ──────────────────────────────────────────────────────&#xA;&#xA;  function parseDuration(s) {&#xA;    s = s.trim().toLowerCase();&#xA;    var t = 0;&#xA;    var h = s.match(/(\d+)h/); if (h) t += +h[1] * 3600;&#xA;    var m = s.match(/(\d+)m/); if (m) t += +m[1] * 60;&#xA;    var sec = s.match(/(\d+)s/); if (sec) t += +sec[1];&#xA;    return t || null;&#xA;  }&#xA;&#xA;  function parsePower(s) {&#xA;    s = s.trim().toLowerCase();&#xA;    var p = s.match(/^(\d+(?:\.\d+)?)%$/);&#xA;    if (p) return { pct: +p[1] / 100, watts: null };&#xA;    var w = s.match(/^(\d+(?:\.\d+)?)w$/);&#xA;    if (w) return { pct: null, watts: +w[1] };&#xA;    return null;&#xA;  }&#xA;&#xA;  function parseSegment(s) {&#xA;    s = s.trim();&#xA;    var m = s.match(/^((?:\d+[hms])+)(\d+(?:\.\d+)?[%w])$/i);&#xA;    if (!m) return null;&#xA;    var dur = parseDuration(m[1]);&#xA;    var pwr = parsePower(m[2]);&#xA;    if (!dur || !pwr) return null;&#xA;    return { dur: dur, pwr: pwr };&#xA;  }&#xA;&#xA;  function resolveWatts(pwr, ftp) {&#xA;    return pwr.watts != null ? pwr.watts : pwr.pct * ftp;&#xA;  }&#xA;&#xA;  function parseWorkout(text, ftp) {&#xA;    var lines = text.split(&#39;\n&#39;);&#xA;    var blocks = [];&#xA;    var lineInfo = new Array(lines.length);&#xA;    for (var k = 0; k &lt; lines.length; k++) lineInfo[k] = { type: &#39;empty&#39; };&#xA;    var error = null;&#xA;&#xA;    var i = 0;&#xA;    while (i &lt; lines.length &amp;&amp; !error) {&#xA;      var line = lines[i];&#xA;      var trimmed = line.trim();&#xA;&#xA;      if (!trimmed || trimmed.startsWith(&#39;#&#39;)) {&#xA;        lineInfo[i] = { type: &#39;comment&#39; };&#xA;        i++;&#xA;        continue;&#xA;      }&#xA;&#xA;      if (/^\s/.test(line)) {&#xA;        lineInfo[i] = { type: &#39;error&#39; };&#xA;        error = &#39;line &#39; + (i + 1) + &#39;: indented line outside repeat block&#39;;&#xA;        break;&#xA;      }&#xA;&#xA;      var repMatch = trimmed.match(/^(\d+)\s*[xX\u00d7]$/);&#xA;      if (repMatch) {&#xA;        var n = +repMatch[1];&#xA;        lineInfo[i] = { type: &#39;repeat-header&#39;, n: n };&#xA;        i++;&#xA;        var segs = [];&#xA;        while (i &lt; lines.length &amp;&amp; /^\s/.test(lines[i])) {&#xA;          var segTrimmed = lines[i].trim();&#xA;          if (!segTrimmed || segTrimmed.startsWith(&#39;#&#39;)) {&#xA;            lineInfo[i] = { type: &#39;comment&#39; };&#xA;            i++;&#xA;            continue;&#xA;          }&#xA;          var seg = parseSegment(segTrimmed);&#xA;          if (!seg) {&#xA;            lineInfo[i] = { type: &#39;error&#39; };&#xA;            error = &#39;line &#39; + (i + 1) + &#39;: unrecognized — &#34;&#39; + segTrimmed + &#39;&#34;&#39;;&#xA;            break;&#xA;          }&#xA;          lineInfo[i] = { type: &#39;repeat-seg&#39;, watts: resolveWatts(seg.pwr, ftp) };&#xA;          segs.push(seg);&#xA;          i++;&#xA;        }&#xA;        if (!error) {&#xA;          if (segs.length === 0) {&#xA;            error = &#39;line &#39; + i + &#39;: empty repeat block&#39;;&#xA;          } else {&#xA;            blocks.push({ type: &#39;repeat&#39;, n: n, segs: segs });&#xA;          }&#xA;        }&#xA;        continue;&#xA;      }&#xA;&#xA;      var seg = parseSegment(trimmed);&#xA;      if (!seg) {&#xA;        lineInfo[i] = { type: &#39;error&#39; };&#xA;        error = &#39;line &#39; + (i + 1) + &#39;: unrecognized — &#34;&#39; + trimmed + &#39;&#34;&#39;;&#xA;        break;&#xA;      }&#xA;      lineInfo[i] = { type: &#39;steady&#39;, watts: resolveWatts(seg.pwr, ftp) };&#xA;      blocks.push({ type: &#39;steady&#39;, dur: seg.dur, pwr: seg.pwr });&#xA;      i++;&#xA;    }&#xA;&#xA;    return { blocks: blocks, lineInfo: lineInfo, error: error };&#xA;  }&#xA;&#xA;  // ── Zones ───────────────────────────────────────────────────────&#xA;&#xA;  var ZONES = [&#xA;    { max: 0.55, color: &#39;#5b8dd9&#39; },&#xA;    { max: 0.75, color: &#39;#52a46a&#39; },&#xA;    { max: 0.90, color: &#39;#d4a017&#39; },&#xA;    { max: 1.05, color: &#39;#e07b30&#39; },&#xA;    { max: 1.20, color: &#39;#c93d3d&#39; },&#xA;    { max: Infinity, color: &#39;#9535b5&#39; }&#xA;  ];&#xA;&#xA;  function zoneColor(w, ftp) {&#xA;    var r = w / ftp;&#xA;    for (var i = 0; i &lt; ZONES.length; i++) {&#xA;      if (r &lt; ZONES[i].max) return ZONES[i].color;&#xA;    }&#xA;    return ZONES[ZONES.length - 1].color;&#xA;  }&#xA;&#xA;  // ── Overlay ─────────────────────────────────────────────────────&#xA;&#xA;  function updateOverlay(lineInfo, ftp) {&#xA;    var overlay = document.getElementById(&#39;watts-overlay&#39;);&#xA;    var html = &#39;&#39;;&#xA;    for (var i = 0; i &lt; lineInfo.length; i++) {&#xA;      var info = lineInfo[i];&#xA;      var label = &#39;&#39;;&#xA;      var color = &#39;&#39;;&#xA;      var cls = &#39;overlay-line&#39;;&#xA;      if (info.type === &#39;error&#39;) {&#xA;        cls += &#39; is-error&#39;;&#xA;      } else if ((info.type === &#39;steady&#39; || info.type === &#39;repeat-seg&#39;) &amp;&amp; info.watts != null) {&#xA;        var w = Math.round(info.watts);&#xA;        color = zoneColor(w, ftp);&#xA;        label = w + &#39;w&#39;;&#xA;      }&#xA;      html += &#39;&lt;div class=&#34;&#39; + cls + &#39;&#34;&gt;&lt;span class=&#34;overlay-watts&#34; style=&#34;color:&#39; + color + &#39;&#34;&gt;&#39; + label + &#39;&lt;/span&gt;&lt;/div&gt;&#39;;&#xA;    }&#xA;    overlay.innerHTML = html;&#xA;  }&#xA;&#xA;  // ── Format helpers ───────────────────────────────────────────────&#xA;&#xA;  function fmtDur(seconds) {&#xA;    var h = Math.floor(seconds / 3600);&#xA;    var m = Math.floor((seconds % 3600) / 60);&#xA;    var s = seconds % 60;&#xA;    var r = &#39;&#39;;&#xA;    if (h) r += h + &#39;h&#39;;&#xA;    if (m) r += m + &#39;m&#39;;&#xA;    if (s) r += s + &#39;s&#39;;&#xA;    return r || &#39;0s&#39;;&#xA;  }&#xA;&#xA;  function fmtPct(ratio) {&#xA;    return Math.round(ratio * 100) + &#39;%&#39;;&#xA;  }&#xA;&#xA;  // ── ZWO import ───────────────────────────────────────────────────&#xA;&#xA;  function zwoToText(xmlText) {&#xA;    var parser = new DOMParser();&#xA;    var doc = parser.parseFromString(xmlText, &#39;text/xml&#39;);&#xA;    if (doc.querySelector(&#39;parsererror&#39;)) return null;&#xA;&#xA;    var nameEl = doc.querySelector(&#39;name&#39;);&#xA;    var name = nameEl ? nameEl.textContent.trim() : &#39;&#39;;&#xA;&#xA;    var workout = doc.querySelector(&#39;workout&#39;);&#xA;    if (!workout) return null;&#xA;&#xA;    var lines = [];&#xA;    var children = workout.children;&#xA;    for (var i = 0; i &lt; children.length; i++) {&#xA;      var el = children[i];&#xA;      var tag = el.tagName;&#xA;      if (tag === &#39;SteadyState&#39;) {&#xA;        var dur = parseInt(el.getAttribute(&#39;Duration&#39;), 10);&#xA;        var power = parseFloat(el.getAttribute(&#39;Power&#39;));&#xA;        lines.push(fmtDur(dur) + fmtPct(power));&#xA;      } else if (tag === &#39;IntervalsT&#39;) {&#xA;        var repeat = parseInt(el.getAttribute(&#39;Repeat&#39;), 10);&#xA;        var onDur = parseInt(el.getAttribute(&#39;OnDuration&#39;), 10);&#xA;        var offDur = parseInt(el.getAttribute(&#39;OffDuration&#39;), 10);&#xA;        var onPower = parseFloat(el.getAttribute(&#39;OnPower&#39;));&#xA;        var offPower = parseFloat(el.getAttribute(&#39;OffPower&#39;));&#xA;        lines.push(repeat + &#39;x&#39;);&#xA;        lines.push(&#39;  &#39; + fmtDur(onDur) + fmtPct(onPower));&#xA;        lines.push(&#39;  &#39; + fmtDur(offDur) + fmtPct(offPower));&#xA;      }&#xA;      // Warmup/Cooldown/Ramp have varying power — skipped&#xA;    }&#xA;&#xA;    return { text: lines.join(&#39;\n&#39;), name: name };&#xA;  }&#xA;&#xA;  // ── ZWO export ──────────────────────────────────────────────────&#xA;&#xA;  function esc(s) {&#xA;    return s.replace(/&amp;/g, &#39;&amp;amp;&#39;).replace(/&lt;/g, &#39;&amp;lt;&#39;).replace(/&gt;/g, &#39;&amp;gt;&#39;).replace(/&#34;/g, &#39;&amp;quot;&#39;);&#xA;  }&#xA;&#xA;  function blocksToZWO(blocks, ftp, name) {&#xA;    var lines = blocks.map(function (b) {&#xA;      if (b.type === &#39;repeat&#39; &amp;&amp; b.segs.length === 2) {&#xA;        var onW = resolveWatts(b.segs[0].pwr, ftp);&#xA;        var offW = resolveWatts(b.segs[1].pwr, ftp);&#xA;        return &#39;    &lt;IntervalsT Repeat=&#34;&#39; + b.n + &#39;&#34;&#39; +&#xA;          &#39; OnDuration=&#34;&#39; + b.segs[0].dur + &#39;&#34;&#39; +&#xA;          &#39; OffDuration=&#34;&#39; + b.segs[1].dur + &#39;&#34;&#39; +&#xA;          &#39; OnPower=&#34;&#39; + (onW / ftp).toFixed(4) + &#39;&#34;&#39; +&#xA;          &#39; OffPower=&#34;&#39; + (offW / ftp).toFixed(4) + &#39;&#34;/&gt;&#39;;&#xA;      }&#xA;      var segs = b.type === &#39;steady&#39; ? [{ dur: b.dur, pwr: b.pwr }]&#xA;        : (function () {&#xA;          var r = [];&#xA;          for (var ii = 0; ii &lt; b.n; ii++) {&#xA;            for (var jj = 0; jj &lt; b.segs.length; jj++) r.push(b.segs[jj]);&#xA;          }&#xA;          return r;&#xA;        }());&#xA;      return segs.map(function (seg) {&#xA;        var w = resolveWatts(seg.pwr, ftp);&#xA;        return &#39;    &lt;SteadyState Duration=&#34;&#39; + seg.dur + &#39;&#34; Power=&#34;&#39; + (w / ftp).toFixed(4) + &#39;&#34;/&gt;&#39;;&#xA;      }).join(&#39;\n&#39;);&#xA;    }).join(&#39;\n&#39;);&#xA;&#xA;    return &#39;&lt;?xml version=&#34;1.0&#34; encoding=&#34;UTF-8&#34;?&gt;\n&#39; +&#xA;      &#39;&lt;workout_file&gt;\n&#39; +&#xA;      &#39;  &lt;author&gt;&lt;/author&gt;\n&#39; +&#xA;      &#39;  &lt;name&gt;&#39; + esc(name) + &#39;&lt;/name&gt;\n&#39; +&#xA;      &#39;  &lt;description&gt;&lt;/description&gt;\n&#39; +&#xA;      &#39;  &lt;sportType&gt;bike&lt;/sportType&gt;\n&#39; +&#xA;      &#39;  &lt;tags/&gt;\n&#39; +&#xA;      &#39;  &lt;workout&gt;\n&#39; +&#xA;      lines + &#39;\n&#39; +&#xA;      &#39;  &lt;/workout&gt;\n&#39; +&#xA;      &#39;&lt;/workout_file&gt;&#39;;&#xA;  }&#xA;&#xA;  function download(content, filename, mime) {&#xA;    var blob = new Blob([content], { type: mime });&#xA;    var url = URL.createObjectURL(blob);&#xA;    var a = document.createElement(&#39;a&#39;);&#xA;    a.href = url;&#xA;    a.download = filename;&#xA;    document.body.appendChild(a);&#xA;    a.click();&#xA;    document.body.removeChild(a);&#xA;    URL.revokeObjectURL(url);&#xA;  }&#xA;&#xA;  function safeName(name) {&#xA;    return (name || &#39;workout&#39;).replace(/[^a-z0-9_\-]/gi, &#39;_&#39;);&#xA;  }&#xA;&#xA;  // ── Main loop ───────────────────────────────────────────────────&#xA;&#xA;  var currentBlocks = [];&#xA;&#xA;  function autoResize() {&#xA;    var ta = document.getElementById(&#39;workout-input&#39;);&#xA;    ta.style.minHeight = &#39;0&#39;;&#xA;    ta.style.minHeight = ta.scrollHeight + &#39;px&#39;;&#xA;  }&#xA;&#xA;  function update() {&#xA;    autoResize();&#xA;    var ta = document.getElementById(&#39;workout-input&#39;);&#xA;    var ftp = parseInt(document.getElementById(&#39;ftp&#39;).value, 10) || 250;&#xA;    var errorEl = document.getElementById(&#39;parse-error&#39;);&#xA;    var ok = false;&#xA;&#xA;    var result = parseWorkout(ta.value, ftp);&#xA;&#xA;    if (result.error) {&#xA;      errorEl.textContent = result.error;&#xA;      currentBlocks = [];&#xA;    } else {&#xA;      errorEl.textContent = &#39;&#39;;&#xA;      currentBlocks = result.blocks;&#xA;      ok = result.blocks.length &gt; 0;&#xA;    }&#xA;&#xA;    document.getElementById(&#39;export-txt-btn&#39;).disabled = !ok;&#xA;    document.getElementById(&#39;export-zwo-btn&#39;).disabled = !ok;&#xA;    updateOverlay(result.lineInfo, ftp);&#xA;  }&#xA;&#xA;  // ── Keyboard helpers ─────────────────────────────────────────────&#xA;&#xA;  document.getElementById(&#39;workout-input&#39;).addEventListener(&#39;keydown&#39;, function (e) {&#xA;    var ta = this;&#xA;    var start = ta.selectionStart;&#xA;    var end = ta.selectionEnd;&#xA;    var val = ta.value;&#xA;&#xA;    if (e.key === &#39;Tab&#39;) {&#xA;      e.preventDefault();&#xA;      ta.value = val.slice(0, start) + &#39;  &#39; + val.slice(end);&#xA;      ta.selectionStart = ta.selectionEnd = start + 2;&#xA;      update();&#xA;      return;&#xA;    }&#xA;&#xA;    if (e.key === &#39;Enter&#39;) {&#xA;      e.preventDefault();&#xA;      var lineStart = val.lastIndexOf(&#39;\n&#39;, start - 1) + 1;&#xA;      var currentLine = val.slice(lineStart, start);&#xA;      var indentMatch = currentLine.match(/^(\s*)/);&#xA;      var indent = indentMatch ? indentMatch[1] : &#39;&#39;;&#xA;      if (/^\d+\s*[xX\u00d7]$/.test(currentLine.trim())) indent = &#39;  &#39;;&#xA;      if (/^\s+$/.test(currentLine)) indent = &#39;&#39;;&#xA;      var insert = &#39;\n&#39; + indent;&#xA;      ta.value = val.slice(0, start) + insert + val.slice(end);&#xA;      ta.selectionStart = ta.selectionEnd = start + insert.length;&#xA;      update();&#xA;    }&#xA;  });&#xA;&#xA;  document.getElementById(&#39;workout-input&#39;).addEventListener(&#39;input&#39;, update);&#xA;  document.getElementById(&#39;ftp&#39;).addEventListener(&#39;input&#39;, update);&#xA;&#xA;  // ── Import ───────────────────────────────────────────────────────&#xA;&#xA;  document.getElementById(&#39;import-txt-btn&#39;).addEventListener(&#39;click&#39;, function () {&#xA;    document.getElementById(&#39;import-txt-file&#39;).click();&#xA;  });&#xA;&#xA;  document.getElementById(&#39;import-txt-file&#39;).addEventListener(&#39;change&#39;, function () {&#xA;    var file = this.files[0];&#xA;    if (!file) return;&#xA;    var reader = new FileReader();&#xA;    reader.onload = function (e) {&#xA;      document.getElementById(&#39;workout-input&#39;).value = e.target.result;&#xA;      var base = file.name.replace(/\.[^.]+$/, &#39;&#39;).replace(/_/g, &#39; &#39;);&#xA;      if (base) document.getElementById(&#39;workout-name&#39;).value = base;&#xA;      update();&#xA;    };&#xA;    reader.readAsText(file);&#xA;    this.value = &#39;&#39;;&#xA;  });&#xA;&#xA;  document.getElementById(&#39;import-zwo-btn&#39;).addEventListener(&#39;click&#39;, function () {&#xA;    document.getElementById(&#39;import-zwo-file&#39;).click();&#xA;  });&#xA;&#xA;  document.getElementById(&#39;import-zwo-file&#39;).addEventListener(&#39;change&#39;, function () {&#xA;    var file = this.files[0];&#xA;    if (!file) return;&#xA;    var reader = new FileReader();&#xA;    reader.onload = function (e) {&#xA;      var result = zwoToText(e.target.result);&#xA;      if (!result) {&#xA;        document.getElementById(&#39;parse-error&#39;).textContent = &#39;could not parse .zwo file&#39;;&#xA;        return;&#xA;      }&#xA;      document.getElementById(&#39;workout-input&#39;).value = result.text;&#xA;      if (result.name) document.getElementById(&#39;workout-name&#39;).value = result.name;&#xA;      update();&#xA;    };&#xA;    reader.readAsText(file);&#xA;    this.value = &#39;&#39;;&#xA;  });&#xA;&#xA;  // ── Export ───────────────────────────────────────────────────────&#xA;&#xA;  document.getElementById(&#39;export-txt-btn&#39;).addEventListener(&#39;click&#39;, function () {&#xA;    var name = document.getElementById(&#39;workout-name&#39;).value || &#39;My Workout&#39;;&#xA;    download(document.getElementById(&#39;workout-input&#39;).value, safeName(name) + &#39;.txt&#39;, &#39;text/plain&#39;);&#xA;  });&#xA;&#xA;  document.getElementById(&#39;export-zwo-btn&#39;).addEventListener(&#39;click&#39;, function () {&#xA;    var name = document.getElementById(&#39;workout-name&#39;).value || &#39;My Workout&#39;;&#xA;    var ftp = parseInt(document.getElementById(&#39;ftp&#39;).value, 10) || 250;&#xA;    download(blocksToZWO(currentBlocks, ftp, name), safeName(name) + &#39;.zwo&#39;, &#39;application/octet-stream&#39;);&#xA;  });&#xA;&#xA;  update();&#xA;})();&#xA;&lt;/script&gt;</description>
    </item>
  </channel>
</rss>
