diff --git a/src/client/main.ts b/src/client/main.ts index 2689d2f..83684a3 100644 --- a/src/client/main.ts +++ b/src/client/main.ts @@ -6,6 +6,7 @@ import { dueMs, recordEnd, type TimeRecord } from "../shared/record.ts"; import { normalizeTag } from "../shared/tag.ts"; import { countdown, pad, pageTitle } from "./title.ts"; import { readyToReload } from "./update.ts"; +import { recentTags } from "./recent-tags.ts"; import { THEMES, THEME_COLORS, isTheme, type Theme } from "./theme.ts"; type View = "timer" | "log" | "insights" | "settings"; @@ -144,6 +145,7 @@ function timerPage() { const completedToday = completed.length; const week = totals(records, rangeWindow("week")); const tagOptions = tags().map(tag => ``).join(""); + const recent = recentTags(records); const latest = completed.sort((a, b) => b.startMs - a.startMs).slice(0, 6); const weekMax = Math.max(...week.trend, 1); return `
@@ -155,9 +157,10 @@ function timerPage() {
${active ? `
TAG
${h(active.tag)}
DURATION
${duration(active.targetSeconds! * 1000)}
` : ` -
-
-
${[15, 25, 30, 45, 60, 120].map(n => ``).join("")}
+
+ ${recent.length ? `
${recent.map(tag => ``).join("")}
` : ""}
+
+
${[15, 25, 30, 45, 60, 120].map(n => ``).join("")}
`}
// TODAY${h(shortDate(todayWindow.start).toUpperCase())}
@@ -422,6 +425,7 @@ async function dispatch(action: string, element: HTMLElement) { switch (action) { case "nav": view = element.dataset.view as View; editingId = null; 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 "stop": await stopSession(); break; case "prev-log": logMonthOffset--; render(); break; diff --git a/src/client/recent-tags.ts b/src/client/recent-tags.ts new file mode 100644 index 0000000..681d0cd --- /dev/null +++ b/src/client/recent-tags.ts @@ -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; +} diff --git a/src/client/styles.css b/src/client/styles.css index f29d9de..b534948 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -120,7 +120,11 @@ h2 { font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacin .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; } .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:hover { border-color: var(--accent); color: var(--accent); } .chip.active { border-color: var(--accent); color: var(--accent-ink); background: var(--accent); } diff --git a/tests/recent-tags.test.ts b/tests/recent-tags.test.ts new file mode 100644 index 0000000..bc28532 --- /dev/null +++ b/tests/recent-tags.test.ts @@ -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); +});