File size: 25,566 Bytes
72448e1
 
 
 
 
570f994
72448e1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
570f994
 
 
72448e1
 
 
 
 
 
 
 
 
 
 
ee47e44
72448e1
 
 
 
 
 
 
 
ee47e44
73f458a
72448e1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
570f994
 
 
72448e1
 
 
570f994
 
 
 
 
 
72448e1
570f994
 
72448e1
 
 
 
 
 
 
 
570f994
72448e1
 
 
 
 
 
 
 
570f994
 
72448e1
73f458a
 
 
570f994
 
 
73f458a
570f994
73f458a
 
 
 
72448e1
570f994
72448e1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
570f994
72448e1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
570f994
72448e1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
570f994
72448e1
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Abu Dhabi Pickleball Club: 16 months of group growth</title>
</head>
<body>
<nav class="sitenav"><a href="index.html">The case for courts &rarr;</a></nav>
<style>.sitenav{max-width:980px;margin:0 auto 1.4rem;font:600 .78rem/1 system-ui,-apple-system,sans-serif;
letter-spacing:.06em;text-transform:uppercase}
.sitenav a{color:inherit;opacity:.62;text-decoration:none;border-bottom:1px solid currentColor;padding-bottom:2px}
.sitenav a:hover,.sitenav a:focus-visible{opacity:1}</style>

<style>
:root{
  --plane:#f2f2ee; --surface:#fcfcfb; --ink:#12130f; --ink-2:#52544c; --muted:#8a8b82;
  --grid:#e1e0d9; --axis:#c3c2b7; --ring:rgba(11,11,11,.10);
  --grow:#2a78d6; --churn:#e34948; --wash:rgba(42,120,214,.10);
  --sp:clamp(1rem,3vw,1.6rem);
}
@media (prefers-color-scheme:dark){ :root:where(:not([data-theme="light"])){
  --plane:#0d0d0c; --surface:#1a1a19; --ink:#fff; --ink-2:#c3c2b7; --muted:#898781;
  --grid:#2c2c2a; --axis:#383835; --ring:rgba(255,255,255,.10);
  --grow:#3987e5; --churn:#e66767; --wash:rgba(57,135,229,.13);
}}
:root[data-theme="dark"]{
  --plane:#0d0d0c; --surface:#1a1a19; --ink:#fff; --ink-2:#c3c2b7; --muted:#898781;
  --grid:#2c2c2a; --axis:#383835; --ring:rgba(255,255,255,.10);
  --grow:#3987e5; --churn:#e66767; --wash:rgba(57,135,229,.13);
}
*{box-sizing:border-box}
body{background:var(--plane);color:var(--ink);
  font:400 16px/1.6 system-ui,-apple-system,"Segoe UI",sans-serif;margin:0;padding:var(--sp)}
.wrap{max-width:1000px;margin:0 auto;display:flex;flex-direction:column;gap:2.6rem}
.eyebrow{font-size:.72rem;letter-spacing:.11em;text-transform:uppercase;color:var(--muted);
  font-weight:600;margin:0}
h1{font-size:clamp(1.8rem,4.6vw,2.85rem);line-height:1.06;letter-spacing:-.03em;font-weight:700;
  margin:.5rem 0 0;text-wrap:balance;max-width:19ch}
h2{font-size:clamp(1.1rem,2.4vw,1.4rem);letter-spacing:-.02em;font-weight:700;margin:0;
  text-wrap:balance}
.lede{font-family:"Iowan Old Style",Georgia,serif;font-size:1.1rem;line-height:1.62;
  color:var(--ink-2);max-width:60ch;margin:1.1rem 0 0}
.prose{font-family:"Iowan Old Style",Georgia,serif;line-height:1.65;color:var(--ink-2);
  max-width:62ch;margin:0}
.prose b,.prose strong{color:var(--ink);font-weight:600}
header{border-bottom:1px solid var(--axis);padding-bottom:2rem}
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(158px,1fr));gap:1px;
  background:var(--axis);border:1px solid var(--axis);border-radius:3px;overflow:hidden}
