From 77a36a22d98739b56e00dabebca6f64e4ba68bfc Mon Sep 17 00:00:00 2001 From: aaverbitskiy Date: Mon, 17 Aug 2026 08:14:45 +0000 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=B0=D1=82=D0=B0=D0=BF=D0=B8=D0=BA?= =?UTF-8?q?=D0=B5=D1=80:=20=D0=BE=D1=82=D0=BA=D1=80=D1=8B=D1=82=D1=8B?= =?UTF-8?q?=D0=B9=20=D0=B4=D0=B8=D0=B0=D0=BF=D0=B0=D0=B7=D0=BE=D0=BD=20?= =?UTF-8?q?=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20=D0=BF=D0=B8=D0=BB=D1=8E=D0=BB?= =?UTF-8?q?=D0=B8-=D0=BA=D0=BE=D0=BD=D1=86=D1=8B=20=C2=AB=D0=A1=C2=BB=20/?= =?UTF-8?q?=20=C2=AB=D0=9F=D0=BE=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Над календарём — две пилюли-конца в стиле чипсов фильтров (accent-weak заливка, индиго-текст, скруглённые). Активная пилюля (куда пишется следующий клик) с индиго-рамкой; клик по дню пишет в активный конец, фокус авто-переходит ко второму. У каждой пилюли × — очистить именно этот конец, что даёт ОТКРЫТЫЙ диапазон: «с X» (без конца) или «по Y» (без начала). «Применить» активна, если задан хотя бы один конец. Нативные #date-start/#date-end по-прежнему источник истины, любой из них может быть пустым; фильтр и URL уже читают from/to по отдельности. Co-Authored-By: Claude Opus 4.8 --- frontend/package.json | 2 +- frontend/src/datepicker.ts | 66 +++++++++++++++++++++++++++++--------- frontend/src/styles.css | 12 +++++++ 3 files changed, 64 insertions(+), 16 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 18debd4..15bbfa9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mapdash-frontend", "private": true, - "version": "0.2.15", + "version": "0.2.16", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/datepicker.ts b/frontend/src/datepicker.ts index a2b8467..cd57f18 100644 --- a/frontend/src/datepicker.ts +++ b/frontend/src/datepicker.ts @@ -2,8 +2,12 @@ // #date-end) stay in the DOM (hidden) as the source of truth — all filter code // keeps reading/writing them. The calendar selects into a temporary state; the // filter is applied (and the popup closed) only when the user presses «Применить». -// Both endpoints are chosen by clicking days; «Сбросить» clears the in-progress -// selection. +// +// Two endpoint pills «С» / «По» sit above the grid: one is active, and clicking a +// day writes into the active endpoint, then focus auto-advances to the other. Each +// pill has a × to clear just that endpoint, so an OPEN range is possible — «с X» +// (no end) or «по Y» (no start). «Применить» is enabled once at least one endpoint +// is set; «Сбросить» clears both. const MONTHS = ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь']; const WEEKDAYS = ['пн', 'вт', 'ср', 'чт', 'пт', 'сб', 'вс']; @@ -35,9 +39,11 @@ export function createDateRange(): DateRange { let viewY = new Date().getFullYear(); let viewM = new Date().getMonth(); - // In-progress selection (committed to the inputs only when both ends are set). + // In-progress selection (committed to the inputs on «Применить»). Either end may + // stay empty → an open range. `activeEnd` is the endpoint the next day-click fills. let selStart = ''; let selEnd = ''; + let activeEnd: 'start' | 'end' = 'start'; function updateText(): void { const s = startInput.value; @@ -53,7 +59,14 @@ export function createDateRange(): DateRange { function render(): void { const s = parseISO(selStart); const e = parseISO(selEnd); + const endPill = (which: 'start' | 'end', label: string, val: string): string => + ``; const parts: string[] = [ + `
${endPill('start', 'С', selStart)}${endPill('end', 'По', selEnd)}
`, `
` + `${MONTHS[viewM]} ${viewY}` + `
`, @@ -73,7 +86,7 @@ export function createDateRange(): DateRange { parts.push(``); } parts.push(''); - const ready = !!(selStart && selEnd); + const ready = !!(selStart || selEnd); // at least one endpoint → applyable (open range OK) parts.push( '
' + `` + @@ -93,16 +106,15 @@ export function createDateRange(): DateRange { } function pick(iso: string): void { - if (!selStart || (selStart && selEnd)) { - // start a fresh selection — first endpoint only + if (activeEnd === 'start') { selStart = iso; - selEnd = ''; - } else if (iso >= selStart) { - // second endpoint — complete the range, but wait for «Применить» - selEnd = iso; + // Крест начала за концом — конец больше не валиден, очищаем его. + if (selEnd && selStart > selEnd) selEnd = ''; + if (!selEnd) activeEnd = 'end'; // авто-переход к незаданному концу } else { - // clicked before the start — move the start (still one endpoint) - selStart = iso; + selEnd = iso; + if (selStart && selEnd < selStart) selStart = ''; + if (!selStart) activeEnd = 'start'; } render(); } @@ -110,7 +122,9 @@ export function createDateRange(): DateRange { function open(): void { selStart = startInput.value; selEnd = endInput.value; - const base = parseISO(selStart) || new Date(); + // Start filling whichever endpoint is empty (start first). + activeEnd = !selStart ? 'start' : !selEnd ? 'end' : 'start'; + const base = parseISO(selStart) || parseISO(selEnd) || new Date(); viewY = base.getFullYear(); viewM = base.getMonth(); render(); @@ -154,14 +168,31 @@ export function createDateRange(): DateRange { const act = target.closest('[data-act]'); if (act) { if (act.dataset.act === 'apply') { - if (selStart && selEnd) { applyRange(); close(); } + if (selStart || selEnd) { applyRange(); close(); } } else if (act.dataset.act === 'reset') { selStart = ''; selEnd = ''; + activeEnd = 'start'; render(); } return; } + // Clear one endpoint via its × (→ open range on that side). Checked before the + // pill activate below, since the × lives inside the pill button. + const clr = target.closest('[data-clear]'); + if (clr) { + const which = clr.dataset.clear as 'start' | 'end'; + if (which === 'start') selStart = ''; else selEnd = ''; + activeEnd = which; // focus the emptied end for the next pick + render(); + return; + } + const endBtn = target.closest('[data-end]'); + if (endBtn) { + activeEnd = endBtn.dataset.end as 'start' | 'end'; + render(); + return; + } const day = target.closest('.dr-day'); if (day && day.dataset.iso) pick(day.dataset.iso); }); @@ -173,7 +204,12 @@ export function createDateRange(): DateRange { return { sync(): void { updateText(); - if (!cal.hidden) { selStart = startInput.value; selEnd = endInput.value; render(); } + if (!cal.hidden) { + selStart = startInput.value; + selEnd = endInput.value; + activeEnd = !selStart ? 'start' : !selEnd ? 'end' : 'start'; + render(); + } }, }; } diff --git a/frontend/src/styles.css b/frontend/src/styles.css index a31b2e8..a687458 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -144,6 +144,18 @@ header { box-shadow: var(--shadow-md); padding: 10px; font-variant-numeric: tabular-nums; } .dr-cal[hidden] { display: none; } +/* Endpoint pills «С» / «По» above the calendar — styled like the active-filter + chips (accent-weak fill, accent text, pill shape); the active one (next click + target) gets an accent border; × clears just that endpoint. */ +.dr-ends { display: flex; gap: 8px; margin-bottom: 10px; } +.dr-end { flex: 1; min-width: 0; display: inline-flex; align-items: center; gap: 5px; padding: 4px 5px 4px 10px; border: 1px solid transparent; border-radius: 999px; background: var(--accent-weak); color: var(--accent); font-size: 12px; font-weight: 500; cursor: pointer; text-align: left; } +.dr-end:hover { border-color: var(--accent-ring); } +.dr-end.active { border-color: var(--accent); } +.dr-end-lbl { flex: 0 0 auto; opacity: .65; } +.dr-end-val { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; } +.dr-end:not(.set) .dr-end-val { opacity: .55; } +.dr-end-x { flex: 0 0 auto; width: 18px; height: 18px; display: inline-flex; align-items: center; justify-content: center; border-radius: 50%; color: inherit; font-size: 15px; line-height: 1; } +.dr-end-x:hover { background: rgba(79, 70, 229, .18); } .dr-cal-h { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; } .dr-cal-title { font-size: 13px; font-weight: 600; color: var(--text); } .dr-nav { border: none; background: none; color: var(--text-muted); cursor: pointer; font-size: 18px; line-height: 1; width: 26px; height: 26px; border-radius: var(--radius-sm); }