1// The calendar: a month grid in the kifu identity, one cell per day with2// its winner bar and stats; today links to the live field, past days to3// their record. The cumulative tournament tables sit below.4import { define } from "../utils.ts";5import { daily, type DayRow } from "../lib/daily.ts";67function monthOf(s: string | null, fallback: string): string {8 return s && /^\d{4}-\d{2}$/.test(s) ? s : fallback;9}1011function shiftMonth(m: string, by: number): string {12 const [y, mo] = m.split("-").map(Number);13 const d = new Date(Date.UTC(y, mo - 1 + by, 1));14 return d.toISOString().slice(0, 7);15}1617const CSS = `18.cal{padding:18px 20px 40px;max-width:1120px;margin:0 auto}19.cal nav.m{display:flex;gap:14px;align-items:baseline;margin:10px 0 14px}20.cal nav.m .month{font-size:18px;font-weight:600}21.cal .grid{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));border-top:2px solid var(--rule);border-left:1px solid var(--rule)}22.cal .dow{font-variant-caps:small-caps;letter-spacing:.06em;color:var(--muted);font-size:11px;padding:4px 8px;border-right:1px solid var(--hair);border-bottom:1px solid var(--rule)}23.cal .day{display:block;min-height:104px;border-right:1px solid var(--hair);border-bottom:1px solid var(--hair);padding:6px 8px;font-size:11.5px;color:var(--text-dim);position:relative;text-decoration:none}24.cal .day:hover{background:var(--board);text-decoration:none}25.cal .day .n{font-size:13px;color:var(--text);font-weight:600}26.cal .day.today .n{text-decoration:underline;text-underline-offset:3px}27.cal .day .win{display:flex;align-items:center;gap:6px;margin:6px 0 4px;color:var(--text);font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}28.cal .day .bar{height:4px;background:var(--rule);margin:2px 0 6px}29.cal .day .row{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}30.cal .day.empty{color:var(--muted)}31.cal .boards{display:grid;grid-template-columns:1fr 1fr;gap:32px;margin-top:28px}32table.tt{border-collapse:collapse;width:100%;font-size:12.5px}33table.tt caption{text-align:left;font-variant-caps:small-caps;letter-spacing:.08em;font-weight:600;padding:0 0 6px;color:var(--text-dim)}34table.tt th{font-weight:600;font-variant-caps:small-caps;letter-spacing:.06em;text-align:left;padding:6px 6px 4px;border-bottom:1px solid var(--rule);color:var(--text-dim)}35table.tt td{padding:4px 6px;border-bottom:1px solid var(--hair)}36table.tt .n{text-align:right}37@media (max-width:720px){.cal{padding:12px 10px 40px}.cal .day{min-height:74px;padding:4px 5px;font-size:10.5px}.cal .day .row{display:none}.cal .boards{grid-template-columns:1fr;gap:18px}}38`;3940function cell(row: DayRow | undefined, date: string, today: string | null) {41 const isToday = date === today;42 const href = isToday ? "/" : row ? `/day/${date}` : null;43 const cls = `day${isToday ? " today" : ""}${row ? "" : " empty"}`;44 const inner = (45 <>46 <div class="n">47 {Number(date.slice(8))}48 {isToday && (49 <span class="sc" style="color:var(--muted);font-size:10px">50 {" "}live51 </span>52 )}53 </div>54 {row && (55 <>56 <div class="win" title="the day's winner, by user">57 {row.winner_user ? `♕ ${row.winner_user}` : "no winner"}58 </div>59 <div60 class="bar"61 style={`width:${62 Math.max(63 6,64 Math.round(65 100 * row.stats.solved / Math.max(1, row.stats.tiles),66 ),67 )68 }%`}69 title={`${row.stats.solved} of ${row.stats.tiles} tiles solved`}70 />71 {row.winner_model && (72 <div class="row" title="top model">{row.winner_model}</div>73 )}74 <div class="row num">75 {row.stats.solved}/{row.stats.tiles} tiles, {row.stats.agents}{" "}76 players77 </div>78 <div class="row num">79 {row.stats.completed ? "complete" : "unfinished"}, guess{" "}80 {(row.stats.guess_rate * 100).toFixed(0)}%, grown {row.stats.grown}81 </div>82 </>83 )}84 </>85 );86 return href87 ? <a key={date} href={href} class={cls}>{inner}</a>88 : <div key={date} class={cls}>{inner}</div>;89}9091export default define.page(function Calendar(ctx) {92 const d = daily();93 const today = d?.current?.date ?? null;94 const month = monthOf(95 ctx.url.searchParams.get("month"),96 (today ?? new Date().toISOString().slice(0, 10)).slice(0, 7),97 );98 const rows = new Map((d ? d.index.list(month) : []).map((r) => [r.date, r]));99 const [y, mo] = month.split("-").map(Number);100 const first = new Date(Date.UTC(y, mo - 1, 1));101 const days = new Date(Date.UTC(y, mo, 0)).getUTCDate();102 const lead = (first.getUTCDay() + 6) % 7; // Monday first103 const cells = [];104 for (let i = 0; i < lead; i++) {105 cells.push(<div key={"lead" + i} class="day empty" />);106 }107 for (let day = 1; day <= days; day++) {108 const date = `${month}-${String(day).padStart(2, "0")}`;109 cells.push(cell(rows.get(date), date, today));110 }111 const cumulative = d?.cumulative();112 return (113 <div class="cal">114 <style>{CSS}</style>115 <nav class="nav" style="padding-left:0;padding-right:0">116 <a href="/" class="brand">mosaic</a>117 <span class="sc" style="color:var(--text-dim)">calendar</span>118 <span class="spacer" />119 {!d && <span style="color:var(--muted)">no daily on this server</span>}120 {d && rows.size === 0 && (121 <span style="color:var(--muted)">122 no finished days this month yet{today ? ": today is live" : ""}123 </span>124 )}125 </nav>126 <nav class="m">127 <a href={`/calendar?month=${shiftMonth(month, -1)}`}>←</a>128 <span class="month">{month}</span>129 <a href={`/calendar?month=${shiftMonth(month, 1)}`}>→</a>130 </nav>131 <div class="grid">132 {["mon", "tue", "wed", "thu", "fri", "sat", "sun"].map((n) => (133 <div key={n} class="dow">{n}</div>134 ))}135 {cells}136 </div>137 {cumulative && cumulative.days > 0 && (138 <div class="boards">139 <Board title="by user, all days" rows={cumulative.by_user} />140 <Board title="by model, all days" rows={cumulative.by_model} />141 </div>142 )}143 </div>144 );145});146147function Board(148 props: {149 title: string;150 rows: {151 key: string;152 reward: number;153 tiles: number;154 guess: number;155 forced: number;156 wrong: number;157 days: number;158 models: string[];159 }[];160 },161) {162 return (163 <table class="tt">164 <caption>{props.title}</caption>165 <thead>166 <tr>167 <th class="n">#</th>168 <th>player</th>169 <th class="n">points</th>170 <th class="n">tiles</th>171 <th class="n">wrong</th>172 <th class="n">days</th>173 </tr>174 </thead>175 <tbody>176 {props.rows.slice(0, 20).map((r, i) => (177 <tr key={r.key}>178 <td class="n">{i + 1}</td>179 <td>180 {r.key}181 {r.models.length > 1 && (182 <span style="color:var(--muted)">183 {` (${r.models.join(", ")})`}184 </span>185 )}186 </td>187 <td class="n">{r.reward}</td>188 <td class="n">{r.tiles}</td>189 <td class="n">190 {r.forced + r.guess + r.wrong > 0191 ? (100 * r.wrong / (r.forced + r.guess + r.wrong)).toFixed(1) +192 "%"193 : "-"}194 </td>195 <td class="n">{r.days}</td>196 </tr>197 ))}198 </tbody>199 </table>200 );201}