.stat{background:var(--surface);padding:1rem 1.1rem;display:flex;flex-direction:column;gap:.3rem}
.stat .k{font-size:.7rem;letter-spacing:.09em;text-transform:uppercase;color:var(--muted);font-weight:600}
.stat .v{font-size:1.85rem;font-weight:700;letter-spacing:-.03em;line-height:1}
.stat .n{font-size:.8rem;color:var(--muted)}
.panel{background:var(--surface);border:1px solid var(--ring);border-radius:3px;
  padding:1.3rem var(--sp) 1.1rem;display:flex;flex-direction:column;gap:.35rem}
.phead{display:flex;flex-wrap:wrap;gap:.6rem 1.2rem;align-items:baseline;justify-content:space-between}
.psub{font-size:.85rem;color:var(--muted);margin:0;max-width:58ch}
.figbox{overflow-x:auto;margin-top:.7rem}
svg{display:block;width:100%;height:auto;overflow:visible}
.figbox.wide svg{min-width:560px}
.figbox.narrow svg{min-width:290px}
.legend{display:flex;gap:1.1rem;flex-wrap:wrap;font-size:.8rem;color:var(--ink-2);align-items:center}
.legend span{display:inline-flex;align-items:center;gap:.42rem}
.sw{width:11px;height:11px;border-radius:2px;flex:none}
.tick{font-size:11px;fill:var(--muted);font-variant-numeric:tabular-nums}
.dlabel{font-size:12px;font-weight:600;fill:var(--ink);font-variant-numeric:tabular-nums}
.grid2{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:1.1rem}
.section{display:flex;flex-direction:column;gap:1.1rem}
table{border-collapse:collapse;width:100%;font-size:.84rem;font-variant-numeric:tabular-nums}
th,td{padding:.44rem .6rem;text-align:right;border-bottom:1px solid var(--grid);white-space:nowrap}
th:first-child,td:first-child{text-align:left}
thead th{font-size:.68rem;letter-spacing:.07em;text-transform:uppercase;color:var(--muted);
  font-weight:600;border-bottom:1px solid var(--axis)}
tbody tr:last-child td{border-bottom:none;color:var(--muted)}
details>summary{cursor:pointer;font-size:.85rem;color:var(--ink-2);font-weight:600;
  padding:.4rem 0;list-style:none;display:flex;gap:.5rem;align-items:center}
details>summary::before{content:"\25B8";color:var(--muted);transition:transform .15s}
details[open]>summary::before{transform:rotate(90deg)}
details>summary:focus-visible{outline:2px solid var(--grow);outline-offset:3px;border-radius:2px}
.note{border-left:2px solid var(--axis);padding:.15rem 0 .15rem 1.1rem;
  display:flex;flex-direction:column;gap:.7rem}
.note h3{font-size:.72rem;letter-spacing:.1em;text-transform:uppercase;color:var(--muted);
  font-weight:600;margin:0}
.note p{margin:0;font-size:.92rem;line-height:1.6;color:var(--ink-2);max-width:64ch}
#tip{position:fixed;pointer-events:none;opacity:0;transition:opacity .1s;z-index:9;
  background:var(--surface);border:1px solid var(--axis);border-radius:3px;padding:.5rem .7rem;
  font-size:.78rem;line-height:1.45;color:var(--ink);box-shadow:0 4px 16px rgba(0,0,0,.14);
  font-variant-numeric:tabular-nums;white-space:nowrap}
