Skip to content
Merged
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ ESM-пакет, Node 22+. SDK — `@modelcontextprotocol/sdk` (`McpServer` + `St

**Эндпоинты, возвращающие `UpdateTaskResponse`.** `PATCH /tasks/:id` и `PATCH /tasks/:id/pomodoro` отдают не задачу, а обёртку `{ task, nextInstance?, deletedInstanceId? }` — из-за повторяющихся задач, где завершение порождает следующий инстанс. Потребители должны читать `body.task`, а не `body`. На фронте разбор один — `applyUpdateResponse` в `features/tasks/model/task-store.ts`.

**`Task.isOverdue` — это «заморожена», а не «просрочена по дате».** Флаг ставит ночная джоба только повторяющимся задачам с `onMissed: 'freeze'`: инстанс помечается `isOverdue=true`, теряет `recurrence` и становится **иммутабельным** — `task.service.ts` кидает `BadRequestException` на любой PATCH, доступно только удаление. Обычная разовая задача со вчерашним `dueDate` имеет `isOverdue=false` и спокойно редактируется. Поэтому «просроченные» в UI-смысле — это `!completed && dueDate < начало сегодня`, и вычислять их через `isOverdue` нельзя. Единственный источник этого предиката на фронте — `features/tasks/lib/today.ts` (`splitTodayBuckets`), см. `docs/tasks/overdue-recurring-tasks.md`.

# Frontend architecture (FSD-like)

`alfy-bot-frontend/src/` устроен как feature-sliced:
Expand All @@ -109,7 +111,9 @@ ESM-пакет, Node 22+. SDK — `@modelcontextprotocol/sdk` (`McpServer` + `St

Авторизация: при старте `main.ts` пробует `authorize()` если есть `Telegram.WebApp.initData` или dev-флаг `VITE_DEV_TELEGRAM_ID`. Router guard в `router/index.ts` редиректит на `/login` любой не-public маршрут при отсутствии токена. Public-маршруты помечены `meta: { public: true }`.

**Секционный блок сайдбара — один компонент на секцию.** `sectionExtraRegistry` в `components/AppLayout.vue` отображает `meta.sectionNav` в **ровно один** компонент (`tasks` → `TasksSidebarSection.vue`). Чтобы добавить в сайдбар ещё один блок, композиция делается внутри секционного компонента, а не расширением реестра до массива. `AppSidebar.vue` рендерит слот `section-extra` **дважды** — в десктопном `<aside>` и в мобильной панели внутри `<Teleport>`; оба живут в DOM одновременно, поэтому секционный компонент монтируется два раза, и всё, что он заводит (интервалы, подписки), существует в двух экземплярах.
**Секционный блок сайдбара — один компонент на секцию.** `sectionExtraRegistry` в `components/AppLayout.vue` отображает `meta.sectionNav` в **ровно один** компонент (`tasks` → `TasksSidebarSection.vue`). Чтобы добавить в сайдбар ещё один блок, композиция делается внутри секционного компонента, а не расширением реестра до массива. `AppSidebar.vue` рендерит **и `SidebarNav`, и слот `section-extra` дважды** — в десктопном `<aside>` и в мобильной панели внутри `<Teleport>`; оба живут в DOM одновременно, поэтому эти компоненты монтируются по два раза, и всё, что они заводят (интервалы, подписки), существует в двух экземплярах.

**Счётчик пункта сайдбара — тунк, а не число.** `NavLink.count` имеет тип `(now: Date) => number`, потому что массивы ссылок (`router/*-nav.ts`) собираются на уровне модуля, до подъёма Pinia: вызов `useTaskStore()` там упадёт. Стор трогаем только внутри тунка, который `SidebarNav` разворачивает уже в setup-контексте. `now` идёт аргументом, иначе счётчик застрянет на вчерашнем дне после полуночи. Регрессия — `tests/router/tasks-nav.spec.ts`.

**`VITE_API_URL` задаётся вместе с префиксом `/api`.** `api/client.ts` подставляет переменную в `baseURL` как есть, а вызовы идут как `api.get('/tasks')` — значит для локального бэка нужно `http://localhost:3002/api`, иначе все запросы получают 404.

Expand Down
20 changes: 20 additions & 0 deletions alfy-bot-frontend/src/components/SidebarNav.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,25 @@
import type { NavLink } from '@/types/navigation'
import { computed, ref } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { useNow } from '@/composables/useNow'
import { useDropTarget } from '@/features/tasks/lib/dnd/use-drop-target'

defineProps<{ links: NavLink[] }>()

const route = useRoute()
const now = useNow()

/**
* Число для бейджа, или null — когда пункт счётчика не заводит либо считать
* нечего. Тунк зовётся здесь, в setup-контексте: на уровне модуля, где
* собираются ссылки, Pinia ещё не поднята.
*/
function linkCount(link: NavLink): number | null {
if (!link.count)
return null
const value = link.count(now.value)
return value > 0 ? value : null
}

function isLinkActive(to: string): boolean {
const [path, queryStr] = to.split('?')
Expand Down Expand Up @@ -54,6 +68,12 @@ const { isHovered: isInboxHovered } = useDropTarget({
>
{{ link.badge }}
</span>
<span
v-if="linkCount(link) !== null"
class="text-xs text-sidebar-foreground/50 tabular-nums"
>
{{ linkCount(link) }}
</span>
</RouterLink>
</nav>
</template>
13 changes: 13 additions & 0 deletions alfy-bot-frontend/src/features/tasks/lib/formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,16 @@ export const formatPomodoro = (value: number): string => {
const rounded = Math.round(value * 100) / 100
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1)
}

/** «1 задача», «2 задачи», «5 задач» — склонение по русским правилам. */
export function formatTaskCount(count: number): string {
const abs = Math.abs(count)
const mod10 = abs % 10
const mod100 = abs % 100

if (mod10 === 1 && mod100 !== 11)
return `${count} задача`
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14))
return `${count} задачи`
return `${count} задач`
}
67 changes: 67 additions & 0 deletions alfy-bot-frontend/src/features/tasks/lib/today.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import type { Task } from '../model/types'
import { endOfDay, startOfDay } from 'date-fns'

export interface TodayBuckets {
/** Невыполненные задачи со сроком раньше сегодняшнего дня. */
overdue: Task[]
/** Невыполненные задачи со сроком внутри сегодняшних суток. */
today: Task[]
}

/**
* Задачи, требующие действия сегодня, разложенные на две группы.
*
* Единственное место, где живут предикаты групп: экран «Сегодня» и счётчик в
* сайдбаре считают по нему же, поэтому разойтись не могут.
*
* `isOverdue` — это не «просрочена по дате», а флаг «заморожена» для
* повторяющихся задач с `onMissed: 'freeze'`. Такие задачи иммутабельны на
* бэкенде, действий с ними нет, и на этом экране им не место.
*
* `now` приходит аргументом, а не читается с часов, чтобы правило оставалось
* чистым и тестировалось без подмены таймеров. Границы суток — локальные, по
* timezone-конвенции фронта: локальное время браузера и есть время пользователя.
*/
export function splitTodayBuckets(tasks: Task[], now: Date): TodayBuckets {
const dayStart = startOfDay(now).getTime()
const dayEnd = endOfDay(now).getTime()

const overdue: Task[] = []
const today: Task[] = []

for (const task of tasks) {
if (task.completed || task.isOverdue || !task.dueDate)
continue

const due = new Date(task.dueDate).getTime()
if (due < dayStart)
overdue.push(task)
else if (due <= dayEnd)
today.push(task)
}

return { overdue: overdue.sort(byDueDate), today: today.sort(byDueDate) }
}

function byDueDate(a: Task, b: Task): number {
return new Date(a.dueDate!).getTime() - new Date(b.dueDate!).getTime()
}

/** Число для бейджа в сайдбаре — обе группы вместе. */
export function countTodayTasks(tasks: Task[], now: Date): number {
const { overdue, today } = splitTodayBuckets(tasks, now)
return overdue.length + today.length
}

/**
* Та же задача, но сегодня: дата подменяется, время суток сохраняется.
* «Вчера 19:30» становится «сегодня 19:30», а задача без времени (00:00)
* остаётся без времени.
*/
export function shiftToSameTimeToday(dueDate: Date, now: Date): Date {
const shifted = new Date(dueDate)
// Все три компоненты за один вызов — иначе задача 31-го числа при переносе
// в месяц покороче переползла бы на следующий месяц.
shifted.setFullYear(now.getFullYear(), now.getMonth(), now.getDate())
return shifted
}
8 changes: 5 additions & 3 deletions alfy-bot-frontend/src/features/tasks/ui/TaskForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -326,13 +326,15 @@ interface TaskFormData extends Omit<Task, 'id' | 'completed' | 'pomodoroComplete

interface Props {
loading?: boolean
/** Дата, с которой форма стартует и к которой возвращается после отправки. */
defaultDueDate?: Date
}

interface Emits {
(e: 'submit', task: Omit<Task, 'id' | 'completed' | 'pomodoroCompleted'>): void
}

withDefaults(defineProps<Props>(), {
const props = withDefaults(defineProps<Props>(), {
loading: false
})

Expand All @@ -341,7 +343,7 @@ const emit = defineEmits<Emits>()
const form = reactive<TaskFormData>({
title: '',
description: '',
dueDate: undefined,
dueDate: props.defaultDueDate,
deadline: undefined,
priority: undefined,
tags: [],
Expand Down Expand Up @@ -380,7 +382,7 @@ const titleInputRef = ref<InstanceType<typeof ContentEditableInput> | null>(null
const resetForm = () => {
form.title = ''
form.description = ''
form.dueDate = undefined
form.dueDate = props.defaultDueDate
dueDateTime.value = ''
form.deadline = undefined
form.priority = undefined
Expand Down
5 changes: 5 additions & 0 deletions alfy-bot-frontend/src/router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ const router = createRouter({
name: 'tasks',
component: () => import('../views/TasksView.vue'),
},
{
path: 'today',
name: 'tasks-today',
component: () => import('../views/TodayView.vue'),
},
{
path: 'calendar',
name: 'tasks-calendar',
Expand Down
10 changes: 9 additions & 1 deletion alfy-bot-frontend/src/router/tasks-nav.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import { Inbox, CalendarDays } from 'lucide-vue-next'
import type { NavLink } from '@/types/navigation'
import { CalendarCheck, CalendarDays, Inbox } from 'lucide-vue-next'
import { countTodayTasks } from '@/features/tasks/lib/today'
import { useTaskStore } from '@/features/tasks/model/task-store'

export const tasksNavLinks: NavLink[] = [
{ to: '/tasks', label: 'Входящие', icon: Inbox },
{
to: '/tasks/today',
label: 'Сегодня',
icon: CalendarCheck,
count: now => countTodayTasks(useTaskStore().tasks, now),
},
{ to: '/tasks/calendar', label: 'Календарь', icon: CalendarDays },
]
7 changes: 7 additions & 0 deletions alfy-bot-frontend/src/types/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,11 @@ export interface NavLink {
icon: Component
/** Опциональный бейдж справа от пункта, напр. "скоро" для разделов в разработке. */
badge?: string
/**
* Счётчик справа от пункта. Тунк, а не число: массивы ссылок собираются на
* уровне модуля, до подъёма Pinia, поэтому стор можно трогать только внутри
* вызова — уже в setup-контексте. `now` приходит аргументом, чтобы счётчик
* пересчитывался вместе с часами и переживал полночь.
*/
count?: (now: Date) => number
}
Loading
Loading