Я жил в календаре. Встреча, встреча, статус, синк, планирование, разбор, созвон про созвон. В какой то момент я понял, что я не работаю, я просто пересаживаю мозг с темы на тему, а вечером у меня остается ноль сил и ощущение, что я весь день был занят чем то важным, но прод не стал лучше. В этой статье я расскажу, как я технически расковырял проблему встреч, как мы измеряли созвоны, как резали их без боли, какие правила сработали, какие сломались, и как сделать так, чтобы команда не превращалась в выжатый лимон. Будет много прагматики, три больших куска кода, и несколько приемов, которые я до сих пор считаю читерскими.

У тебя тоже бывает такое, что после трех созвонов подряд т�� смотришь в IDE и не понимаешь, кто ты и что тут происходит? Я раньше думал, что это я слабый и не умею держать фокус. Потом заметил, что в дни без встреч я вдруг становлюсь почти гением, а в дни с встречами я максимум могу переименовать переменную и забыть, зачем.

Самое обидное, что встречи сами по себе не зло. Иногда созвон реально экономит неделю переписок и спасает проект от глупой ошибки. Проблема начинается, когда встречи становятся дефолтом. Типа, если непонятно что делать, давай созвонимся. Если кто то переживает, давай созвонимся. Если надо показать прогресс, давай созвонимся. И вот у тебя команда, где календарь решает, кто сегодня продуктивный.

Я не тимлид, я тот самый человек, который сначала ноет, потом идет ковыряться, потом приносит штуку, которая вроде бы странная, но внезапно работает. Так и получилось: я пришел к ребятам не с мотивационной речью, а с цифрами, логами и инструментами.


Я понял, что мы тонем, когда начал считать, а не чувствовать

Первые попытки были максимально наивные. Я просто записывал на бумажке, сколько часов созвонов в день, и почему то каждый раз удивлялся. Но бумажка быстро превратилась в мусор, потому что важно не только сколько часов, а как они размазаны. Два часа одним куском это терпимо. Четыре созвона по 30 минут, рассыпанные между задачами, это минус день. Мозг не любит прыгать.

Я сделал простую метрику: встречная фрагментация. Идея тупая, но полезная: если у тебя больше двух переключений контекста из за календаря за полдня, ты почти гарантированно не сделаешь сложную штуку. Особенно если в созвонах еще надо быть умным, а не просто слушать.

Потом я заметил вторую штуку: созвоны размножаются как кролики. Одна встреча рождает другую. На созвоне обнаружили проблему, назначили еще один. На втором созвоне выяснили, что нужен третий, но уже с другими людьми. И вот уже неделя забита.

Я спросил у ребят, без давления: а вы сами чувствуете, что встречи вам помогают? И большинство ответило что да, помогают, но при этом все жаловались на усталость и на то, что задачи двигаются медленнее. Это такой когнитивный баг: встреча дает ощущение прогресса, потому что ты говорил умные слова. Но код от этого не пишется.

Тогда я решил, что надо сделать встречам такую же судьбу, как логам в проде: сначала собрать данные, потом выбрать SLO, потом настроить алерты, потом резать шум. Да, звучит странно, но это реально тот же паттерн. Мы же не лечим перформанс по ощущениям. Почему мы лечим календарь по ощущениям?


Инструмент номер один: вытащить календарь в данные и перестать спорить на уровне ощущений

Самая боль была в том, что календарь у всех разный, а обсуждать хочется общую картину. Я не стал лезть в чужие календари через API, потому что это сразу запах контроля и недоверия, а мне нужно было наоборот: чтобы люди не напрягались.

Я попросил каждого, кто хотел участвовать, экспортнуть свой календарь за последние 4 недели в ics файл и кинуть мне в личку. Кому не ок, не участвует, без проблем. Набралось достаточно, чтобы увидеть тенденции.

Дальше я написал парсер и анализатор. Не идеальный, но честный. Считал суммарные часы встреч, количество коротких окон для работы, и сколько раз день разрезан кале��дарем.

package main

import (
	"bufio"
	"fmt"
	"os"
	"strconv"
	"strings"
	"time"
)

type Event struct {
	Start time.Time
	End   time.Time
	Sum   string
}

