Cannabis Freedom Index

Changelog bb7c5db

more history

405 history entries added3 history entries edited16 files of code, design or docs +5,705 −477 lines

History changes.yaml

Added 405

Edited 3

Uruguay becomes the first country to legalise

date

2013-12-23

description

President Mujica signed Law 19.172, which put the whole chain under state control. Registered residents choose one of three routes: … o 40 g a month in pharmacies. Possession for personal use had not been a crime since 1974.

Switzerland introduces a CHF 100 fine

description

AdultsUnder an amendment passed in 2012, adults caught using cannabis with up to 10 g on them pay a fixed CHF 100 fine and are not prosecuted. Courts later held that simply possessing up to 10 g is not punishable at all.

Luxembourg drops prison for personal use

date

2001-0504

description

A reform of the drug law put cannabis in its own, lower category and made using, possessing or buying cannabisit for personal use punishable by a fine only. Prison remained for use near schools or in front of minors, and for dealing.

Code, design and documents 16 files

README.md +1 −1
@@ -39,5 +39,5 @@ Built with [Astro](https://astro.build). Pushing to `main` deploys to GitHub Pag
 | `countries/us/` | A federation: `index.yaml` for the country, one file per state |
 | `segments.yaml` | The six segments, their weights and level descriptions |
-| `changes.yaml` | Laws and events that moved a country's score, with the scores before; the home page shows the latest four |
+| `changes.yaml` | The History page: laws and events that moved a country's score, with the scores before, plus treaties; the home page shows the latest four |
 | `src/` | The website: pages, components, styles |
 

src/components/AverageChart.astro +82 −47
@@ -1,6 +1,7 @@
 ---
 // World average score over time: a step line that moves at each recorded change.
+// Two views are rendered, the whole history and the last ten years; a switch shows one of them.
 // The plot is a stretched SVG; labels and markers are HTML so they stay readable at any width.
-import { averageHistory, formatDate, lastUpdated } from "../lib/countries.js";
+import { averageHistory, formatDate, dateTime, lastUpdated } from "../lib/countries.js";
 
 // `note` adds the caveat under the chart.
@@ -8,64 +9,98 @@ const { note = false } = Astro.props;
 const base = import.meta.env.BASE_URL;
 const fmt = (v) => v.toFixed(1);
-const time = (iso) => Date.parse(iso.length === 7 ? `${iso}-15` : iso);
+const time = dateTime;
 
-const first = averageHistory[0];
 const now = averageHistory.at(-1).after;
-const startYear = Math.floor(+first.date.slice(0, 4) / 5) * 5; // start the axis on a round year
 const endYear = +lastUpdated.slice(0, 4);
-const t0 = Date.UTC(startYear, 0, 1);
 const t1 = time(lastUpdated);
-const x = (t) => ((t - t0) / (t1 - t0)) * 100;
 
-// y axis: half-point steps that enclose the data (the range is small, so it does not start at zero)
-const values = averageHistory.flatMap((p) => [p.before, p.after]);
-const yMin = Math.floor(Math.min(...values) * 2) / 2;
-const yMax = Math.ceil(Math.max(...values) * 2) / 2;
-const y = (v) => (1 - (v - yMin) / (yMax - yMin)) * 100;
-const ticks = Array.from({ length: Math.round((yMax - yMin) * 2) + 1 }, (_, i) => yMin + i / 2);
-// at most about a dozen year labels, on round steps
-const span = endYear - startYear;
-const step = span > 50 ? 10 : span > 20 ? 5 : span > 14 ? 2 : 1;
-const years = Array.from({ length: span + 1 }, (_, i) => startYear + i).filter((yr) => yr % step === 0);
+function view(id, label, startYear) {
+  const t0 = Date.UTC(startYear, 0, 1);
+  const x = (t) => ((t - t0) / (t1 - t0)) * 100;
+  const inRange = averageHistory.filter((p) => time(p.date) >= t0);
+  const start = inRange[0].before; // the average when the view begins
 
-const points = averageHistory.map((p) => ({ ...p, x: x(time(p.date)), y: y(p.after) }));
-const line = `M0,${y(first.before)}` + points.map((p) => `H${p.x}V${p.y}`).join("") + "H100";
-const delta = now - first.before;
+  // y axis: round steps that enclose the data (the range is small, so it does not start at zero)
+  const values = inRange.flatMap((p) => [p.before, p.after]);
+  const spread = Math.max(...values) - Math.min(...values);
+  const tick = spread > 50 ? 20 : spread > 25 ? 10 : spread > 12 ? 5 : spread > 6 ? 2 : spread > 3 ? 1 : 0.5;
+  const yMin = Math.floor(Math.min(...values) / tick) * tick;
+  const yMax = Math.ceil(Math.max(...values) / tick) * tick;
+  const y = (v) => (1 - (v - yMin) / (yMax - yMin)) * 100;
+  const ticks = Array.from({ length: Math.round((yMax - yMin) / tick) + 1 }, (_, i) => yMin + i * tick);
+
+  // at most about a dozen year labels, on round steps
+  const span = endYear - startYear;
+  const step = span > 50 ? 10 : span > 20 ? 5 : span > 14 ? 2 : 1;
+  const years = Array.from({ length: span + 1 }, (_, i) => startYear + i)
+    .filter((yr) => yr % step === 0)
+    .map((yr) => ({ yr, x: x(Date.UTC(yr, 0, 1)) }));
+
+  const steps = inRange.map((p) => ({ ...p, x: x(time(p.date)), y: y(p.after) }));
+  const line = `M0,${y(start)}` + steps.map((p) => `H${p.x}V${p.y}`).join("") + "H100";
+  const points = steps.filter((p) => p.change); // estimated changes move the line but get no dot
+  return { id, label, startYear, whole: tick >= 1, delta: now - start, ticks: ticks.map((t) => ({ t, y: y(t) })), years, points, line };
+}
+
+const views = [
+  view("all", "All", 1900), // a few bans are older; they are already counted in the starting value
+  view("10y", "Last 10 years", endYear - 10),
+];
 ---
-<section class="card avg" aria-labelledby="avgTitle">
+<section class="card avg" id="avgChart" aria-labelledby="avgTitle">
   <div class="avg-head">
-    <h2 id="avgTitle">World average score</h2>
-    <p class="avg-now"><b>{fmt(now)}</b><span class={`delta ${delta >= 0 ? "up" : "down"}`}>{delta >= 0 ? "+" : "−"}{fmt(Math.abs(delta))} since {startYear}</span></p>
-  </div>
-
-  <div class="avg-chart" id="avgChart">
-    <div class="avg-y" aria-hidden="true">
-      {ticks.map((t) => <span style={`top:${y(t)}%`}>{fmt(t)}</span>)}
+    <div>
+      <h2 id="avgTitle">World average score</h2>
+      <div class="avg-switch" role="group" aria-label="Period shown">
+        {views.map((v, i) => <button type="button" data-view={v.id} aria-pressed={i === 0 ? "true" : "false"}>{v.label}</button>)}
+      </div>
     </div>
-    <div class="avg-plot">
-      <svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
-        {ticks.map((t) => <line class="grid" x1="0" x2="100" y1={y(t)} y2={y(t)} />)}
-        <path class="line" d={line} />
-      </svg>
-      {points.map((p) => (
-        <a class="avg-dot" href={`${base}changes/${p.change.id}/`} style={`left:${p.x}%;top:${p.y}%`}
-          data-date={formatDate(p.date)} data-title={`${p.change.country.flag} ${p.change.title}`}
-          data-avg={`World average ${fmt(p.before)} → ${fmt(p.after)}`}
-          aria-label={`${formatDate(p.date)}: ${p.change.title}. World average ${fmt(p.before)} to ${fmt(p.after)}.`}></a>
+    <p class="avg-now">
+      <b>{fmt(now)}</b>
+      {views.map((v, i) => (
+        <span class={`delta ${v.delta >= 0 ? "up" : "down"}`} data-view={v.id} hidden={i > 0}>{v.delta >= 0 ? "+" : "−"}{fmt(Math.abs(v.delta))} since {v.startYear}</span>
       ))}
-      <div class="avg-tip" id="avgTip" hidden><small></small><b></b><span></span></div>
-    </div>
-    <div class="avg-x" aria-hidden="true">
-      {years.map((yr, i) => <span class={i % 2 ? "odd" : ""} style={`left:${x(Date.UTC(yr, 0, 1))}%`}>{yr}</span>)}
-    </div>
+    </p>
   </div>
-  {note && <p class="avg-note">Rebuilt from the changes on this page, so it is only as complete as that list: a country with no recorded change is held at today's score for the whole period. The scale does not start at zero.</p>}
+
+  {views.map((v, i) => (
+    <div class="avg-chart" data-view={v.id} hidden={i > 0}>
+      <div class="avg-y" aria-hidden="true">
+        {v.ticks.map(({ t, y }) => <span style={`top:${y}%`}>{v.whole ? t : fmt(t)}</span>)}
+      </div>
+      <div class="avg-plot">
+        <svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
+          {v.ticks.map(({ y }) => <line class="grid" x1="0" x2="100" y1={y} y2={y} />)}
+          <path class="line" d={v.line} />
+        </svg>
+        {v.points.map((p) => (
+          <a class="avg-dot" href={`${base}history/${p.change.id}/`} style={`left:${p.x}%;top:${p.y}%`}
+            data-date={formatDate(p.date)} data-title={`${p.change.country.flag} ${p.change.title}`}
+            data-avg={`World average ${fmt(p.before)} → ${fmt(p.after)}`}
+            aria-label={`${formatDate(p.date)}: ${p.change.title}. World average ${fmt(p.before)} to ${fmt(p.after)}.`}></a>
+        ))}
+        <div class="avg-tip" hidden><small></small><b></b><span></span></div>
+      </div>
+      <div class="avg-x" aria-hidden="true">
+        {v.years.map(({ yr, x }) => <span style={`left:${x}%`}>{yr}</span>)}
+      </div>
+    </div>
+  ))}
+  {note && <p class="avg-note">Rebuilt from the recorded changes, so it is only as complete as that list: a country with no recorded change is held at today's score for the whole period. The scale does not start at zero.</p>}
 </section>
 
 <script>
   const chart = document.querySelector("#avgChart");
-  const plot = chart?.querySelector(".avg-plot");
-  const tip = chart?.querySelector("#avgTip");
-  if (chart && plot && tip) {
+
+  // Period switch: show one view and its "since" figure.
+  chart?.querySelectorAll(".avg-switch button").forEach((btn) => {
+    btn.addEventListener("click", () => {
+      chart.querySelectorAll(".avg-switch button").forEach((b) => b.setAttribute("aria-pressed", String(b === btn)));
+      chart.querySelectorAll(".avg-chart, .avg-now .delta").forEach((el) => { el.hidden = el.dataset.view !== btn.dataset.view; });
+    });
+  });
+
+  chart?.querySelectorAll(".avg-plot").forEach((plot) => {
+    const tip = plot.querySelector(".avg-tip");
     const dots = [...plot.querySelectorAll(".avg-dot")];
     const [date, title, avg] = tip.children;
@@ -109,4 +144,4 @@ const delta = now - first.before;
       dot.addEventListener("blur", hide);
     });
-  }
+  });
 </script>

src/components/Changes.astro +4 −4
@@ -10,5 +10,5 @@ const base = import.meta.env.BASE_URL;
 // "1 Jan 2026", or "Feb 2026" when the day is not known
 const shortDate = (iso) =>
-  new Date(`${iso.length === 7 ? iso + "-01" : iso}T00:00:00Z`).toLocaleDateString("en-GB", { day: iso.length === 7 ? undefined : "numeric", month: "short", year: "numeric", timeZone: "UTC" });
+  iso.length === 4 ? iso : new Date(`${iso.length === 7 ? iso + "-01" : iso}T00:00:00Z`).toLocaleDateString("en-GB", { day: iso.length === 7 ? undefined : "numeric", month: "short", year: "numeric", timeZone: "UTC" });
 ---
 <div class="home-split">
@@ -16,10 +16,10 @@ const shortDate = (iso) =>
     <div class="changes-head">
       <h2 class="changes-title" id="changesTitle">Latest changes</h2>
-      <a href={`${base}changes/`}>All changes →</a>
+      <a href={`${base}history/`}>Full history →</a>
     </div>
     <ol class="mini-tl">
-      {changes.slice(0, 4).map((ch) => (
+      {changes.filter((ch) => ch.score).slice(0, 4).map((ch) => (
         <li style={`--c:${tierColor(ch.score.after)}`}>
-          <a href={`${base}changes/${ch.id}/`}>
+          <a href={`${base}history/${ch.id}/`}>
             <time datetime={ch.date}>{shortDate(ch.date)}</time>
             <span class="mini-tl-body">

src/components/CountryArticle.astro +6 −19
@@ -4,4 +4,6 @@
 import { SEGMENTS, REC_LABEL, MED_LABEL, countries, changes, segAvg, segmentPoints, segmentMax, subunitsOf, formatDate } from "../lib/countries.js";
 import { tierOf, tierColor } from "../lib/tiers.js";
+import CountryChanges from "./CountryChanges.astro";
+import CountryChart from "./CountryChart.astro";
 
 // `sub` is optional: one state, province or territory of `c`. Without it the national view is shown.
@@ -19,4 +21,5 @@ const byName = group ? [...group.units].sort((a, b) => a.name.localeCompare(b.na
 // Recorded changes to the national score, newest first (not shown for a single state).
 const history = sub ? [] : changes.filter((ch) => ch.country === c);
+const scored = history.filter((ch) => ch.score); // the ones that moved the score
 const r = 42;
 const circ = 2 * Math.PI * r;
@@ -50,23 +53,6 @@ const circ = 2 * Math.PI * r;
     </div>
     <p class="d-note">{v.note}</p>
-    {history.length > 0 && (
-      <div class="c-changes">
-        <h2>{history.length > 1 ? "Changes" : "Latest change"}</h2>
-        <ul>
-          {history.map((ch) => (
-            <li>
-              <a href={`${base}changes/${ch.id}/`}>
-                <time datetime={ch.date}>{formatDate(ch.date)}</time>
-                <b>{ch.title}</b>
-                <span class="c-change-score">
-                  {ch.score.before} <span aria-hidden="true">→</span><span class="visually-hidden">to</span> {ch.score.after}
-                  <span class={`delta ${ch.score.after >= ch.score.before ? "up" : "down"}`}>{ch.score.after >= ch.score.before ? "+" : "−"}{Math.abs(ch.score.after - ch.score.before)}</span>
-                </span>
-              </a>
-            </li>
-          ))}
-        </ul>
-      </div>
-    )}
+    {scored.length > 0 && <CountryChart history={scored} />}
+    {history.length > 0 && <CountryChanges history={history} place="side" />}
     {group && (
       <label class="field sub-select">
@@ -137,4 +123,5 @@ const circ = 2 * Math.PI * r;
       );
     })}
+    {history.length > 0 && <CountryChanges history={history} place="main" />}
 
     {group && (

src/components/CountryChanges.astro +25 −0
@@ -0,0 +1,25 @@
+---
+// A country's recorded changes, newest first. Placed twice in the country article: in the side
+// pane on wide screens, and under the score breakdown where the layout is a single column.
+import { changeDate } from "../lib/countries.js";
+
+const { history, place } = Astro.props;
+const base = import.meta.env.BASE_URL;
+---
+<div class={`c-changes c-changes-${place}`}>
+  <h2>History</h2>
+  <ul>
+    {history.map((ch) => (
+      <li>
+        <a href={`${base}history/${ch.id}/`}>
+          <time datetime={ch.date}>{changeDate(ch)}</time>
+          <b>{ch.title}</b>
+          {ch.score && <span class="c-change-score">
+            {ch.score.before} <span aria-hidden="true">→</span><span class="visually-hidden">to</span> {ch.score.after}
+            <span class={`delta ${ch.score.after >= ch.score.before ? "up" : "down"}`}>{ch.score.after >= ch.score.before ? "+" : "−"}{Math.abs(ch.score.after - ch.score.before)}</span>
+          </span>}
+        </a>
+      </li>
+    ))}
+  </ul>
+</div>

src/components/CountryChart.astro +75 −0
@@ -0,0 +1,75 @@
+---
+// A country's index score over time: a step line that moves at each of its recorded changes.
+// No script, so it also works when the article is loaded into the drawer.
+import { changeDate, dateTime, lastUpdated, ESTIMATE_WINDOW } from "../lib/countries.js";
+
+const { history } = Astro.props; // the country's changes, newest first
+const base = import.meta.env.BASE_URL;
+const time = dateTime;
+
+const [estFrom, estTo] = ESTIMATE_WINDOW;
+// Where a change sits in time. An estimated one runs across the whole window.
+const span0 = (ch) => (ch.estimate ? [Date.UTC(estFrom, 0, 1), Date.UTC(estTo, 0, 1)] : [time(ch.date), time(ch.date)]);
+
+const all = [...history].reverse();
+// The axis never starts before 1900; older changes are folded into the starting value.
+const FLOOR = 1900;
+const changes = all.filter((ch) => +ch.date.slice(0, 4) >= FLOOR);
+const older = all.filter((ch) => +ch.date.slice(0, 4) < FLOOR);
+const startRaw = older.length ? older.at(-1).raw.after : all[0].raw.before;
+const startScore = older.length ? older.at(-1).score.after : all[0].score.before;
+const endYear = +lastUpdated.slice(0, 4);
+// a lead-in before the first change shows where the score started
+const firstYear = changes.length ? +changes[0].date.slice(0, 4) : endYear - 20;
+const startYear = Math.max(FLOOR, firstYear - Math.max(1, Math.round((endYear - firstYear) * 0.15)));
+const t0 = Date.UTC(startYear, 0, 1);
+const t1 = time(lastUpdated);
+const x = (t) => ((t - t0) / (t1 - t0)) * 100;
+
+// y axis from zero, with headroom for the labels above the dots
+const top = Math.max(startScore, ...changes.flatMap((ch) => [ch.score.before, ch.score.after]));
+const tick = top + 5 > 40 ? 20 : 10;
+const yMax = Math.min(100, Math.max(20, Math.ceil((top + 5) / tick) * tick)); // never zoom in further than 0–20
+const y = (v) => (1 - v / yMax) * 100;
+const ticks = Array.from({ length: yMax / tick + 1 }, (_, i) => i * tick);
+
+const span = endYear - startYear;
+const step = span > 50 ? 20 : span > 20 ? 5 : span > 8 ? 2 : 1;
+const years = Array.from({ length: span + 1 }, (_, i) => startYear + i).filter((yr) => yr % step === 0);
+
+const points = changes
+  .map((ch) => ({ ch, x0: x(span0(ch)[0]), x: x(span0(ch)[1]), y: y(ch.raw.after) }))
+  // where two changes sit close together, only the later one gets a number, so labels do not overlap
+  .map((p, i, list) => ({ ...p, label: !list[i + 1] || list[i + 1].x - p.x > 6 }));
+// Labels sit above a dot after a rise and below it after a fall; the starting value goes on the
+// other side of the line from the first dot's label, so the two never collide.
+const firstCh = changes[0];
+const startBelow = firstCh && firstCh.raw.after > firstCh.raw.before && startRaw / yMax >= 0.2;
+// a dated change is a vertical step; an estimated one slopes across its window
+const line = `M0,${y(startRaw)}` + points.map((p) => (p.ch.estimate ? `H${p.x0}L${p.x},${p.y}` : `H${p.x}V${p.y}`)).join("") + "H100";
+---
+<div class="c-chart">
+  <h2>Score over time</h2>
+  <div class="avg-chart">
+    <div class="avg-y" aria-hidden="true">
+      {ticks.map((t) => <span style={`top:${y(t)}%`}>{t}</span>)}
+    </div>
+    <div class="avg-plot">
+      <svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
+        {ticks.map((t) => <line class="grid" x1="0" x2="100" y1={y(t)} y2={y(t)} />)}
+        <path class="line" d={line} />
+      </svg>
+      <span class={`c-chart-val start ${startBelow ? "below" : ""}`} style={`top:${y(startRaw)}%`} aria-hidden="true">{startScore}</span>
+      {points.map((p) => (
+        <a class="avg-dot" href={`${base}history/${p.ch.id}/`} style={`left:${p.x}%;top:${p.y}%`}
+          title={`${changeDate(p.ch)}: ${p.ch.title} (${p.ch.score.before} → ${p.ch.score.after})`}
+          aria-label={`${changeDate(p.ch)}: ${p.ch.title}. Score ${p.ch.score.before} to ${p.ch.score.after}.`}>
+          {p.label && <span class={`c-chart-val ${p.ch.raw.after < p.ch.raw.before ? "below" : ""}`} aria-hidden="true">{p.ch.score.after}</span>}
+        </a>
+      ))}
+    </div>
+    <div class="avg-x" aria-hidden="true">
+      {years.map((yr) => <span style={`left:${x(Date.UTC(yr, 0, 1))}%`}>{yr}</span>)}
+    </div>
+  </div>
+</div>

src/components/Lore.astro +0 −45
@@ -1,45 +0,0 @@
----
-// A short history: how long people and cannabis have been together, and how recent prohibition is.
-// Every dated claim has a source in the list at the bottom.
-const timeline = [
-  ["c. 10,000 BCE", "Domesticated in East Asia", "Genome studies trace all of today's hemp and drug varieties back to plants first cultivated in early Neolithic East Asia — making cannabis one of humanity's oldest crops, grown for fibre, seed and oil."],
-  ["c. 500 BCE", "Smoked in the Pamir mountains", "Wooden braziers from a 2,500-year-old cemetery in western China still carry residue of high-THC cannabis — the earliest direct chemical evidence of people using it for its effect on the mind."],
-  ["1961", "Banned by treaty", "The UN Single Convention on Narcotic Drugs places cannabis in its strictest category, alongside heroin. Most national prohibitions date from this century, not earlier."],
-  ["1964 – 1992", "The body's own cannabinoids", "Chemists in Israel isolate THC in 1964. Researchers then find receptors for it in the brain and, in 1992, a molecule the body makes itself to fit them. They name it anandamide, from the Sanskrit for bliss."],
-  ["2013", "The first country legalises", "Uruguay becomes the first nation to make cannabis legal for adults. A handful of others have followed."],
-  ["2020", "The UN steps back", "After 59 years, a UN commission votes 27 to 25 to remove cannabis from the treaty's most restrictive schedule, recognising its medical use."],
-];
-const sources = [
-  ["Ren et al. 2021, Science Advances — domestication history of Cannabis sativa", "https://doi.org/10.1126/sciadv.abg2286"],
-  ["Ren et al. 2019, Science Advances — the origins of cannabis smoking in the Pamirs", "https://doi.org/10.1126/sciadv.aaw1391"],
-  ["Gaoni & Mechoulam 1964, J. Am. Chem. Soc. — isolation of THC", "https://doi.org/10.1021/ja01062a046"],
-  ["Devane et al. 1992, Science — discovery of anandamide", "https://doi.org/10.1126/science.1470919"],
-  ["UN News, December 2020 — UN commission reclassifies cannabis", "https://news.un.org/en/story/2020/12/1079132"],
-];
----
-<section class="section lore" id="story">
-  <div class="lore-grid">
-    <div class="lore-text">
-      <p class="eyebrow">The long story</p>
-      <h2>Twelve thousand years together.<br>One century apart.</h2>
-      <p>People have grown cannabis for about as long as they have grown anything. It gave rope, cloth, food and oil long before anyone wrote laws, and it travelled with farmers, traders and healers across Asia, Africa, Europe and the Americas.</p>
-      <p>The connection runs deeper than farming. Every human body has an <strong>endocannabinoid system</strong>: a network of receptors and home-made signalling molecules that helps regulate mood, appetite, pain, memory and sleep. It evolved for the body's own chemistry, hundreds of millions of years before the plant existed — but the plant's compounds happen to fit the same receptors. That is why cannabis affects us at all.</p>
-      <p>Against that history, prohibition is new. The worldwide ban is barely a lifetime old, and this index measures how much of it is still standing.</p>
-    </div>
-    <ol class="timeline">
-      {timeline.map(([when, title, text]) => (
-        <li>
-          <span class="t-when">{when}</span>
-          <h3>{title}</h3>
-          <p>{text}</p>
-        </li>
-      ))}
-    </ol>
-  </div>
-  <p class="lore-src">
-    Sources:
-    {sources.map(([label, url], i) => (
-      <>{i > 0 && " · "}<a href={url} target="_blank" rel="noopener noreferrer">{label}</a></>
-    ))}
-  </p>
-</section>

src/content.config.ts +10 −4
@@ -97,12 +97,18 @@ const changes = defineCollection({
     .object({
       id: z.string(),
-      date: z.string().regex(/^\d{4}-\d{2}(-\d{2})?$/, "use YYYY-MM-DD or YYYY-MM"),
-      country: z.string().regex(/^[a-z]{2}$/), // file name in /countries
+      date: z.string().regex(/^\d{4}(-\d{2}(-\d{2})?)?$/, "use YYYY-MM-DD, YYYY-MM or YYYY"),
+      // A change has `country` and `before`. Without `before` the entry is context that moves no
+      // score: a report or ruling in that country, or with no `country` a treaty or UN decision.
+      country: z.string().regex(/^[a-z]{2}$/).optional(), // file name in /countries
       title: z.string().min(1),
       description: z.string().min(1),
-      before: perSegment(score), // segment scores before the change
+      before: perSegment(score).optional(), // segment scores before the change
+      // true when no first law is known for the country: the change is assumed to have happened
+      // at some point between the 1925 and 1961 treaties, and `date` is the start of that window
+      estimate: z.boolean().optional(),
       source,
     })
-    .strict(),
+    .strict()
+    .refine((ch) => ch.country || !ch.before, { message: "`before` needs a `country`" }),
 });
 

src/layouts/Base.astro +1 −2
@@ -40,6 +40,5 @@ const fullTitle = title ? `${title} — Cannabis Freedom Index` : "Cannabis Free
       <a href={`${base}#rankings`}>Rankings</a>
       <a href={`${base}#methodology`}>Methodology</a>
-      <a href={`${base}#story`}>Story</a>
-      <a href={`${base}changes/`}>Changes</a>
+      <a href={`${base}history/`}>History</a>
       <a href={`${base}compare/`}>Compare</a>
     </nav>

src/lib/countries.js +31 −10
@@ -108,10 +108,15 @@ export function subunitsOf(id) {
 // Each entry records the segment scores before the change. The scores after it are the "before"
 // of the same country's next change, or the country's current scores if there is none.
-const changeEntries = (await getCollection("changes")).map(({ data }) => data).sort((a, b) => a.date.localeCompare(b.date));
+// Where a date falls on a time axis: the middle of the month or year when that is all we know.
+// Entries are ordered by this everywhere, so a bare year never sorts ahead of where it is drawn.
+export const dateTime = (iso) => Date.parse(iso.length === 4 ? `${iso}-07-01` : iso.length === 7 ? `${iso}-15` : iso);
+const changeEntries = (await getCollection("changes")).map(({ data }) => data).sort((a, b) => dateTime(a.date) - dateTime(b.date) || a.id.localeCompare(b.id));
 export const changes = changeEntries
   .map((data, i) => {
-    const country = countries.find((c) => c.slug === data.country);
-    if (!country) throw new Error(`changes.yaml: "${data.id}" refers to unknown country "${data.country}"`);
-    const next = changeEntries.slice(i + 1).find((x) => x.country === data.country);
+    const country = data.country ? countries.find((c) => c.slug === data.country) : null;
+    if (data.country && !country) throw new Error(`changes.yaml: "${data.id}" refers to unknown country "${data.country}"`);
+    // context only (a treaty, report or ruling): part of the history, but it moves no score
+    if (!data.before) return { ...data, country, place: country ?? { flag: "🌐", name: "International" }, source: pair(data.source), score: null, segments: [] };
+    const next = changeEntries.slice(i + 1).find((x) => x.country === data.country && x.before);
     const before = scoreList(data.before);
     const after = next ? scoreList(next.before) : country.s;
@@ -120,4 +125,5 @@ export const changes = changeEntries
       ...data,
       country,
+      place: country,
       latest: !next, // the country's most recent change: "after" is its current score
       source: pair(data.source),
@@ -130,13 +136,25 @@ export const changes = changeEntries
   .reverse();
 
+// Where no first law is known for a country, its first restriction is an estimate: it is assumed
+// to have come at some point between the 1925 Geneva convention and the 1961 Single Convention.
+export const ESTIMATE_WINDOW = [1925, 1961];
+
 // World average over time, oldest first, rebuilt by undoing the recorded changes one by one.
-// Only as complete as changes.yaml: countries with no recorded change are held at today's score.
+// A change with a known date is one step. An estimated one is spread evenly over the window,
+// a year at a time, so that a guess never shows up as a sudden drop on one day.
 export const averageHistory = (() => {
   const n = countries.length;
+  const [from, to] = ESTIMATE_WINDOW;
+  const scored = changes.filter((ch) => ch.score);
+  const events = scored.filter((ch) => !ch.estimate).map((ch) => ({ change: ch, date: ch.date, delta: ch.raw.after - ch.raw.before }));
+  // all the estimates together, as one small step per year of the window
+  const estimated = scored.filter((ch) => ch.estimate).reduce((a, ch) => a + ch.raw.after - ch.raw.before, 0);
+  for (let year = from + 1; year <= to; year++) events.push({ change: null, date: `${year}-01-01`, delta: estimated / (to - from) });
+  events.sort((a, b) => dateTime(b.date) - dateTime(a.date)); // newest first
   let sum = countries.reduce((a, c) => a + c.raw, 0);
-  const points = changes.map((ch) => {
+  const points = events.map((e) => {
     const after = sum / n;
-    sum -= ch.raw.after - ch.raw.before;
-    return { change: ch, date: ch.date, before: sum / n, after };
+    sum -= e.delta;
+    return { change: e.change, date: e.date, before: sum / n, after };
   });
   return points.reverse();
@@ -144,7 +162,10 @@ export const averageHistory = (() => {
 
 // Review dates are stored as YYYY-MM-DD; this is how they are shown everywhere.
-// A date without a day ("2026-02") is shown as month and year.
+// A date without a day ("2026-02") is shown as month and year, and a bare year as the year.
 export const formatDate = (iso) =>
-  new Date(`${iso.length === 7 ? iso + "-01" : iso}T00:00:00Z`).toLocaleDateString("en-GB", { day: iso.length === 7 ? undefined : "numeric", month: "long", year: "numeric", timeZone: "UTC" });
+  iso.length === 4 ? iso : new Date(`${iso.length === 7 ? iso + "-01" : iso}T00:00:00Z`).toLocaleDateString("en-GB", { day: iso.length === 7 ? undefined : "numeric", month: "long", year: "numeric", timeZone: "UTC" });
+// How an entry's date is shown: an estimated one as its window.
+export const changeDate = (ch) => (ch.estimate ? `Between ${ESTIMATE_WINDOW[0]} and ${ESTIMATE_WINDOW[1]} (estimate)` : formatDate(ch.date));
+
 
 // The most recent review date across every country and state file.

src/pages/changes/[id].astro +0 −66
@@ -1,66 +0,0 @@
----
-import Base from "../../layouts/Base.astro";
-import ScoreChange from "../../components/ScoreChange.astro";
-import { changes, formatDate } from "../../lib/countries.js";
-import { tierColor } from "../../lib/tiers.js";
-
-export function getStaticPaths() {
-  return changes.map((ch, i) => ({ params: { id: ch.id }, props: { ch, newer: changes[i - 1], older: changes[i + 1] } }));
-}
-
-const { ch, newer, older } = Astro.props;
-const base = import.meta.env.BASE_URL;
-const c = ch.country;
-const diff = (seg) => seg.after - seg.before;
----
-<Base title={ch.title} description={`${ch.title}: ${c.name} moved from ${ch.score.before} to ${ch.score.after} on the Cannabis Freedom Index. ${ch.description}`}>
-  <main class="change-page">
-    <div class="page-bar">
-      <a class="back" href={`${base}changes/`}>← All changes</a>
-      <a class="btn" href={`${base}country/${c.slug}/`}>{c.flag} {c.name} in full</a>
-    </div>
-
-    <article class="change-detail">
-      <p class="eyebrow"><time datetime={ch.date}>{formatDate(ch.date)}</time> · {c.name}</p>
-      <h1>{ch.title}</h1>
-      <p class="lede">{ch.description}</p>
-
-      <h2 class="d-h">Index score</h2>
-      <ScoreChange score={ch.score} />
-
-      <h2 class="d-h">Score by segment</h2>
-      <table class="change-table">
-        <thead>
-          <tr><th scope="col">Segment</th><th scope="col">Before</th><th scope="col">After</th><th scope="col">Change</th></tr>
-        </thead>
-        <tbody>
-          {ch.segments.map((seg) => (
-            <tr class={diff(seg) === 0 ? "same" : ""}>
-              <th scope="row">{seg.name}<small>{seg.weight}% of the index</small></th>
-              <td><span class="dot" style={`background:${tierColor(seg.before * 10)}`}></span>{seg.before}</td>
-              <td><span class="dot" style={`background:${tierColor(seg.after * 10)}`}></span>{seg.after}</td>
-              <td>{diff(seg) === 0
-                ? "—"
-                : <span class={`delta ${diff(seg) > 0 ? "up" : "down"}`}>{diff(seg) > 0 ? "+" : "−"}{Math.abs(diff(seg))}</span>}</td>
-            </tr>
-          ))}
-        </tbody>
-      </table>
-      <p class="change-note">Segments are scored from 0 to 10. {ch.latest ? `The scores after the change are ${c.name}'s current scores.` : `${c.name}'s scores have changed again since.`}</p>
-
-      <h2 class="d-h">Source</h2>
-      <ul class="srcs">
-        <li><a href={ch.source[1]} target="_blank" rel="noopener noreferrer">{ch.source[0]}</a></li>
-      </ul>
-    </article>
-
-    <nav class="pager" aria-label="Other changes">
-      {newer
-        ? <a href={`${base}changes/${newer.id}/`}><small>Newer · {formatDate(newer.date)}</small><span>{newer.country.flag} {newer.title}</span></a>
-        : <span></span>}
-      {older
-        ? <a class="next" href={`${base}changes/${older.id}/`}><small>Older · {formatDate(older.date)}</small><span>{older.country.flag} {older.title}</span></a>
-        : <span></span>}
-    </nav>
-  </main>
-</Base>

src/pages/changes/index.astro +0 −43
@@ -1,43 +0,0 @@
----
-import Base from "../../layouts/Base.astro";
-import ScoreChange from "../../components/ScoreChange.astro";
-import { changes, formatDate } from "../../lib/countries.js";
-import { tierColor } from "../../lib/tiers.js";
-
-const base = import.meta.env.BASE_URL;
-// Newest first, grouped by year.
-const years = [...new Set(changes.map((ch) => ch.date.slice(0, 4)))]
-  .map((year) => ({ year, items: changes.filter((ch) => ch.date.startsWith(year)) }));
----
-<Base title="Changes" description="New laws and other developments that moved a country's score on the Cannabis Freedom Index, newest first.">
-  <main class="changes-page">
-    <section class="hero hero-sm">
-      <p class="eyebrow">Changes</p>
-      <h1>What moved the scores</h1>
-      <p class="lede">New laws and other important developments that changed a country's score, newest first. Open one to see which segments moved and the source.</p>
-    </section>
-
-    <div class="chlog">
-      {years.map(({ year, items }) => (
-        <section aria-labelledby={`y${year}`}>
-          <h2 class="chl-year" id={`y${year}`}>{year}</h2>
-          <ol class="chl-list">
-            {items.map((ch) => (
-              <li class="chl-item" style={`--c:${tierColor(ch.score.after)}`}>
-                <a class="card chl-card" href={`${base}changes/${ch.id}/`}>
-                  <p class="change-meta">
-                    <time datetime={ch.date}>{formatDate(ch.date)}</time>
-                    <span>{ch.country.flag} {ch.country.name}</span>
-                  </p>
-                  <h3>{ch.title}</h3>
-                  <p class="chl-text">{ch.description}</p>
-                  <ScoreChange score={ch.score} />
-                </a>
-              </li>
-            ))}
-          </ol>
-        </section>
-      ))}
-    </div>
… 3 more lines
src/pages/history/[id].astro +73 −0

Not shown: a picture, a generated file, or past the length shown per change.

src/pages/history/index.astro +45 −0

Not shown: a picture, a generated file, or past the length shown per change.

src/pages/index.astro +1 −2

Not shown: a picture, a generated file, or past the length shown per change.

src/styles/global.css +25 −21

Not shown: a picture, a generated file, or past the length shown per change.

    ↑↓ to move, ↵ to open.