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:
1 parent
c90c0ddf9d
commit
52e8948d73
4 files changed
+59
-4
No files matched your search
+7
-3
@@ -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 => `<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 weekMax = Math.max(...week.trend, 1);
|
||||
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>
|
||||
${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 & 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><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>
|
||||
<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 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>
|
||||
${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><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">> START SESSION</button>`}
|
||||
</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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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); }
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user