func parseICalTime(v string) (time.Time, error) {
	// Поддержка формата вида 20260115T090000Z и 20260115T090000
	layoutZ := "20060102T150405Z"
	layout := "20060102T150405"
	if strings.HasSuffix(v, "Z") {
		return time.Parse(layoutZ, v)
	}
	return time.Parse(layout, v)
}

func readICS(path string) ([]Event, error) {
	f, err := os.Open(path)
	if err != nil {
		return nil, err
	}
	defer f.Close()

	var events []Event
	var inEvent bool
	var cur Event

	sc := bufio.NewScanner(f)
	for sc.Scan() {
		line := sc.Text()

		// Линии в ics могут переноситься, но я сознательно упрощаю.
		// Если захочешь, допилишь unfolding.
		if line == "BEGIN:VEVENT" {
			inEvent = true
			cur = Event{}
			continue
		}
		if line == "END:VEVENT" {
			inEvent = false
			if !cur.Start.IsZero() && !cur.End.IsZero() {
				events = append(events, cur)
			}
			continue
		}
		if !inEvent {
			continue
		}

		if strings.HasPrefix(line, "DTSTART") {
			parts := strings.SplitN(line, ":", 2)
			if len(parts) == 2 {
				t, e := parseICalTime(strings.TrimSpace(parts[1]))
				if e == nil {
					cur.Start = t
				}
			}
		}

		if strings.HasPrefix(line, "DTEND") {
			parts := strings.SplitN(line, ":", 2)
			if len(parts) == 2 {
				t, e := parseICalTime(strings.TrimSpace(parts[1]))
				if e == nil {
					cur.End = t
				}
			}
		}

		if strings.HasPrefix(line, "SUMMARY") {
			parts := strings.SplitN(line, ":", 2)
			if len(parts) == 2 {
				cur.Sum = strings.TrimSpace(parts[1])
			}
		}
	}
	return events, sc.Err()
}

type DayStats struct {
	MeetingMinutes int
	MeetingsCount  int
	Cuts           int
	LongestFocus   int
}

func sameDay(a, b time.Time) bool {
	ay, am, ad := a.Date()
	by, bm, bd := b.Date()
	return ay == by && am == bm && ad == bd
}

func clampToDay(t time.Time) time.Time {
	y, m, d := t.Date()
	return time.Date(y, m, d, 0, 0, 0, 0, t.Location())
}

func minutesBetween(a, b time.Time) int {
	if b.Before(a) {
		return 0
	}
	return int(b.Sub(a).Minutes())
}

func analyze(events []Event, workStart, workEnd int) map[string]DayStats {
	stats := map[string]DayStats{}

	// Считаем по дням, игнорим алл дей события.
	for _, e := range events {
		if e.End.Sub(e.Start) >= 23*time.Hour {
			continue
		}

		day := clampToDay(e.Start)
		key := day.Format("2006-01-02")

		s := stats[key]
		s.MeetingsCount++

		// Обрезаем встречу рамками рабочего дня
		ws := time.Date(day.Year(), day.Month(), day.Day(), workStart, 0, 0, 0, day.Location())
		we := time.Date(day.Year(), day.Month(), day.Day(), workEnd, 0, 0, 0, day.Location())

		start := e.Start
		end := e.End
		if start.Before(ws) {
			start = ws
		}
		if end.After(we) {
			end = we
		}
		s.MeetingMinutes += minutesBetween(start, end)

		stats[key] = s
	}

	// Фрагментация: сколько раз календарь режет день.
	// И еще прикидываем самый длинный фокус блок.
	for dayKey := range stats {
		day, _ := time.Parse("2006-01-02", dayKey)
		ws := time.Date(day.Year(), day.Month(), day.Day(), workStart, 0, 0, 0, time.Local)
		we := time.Date(day.Year(), day.Month(), day.Day(), workEnd, 0, 0, 0, time.Local)

		var dayEvents []Event
		for _, e := range events {
			if sameDay(e.Start, day) {
				dayEvents = append(dayEvents, e)
			}
		}

		// Сортировка простая, пузырек, потому что мне было лень
		for i := 0; i < len(dayEvents); i++ {
			for j := i + 1; j < len(dayEvents); j++ {
				if dayEvents[j].Start.Before(dayEvents[i].Start) {
					dayEvents[i], dayEvents[j] = dayEvents[j], dayEvents[i]
				}
			}
		}

		s := stats[dayKey]
		prev := ws
		longest := 0
		cuts := 0

		for _, e := range dayEvents {
			start := e.Start
			end := e.End
			if end.Before(ws) || start.After(we) {
				continue
			}
			if start.Before(ws) {
				start = ws
			}
			if end.After(we) {
				end = we
			}

			gap := minutesBetween(prev, start)
			if gap > longest {
				longest = gap
			}
			if start.After(prev) {
				// Есть разрез между кусками
				if !prev.Equal(ws) {
					cuts++
				}
			}
			if end.After(prev) {
				prev = end
			}
		}

		gap := minutesBetween(prev, we)
		if gap > longest {
			longest = gap
		}

		s.Cuts = cuts
		s.LongestFocus = longest
		stats[dayKey] = s
	}

	return stats
}