#tip b{font-weight:600}
#tip .r{display:flex;justify-content:space-between;gap:1rem;color:var(--ink-2)}
#tip .r i{font-style:normal;color:var(--ink);font-weight:600}
footer{color:var(--muted);font-size:.8rem;border-top:1px solid var(--axis);padding-top:1.2rem}
@media (prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
</style>

<div class="wrap">
<header>
  <p class="eyebrow">WhatsApp group export &middot; 20 Apr 2025 &ndash; 8 Aug 2026</p>
  <h1>From 13 friends to 505 members in 16 months</h1>
  <p class="lede">Abu Dhabi Pickleball Club began with a small group organising a
  Tuesday morning game. Sixteen months later, the group has more than 500 members.
  This page shows how the membership changed and how many members posted each month.</p>
</header>

<div class="stats">
  <div class="stat"><span class="k">Members now</span><span class="v">505</span><span class="n">from 13 at founding</span></div>
  <div class="stat"><span class="k">Ever joined</span><span class="v">695</span><span class="n">across 16 months</span></div>
  <div class="stat"><span class="k">Departed</span><span class="v">190</span><span class="n">27% of everyone who joined</span></div>
  <div class="stat"><span class="k">Messages</span><span class="v">13,577</span><span class="n">peak 1,924 in Apr 2026</span></div>
</div>

<section class="section">
  <div class="panel">
    <div class="phead"><h2>Membership, day by day</h2></div>
    <p class="psub">Reconstructed from every add, invite, leave and removal event in
      the export. Hover for any date.</p>
    <div class="figbox wide"><svg id="c1" viewBox="0 0 900 330" role="img"
      aria-label="Membership grew from 14 on 20 April 2025 to 505 on 8 August 2026,
      climbing steadily through the first year then steepening sharply from April 2026."></svg></div>
  </div>
  <p class="prose">Growth was steady but not constant. The first twelve months added
  about <b>23 members a month</b>. Then something shifted in spring 2026: the four
  months from April to July alone brought in <b>307 people</b>, taking membership from
  288 to 491. That turning point is the clearest single feature in the data.</p>
</section>

<section class="section">
  <div class="panel">
    <div class="phead">
      <h2>Who arrived, who left</h2>
      <div class="legend">
        <span><i class="sw" style="background:var(--grow)"></i>Joined</span>
        <span><i class="sw" style="background:var(--churn)"></i>Left</span>
      </div>
    </div>
    <p class="psub">Monthly arrivals above the line, departures below, on one shared
      scale. Nobody left at all for the first three months.</p>
    <div class="figbox wide"><svg id="c2" viewBox="0 0 900 300" role="img"
      aria-label="Monthly joins peaked at 94 in May 2026; departures peaked at 36 in
      June 2026. No departures occurred before July 2025."></svg></div>
  </div>
  <p class="prose">People started leaving in July 2025. No one left in the first
  <b>three months</b>. Since then, <b>13&ndash;36 people a month</b> have left, while
  more people have joined each month.</p>
</section>

<section class="section">
  <div class="panel">
    <div class="phead"><h2>Members who posted</h2></div>
    <p class="psub">Number of different members who posted each month.</p>
    <div class="figbox narrow"><svg id="c3" viewBox="0 0 440 260" role="img"
      aria-label="The number of different members posting each month rose from 10 to a peak
      of 131 in May 2026, then fell to 89 in July 2026."></svg></div>
  </div>
  <p class="prose">The number of members who posted grew as the group grew. It reached
  <b>131 members</b> in May 2026. The July figure was <b>89 members</b>.</p>
</section>

<div class="panel">
  <details>
    <summary>Month-by-month figures</summary>
    <div class="figbox wide" style="margin-top:.8rem">
    <table>
      <thead><tr><th>Month</th><th>Members</th><th>Joined</th><th>Left</th>
        <th>Net</th><th>Posted</th><th>Messages</th></tr></thead>
      <tbody id="tbody"></tbody>
    </table>
    </div>
  </details>
</div>

<div class="note">
  <h3>How this was counted</h3>
  <p>A WhatsApp export does not include a member list. It only includes events that change
  membership. This timeline replays all 908 membership events (adds, group-link joins, personal invites,
  leaves, removals) from the group&rsquo;s creation forward.</p>
  <p>Three tricky cases matter here. Multi-person adds
  (<i>&ldquo;X added A, B, and C&rdquo;</i>) are one line of text but three people. The line
  <i>&ldquo;removed this group from the community&rdquo;</i> is not someone leaving. And
  arrivals are written in four different ways: a group link, an invite link,
  a personal invite, and a direct add. Missing any one of them would make the
  count too low. All four are included.</p>
  <p>Thirteen members are counted as already there on day one: seven were posting before
  any join event was recorded, and six more are inferred. Every other way of
  joining the group leaves a record, so a member with no record of joining can only have
  been there from the start. That makes the total match the <b>505</b> members WhatsApp
  reports exactly. Seven later members post under a display name that never matched a
  join event; they are counted from when they first appear, which places a few arrivals
  slightly later than they truly happened.</p>
  <p>August 2026 covers only 8 days and is shown with dashed lines. Do not read
  it as a monthly figure. Members whose contact is not saved appear as phone numbers,
  and 22 people changed their number mid-history, so a small number of individuals may
  be represented twice.</p>
</div>

<footer>Source: <i>WhatsApp Chat - Abu Dhabi Pickleball Club.zip</i> &middot;
  38,165 lines &middot; 13,577 messages &middot; 908 membership events.</footer>
</div>
<div id="tip" role="status" aria-live="polite"></div>

<script>
const DAILY = [14, 15, 19, 19, 19, 22, 23, 25, 25, 26, 26, 27, 27, 27, 27, 27, 28, 29, 29, 29, 29, 29, 29, 29, 29, 30, 30, 30, 31, 31, 31, 31, 31, 34, 39, 42, 42, 42, 43, 43, 43, 50, 52, 52, 52, 52, 52, 53, 54, 60, 61, 62, 63, 63, 63, 63, 66, 66, 66, 66, 67, 68, 68, 68, 71, 73, 74, 74, 74, 76, 76, 76, 76, 78, 78, 79, 84, 85, 85, 84, 85, 86, 86, 86, 87, 87, 87, 88, 87, 87, 88, 88, 88, 88, 88, 90, 90, 91, 93, 106, 115, 120, 120, 120, 121, 122, 122, 122, 122, 122, 123, 124, 127, 127, 127, 127, 128, 128, 129, 130, 133, 134, 138, 139, 141, 141, 141, 142, 142, 142, 146, 146, 148, 150, 152, 154, 154, 153, 153, 153, 153, 154, 154, 154, 154, 154, 154, 154, 157, 157, 157, 156, 155, 155, 156, 159, 159, 159, 161, 161, 162, 164, 167, 167, 167, 168, 169, 171, 171, 172, 172, 172, 173, 173, 171, 175, 175, 177, 177, 177, 176, 177, 177, 178, 179, 178, 178, 179, 179, 179, 177, 177, 177, 180, 182, 181, 184, 185, 188, 188, 189, 190, 191, 191, 192, 192, 193, 192, 192, 195, 196, 196, 196, 198, 198, 198, 202, 206, 209, 211, 211, 211, 214, 214, 213, 211, 212, 215, 215, 215, 216, 216, 221, 221, 222, 222, 222, 222, 222, 223, 222, 224, 224, 223, 222, 221, 220, 220, 221, 221, 222, 223, 223, 223, 225, 225, 225, 225, 228, 228, 228, 229, 229, 233, 233, 234, 238, 244, 242, 242, 244, 246, 245, 246, 249, 253, 254, 256, 259, 259, 257, 259, 263, 262, 262, 263, 265, 269, 269, 269, 269, 270, 271, 272, 272, 272, 273, 272, 272, 276, 277, 275, 274, 276, 276, 276, 276, 275, 276, 276, 275, 275, 274, 277, 277, 277, 277, 276, 276, 276, 275, 274, 274, 276, 276, 276, 277, 278, 279, 280, 280, 282, 283, 283, 284, 285, 285, 284, 284, 285, 283, 285, 284, 283, 284, 288, 289, 294, 300, 302, 301, 302, 302, 302, 303, 304, 311, 313, 313, 316, 321, 322, 323, 325, 327, 329, 331, 330, 332, 329, 330, 331, 330, 329, 329, 331, 334, 337, 335, 340, 341, 343, 352, 356, 357, 357, 359, 361, 360, 362, 365, 372, 375, 382, 384, 384, 390, 390, 392, 393, 396, 396, 399, 400, 399, 399, 404, 408, 413, 416, 418, 420, 421, 424, 425, 426, 426, 430, 431, 435, 439, 440, 439, 439, 439, 442, 442, 444, 442, 446, 446, 447, 447, 447, 448, 447, 449, 450, 450, 450, 447, 450, 451, 448, 446, 445, 445, 445, 460, 464, 468, 473, 474, 477, 477, 480, 480, 481, 482, 482, 482, 483, 485, 484, 491, 490, 490, 491, 492, 491, 493, 494, 497, 499, 503, 505];
const M = [{"month":"2025-04","members":26,"joined":13,"left":0,"active":10,"msgs":63,"pct":38.5},{"month":"2025-05","members":50,"joined":24,"left":0,"active":21,"msgs":127,"pct":42.0},{"month":"2025-06","members":76,"joined":26,"left":0,"active":33,"msgs":562,"pct":43.4},{"month":"2025-07","members":120,"joined":48,"left":4,"active":60,"msgs":981,"pct":50.0},{"month":"2025-08","members":150,"joined":37,"left":7,"active":75,"msgs":1363,"pct":50.0},{"month":"2025-09","members":167,"joined":25,"left":8,"active":74,"msgs":1001,"pct":44.3},{"month":"2025-10","members":182,"joined":25,"left":10,"active":61,"msgs":433,"pct":33.5},{"month":"2025-11","members":213,"joined":45,"left":14,"active":63,"msgs":919,"pct":29.6},{"month":"2025-12","members":225,"joined":28,"left":16,"active":67,"msgs":687,"pct":29.8},{"month":"2026-01","members":265,"joined":58,"left":18,"active":98,"msgs":1028,"pct":37.0},{"month":"2026-02","members":277,"joined":26,"left":14,"active":79,"msgs":561,"pct":28.5},{"month":"2026-03","members":288,"joined":24,"left":13,"active":74,"msgs":962,"pct":25.7},{"month":"2026-04","members":331,"joined":68,"left":25,"active":122,"msgs":1924,"pct":36.9},{"month":"2026-05","members":404,"joined":94,"left":21,"active":131,"msgs":1480,"pct":32.4},{"month":"2026-06","members":449,"joined":81,"left":36,"active":123,"msgs":870,"pct":27.4},{"month":"2026-07","members":491,"joined":64,"left":22,"active":89,"msgs":561,"pct":18.1},{"month":"2026-08","members":505,"joined":17,"left":3,"active":19,"msgs":55,"pct":3.8}];
const START = Date.UTC(2025, 3, 20);
const MN = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
const NS = "http://www.w3.org/2000/svg";
const PARTIAL = M.length - 1;                     // Aug 2026 = 8 days only

const el = (t, a) => { const n = document.createElementNS(NS, t);
  for (const k in a) n.setAttribute(k, a[k]); return n; };
const fmtM = s => { const [y, m] = s.split("-"); return MN[+m - 1] + " " + y.slice(2); };
const dayLabel = i => { const d = new Date(START + i * 864e5);
  return d.getUTCDate() + " " + MN[d.getUTCMonth()] + " " + d.getUTCFullYear(); };

/* bar path: rounded at the data end, square at the baseline */
function bar(x, w, base, val, r, up) {
  const h = Math.abs(val - base); if (h < .5) return "";
  const rr = Math.min(r, h, w / 2), y = up ? val : base;
  return up
    ? "M" + x + "," + base + " V" + (y + rr) + " q0," + (-rr) + " " + rr + "," + (-rr) +
      " h" + (w - 2 * rr) + " q" + rr + ",0 " + rr + "," + rr + " V" + base + " Z"
    : "M" + x + "," + base + " V" + (base + h - rr) + " q0," + rr + " " + rr + "," + rr +
      " h" + (w - 2 * rr) + " q" + rr + ",0 " + rr + "," + (-rr) + " V" + base + " Z";
}

const tip = document.getElementById("tip");
function showTip(e, html) {
  tip.innerHTML = html; tip.style.opacity = 1;
  const r = tip.getBoundingClientRect();
  let x = e.clientX + 14, y = e.clientY - r.height - 12;
  if (x + r.width > innerWidth - 8) x = e.clientX - r.width - 14;
  if (y < 8) y = e.clientY + 18;
  tip.style.left = x + "px"; tip.style.top = y + "px";
}
const hideTip = () => tip.style.opacity = 0;
const rowsFor = m => "<div class='r'><span>Joined</span><i>+" + m.joined + "</i></div>" +
  "<div class='r'><span>Left</span><i>&minus;" + m.left + "</i></div>" +
  "<div class='r'><span>Roster end</span><i>" + m.members + "</i></div>" +
  "<div class='r'><span>Posted</span><i>" + m.active + "</i></div>" +
  "<div class='r'><span>Messages</span><i>" + m.msgs.toLocaleString() + "</i></div>";

/* ---------- 1. daily membership ---------- */
(function () {
  const svg = document.getElementById("c1");
  const W = 900, H = 330, L = 44, R = 52, T = 16, B = 30;
  const pw = W - L - R, ph = H - T - B, max = 550;
  const X = i => L + i * pw / (DAILY.length - 1);
  const Y = v => T + ph - v / max * ph;

  for (let v = 0; v <= 500; v += 100) {
    svg.append(el("line", { x1: L, x2: L + pw, y1: Y(v), y2: Y(v),
      stroke: "var(--grid)", "stroke-width": 1 }));
    const t = el("text", { x: L - 8, y: Y(v) + 4, "text-anchor": "end", class: "tick" });
    t.textContent = v; svg.append(t);
  }
  let area = "M" + X(0) + "," + Y(0), line = "";
  DAILY.forEach((v, i) => { area += " L" + X(i) + "," + Y(v);
    line += (i ? " L" : "M") + X(i) + "," + Y(v); });
  area += " L" + X(DAILY.length - 1) + "," + Y(0) + " Z";
  svg.append(el("path", { d: area, fill: "var(--wash)" }));
  svg.append(el("path", { d: line, fill: "none", stroke: "var(--grow)",
    "stroke-width": 2, "stroke-linejoin": "round", "stroke-linecap": "round" }));

  M.forEach(m => {
    const [y, mo] = m.month.split("-");
    if (+mo % 3 !== 1) return;
    const i = Math.max(0, Math.round((Date.UTC(+y, +mo - 1, 1) - START) / 864e5));
    if (i >= DAILY.length) return;
    const t = el("text", { x: X(i), y: H - 8, "text-anchor": "middle", class: "tick" });
    t.textContent = fmtM(m.month); svg.append(t);
  });

  const last = DAILY.length - 1;
  svg.append(el("circle", { cx: X(last), cy: Y(DAILY[last]), r: 4.5, fill: "var(--grow)",
    stroke: "var(--surface)", "stroke-width": 2 }));
  const lab = el("text", { x: X(last) + 10, y: Y(DAILY[last]) + 4, class: "dlabel" });
  lab.textContent = "505"; svg.append(lab);

  const cross = el("line", { y1: T, y2: T + ph, stroke: "var(--axis)", "stroke-width": 1, opacity: 0 });
  const dot = el("circle", { r: 4.5, fill: "var(--grow)", stroke: "var(--surface)",
    "stroke-width": 2, opacity: 0 });
  svg.append(cross, dot);
  const hit = el("rect", { x: L, y: T, width: pw, height: ph, fill: "transparent" });
  svg.append(hit);
  hit.addEventListener("pointermove", e => {
    const b = svg.getBoundingClientRect();
    const i = Math.max(0, Math.min(DAILY.length - 1,
      Math.round(((e.clientX - b.left) / b.width * W - L) / pw * (DAILY.length - 1))));
    cross.setAttribute("x1", X(i)); cross.setAttribute("x2", X(i)); cross.setAttribute("opacity", 1);
    dot.setAttribute("cx", X(i)); dot.setAttribute("cy", Y(DAILY[i])); dot.setAttribute("opacity", 1);
    showTip(e, "<b>" + DAILY[i] + " members</b><div class='r'>" + dayLabel(i) + "</div>");
  });
  hit.addEventListener("pointerleave", () => {
    hideTip(); cross.setAttribute("opacity", 0); dot.setAttribute("opacity", 0);
  });
})();

/* ---------- 2. joins / leaves, one shared scale ---------- */
(function () {
  const svg = document.getElementById("c2");
  const W = 900, H = 300, L = 44, R = 14, T = 14, B = 36;
  const pw = W - L - R, ph = H - T - B;
  const UP = 100, DN = 40, unit = ph / (UP + DN), zero = T + UP * unit;
  const YU = v => zero - v * unit, YD = v => zero + v * unit;
  const band = pw / M.length, bw = Math.min(24, band - 2);

  const addGrid = (y, v) => {
    svg.append(el("line", { x1: L, x2: L + pw, y1: y, y2: y, stroke: "var(--grid)", "stroke-width": 1 }));
    const t = el("text", { x: L - 8, y: y + 4, "text-anchor": "end", class: "tick" });
    t.textContent = v; svg.append(t);
  };
  [25, 50, 75, 100].forEach(v => addGrid(YU(v), v));
  [25].forEach(v => addGrid(YD(v), v));
  svg.append(el("line", { x1: L, x2: L + pw, y1: zero, y2: zero,
    stroke: "var(--axis)", "stroke-width": 1 }));

  M.forEach((m, i) => {
    const x = L + i * band + (band - bw) / 2, part = i === PARTIAL;
    const mk = (d, fill) => { if (!d) return;
      svg.append(el("path", { d, fill: part ? "none" : fill, stroke: part ? fill : "none",
        "stroke-width": part ? 1.5 : 0, "stroke-dasharray": part ? "3 2" : "" })); };
    mk(bar(x, bw, zero - 1, YU(m.joined), 4, true), "var(--grow)");
    mk(bar(x, bw, zero + 1, YD(m.left), 4, false), "var(--churn)");

    const t = el("text", { x: x + bw / 2, y: H - 12, "text-anchor": "middle", class: "tick" });
    t.textContent = fmtM(m.month).split(" ")[0];
    t.setAttribute("opacity", part ? .55 : 1); svg.append(t);
    if (i === 0 || m.month.endsWith("-01")) {
      const y2 = el("text", { x: x + bw / 2, y: H, "text-anchor": "middle", class: "tick" });
      y2.textContent = "20" + m.month.slice(2, 4); svg.append(y2);
    }
    if (m.joined === 94) {
      const v = el("text", { x: x + bw / 2, y: YU(m.joined) - 8, "text-anchor": "middle", class: "dlabel" });
      v.textContent = "94"; svg.append(v);
    }
    const hit = el("rect", { x: L + i * band, y: T, width: band, height: ph, fill: "transparent" });
    hit.addEventListener("pointermove", e => showTip(e,
      "<b>" + fmtM(m.month) + (part ? " (8 days)" : "") + "</b>" + rowsFor(m)));
    hit.addEventListener("pointerleave", hideTip);
    svg.append(hit);
  });
})();

/* ---------- 3. monthly posters ---------- */
function small(id, key, max, ticks, fmt, kind) {
  const svg = document.getElementById(id);
  const W = 440, H = 260, L = 38, R = 14, T = 16, B = 30;
  const pw = W - L - R, ph = H - T - B;
  const Y = v => T + ph - v / max * ph;
  const band = pw / M.length, bw = Math.min(22, band - 2);
  const cx = i => L + i * band + band / 2;

  ticks.forEach(v => {
    svg.append(el("line", { x1: L, x2: L + pw, y1: Y(v), y2: Y(v), stroke: "var(--grid)", "stroke-width": 1 }));
    const t = el("text", { x: L - 7, y: Y(v) + 4, "text-anchor": "end", class: "tick" });
    t.textContent = fmt(v); svg.append(t);
  });
  svg.append(el("line", { x1: L, x2: L + pw, y1: Y(0), y2: Y(0), stroke: "var(--axis)", "stroke-width": 1 }));

  if (kind === "line") {
    let d = "";
    M.forEach((m, i) => { if (i === PARTIAL) return;
      d += (d ? " L" : "M") + cx(i) + "," + Y(m[key]); });
    svg.append(el("path", { d, fill: "none", stroke: "var(--grow)", "stroke-width": 2,
      "stroke-linejoin": "round", "stroke-linecap": "round" }));
    svg.append(el("path", { d: "M" + cx(PARTIAL - 1) + "," + Y(M[PARTIAL - 1][key]) +
      " L" + cx(PARTIAL) + "," + Y(M[PARTIAL][key]), fill: "none", stroke: "var(--grow)",
      "stroke-width": 2, "stroke-dasharray": "3 3", opacity: .55 }));
    M.forEach((m, i) => { if (i === PARTIAL) return;
      svg.append(el("circle", { cx: cx(i), cy: Y(m[key]), r: 3.4, fill: "var(--grow)",
        stroke: "var(--surface)", "stroke-width": 2 })); });
    svg.append(el("circle", { cx: cx(PARTIAL), cy: Y(M[PARTIAL][key]), r: 3.4, fill: "var(--surface)",
      stroke: "var(--grow)", "stroke-width": 1.5, opacity: .7 }));
  } else {
    M.forEach((m, i) => {
      const x = L + i * band + (band - bw) / 2, part = i === PARTIAL;
      const d = bar(x, bw, Y(0), Y(m[key]), 4, true); if (!d) return;
      svg.append(el("path", { d, fill: part ? "none" : "var(--grow)",
        stroke: part ? "var(--grow)" : "none", "stroke-width": part ? 1.5 : 0,
        "stroke-dasharray": part ? "3 2" : "" }));
    });
  }

  const iL = PARTIAL - 1, mL = M[iL];
  const lab = el("text", { x: cx(iL), y: Y(mL[key]) - 10, class: "dlabel", "text-anchor": "middle" });
  lab.textContent = fmt(mL[key]); svg.append(lab);

  M.forEach((m, i) => {
    if (i % 3 === 0) {
      const t = el("text", { x: cx(i), y: H - 10, "text-anchor": "middle", class: "tick" });
      t.textContent = fmtM(m.month); svg.append(t);
    }
    const hit = el("rect", { x: L + i * band, y: T, width: band, height: ph, fill: "transparent" });
    hit.addEventListener("pointermove", e => showTip(e,
      "<b>" + fmtM(m.month) + (i === PARTIAL ? " (8 days)" : "") + "</b>" + rowsFor(m)));
    hit.addEventListener("pointerleave", hideTip);
    svg.append(hit);
  });
}
small("c3", "active", 140, [0, 35, 70, 105, 140], v => v, "bar");

/* ---------- table ---------- */
document.getElementById("tbody").innerHTML = M.map((m, i) => {
  const net = m.joined - m.left;
  return "<tr><td>" + fmtM(m.month) + (i === PARTIAL ? " (8d)" : "") + "</td><td>" + m.members +
    "</td><td>+" + m.joined + "</td><td>" + (m.left ? "&minus;" + m.left : "0") +
    "</td><td>" + (net > 0 ? "+" : "") + net + "</td><td>" + m.active +
    "</td><td>" + m.msgs.toLocaleString() + "</td></tr>";
}).join("");
</script>

</body>
</html>