Что делает CRM: следующий шаг с датой
Для кого. Ты прочитал статьи 0–11, и у тебя есть песочница
game-dayпосле статьи 11. Особенно пригодятся остановленные часыClock(статья 1), выборка по времени с границей (статья 9),filterиsorted(статья 11). CRM мог не видеть ни разу — с неё статья и начинается.Что будет. Что такое CRM и почему её центр — не стадия, а задача менеджеру с двумя сроками; что сломается, если сроки перепутать; чего CRM не хранит и почему это правило дела, а не техники.
Откуда статья. Раздел «Буткемп / Лаборатория» — о том, как пишут код команды Hammerhall: буткемп работает в открытых репозиториях, лаборатория — в закрытых. Серия «От системы к делу» — следующий шаг после «От Spring к системе»: как правила дела — сроки, деньги, обещания людям — живут в коде. Примеры — учебные: их можно повторить у себя.
Зачем джуну правила дела#
Две прошлые серии учили строить: запись, бин, таблицу, ручку, тест — на журнале игрового дня. Эта серия — о том, ради чего систему пишут. «Дело» — продажи, деньги, сроки, люди. Правило дела обычно звучит просто: «напомни клиенту за неделю до конца абонемента». Записать его в код можно десятком способов, и часть из них работает только на вид.
На работе джун редко пишет с нуля. Чаще он открывает чужой работающий код и пытается понять, почему тот устроен так, а не иначе. Ответ почти всегда — в деле: какое обещание человеку код держит и что случится, если не сдержит. Серия учит видеть это обещание за строками кода.
Каждая статья серии идёт одной дорогой: правило дела → как оно записано в коде → что сломалось бы иначе → чему это учит вообще. Инструменты появятся там, где без них не прочитать код.
Учебный пример: клуб и его CRM
Пример серии — CRM, система отдела продаж. Она есть почти в каждой компании, которая что-то продаёт, а правила дела в ней на виду: кому позвонить, когда и о чём. Чужие CRM мы не разбираем — у каждой своя история. Берём учебную: спортивный клуб с абонементами, пробными тренировками и группами, куда записываются заранее.
У клуба две программы. Система клуба знает, что
человек купил и как ходит: абонементы, посещения, расписание групп.
CRM ведёт разговор с тем, кто ещё не купил или вот-вот
перестанет платить. Код серии — модель правил на чистой Java в песочнице
game-day, рядом с журналом.
Дорога человека через CRM#
Серия идёт по одной дороге, статьи — остановки на ней:
лид: оставил заявку на пробную тренировку или пришёл сам
→ менеджер ведёт разговор
→ абонемент кончается — напомнить о продлении
→ ходит часто — предложить годовой абонемент или тренера
→ сколько он заплатит
→ придержал место в группе — довести до оплаты
→ оплатил — через месяц снова на дороге: продлениеДальше серия пройдёт её по остановкам: откуда берётся лид и что значат его статусы; сколько человек заплатит — деньги в коде; когда напомнить и что предложить — правила продаж в настройках; бронь места и оплата, о которой CRM никто не сообщил. И два разговора о самом коде: код, который пишут за тебя, и миграции, которые помнят решения. Эта статья — вся дорога разом и то, что держит её вместе: задача менеджеру.
Слово «задача» здесь двоится. У программиста задача — работа: «добавить напоминания о продлении». Задача менеджеру — строка в CRM: «напомни такому-то о продлении, срок — пятница». Дальше в статье «задача» без уточнения — вторая.
Часть 1. Что такое CRM#
CRM в двух словах#
CRM — система, в которой отдел продаж ведёт людей от первого интереса до оплаты: кто пришёл, о чём с ним говорили, что и когда сделать дальше. Название — от Customer Relationship Management, «управление отношениями с клиентами». Готовых CRM десятки — Salesforce, HubSpot, в России чаще amoCRM и Битрикс24. Их берут готовыми и настраивают под себя.
Бывает, что CRM пишут сами: когда продажам нужно видеть рядом с разговором то, чего готовая не покажет, — данные из собственной системы компании. В клубе это, скажем, как часто человек ходит и на какие тренировки. Тогда CRM — ещё одна программа рядом с основной, и правила продаж в её коде пишут программисты.
Три слова, которые встретятся во всей серии:
- лид (lead) — человек, который проявил интерес, но ещё не купил: оставил заявку, позвонил, пришёл на пробную тренировку;
- менеджер — человек из отдела продаж, который ведёт разговор с лидом;
- стадия — где человек на пути: новый, в работе, связались, купил, отказался. Ряд стадий называют воронкой (funnel): на каждом следующем шаге людей меньше. О стадиях лида — в продолжении серии.
Где CRM кончается#
Когда CRM пишут сами, первым делом решают, где её граница. Удобное правило: CRM ведёт человека до оплаты. После оплаты его ведёт система клуба: расписание, посещения, сколько дней осталось у абонемента. CRM помнит разговор, система клуба знает, что человек купил.
Вторая мысль — про форму дороги. В учебнике продаж она кончается сделкой. При помесячной оплате — нет: оплатил абонемент — через месяц снова на дороге, с напоминанием о продлении. Дорога замкнута в круг, и сегодняшний клиент через месяц — снова лид.
Часть 2. Центр CRM — следующий шаг с датой
Стадия говорит «где», действие — «что дальше»
Если из CRM убрать всё, кроме одного, что должно остаться? Ответ, ради которого эта статья: не стадия, а следующее действие с датой и ответственным. Стадия описывает, где человек; следующее действие — единственное, что двигает его дальше. Система, которая показывает стадии без дат, ни о чём не напоминает.
Представь доску лидов по стадиям: сорок карточек в колонке «связались». Кому звонить сегодня? Доска не ответит. Стадия — снимок. А продажа — движение, и двигает её только поступок с датой: «позвонить в четверг», «напомнить о продлении до 12-го».
Стадия отвечает «где человек», действие — «что сделать и когда». Утренний список менеджера строится из действий, а не из стадий.
Как это записать: FollowUp#
Следующее действие в учебной модели — запись FollowUp,
задача менеджеру. Где она живёт в песочнице — в части 3. Строк
import в коротких примерах нет, полные файлы — в конце
статьи.
public record FollowUp(String kind, String who, Instant dueAt, Instant untilAt, boolean done) {
public FollowUp {
if (dueAt == null) { // задача без срока ни о чём не напомнит — такой не бывает
throw new IllegalArgumentException("у задачи нет срока: " + kind + " — " + who);
}
}
}Поля по смыслу:
| поле | что |
|---|---|
kind |
вид задачи — о чём разговор: продление, предложение, бронь |
who |
о ком |
dueAt |
когда менеджеру сделать — срок задачи |
untilAt |
когда станет поздно — срок предмета: абонемента, брони |
done |
закрыта ли |
untilAt может быть null — у предложения
срока нет. dueAt — не может: неверный объект не должен
существовать (статья 0). В базе такой столбец объявили бы
NOT NULL, как detected_at журнала в статье
3а.
Ответственного в записи нет: в учебной модели список один на весь отдел. В рабочей CRM у задачи полей больше — кто ведёт человека, готовый текст для менеджера, когда задачу закрыли. Учебной модели хватит пяти: правило статьи держится на двух сроках.
Кто заводит задачи#
Задачи заводит не менеджер, а сама CRM — по правилам продаж. В учебном клубе их три, и сроки каждое ставит по-своему:
- продление (
RENEWAL) — абонемент кончается, напомнить об оплате.untilAt— конец абонемента, его сообщает система клуба.dueAt— за неделю до него, а если неделя уже прошла, то сейчас. Скажем, CRM узнала об абонементе за три дня до конца: поздно, но молчать нельзя; - предложение (
OFFER) — человек ходит три раза в неделю, пора предложить годовой абонемент; бросил ходить — предложить тренера.dueAt— сейчас, в момент, когда CRM заметила повод.untilAtнет: ничего не сгорает; - бронь (
HOLD) — человек придержал место в группе. Бронь держится сутки, и за это время её надо довести до оплаты.dueAtставится на сейчас,untilAt— когда бронь сгорит.
Как посчитать такую точку напоминания и проверить её на часах — в продолжении серии.
Два срока, которые нельзя путать
⚠️ Два поля одного типа, оба «срок» — и отвечают они на разные вопросы:
dueAt— когда действовать. Его ставит CRM по правилу продаж: за неделю, сразу;untilAt— когда станет поздно. Его сообщает система клуба: абонемент кончится 12-го, бронь сгорит завтра в 11:00.
Абонемент кончается 12 октября — это untilAt. Напомнить
надо 5-го — это dueAt. Между ними неделя, ради которой
правило продления и написано: успеть поговорить до срока, а не
после.
💡 В «Примере целиком» у FollowUp над записью стоит
описание полей. /** … */ — javadoc:
комментарий-описание к классу, полю или методу, из него строят
HTML-документацию. @param описывает один параметр, а у
записи — одно поле. Такое описание пишут, когда поле появляется.
Представь: сначала задачи были только о продлении, и у
untilAt написали «срок абонемента». Потом в то же поле лёг
срок брони, а у предложений его не стало вовсе, но описание осталось
прежним. Комментарий никто не компилирует, и он молча отстаёт от кода.
Поэтому смысл поля проверяй по местам, где его заполняют: все их найдёт
поиск по проекту new FollowUp(, в IDE или через
grep -rn.
Кто закрывает#
Менеджер — сам: позвонил — отметил. Но часть задач CRM закрывает и сама. Бронь оплатили или она сгорела — задача «довести до оплаты» больше не правда, и ждать, пока менеджер это заметит, нельзя.
За этим — общее правило: факт, который держится на том, что человек не забудет его отметить, рано или поздно соврёт. Человек оплатил, а менеджер узнал не сразу — задача висит и врёт. Список, которому не верят, хуже пустого: в него перестают смотреть, и вместе с неправдой пропадает правда. Как CRM узнаёт об оплате, о которой ей никто не сообщил, — в продолжении серии.
Часть 3. Список на сегодня — своя модель
Что горит сегодня#
Утром менеджер открывает список «на сегодня». В CRM с базой это та же
выборка по времени, что openLongerThan журнала в статье 9:
задача не закрыта (done_at is null), и её срок раньше
границы (due_at < ?). Только граница другая: не «сейчас
минус N минут», а начало завтрашнего дня. Просроченные
с прошлых дней тоже горят, и молчать о них нельзя. Граница — тоже
решение (статья 9), и здесь его диктует дело.
Правило отбора проверим своей моделью — на чистой Java, без базы.
Задачи — записи FollowUp в обычном List,
отбирает их stream() из статьи 11.
Модель живёт в песочнице game-day, в пакете
example.crm: папки src/main/java/example/crm и
src/test/java/example/crm. Пакет лежит рядом с корнем
example.gameday, а не внутри него, и сканирование Spring
его не увидит (ловушка из статьи 2). Нам это и нужно: модели Spring не
нужен, а приложение журнала о ней не знает.
TodayList#
public final class TodayList {
private final Clock clock;
public TodayList(Clock clock) {
this.clock = clock;
}
/** Открытые задачи со сроком до конца сегодняшнего дня, просроченные тоже; ранний срок — первым. */
public List<FollowUp> select(List<FollowUp> all) {
Instant tomorrow = LocalDate.now(clock) // какой сегодня день — в поясе часов
.plusDays(1) // завтрашний
.atStartOfDay(clock.getZone()) // его полночь в том же поясе
.toInstant(); // и как момент
return all.stream()
.filter(task -> !task.done()) // закрытые не показываем
.filter(task -> task.dueAt().isBefore(tomorrow)) // срок ЗАДАЧИ, а не предмета
.sorted(Comparator.comparing(FollowUp::dueAt)) // ранний срок — первым
.toList();
}
}Как читать:
- часы приходят в конструкторе, как в статье 1, и тест подставит остановленные;
- «конец сегодняшнего дня».
Instantдней не знает — это точка на оси времени. День бывает только в каком-то поясе: полночь по Москве — 21:00 по UTC накануне.LocalDate— дата без времени и пояса, «5 октября».LocalDate.now(clock)— какое число показывают часы в своём поясе;plusDays(1)— завтра;atStartOfDay(пояс)— полночь этой даты в поясе;toInstant()— снова момент; - пояс у часов свой —
clock.getZone(). В статье 1 это был UTC, и наinstant()он не влиял. Здесь от него зависит, где кончается день, поэтому и момент, и пояс модель берёт из одних часов — тест задаёт оба; Comparator— правило, по которомуsortedрешает, кто из двух раньше.Comparator.comparing(FollowUp::dueAt)сравнивает задачи по ключуdueAt;FollowUp::dueAt— ссылка на метод из статьи 3б.
⚠️ LocalDate.now() без часов — та же ошибка, что
Instant.now(): «сегодня» возьмётся из настоящих часов
машины и её пояса, а не из часов теста. Тест это заметит — первый и
второй покраснеют: для них «сегодня» уже не 5 октября. А сторож из
статьи 11 промолчит: он смотрит только в папку журнала и ищет только
Instant. Хочешь, чтобы он стерёг и модель, — добавь папку и
расширь выражение сам.
Почему не untilAt#
Понедельник, 5 октября, полдень по Москве. Шесть задач:
| задача | dueAt, МСК |
untilAt, МСК |
в списке? |
|---|---|---|---|
| напомнить о продлении | 02.10 12:00 | 09.10 12:00 | да — просрочено, молчать нельзя |
| предложить годовой абонемент | 05.10 02:30 | — | да |
| напомнить о продлении | 05.10 09:00 | 12.10 09:00 | да |
| довести бронь до оплаты | 05.10 11:15 | 06.10 11:00 | да |
| напомнить о продлении | 08.10 03:00 | 15.10 03:00 | нет — через три дня |
| напомнить, уже закрыта | 05.10 09:00 | 12.10 09:00 | нет — сделано |
Теперь замени в отборе dueAt на untilAt:
«предмет кончается до конца сегодняшнего дня». Из четырёх нужных задач в
список не попадёт ни одна:
- у предложения
untilAtнет. Не проверишь наnull— отбор упадёт сNullPointerException; проверишь — предложения не покажутся никогда; - напоминание всплывёт 12-го, в день, когда абонемент кончился. Неделя на разговор, ради которой писали правило продления, пропала;
- бронь всплывёт завтра, за несколько часов до того, как сгорит. А сутки на разговор были с сегодняшнего утра;
- просроченное напоминание — тоже не сегодня: абонемент кончится 9-го.
С проверкой на null — пустой список в полдень
понедельника и ни одной ошибки в логе. Код работает, правило дела —
нет.
Ещё две поломки того же отбора. «Строго сегодня», то есть
dueAt от полуночи до полуночи, теряет просроченное. Задача,
которую вчера не сделали, сегодня важнее, а не наоборот. Без
!task.done() в списке висит сделанное — и список снова
врёт.
Тест#
Полный тест — в «Примере целиком», здесь главное:
// Понедельник, 5 октября 2026, 12:00 по Москве (09:00 по UTC).
private static final Clock NOON =
Clock.fixed(Instant.parse("2026-10-05T09:00:00Z"), ZoneId.of("Europe/Moscow"));
// …
assertThat(shown).containsExactly(overdue, offer, remindToday, hold);ZoneId — пояс по имени из базы поясов:
Europe/Moscow. У Москвы с 2014 года круглый год +03:00, без
перехода на летнее время. Задачи теста — строки таблицы выше, а
containsExactly проверяет и состав, и порядок. Второй тест
— граница дня: задача на 23:59 по Москве в списке, на 00:00 — уже нет.
Третий — задача без dueAt не создаётся.
Проверь, что тест умеет краснеть (статья 11): подмени в отборе
task.dueAt() на task.untilAt(). Первый тест
упадёт на предложении с NullPointerException, второй
покраснеет с Expecting actual: и пустым [] —
список пуст. Верни как было.
💡 Порядок. Наш список идёт по dueAt. Но у брони
dueAt — момент, когда CRM её заметила, а не когда она
сгорит. Посмотри на свой тест: бронь, которая сгорит завтра в 11:00, в
списке последняя — ниже предложения и напоминаний, до сроков которых ещё
четыре и семь дней.
Поэтому список полезно уметь упорядочить и по untilAt —
«что сгорит раньше». Попробуй сам:
Comparator.comparing(FollowUp::untilAt, Comparator.nullsLast(Comparator.naturalOrder()))
поставит бронь первой, а предложение — в конец.
naturalOrder() — обычный порядок моментов, ранний первым;
nullsLast ставит null после всех. База делает
так же без подсказки: Postgres при сортировке по возрастанию ставит
NULL последними.
🔑 Два срока — две работы. dueAt отвечает «пора ли
действовать» — по нему отбирают. untilAt отвечает «что
сгорит раньше» — по нему можно упорядочить, но не отбирать.
Часть 4. Чего CRM не хранит#
Вычислимое — спрашивают у источника
Есть правило, которое отличает аккуратную CRM: она не хранит то, что можно спросить. Сколько раз человек был в клубе за месяц, до какого числа у него абонемент, на какие тренировки он ходит — это знает система клуба. CRM спрашивает её в ту минуту, когда менеджер открывает карточку, показывает ответ и ничего не сохраняет.
Почему так строго? Копия факта начинает устаревать в ту минуту, когда её сняли. Разойдётся она с источником обязательно — вопрос только, кто и когда это заметит.
Пример: в CRM лежала бы ночная копия — «был в клубе три раза за месяц». За утро человек сходил ещё раз и купил годовой абонемент, а менеджер строит разговор на вчерашнем и предлагает то, что уже куплено. База не ошиблась — ошибся человек, который поверил копии.
Перед тем как завести в CRM новое поле или таблицу, полезно задать три вопроса:
- Знает ли ответ система клуба? Знает — спрашивать, а не хранить.
- Это про разговор или про то, что человек купил и как ходит? Разговор ведёт CRM, остальное — у системы клуба.
- Что будет, если два места разойдутся? Ответ «никто не заметит» значит, что хранить нельзя: незаметное расхождение и есть самое опасное.
Переписка — в мессенджере#
Тот же принцип — с перепиской. Разговор с человеком идёт в мессенджере и там остаётся. CRM хранит, как связаться, — мессенджер и ссылку на чат, а свои заметки о разговоре менеджер пишет к лиду. Сами сообщения CRM не копирует: копия переписки — та же вторая правда, да ещё и чужие личные слова в лишнем месте.
А untilAt — не копия?#
Внимательный читатель спросит: untilAt — срок
абонемента, а абонементы CRM не хранит. Противоречие?
Нет — разница в том, о чём факт. untilAt в задаче — не
«до какого числа абонемент сейчас», а «о каком сроке был разговор».
Продлит человек абонемент — у старой задачи untilAt не
переписывают: новый срок — это новое напоминание. Копия состояния
устаревает; запись о разговоре — нет, разговор был именно о том
сроке.
🔑 Что с человеком сейчас — спрашивают у системы клуба. О чём с ним говорили и что обещали — помнит CRM.
Спрашивает сама, по расписанию
Откуда CRM узнаёт, что абонемент кончается через неделю? Система клуба ей об этом не сообщает — CRM спрашивает сама. Ночью — у кого скоро кончается абонемент и кому что предложить. Брони — чаще: бронь живёт сутки, и ночной обход заметил бы её слишком поздно, а то и сгоревшей. Остальное — в момент, когда менеджер открывает карточку.
Почему не ждать, пока система клуба сообщит? Потому что «абонемент кончается через неделю» — не событие. В эту минуту ничего не случается: никто не нажал кнопку, ничего не записалось — сообщать не о чем. Такой факт можно только заметить, посмотрев на календарь, — и смотрит обход по расписанию. Как расписание записывают и почему его выносят в настройки — в продолжении серии.
У выбора «спрашивать самому или ждать сообщений» есть и доводы потоньше — про надёжность доставки между системами. В рабочем проекте их взвешивает мидл.
Проверь себя#
Ответь своими словами — вслух или на бумаге. Не получается — перечитай раздел.
- Чем стадия отличается от следующего действия? Почему в центре CRM — действие, а не стадия?
- У задачи два срока. Что значит каждый и кто его ставит? Объясни на продлении абонемента.
- Отбор «на сегодня» написали по
untilAt. Каких задач менеджер утром не увидит? Объясни на предложении, продлении и брони. - Почему просроченная задача попадает в список «на сегодня»?
- Где в
TodayListкончается «сегодня»? Зачем для этого пояс и откуда он берётся? - Почему задачи по брони CRM закрывает сама, а не ждёт менеджера?
- CRM не хранит, до какого числа у человека абонемент, но хранит
untilAt. Почему это не противоречие? - Описание поля говорит одно, а заполняют его иначе. Как узнать, что поле значит на самом деле?
Что дальше#
Дальше в серии — первая остановка на дороге: лид. Откуда он берётся, что значат его статусы и почему один из них руками поставить нельзя.
Первоисточники#
- Java SE 25: Clock — остановленные часы и пояс, в котором часы показывают дату;
- Java
SE 25: LocalDate —
now(Clock)иatStartOfDay(ZoneId): что бывает с полуночью при переходе на летнее время; - Java
SE 25: Comparator —
comparing,naturalOrderиnullsLast: порядок по ключу и куда детьnull; - PostgreSQL:
Sorting Rows (ORDER BY) — где при сортировке оказываются
NULLи как это поменять.
Пример целиком — с импортами#
Учебный пример. Запускай в песочнице game-day после
статьи 11: журнал в example.gameday.journal со всем, что
накопили статьи 1–10, сторожа статьи 11, Postgres в контейнере, охрана,
Actuator и стенд — всё остаётся, менять ничего не нужно. В
pom.xml ничего не добавляй: модели Spring не нужен, JUnit и
AssertJ — со spring-boot-starter-test.
Что добавить: в src/main/java/example/crm/ —
FollowUp (задача менеджеру) и TodayList (что
показать сегодня), в src/test/java/example/crm/ —
TodayListTest. Пакет example.crm — рядом с
example.gameday, не внутри: сканирование Spring его не
видит, а сторож статьи 11 смотрит только в папку журнала.
Запуск: один класс — треугольником в IDE или
./mvnw test -Dtest=TodayListTest; весь
./mvnw verify гоняет и *IT — нужен запущенный
Docker. Путь к файлу — комментарием в первой строке.
// src/main/java/example/crm/FollowUp.java
package example.crm;
import java.time.Instant;
/**
* Задача менеджеру: что сделать, о ком и к какому сроку. Учебная модель.
*
* @param kind вид: RENEWAL — продление, OFFER — предложение, HOLD — бронь места
* @param who о ком задача
* @param dueAt срок задачи: когда менеджеру сделать
* @param untilAt срок предмета: когда кончится абонемент или сгорит бронь; у предложения его нет — null
* @param done закрыта ли
*/
public record FollowUp(String kind, String who, Instant dueAt, Instant untilAt, boolean done) {
public FollowUp {
if (dueAt == null) { // задача без срока ни о чём не напомнит — такой не бывает
throw new IllegalArgumentException("у задачи нет срока: " + kind + " — " + who);
}
}
}// src/main/java/example/crm/TodayList.java
package example.crm;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.util.Comparator;
import java.util.List;
/**
* Что показать менеджеру сегодня. Учебная модель.
*/
public final class TodayList {
private final Clock clock;
public TodayList(Clock clock) {
this.clock = clock;
}
/** Открытые задачи со сроком до конца сегодняшнего дня, просроченные тоже; ранний срок — первым. */
public List<FollowUp> select(List<FollowUp> all) {
Instant tomorrow = LocalDate.now(clock) // какой сегодня день — в поясе часов
.plusDays(1) // завтрашний
.atStartOfDay(clock.getZone()) // его полночь в том же поясе
.toInstant(); // и как момент
return all.stream()
.filter(task -> !task.done()) // закрытые не показываем
.filter(task -> task.dueAt().isBefore(tomorrow)) // срок ЗАДАЧИ, а не предмета
.sorted(Comparator.comparing(FollowUp::dueAt)) // ранний срок — первым
.toList();
}
}// src/test/java/example/crm/TodayListTest.java
package example.crm;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneId;
import java.util.List;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class TodayListTest {
// Понедельник, 5 октября 2026, 12:00 по Москве (09:00 по UTC).
private static final Clock NOON =
Clock.fixed(Instant.parse("2026-10-05T09:00:00Z"), ZoneId.of("Europe/Moscow"));
private final TodayList today = new TodayList(NOON);
@Test
void showsOpenTasksDueTodayAndOverdue() {
FollowUp overdue = new FollowUp("RENEWAL", "человек-1",
Instant.parse("2026-10-02T09:00:00Z"), Instant.parse("2026-10-09T09:00:00Z"), false);
FollowUp remindToday = new FollowUp("RENEWAL", "человек-2", // по untilAt — только 12.10
Instant.parse("2026-10-05T06:00:00Z"), Instant.parse("2026-10-12T06:00:00Z"), false);
FollowUp remindLater = new FollowUp("RENEWAL", "человек-3",
Instant.parse("2026-10-08T00:00:00Z"), Instant.parse("2026-10-15T00:00:00Z"), false);
FollowUp offer = new FollowUp("OFFER", "человек-4", // по untilAt — никогда
Instant.parse("2026-10-04T23:30:00Z"), null, false);
FollowUp hold = new FollowUp("HOLD", "человек-5", // по untilAt — завтра, поздно
Instant.parse("2026-10-05T08:15:00Z"), Instant.parse("2026-10-06T08:00:00Z"), false);
FollowUp closed = new FollowUp("RENEWAL", "человек-6",
Instant.parse("2026-10-05T06:00:00Z"), Instant.parse("2026-10-12T06:00:00Z"), true);
List<FollowUp> shown = today.select(List.of(closed, hold, remindLater, offer, remindToday, overdue));
assertThat(shown).containsExactly(overdue, offer, remindToday, hold);
}
@Test
void dayEndsAtMidnightInTheClockZone() {
FollowUp lastMinute = new FollowUp("RENEWAL", "человек-7", // 23:59 по Москве
Instant.parse("2026-10-05T20:59:00Z"), Instant.parse("2026-10-12T20:59:00Z"), false);
FollowUp midnight = new FollowUp("RENEWAL", "человек-8", // 00:00 по Москве — уже завтра
Instant.parse("2026-10-05T21:00:00Z"), Instant.parse("2026-10-12T21:00:00Z"), false);
assertThat(today.select(List.of(midnight, lastMinute))).containsExactly(lastMinute);
}
@Test
void taskWithoutDueDateDoesNotExist() {
assertThatThrownBy(() -> new FollowUp("OFFER", "человек-9", null, null, false))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("нет срока");
}
}Порядок проверки — из папки песочницы:
./mvnw test -Dtest=TodayListTest— три теста зелёные;./mvnw verify— зелёный вместе с тестами прошлых статей, у сторожа статьи 11 по-прежнему 43 запуска: в папку журнала модель не попала;- что будет, если:
task.untilAt()вместоtask.dueAt()в отборе — первый тест падает сNullPointerException, второй — сExpecting actual:и[]; та же подмена с проверкой наnull— красные оба, список пуст; без!task.done()— красный первый; «строго сегодня» — красный первый, просроченное пропало; полночь по UTC вместоclock.getZone()— красный только второй;LocalDate.now()без часов — красные первый и второй; без проверки в компактном конструкторе — красный третий; - 💡 из части 3:
nullsLastпоuntilAt— бронь первой, предложение последним.