func main() {
	if len(os.Args) < 2 {
		fmt.Println("usage: meetstat file.ics [workStart] [workEnd]")
		return
	}

	workStart := 10
	workEnd := 19
	if len(os.Args) >= 4 {
		a, _ := strconv.Atoi(os.Args[2])
		b, _ := strconv.Atoi(os.Args[3])
		if a > 0 && b > a {
			workStart = a
			workEnd = b
		}
	}

	events, err := readICS(os.Args[1])
	if err != nil {
		panic(err)
	}

	stats := analyze(events, workStart, workEnd)
	for day, s := range stats {
		fmt.Printf("%s meetings=%d minutes=%d cuts=%d longest_focus=%d\n",
			day, s.MeetingsCount, s.MeetingMinutes, s.Cuts, s.LongestFocus)
	}
}

Главный эффект от этой штуки был не в цифрах, а в том, что спорить стало не о чем. Раньше было так: мне кажется встреч много. Мне кажется нормально. А теперь было: у нас три дня подряд самый длинный фокус блок меньше 40 минут. Ты серьезно думаешь, что на таком можно писать сложную часть системы?

Кстати, вопрос: если бы ты увидел, что твой longest focus блок в среднем 35 минут, ты бы продолжил верить, что проблема в твоей дисциплине?


Мы сделали правила, но не из воздуха: встречи как протокол, а не как традиция

С цифрами на руках мы не пошли рубить с плеча. Мы составили список типов встреч и спросили себя максимально приземленно: какая у этой встречи функция и чем она измеряется.

Вот что реально зашло.

Первое. Любая встреча должна иметь артефакт до созвона. Не после, а до. Я называю это входной билет. Минимум: цель, повестка, что хотим решить, какие варианты уже рассматривали. Если входного билета нет, встреча почти всегда превращается в разговор ради разговора. И да, я сам на этом ловился, особенно когда хотелось просто поговорить, потому что страшно принимать решение.

Второе. Асинхронный статус по умолчанию. Мы вынесли статусы в короткий текстовый отчет в чате: вчера, сегодня, блокеры. Важно: не превращать это в бюрократию. Я делал это одним сообщением, без красивостей. И у нас случилось чудо: ежедневный созвон стал не нужен. Он остался только в те дни, когда реально была тема, которую нельзя решить перепиской.

Третье. Встречи сжимаются в пачки. Мы договорились: если нужны созвоны, ставим их рядом, чтобы не резать день на куски. У кого то это было утро, у кого то после обеда. Смешно, но это самый быстрый способ почувствовать облегчение. Меньше контекстных переключений, больше длинных кусков работы.

Четвертое. У каждой встречи есть владелец, и он отвечает за результат. Не ведущий, а именно владелец результата. Он же делает итоговый артефакт: решение, список задач, или четкое понимание что мы не решили и почему.

Пятое. Право на выход. Если ты понял, что тебе нечего делать на встрече, ты можешь уйти. Это звучит как наглость, но оно работает, если ты уходишь вежливо и оставляешь след: что я услышал, чем могу помочь, и что мне дальше не требуется присутствовать. У нас сначала было неловко, потом стало нормально.

Самое важное: мы не пытались сделать идеальную систему. Мы сделали систему, которая переживает плохие дни. Потому что в плохой день у тебя нет сил быть дисциплинированным. Система должна работать, когда ты уставший.


Автоматизация, чтобы правила не держались на моей памяти и чувстве вины

