From 1b757a8d67b2bc4dc09efcb2676d1ec05b9fefe0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Mon, 22 Jun 2026 20:54:28 +0300 Subject: [PATCH] =?UTF-8?q?feat(projects):=20leadDate=20util=20+=20source-?= =?UTF-8?q?lock=20=D0=BF=D0=BE=D0=BB=D1=8F=20=D0=B2=20=D1=82=D0=B8=D0=BF?= =?UTF-8?q?=D0=B5=20Project?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- app/resources/js/stores/projectsStore.ts | 4 ++++ app/resources/js/utils/leadDate.ts | 24 ++++++++++++++++++++++++ app/tests/Frontend/leadDate.spec.ts | 24 ++++++++++++++++++++++++ 3 files changed, 52 insertions(+) create mode 100644 app/resources/js/utils/leadDate.ts create mode 100644 app/tests/Frontend/leadDate.spec.ts diff --git a/app/resources/js/stores/projectsStore.ts b/app/resources/js/stores/projectsStore.ts index 462597fe..50416322 100644 --- a/app/resources/js/stores/projectsStore.ts +++ b/app/resources/js/stores/projectsStore.ts @@ -19,6 +19,10 @@ export interface Project { delivery_days_mask?: number; sync_status: 'ok' | 'pending' | 'failed'; last_synced_at?: string | null; + // Блокировка смены источника (спека 2026-06-22-project-source-edit-lock-ux). + source_locked?: boolean; + source_unlock_at?: string | null; + source_unlock_projected?: boolean; } export const useProjectsStore = defineStore('projects', () => { diff --git a/app/resources/js/utils/leadDate.ts b/app/resources/js/utils/leadDate.ts new file mode 100644 index 00000000..7bef68f0 --- /dev/null +++ b/app/resources/js/utils/leadDate.ts @@ -0,0 +1,24 @@ +/** + * Форматирование дат для UI блокировки источника и баннера нового проекта. + * Спека: docs/superpowers/specs/2026-06-22-project-source-edit-lock-ux-design.md. + */ + +/** ISO-строку → «23 июня» (ru). Пусто/невалид → ''. */ +export function formatLeadDate(iso: string | null | undefined): string { + if (!iso) return ''; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ''; + return new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'long' }).format(d); +} + +/** + * Дата старта лидов нового проекта по правилу слепка 18:00 МСК: + * создал до 18:00 МСК → лиды с завтра; после 18:00 → с послезавтра. + * РФ круглый год UTC+3 (без перехода на летнее время). + */ +export function firstLeadDate(now: Date = new Date()): string { + const msk = new Date(now.getTime() + 3 * 3600 * 1000); + const addDays = msk.getUTCHours() >= 18 ? 2 : 1; + const target = new Date(Date.UTC(msk.getUTCFullYear(), msk.getUTCMonth(), msk.getUTCDate() + addDays)); + return new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'long', timeZone: 'UTC' }).format(target); +} diff --git a/app/tests/Frontend/leadDate.spec.ts b/app/tests/Frontend/leadDate.spec.ts new file mode 100644 index 00000000..3f8878ac --- /dev/null +++ b/app/tests/Frontend/leadDate.spec.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest'; +import { formatLeadDate, firstLeadDate } from '../../resources/js/utils/leadDate'; + +describe('formatLeadDate', () => { + it('formats ISO to "D MMMM" in Russian', () => { + expect(formatLeadDate('2026-06-23T21:00:00+03:00')).toBe('23 июня'); + }); + it('returns empty string for null/invalid', () => { + expect(formatLeadDate(null)).toBe(''); + expect(formatLeadDate('')).toBe(''); + expect(formatLeadDate('not-a-date')).toBe(''); + }); +}); + +describe('firstLeadDate (порог 18:00 МСК)', () => { + it('до 18:00 МСК → завтра', () => { + // 2026-06-22 12:00 UTC = 15:00 МСК → завтра 23 июня + expect(firstLeadDate(new Date('2026-06-22T12:00:00Z'))).toBe('23 июня'); + }); + it('после 18:00 МСК → послезавтра', () => { + // 2026-06-22 16:00 UTC = 19:00 МСК → послезавтра 24 июня + expect(firstLeadDate(new Date('2026-06-22T16:00:00Z'))).toBe('24 июня'); + }); +});