Add recent tag buttons under the tag picker

Under the TAG picker, the timer form shows buttons for the 3 different
tags from the most recent sessions, newest first. A tap selects the
tag. It does not start a session.

The minute buttons move under the DURATION field. They share the column
width, so all six stay on one line on desktop and on mobile.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
minhtrannhatandClaude Opus 5.5 committed 2026-11-08 15:10:12 -05:00
1 parent c90c0ddf9d
commit 52e8948d73
4 files changed
+59 -4

No files matched your search

+7 -3
View File
@@ -6,6 +6,7 @@ import { dueMs, recordEnd, type TimeRecord } from "../shared/record.ts";
import { normalizeTag } from "../shared/tag.ts"; import { normalizeTag } from "../shared/tag.ts";
import { countdown, pad, pageTitle } from "./title.ts"; import { countdown, pad, pageTitle } from "./title.ts";
import { readyToReload } from "./update.ts"; import { readyToReload } from "./update.ts";
import { recentTags } from "./recent-tags.ts";
import { THEMES, THEME_COLORS, isTheme, type Theme } from "./theme.ts"; import { THEMES, THEME_COLORS, isTheme, type Theme } from "./theme.ts";
type View = "timer" | "log" | "insights" | "settings"; type View = "timer" | "log" | "insights" | "settings";
@@ -144,6 +145,7 @@ function timerPage() {
const completedToday = completed.length; const completedToday = completed.length;
const week = totals(records, rangeWindow("week")); const week = totals(records, rangeWindow("week"));
const tagOptions = tags().map(tag => `<option value="${h(tag)}" ${tag === selectedTag ? "selected" : ""}>${h(tag)}</option>`).join(""); const tagOptions = tags().map(tag => `<option value="${h(tag)}" ${tag === selectedTag ? "selected" : ""}>${h(tag)}</option>`).join("");
const recent = recentTags(records);
const latest = completed.sort((a, b) => b.startMs - a.startMs).slice(0, 6); const latest = completed.sort((a, b) => b.startMs - a.startMs).slice(0, 6);
const weekMax = Math.max(...week.trend, 1); const weekMax = Math.max(...week.trend, 1);
return `<section class="page"> return `<section class="page">
@@ -155,9 +157,10 @@ function timerPage() {
<div class="progress-track"><div class="progress-fill" id="progress-fill" style="width:${active ? progress(active) : 0}%"></div></div> <div class="progress-track"><div class="progress-fill" id="progress-fill" style="width:${active ? progress(active) : 0}%"></div></div>
${active ? `<div class="field-grid"><div><span class="field-label">TAG</span><div class="field" style="display:flex;align-items:center">${h(active.tag)}</div></div><div><span class="field-label">DURATION</span><div class="field" style="display:flex;align-items:center">${duration(active.targetSeconds! * 1000)}</div></div></div> ${active ? `<div class="field-grid"><div><span class="field-label">TAG</span><div class="field" style="display:flex;align-items:center">${h(active.tag)}</div></div><div><span class="field-label">DURATION</span><div class="field" style="display:flex;align-items:center">${duration(active.targetSeconds! * 1000)}</div></div></div>
<button class="primary-btn outline" data-action="stop" type="button">■ STOP EARLY &amp; LOG TIME</button>` : ` <button class="primary-btn outline" data-action="stop" type="button">■ STOP EARLY &amp; LOG TIME</button>` : `
<div class="field-grid"><div><label class="field-label" for="timer-tag">TAG</label><select class="field" id="timer-tag">${tagOptions}<option value="__new__">+ New tag...</option></select></div> <div class="field-grid"><div><label class="field-label" for="timer-tag">TAG</label><select class="field" id="timer-tag">${tagOptions}<option value="__new__">+ New tag...</option></select>
<div><label class="field-label" for="timer-duration">DURATION / MIN</label><input class="field" id="timer-duration" type="number" min="1" max="180" inputmode="numeric" value="${selectedDuration}" /></div></div> ${recent.length ? `<div class="chip-row" role="group" aria-label="Recent tags">${recent.map(tag => `<button class="chip tag-chip ${tag === selectedTag ? "active" : ""}" data-action="pick-tag" data-tag="${h(tag)}" title="${h(tag)}" type="button">${h(tag)}</button>`).join("")}</div>` : ""}</div>
<div class="duration-row">${[15, 25, 30, 45, 60, 120].map(n => `<button class="chip ${n === selectedDuration ? "active" : ""}" data-action="duration" data-minutes="${n}" type="button">${n}m</button>`).join("")}</div> <div><label class="field-label" for="timer-duration">DURATION / MIN</label><input class="field" id="timer-duration" type="number" min="1" max="180" inputmode="numeric" value="${selectedDuration}" />
<div class="chip-row fill" role="group" aria-label="Durations">${[15, 25, 30, 45, 60, 120].map(n => `<button class="chip ${n === selectedDuration ? "active" : ""}" data-action="duration" data-minutes="${n}" type="button">${n}m</button>`).join("")}</div></div></div>
<button class="primary-btn" data-action="start" type="button">&gt; START SESSION</button>`} <button class="primary-btn" data-action="start" type="button">&gt; START SESSION</button>`}
</div></div> </div></div>
<div class="panel"><div class="panel-inner"><div class="panel-label"><span>// TODAY</span><span class="end">${h(shortDate(todayWindow.start).toUpperCase())}</span></div> <div class="panel"><div class="panel-inner"><div class="panel-label"><span>// TODAY</span><span class="end">${h(shortDate(todayWindow.start).toUpperCase())}</span></div>
@@ -422,6 +425,7 @@ async function dispatch(action: string, element: HTMLElement) {
switch (action) { switch (action) {
case "nav": view = element.dataset.view as View; editingId = null; render(); break; case "nav": view = element.dataset.view as View; editingId = null; render(); break;
case "duration": selectedDuration = Number(element.dataset.minutes); render(); break; case "duration": selectedDuration = Number(element.dataset.minutes); render(); break;
case "pick-tag": selectedTag = normalizeTag(element.dataset.tag || selectedTag); render(); break;
case "start": await startSession(); break; case "start": await startSession(); break;
case "stop": await stopSession(); break; case "stop": await stopSession(); break;
case "prev-log": logMonthOffset--; render(); break; case "prev-log": logMonthOffset--; render(); break;
+12
View File
@@ -0,0 +1,12 @@
type TaggedRecord = { startMs: number; tag: string; deleted: boolean };
/** The different tags of the most recent sessions, newest first. */
export function recentTags(records: TaggedRecord[], limit = 3): string[] {
const tags: string[] = [];
for (const record of [...records].sort((a, b) => b.startMs - a.startMs)) {
if (record.deleted || tags.includes(record.tag)) continue;
tags.push(record.tag);
if (tags.length === limit) break;
}
return tags;
}
+5 -1
View File
@@ -120,7 +120,11 @@ h2 { font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacin
.field::placeholder { color: var(--faint); } .field::placeholder { color: var(--faint); }
select.field { appearance: none; background-image: linear-gradient(45deg, transparent 50%, var(--muted) 50%), linear-gradient(135deg, var(--muted) 50%, transparent 50%); background-position: calc(100% - 20px) 20px, calc(100% - 15px) 20px; background-size: 5px 5px; background-repeat: no-repeat; padding-right: 35px; } select.field { appearance: none; background-image: linear-gradient(45deg, transparent 50%, var(--muted) 50%), linear-gradient(135deg, var(--muted) 50%, transparent 50%); background-position: calc(100% - 20px) 20px, calc(100% - 15px) 20px; background-size: 5px 5px; background-repeat: no-repeat; padding-right: 35px; }
.field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; } .field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
.duration-row { display: flex; gap: 7px; flex-wrap: wrap; margin-top: 9px; } .chip-row { display: flex; gap: 7px; flex-wrap: wrap; margin-top: 9px; }
/* Minute buttons share the column width, so all six fit on one line in a narrow column. */
.chip-row.fill { gap: 6px; }
.chip-row.fill .chip { flex: 1 0 auto; padding-inline: 4px; }
.tag-chip { max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.chip { border: 1px solid var(--line); background: transparent; color: var(--muted); min-height: 34px; padding: 5px 10px; font-size: 11px; } .chip { border: 1px solid var(--line); background: transparent; color: var(--muted); min-height: 34px; padding: 5px 10px; font-size: 11px; }
.chip:hover { border-color: var(--accent); color: var(--accent); } .chip:hover { border-color: var(--accent); color: var(--accent); }
.chip.active { border-color: var(--accent); color: var(--accent-ink); background: var(--accent); } .chip.active { border-color: var(--accent); color: var(--accent-ink); background: var(--accent); }
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, test } from "bun:test";
import { recentTags } from "../src/client/recent-tags.ts";
const at = (minute: number, tag: string, deleted = false) => ({ startMs: minute * 60_000, tag, deleted });
describe("recent tags", () => {
test("gives the 3 different tags from the most recent sessions, newest first", () => {
const records = [at(1, "Read"), at(2, "Study"), at(3, "Gym"), at(4, "Study"), at(5, "Code"), at(6, "Study")];
expect(recentTags(records)).toEqual(["Study", "Code", "Gym"]);
});
test("does not depend on the order of the records", () => {
expect(recentTags([at(9, "Code"), at(1, "Read"), at(5, "Study")])).toEqual(["Code", "Study", "Read"]);
});
test("ignores deleted sessions", () => {
expect(recentTags([at(1, "Read"), at(2, "Study", true)])).toEqual(["Read"]);
});
test("gives fewer tags when there are fewer than 3", () => {
expect(recentTags([at(1, "Study"), at(2, "Study")])).toEqual(["Study"]);
expect(recentTags([])).toEqual([]);
});
});
test("the timer form puts each row of buttons under its field", async () => {
const source = await Bun.file("src/client/main.ts").text();
const form = source.slice(source.indexOf('id="timer-tag"'), source.indexOf('data-action="start"'));
const tagRow = form.indexOf('data-action="pick-tag"');
const durationField = form.indexOf('id="timer-duration"');
const minuteRow = form.indexOf('data-action="duration"');
expect(tagRow).toBeGreaterThan(-1);
expect(tagRow).toBeLessThan(durationField);
expect(minuteRow).toBeGreaterThan(durationField);
});