Одна проблема с правилами встреч такая: все кивают, неделю держатся, потом начинается сезон релизов или просто усталость, и встречи снова растут. Мне хотелось сделать так, чтобы календарная гигиена была как линтер: не запрещает думать, но не дает скатиться в хаос.

Я сделал простой пайплайн: собирать логи встреч, складывать их в базу, строить дашборд и иногда пинать команду мягкими напоминаниями. Не публично, не с шеймингом, а типа: ребят, у нас снова longest focus блоки упали, давайте посмотрим, какие встречи можно ужать.

Сначала я вел данные просто в csv, но быстро переехал в PostgreSQL, потому что мне хотелось нормальные запросы. Схема была примитивная: дата, длительность, команда, тип встречи, обязательная или нет, и флажок про наличие входного билета. Тип встречи мы выбирали вручную, да, это субъективно, но иначе никак.

-- PostgreSQL
-- Моя таблица логов встреч
create table if not exists meeting_log (
	id bigserial primary key,
	day date not null,
	start_ts timestamp not null,
	end_ts timestamp not null,
	team text not null,
	meeting_type text not null,
	has_prep boolean not null default false,
	is_required boolean not null default false
);

-- Вьюха для ежедневных метрик по командам
create or replace view meeting_daily_stats as
select
	day,
	team,
	count(*) as meetings_count,
	sum(extract(epoch from (end_ts - start_ts)) / 60)::int as meeting_minutes,
	sum(case when has_prep then 1 else 0 end) as meetings_with_prep,
	sum(case when is_required then 1 else 0 end) as required_meetings
from meeting_log
group by day, team;

-- Самая токсичная штука: это встречи, которые делят день на мелкие кускочик.
-- Прикидываем количество переключений: так если между встречами проходит меньше 45 минут, считаем это плохим окном.
with ordered as (
	select
		team,
		day,
		start_ts,
		end_ts,
		lag(end_ts) over (partition by team, day order by start_ts) as prev_end
	from meeting_log
),
gaps as (
	select
		team,
		day,
		case
			when prev_end is null then null
			else extract(epoch from (start_ts - prev_end)) / 60
		end as gap_minutes
	from ordered
)
select
	team,
	day,
	count(*) filter (where gap_minutes is not null and gap_minutes < 45) as bad_gaps_count
from gaps
group by team, day
order by day desc, bad_gaps_count desc;

-- Быстрый срез: какие типы встреч дают больше всего минут без подготовки
select
	meeting_type,
	count(*) as c,
	sum(extract(epoch from (end_ts - start_ts)) / 60)::int as minutes
from meeting_log
where has_prep = false
group by meeting_type
order by minutes desc, c desc;

Когда мы увидели, что один конкретный тип встречи стабильно дает много минут без подготовки, было даже смешно. Потому что это была встреча в стиле давайте синкнемся. Мы заменили ее на короткий асинхронный тред по шаблону. И внезапно ничего не сломалось.

Дальше я пошел еще немного в сторону автоматизации. Мне хотелось быстро конвертить выгрузки в базу. Я сделал тупой, но рабочий Bash скрипт: берет вывод анализатора, превращает в insert и заливает в Postgres. Да, не красиво, но это был мой студенческий вайб: работает, значит ок.

#!/usr/bin/env bash
set -euo pipefail

# Зависимости: psql, date, awk
# Формат входа: CSV без кавычек
# day,start_ts,end_ts,team,meeting_type,has_prep,is_required
# Пример строки:
# 2026-01-15,2026-01-15T11:00:00,2026-01-15T11:30:00,core,planning,1,1

