$value) {
if ($value === null || $value === '') {
continue;
}
$filtered[$key] = $value;
}
return $filtered ? ('?' . http_build_query($filtered)) : '';
}
function redirectWithState(array $params): void
{
header('Location: index.php' . buildQuery($params));
exit;
}
function setFlash(string $type, string $message): void
{
$_SESSION['flash'] = ['type' => $type, 'message' => $message];
}
function getFlash(): ?array
{
if (!isset($_SESSION['flash'])) {
return null;
}
$flash = $_SESSION['flash'];
unset($_SESSION['flash']);
return $flash;
}
function ensureTodoTable(PDO $pdo): void
{
$pdo->exec(
"CREATE TABLE IF NOT EXISTS todos (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(140) NOT NULL,
description TEXT NULL,
priority ENUM('low', 'medium', 'high') NOT NULL DEFAULT 'medium',
is_completed TINYINT(1) NOT NULL DEFAULT 0,
completed_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_completed (is_completed),
INDEX idx_priority (priority),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
$count = (int) $pdo->query('SELECT COUNT(*) FROM todos')->fetchColumn();
if ($count === 0) {
$seed = $pdo->prepare(
'INSERT INTO todos (title, description, priority, is_completed, completed_at) VALUES (:title, :description, :priority, :is_completed, :completed_at)'
);
$demoTasks = [
[
'title' => 'Подготовить список задач на неделю',
'description' => 'Собрать важные личные и рабочие дела, чтобы всё было в одном месте.',
'priority' => 'high',
'is_completed' => 0,
'completed_at' => null,
],
[
'title' => 'Разобрать входящие письма',
'description' => 'Оставить только действительно нужные письма и отметить follow-up.',
'priority' => 'medium',
'is_completed' => 0,
'completed_at' => null,
],
[
'title' => 'Забронировать тренировку',
'description' => 'Выбрать время на этой неделе и подтвердить занятие.',
'priority' => 'low',
'is_completed' => 1,
'completed_at' => date('Y-m-d H:i:s', strtotime('-1 day')),
],
];
foreach ($demoTasks as $task) {
$seed->execute([
':title' => $task['title'],
':description' => $task['description'],
':priority' => $task['priority'],
':is_completed' => $task['is_completed'],
':completed_at' => $task['completed_at'],
]);
}
}
}
$pdo = db();
ensureTodoTable($pdo);
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
$csrfToken = $_SESSION['csrf_token'];
$status = $_GET['status'] ?? 'all';
$status = in_array($status, ['all', 'active', 'completed'], true) ? $status : 'all';
$sort = $_GET['sort'] ?? 'newest';
$sort = in_array($sort, ['newest', 'oldest', 'priority'], true) ? $sort : 'newest';
$q = trim((string) ($_GET['q'] ?? ''));
$selectedTaskId = filter_input(INPUT_GET, 'task', FILTER_VALIDATE_INT) ?: null;
$editTaskId = filter_input(INPUT_GET, 'edit', FILTER_VALIDATE_INT) ?: null;
$baseState = [
'status' => $status,
'sort' => $sort,
'q' => $q,
];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$postedToken = $_POST['csrf_token'] ?? '';
$postStatus = $_POST['status'] ?? 'all';
$postStatus = in_array($postStatus, ['all', 'active', 'completed'], true) ? $postStatus : 'all';
$postSort = $_POST['sort'] ?? 'newest';
$postSort = in_array($postSort, ['newest', 'oldest', 'priority'], true) ? $postSort : 'newest';
$postQ = trim((string) ($_POST['q'] ?? ''));
$returnTask = filter_input(INPUT_POST, 'task', FILTER_VALIDATE_INT) ?: null;
$redirectState = ['status' => $postStatus, 'sort' => $postSort, 'q' => $postQ];
if ($returnTask) {
$redirectState['task'] = $returnTask;
}
if (!hash_equals($csrfToken, $postedToken)) {
setFlash('danger', 'Сессия устарела. Попробуйте ещё раз.');
redirectWithState($redirectState);
}
$action = $_POST['action'] ?? '';
try {
if ($action === 'create') {
$title = trim((string) ($_POST['title'] ?? ''));
$description = trim((string) ($_POST['description'] ?? ''));
$priority = $_POST['priority'] ?? 'medium';
$priority = in_array($priority, ['low', 'medium', 'high'], true) ? $priority : 'medium';
if ($title === '' || utf8Length($title) > 140) {
throw new RuntimeException('Название задачи обязательно и должно быть короче 140 символов.');
}
if (utf8Length($description) > 1000) {
throw new RuntimeException('Описание должно быть короче 1000 символов.');
}
$stmt = $pdo->prepare('INSERT INTO todos (title, description, priority) VALUES (:title, :description, :priority)');
$stmt->bindValue(':title', $title);
$stmt->bindValue(':description', $description !== '' ? $description : null, $description !== '' ? PDO::PARAM_STR : PDO::PARAM_NULL);
$stmt->bindValue(':priority', $priority);
$stmt->execute();
$newId = (int) $pdo->lastInsertId();
setFlash('success', 'Задача добавлена.');
$redirectState['task'] = $newId;
redirectWithState($redirectState);
}
if ($action === 'update') {
$taskId = filter_input(INPUT_POST, 'task_id', FILTER_VALIDATE_INT);
$title = trim((string) ($_POST['title'] ?? ''));
$description = trim((string) ($_POST['description'] ?? ''));
$priority = $_POST['priority'] ?? 'medium';
$priority = in_array($priority, ['low', 'medium', 'high'], true) ? $priority : 'medium';
if (!$taskId) {
throw new RuntimeException('Не удалось определить задачу для редактирования.');
}
if ($title === '' || utf8Length($title) > 140) {
throw new RuntimeException('Название задачи обязательно и должно быть короче 140 символов.');
}
if (utf8Length($description) > 1000) {
throw new RuntimeException('Описание должно быть короче 1000 символов.');
}
$stmt = $pdo->prepare('UPDATE todos SET title = :title, description = :description, priority = :priority WHERE id = :id LIMIT 1');
$stmt->bindValue(':title', $title);
$stmt->bindValue(':description', $description !== '' ? $description : null, $description !== '' ? PDO::PARAM_STR : PDO::PARAM_NULL);
$stmt->bindValue(':priority', $priority);
$stmt->bindValue(':id', $taskId, PDO::PARAM_INT);
$stmt->execute();
setFlash('success', 'Изменения сохранены.');
$redirectState['task'] = $taskId;
redirectWithState($redirectState);
}
if ($action === 'toggle') {
$taskId = filter_input(INPUT_POST, 'task_id', FILTER_VALIDATE_INT);
if (!$taskId) {
throw new RuntimeException('Не удалось определить задачу.');
}
$stmt = $pdo->prepare('SELECT is_completed FROM todos WHERE id = :id LIMIT 1');
$stmt->execute([':id' => $taskId]);
$task = $stmt->fetch();
if (!$task) {
throw new RuntimeException('Задача не найдена.');
}
$nextState = (int) !$task['is_completed'];
$update = $pdo->prepare('UPDATE todos SET is_completed = :is_completed, completed_at = :completed_at WHERE id = :id LIMIT 1');
$update->bindValue(':is_completed', $nextState, PDO::PARAM_INT);
$update->bindValue(':completed_at', $nextState ? date('Y-m-d H:i:s') : null, $nextState ? PDO::PARAM_STR : PDO::PARAM_NULL);
$update->bindValue(':id', $taskId, PDO::PARAM_INT);
$update->execute();
setFlash('success', $nextState ? 'Задача отмечена как выполненная.' : 'Задача снова активна.');
$redirectState['task'] = $taskId;
redirectWithState($redirectState);
}
if ($action === 'delete') {
$taskId = filter_input(INPUT_POST, 'task_id', FILTER_VALIDATE_INT);
if (!$taskId) {
throw new RuntimeException('Не удалось определить задачу.');
}
$stmt = $pdo->prepare('DELETE FROM todos WHERE id = :id LIMIT 1');
$stmt->bindValue(':id', $taskId, PDO::PARAM_INT);
$stmt->execute();
setFlash('success', 'Задача удалена.');
unset($redirectState['task']);
redirectWithState($redirectState);
}
setFlash('warning', 'Неизвестное действие.');
redirectWithState($redirectState);
} catch (Throwable $exception) {
setFlash('danger', $exception->getMessage());
redirectWithState($redirectState);
}
}
$where = [];
$params = [];
if ($status === 'active') {
$where[] = 'is_completed = 0';
} elseif ($status === 'completed') {
$where[] = 'is_completed = 1';
}
if ($q !== '') {
$where[] = '(title LIKE :search OR description LIKE :search)';
$params[':search'] = '%' . $q . '%';
}
$whereSql = $where ? ('WHERE ' . implode(' AND ', $where)) : '';
$orderSql = match ($sort) {
'oldest' => 'ORDER BY created_at ASC, id ASC',
'priority' => "ORDER BY CASE priority WHEN 'high' THEN 1 WHEN 'medium' THEN 2 ELSE 3 END ASC, is_completed ASC, created_at DESC",
default => 'ORDER BY created_at DESC, id DESC',
};
$listStmt = $pdo->prepare("SELECT id, title, description, priority, is_completed, completed_at, created_at, updated_at FROM todos {$whereSql} {$orderSql}");
foreach ($params as $key => $value) {
$listStmt->bindValue($key, $value);
}
$listStmt->execute();
$tasks = $listStmt->fetchAll();
$stats = $pdo->query(
"SELECT
COUNT(*) AS total_count,
SUM(CASE WHEN is_completed = 0 THEN 1 ELSE 0 END) AS active_count,
SUM(CASE WHEN is_completed = 1 THEN 1 ELSE 0 END) AS completed_count,
SUM(CASE WHEN priority = 'high' AND is_completed = 0 THEN 1 ELSE 0 END) AS urgent_count
FROM todos"
)->fetch() ?: ['total_count' => 0, 'active_count' => 0, 'completed_count' => 0, 'urgent_count' => 0];
$selectedTask = null;
if ($selectedTaskId) {
$detailStmt = $pdo->prepare('SELECT * FROM todos WHERE id = :id LIMIT 1');
$detailStmt->execute([':id' => $selectedTaskId]);
$selectedTask = $detailStmt->fetch() ?: null;
}
$editingTask = null;
if ($editTaskId) {
if ($selectedTask && (int) $selectedTask['id'] === $editTaskId) {
$editingTask = $selectedTask;
} else {
$editStmt = $pdo->prepare('SELECT * FROM todos WHERE id = :id LIMIT 1');
$editStmt->execute([':id' => $editTaskId]);
$editingTask = $editStmt->fetch() ?: null;
}
}
if (!$selectedTask && !empty($tasks)) {
$selectedTask = $tasks[0];
$selectedTaskId = (int) $selectedTask['id'];
}
$flash = getFlash();
$projectName = trim((string) ($_SERVER['PROJECT_NAME'] ?? '')) ?: 'Task Ledger';
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? 'Personal todo application with database persistence and a modern focused interface.';
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
$pageTitle = $projectName . ' — Personal Todo';
$assetVersion = (string) @filemtime(__DIR__ . '/assets/css/custom.css');
?>
= h($pageTitle) ?>
Всего задач
= h((string) ($stats['total_count'] ?? 0)) ?>
Личная база задач
Активные
= h((string) ($stats['active_count'] ?? 0)) ?>
В работе сейчас
Выполнены
= h((string) ($stats['completed_count'] ?? 0)) ?>
История прогресса
Высокий приоритет
= h((string) ($stats['urgent_count'] ?? 0)) ?>
Требуют внимания
Ничего не найдено
Измените фильтры или добавьте первую задачу, чтобы начать список.
Создать задачу
$task['id']]));
$editLink = buildQuery(array_merge($baseState, ['task' => $task['id'], 'edit' => $task['id']]));
?>
= h($task['title']) ?>
= h(match ($task['priority']) {
'high' => 'Высокий',
'low' => 'Низкий',
default => 'Средний',
}) ?>
= h($task['description'] !== null && $task['description'] !== '' ? utf8Excerpt($task['description'], 120) : 'Без дополнительного описания.') ?>
= h(date('d M Y, H:i', strtotime($task['created_at']))) ?>
= $isDone ? 'Выполнено' : 'Активно' ?>
= h(match ($selectedTask['priority']) {
'high' => 'Высокий приоритет',
'low' => 'Низкий приоритет',
default => 'Средний приоритет',
}) ?>
= $selectedDone ? 'Выполнена' : 'Активна' ?>
= h($selectedTask['title']) ?>
= h($selectedTask['description'] ?: 'Описание не добавлено. Используйте это поле для контекста, критериев готовности или коротких заметок.') ?>
Создана
= h(date('d.m.Y H:i', strtotime($selectedTask['created_at']))) ?>
Последнее обновление
= h(date('d.m.Y H:i', strtotime($selectedTask['updated_at']))) ?>
Завершение
= $selectedTask['completed_at'] ? h(date('d.m.Y H:i', strtotime($selectedTask['completed_at']))) : 'Ещё в работе' ?>
Редактировать
Выберите задачу
После выбора здесь появятся детали и быстрые действия.
= h($flash['message']) ?>