DB_URL=${DB_URL:-postgres://localhost:5432/meetings}

if [[ $# -lt 1 ]]; then
  echo usage: load_meetings.sh input.csv
  exit 1
fi

INPUT=$1

tmp=$(mktemp)
trap "rm -f $tmp" EXIT

awk -F, '
BEGIN {
  print "begin;";
}
{
  day=$1
  start=$2
  end=$3
  team=$4
  type=$5
  prep=$6
  req=$7

  gsub(/\r/,"",req)

  # Булевы значения приводим
  if (prep == 1) prep="true"; else prep="false";
  if (req == 1) req="true"; else req="false";

  printf "insert into meeting_log(day,start_ts,end_ts,team,meeting_type,has_prep,is_required) values (%s,%s,%s,%s,%s,%s,%s);\n",
    "date '\''" day "'\''",
    "timestamp '\''" start "'\''",
    "timestamp '\''" end "'\''",
    "'\''" team "'\''",
    "'\''" type "'\''",
    prep,
    req
}
END {
  print "commit;";
}
' "$INPUT" > "$tmp"

psql "$DB_URL" -v ON_ERROR_STOP=1 -f "$tmp"
echo loaded

Да, тут есть штуки типа экранирования, и я знаю, что это не идеал. Но мне нужен был быстрый путь от данных к обсуждению. Мы не делали продукт, мы лечили календарь.

А у тебя в команде что больше ломает работу: длинные созвоны, или короткие, но частые?


Самое сложное: сохранить качество обсуждений, когда встреч стало меньше

Тут был мой главный страх. Я боялся, что мы уменьшим встречи, но потеряем синхронизацию, начнем делать разное, и потом будем разгребать конфликт решений. И да, один раз так и случилось.

Мы сократили встречи по архитектуре, потому что они были тяжелые. И на второй неделе два человека параллельно сделали разные варианты одной и той же части, потому что никто не проговорил контракт. Было неприятно, но это прям важный урок: созвоны не надо просто резать. Их нужно заменять механизмом согласования.

Что сработало у нас.

Дизайн документ вместо обсуждения на встрече. Не академический, а максимально бытовой. Полстраницы: контекст, проблема, варианты, выбранный вариант, риски, миграция. Важно: он должен быть читабельный, а не как диплом. И его можно комментировать асинхронно. Созвон нужен только если в комментах возник тупик.

Decision log. Я сначала смеялся над этим, а потом втянулся. Просто файл или страница, куда мы коротко пишем решения. Почему выбрали так, а не иначе. Через месяц это спасает от бесконечных повторных обсуждений, потому что память у команды не резиновая.

Окна для синка. Мы оставили один стабильный общий созвон в неделю на 30 минут, но он не про статус. Он про изменения контекста: что изменилось в продукте, что изменилось в рисках, что требует внимания. Это как раз тот случай, когда встреча полезна: общий слой реальности обновляется быстро.

И еще мы сделали маленькую штуку: правило двух сообщений. Если обсуждение в чате превратилось в пинг понг и ты видишь, что дальше будет хуже, ты предлагаешь либо краткий созвон на 10 минут, либо формулируешь вопрос так, чтобы можно было ответить одним сообщением. Смысл в том, что хаотичный чат тоже токсичен, просто по другому.

Качество мы сохранили не за счет того, что стали молчать. Мы стали писать. И да, это больно. Писать сложнее, чем говорить. Но писать масштабируется.


Что я бы сделал иначе, если бы отмотал время назад

Во первых, я бы не начинал с правил. Я бы начинал с боли команды и с измерений. Когда люди видят, что проблема реальная, они сами начинают предлагать решения. Когда им просто говорят сократим встречи, они чувствуют, что у них отнимают безопасность.

Во вторых, я бы сразу договорился о двух вещах: какие встречи мы оставляем священными, и какие режем первыми. У нас сначала было ощущение, что мы под нож пускаем все. Это пугало. Потом мы сделали белый список: инциденты, сложные решения, ретро. И черный список: статус ради статуса, синк без повестки, встреча просто потому что так принято.

В третьих, я бы не пытался быть полицией календаря. Это прям плохая роль. Моя задача была дать инструмент и подсветить тренды, а не ходить и говорить кому то отменяй встречу. Как только кто то чувствует контроль, он начинает защищаться, и вся идея умирает.

В четвертых, я бы уважал социальную часть. Иногда люди хотят созвон просто чтобы не чувствовать себя одиноко в удаленке. И это нормально. Просто не надо маскировать это под срочную синхронизацию. Мы начали делать отдельный короткий нерабочий созвон раз в неделю, без обязательств. И после этого стало легче резать мусорные рабочие встречи, потому что потребность пообщаться не исчезла, она просто получила нормальный канал.

И последнее. Я бы раньше понял, что цель не в том, чтобы встреч стало мало. Цель в том, чтобы у людей появился фокус и энергия. Встречи это просто один из рычагов. Если у вас токсичные дедлайны, кривые процессы и вечные пожары, вы можете отменить половину созвонов и все равно будете выжатые.