Merge pull request #10 from Arduinum/mvp1

Mvp1
This commit is contained in:
Arduinum
2026-04-23 22:20:48 +03:00
committed by GitHub
12 changed files with 536 additions and 77 deletions

1
.gitignore vendored
View File

@@ -22,6 +22,7 @@ gen/
# Database
*.db
*.db*
*.sqlite
*.sqlite3

View File

@@ -0,0 +1,73 @@
import { ref, computed } from 'vue';
import { CountdownOptions, CountdownReturn } from '@/types/timerType';
/** Composable для управления обратным отсчётом времени */
export function useCountdown({ seconds, onFinish }: CountdownOptions): CountdownReturn {
const totalSeconds = seconds * 60;
/** Реактивное оставшееся время в секундах */
const timeLeft = ref<number>(totalSeconds);
/** Флаг активного состояния таймера */
const isRunning = ref<boolean>(false);
let interval: ReturnType<typeof setInterval> | null = null;
/** Форматирует оставшееся время в строку ЧЧ:ММ:СС */
const formatted = computed<string>(() => {
const hours = Math.floor(timeLeft.value / 3600);
const mins = Math.floor((timeLeft.value % 3600) / 60);
const secs = timeLeft.value % 60;
return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
});
/** Вычисляет процент прошедшего времени от общего */
const progress = computed<number>(() => {
return ((totalSeconds - timeLeft.value) / totalSeconds) * 100;
});
/** Запускает таймер */
const start = (): void => {
if (isRunning.value) return
isRunning.value = true;
interval = setInterval(() => {
if (timeLeft.value > 0) {
timeLeft.value--;
} else {
stop();
onFinish?.();
}
}, 1000);
}
/** Останавливает таймер и сбрасывает время до начального */
const stop = (): void => {
if (interval) {
clearInterval(interval);
interval = null;
}
isRunning.value = false;
timeLeft.value = totalSeconds;
}
/** Приостанавливает таймер без сброса оставшегося времени */
const pause = (): void => {
if (!isRunning.value) return
if (interval) {
clearInterval(interval);
interval = null;
}
isRunning.value = false;
}
return {
timeLeft,
isRunning,
formatted,
progress,
start,
stop,
pause
};
}

View File

@@ -1,6 +1,6 @@
import { getDb } from "@/db/initDb";
import { SELECT_TIMERS } from "@/db/dml/timerDML";
import { TimersRow } from "@/types/timerType"
import { SELECT_TIMERS, SELECT_TIMER } from "@/db/dml/timerDML";
import { TimersRow, TimerRow } from "@/types/timerType";
/** Получает список таймеров */
@@ -8,3 +8,9 @@ export async function getTimers(): Promise<TimersRow[]> {
const db = await getDb();
return await db.select<TimersRow[]>(SELECT_TIMERS);
}
/* Получает таймер по его id **/
export async function getTimer(TimerId: number): Promise<TimerRow> {
const db = await getDb();
return await db.select<TimerRow>(SELECT_TIMER, [TimerId]);
}

View File

@@ -4,3 +4,4 @@ export const INSERT_SEED_DB = `
INSERT INTO timer (title, pomodoro_time, break_time, break_long_time, count_pomodoro) VALUES
('Timer mini example', 10, 3, 15, 2), ('Timer max example', 90, 10, 40, 8)
`
export const SELECT_TIMER = 'SELECT * FROM timer WHERE id = ?'

View File

@@ -1,6 +1,7 @@
import { createRouter, createWebHistory } from '@ionic/vue-router';
import type { RouteRecordRaw } from 'vue-router';
import TimersList from '@/views/TimersList/TimersList.vue';
import Timer from '@/views/Timer/Timer.vue';
const routes: RouteRecordRaw[] = [
{
@@ -11,6 +12,11 @@ const routes: RouteRecordRaw[] = [
path: '/timers',
name: 'Timers',
component: TimersList
},
{
path: '/timer/:id',
name: 'Timer',
component: Timer
}
];

View File

@@ -1,3 +1,6 @@
import type { Ref, ComputedRef } from 'vue';
/** Строка списка таймеров с основными полями */
export type TimersRow = {
id: number;
title: string;
@@ -5,4 +8,32 @@ export type TimersRow = {
count_pomodoro: number;
};
/** Тип для хранения результата COUNT-запроса */
export type CountRow = { cnt: number };
/** Полная строка таймера со всеми временными параметрами */
export type TimerRow = {
id: number;
title: string;
pomodoro_time: number;
break_time: number;
break_long_time: number;
count_pomodoro: number;
}
/** Параметры инициализации обратного отсчёта */
export type CountdownOptions = {
seconds: number;
onFinish?: () => void;
}
/** Возвращаемые значения и методы composable обратного отсчёта */
export type CountdownReturn = {
timeLeft: Ref<number>;
isRunning: Ref<boolean>;
formatted: ComputedRef<string>; // ЧЧ:ММ:СС
progress: Ref<number>;
start: () => void;
stop: () => void;
pause: () => void;
}

View File

@@ -0,0 +1,173 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
.timer-page-container {
background-color: #f5f5f5;
min-height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Helvetica Neue', Arial, sans-serif;
display: flex;
align-items: center;
justify-content: center;
}
.timer-screen {
padding: 24px 16px;
width: 100%;
max-width: 500px;
display: flex;
flex-direction: column;
align-items: center;
}
.timer-display-card {
background-color: #ffffff;
border-radius: 16px;
padding: 28px 24px 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
width: 100%;
}
.timer-card-labels {
display: flex;
justify-content: space-between;
width: 100%;
}
.label-left,
.label-right {
font-size: 13px;
color: #9e9e9e;
font-weight: 400;
letter-spacing: 0.3px;
}
.timer-time {
width: 100%;
text-align: center;
}
.time-text {
font-size: 56px;
font-weight: 400;
color: #212121;
letter-spacing: 2px;
font-variant-numeric: tabular-nums;
line-height: 1;
}
.timer-progress-bar {
width: 100%;
height: 2px;
background-color: #e0e0e0;
border-radius: 2px;
overflow: hidden;
}
.timer-progress-fill {
height: 100%;
background-color: #1976d2;
border-radius: 2px;
transition: width 1s linear;
}
.timer-controls {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
width: 100%;
height: 48px;
}
.btn-timer {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
width: 120px;
height: 44px;
flex-shrink: 0; /* запрет на сжатие */
background-color: transparent;
font-size: 14px;
font-weight: 500;
border-radius: 50px;
cursor: pointer;
letter-spacing: 0.5px;
border: 2px solid;
}
.btn-timer ion-icon {
font-size: 17px;
}
.btn-timer-start {
border-color: #4caf50;
color: #4caf50;
}
.btn-timer-start:hover {
background-color: rgba(76, 175, 80, 0.10);
}
.btn-timer-start:active {
background-color: rgba(76, 175, 80, 0.22);
}
.btn-timer-stop {
border-color: #f44336;
color: #f44336;
}
.btn-timer-stop:hover {
background-color: rgba(244, 67, 54, 0.10);
}
.btn-timer-stop:active {
background-color: rgba(244, 67, 54, 0.22);
}
.btn-timer-pause {
border-color: #ffc107;
color: #f9a825;
}
.btn-timer-pause:hover {
background-color: rgba(255, 193, 7, 0.10);
}
.btn-timer-pause:active {
background-color: rgba(255, 193, 7, 0.22);
}
.btn-timer {
transition: all 0.2s ease, opacity 0.25s ease, transform 0.25s ease;
}
.btn-timer.hidden {
display: none;
}
@media (max-width: 600px) {
.timer-display-card {
padding: 20px 16px;
border-radius: 12px;
gap: 16px;
}
.time-text {
font-size: 44px;
}
.btn-timer {
padding: 8px 16px;
font-size: 13px;
}
}

View File

@@ -0,0 +1,70 @@
<ion-page>
<ion-content>
<div class="timer-page-container">
<div class="timer-screen">
<!-- Timer Card -->
<div class="timer-display-card">
<!-- Card top labels -->
<div class="timer-card-labels">
<span v-if="timer" class="label-left">{{ timer.title }}</span>
<span class="label-right">Помидор</span>
</div>
<!-- Time display -->
<div class="timer-time">
<span class="time-text">{{ countdown?.formatted }}</span>
</div>
<!-- Progress bar -->
<div class="timer-progress-bar">
<div class="timer-progress-fill" :style="{ width: `${countdown?.progress.value}%` }"></div>
</div>
<!-- Buttons -->
<div class="timer-controls">
<!-- Видна только при running и paused -->
<button
class="btn-timer
btn-timer-stop"
id="btn-stop"
v-show="state === 'running' || state === 'paused'"
@click="() => { setState('idle'); countdown?.stop(); }"
>
<ion-icon :icon="stopOutline"></ion-icon>
Стоп
</button>
<!-- Видна при idle и paused -->
<button
class="btn-timer
btn-timer-start"
id="btn-start"
v-show="state === 'idle' || state === 'paused'"
@click="() => { setState('running'); countdown?.start(); }"
>
<ion-icon :icon="playOutline"></ion-icon>
Старт
</button>
<!-- Видна только при running -->
<button
class="btn-timer
btn-timer-pause"
id="btn-pause"
v-show="state === 'running'"
@click="() => { setState('paused'); countdown?.pause(); }"
>
<ion-icon :icon="pauseOutline"></ion-icon>
Пауза
</button>
</div>
</div>
</div>
</div>
</ion-content>
</ion-page>

View File

@@ -0,0 +1,82 @@
import { defineComponent, onMounted, onUnmounted, ref, shallowRef } from 'vue';
import { IonIcon, IonPage, IonContent } from '@ionic/vue';
import { useRoute } from 'vue-router';
import {
stopOutline,
playOutline,
pauseOutline
} from 'ionicons/icons';
import { getTimer } from '@/db/crud/timerCrud';
import type { TimerRow } from '@/types/timerType';
import { useCountdown } from '@/composables/useTimer';
import { CountdownReturn } from '@/types/timerType';
/** Возможные состояния таймера */
type TimerState = 'idle' | 'running' | 'paused';
/** Компонент страницы таймера с управлением обратным отсчётом */
export default defineComponent({
name: 'Timer',
components: { IonIcon, IonPage, IonContent },
/** Инициализирует данные и логику компонента таймера */
setup() {
const route = useRoute();
const id = Number(route.params.id);
/** Реактивные данные загруженного таймера */
const timer = ref<TimerRow>();
/** Сообщение об ошибке при загрузке */
const error = ref<string | null>(null);
/** Текущее состояние кнопок управления */
const state = ref<TimerState>('idle');
/** Обновляет текущее состояние таймера */
const setState = (newState: TimerState) => {
state.value = newState;
};
/** Shallow-ссылка на экземпляр composable обратного отсчёта */
const countdown = shallowRef<CountdownReturn | null>(null);
/** Останавливает таймер при размонтировании компонента */
onUnmounted(() => {
countdown.value?.stop();
});
/** Загружает данные таймера из БД и инициализирует отсчёт */
const loadTimer = async (): Promise<void> => {
try {
const result = await getTimer(id);
const timerData = Array.isArray(result) ? result[0] : result;
timer.value = timerData;
countdown.value = useCountdown({
seconds: timerData.pomodoro_time ?? 0,
onFinish: () => alert('Время вышло!')
});
} catch (e) {
error.value = 'Ошибка загрузки таймера';
console.error(e);
}
};
onMounted(loadTimer);
return {
timer,
error,
state,
setState,
stopOutline,
playOutline,
pauseOutline,
countdown
};
}
});

View File

@@ -0,0 +1,4 @@
<!-- Timer.vue -->
<template src="@/views/Timer/Timer.html"></template>
<script lang="ts" src="@/views/Timer/Timer.ts"></script>
<style src="@/views/Timer/Timer.css" scoped></style>

View File

@@ -1,81 +1,85 @@
<div class="timers-container">
<div class="page-header">
<h1>Таймеры</h1>
</div>
<ion-page>
<ion-content>
<div class="timers-container">
<div class="page-header">
<h1>Таймеры</h1>
</div>
<div class="timers-list">
<!-- Loading state -->
<div v-if="loading" class="loading">
<p>Загрузка таймеров...</p>
</div>
<!-- Error state -->
<div v-else-if="error" class="error-message">
<p>{{ error }}</p>
</div>
<!-- Empty state -->
<div v-else-if="timers.length === 0" class="empty-state">
<p>Нет таймеров</p>
</div>
<!-- Timers list -->
<div v-else class="timer-cards">
<div
v-for="timer in timers"
:key="timer.id"
class="timer-card"
:class="{ expanded: expandedId === timer.id }"
>
<!-- Card header -->
<div class="card-header" @click="toggleExpand(timer.id)">
<div class="timer-icon">
<ion-icon :icon="timerOutline"></ion-icon>
</div>
<div class="timer-info">
<h3 class="timer-title">{{ timer.title }}</h3>
<p class="timer-subtitle">{{ timer.count_pomodoro }} помидоров по {{ timer.pomodoro_time }} минут</p>
</div>
<div class="expand-icon">
<ion-icon
:icon="expandedId === timer.id ? chevronUp : chevronDown"
></ion-icon>
</div>
<div class="timers-list">
<!-- Loading state -->
<div v-if="loading" class="loading">
<p>Загрузка таймеров...</p>
</div>
<!-- Card content -->
<div v-show="expandedId === timer.id" class="card-content">
<div class="timer-details">
<div class="detail-row">
<span class="detail-label">Название:</span>
<span class="detail-value">{{ timer.title }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Длительность помидора:</span>
<span class="detail-value">{{ timer.pomodoro_time }} минут</span>
</div>
<div class="detail-row">
<span class="detail-label">Количество помидоров:</span>
<span class="detail-value">{{ timer.count_pomodoro }}</span>
</div>
</div>
<!-- Error state -->
<div v-else-if="error" class="error-message">
<p>{{ error }}</p>
</div>
<div class="card-actions">
<button class="btn btn-primary">
<ion-icon :icon="playCircleOutline"></ion-icon>
Открыть
</button>
<button class="btn btn-secondary">
<ion-icon :icon="pencilOutline"></ion-icon>
Редактировать
</button>
<button class="btn btn-danger">
<ion-icon :icon="trashOutline"></ion-icon>
Удалить
</button>
<!-- Empty state -->
<div v-else-if="timers.length === 0" class="empty-state">
<p>Нет таймеров</p>
</div>
<!-- Timers list -->
<div v-else class="timer-cards">
<div
v-for="timer in timers"
:key="timer.id"
class="timer-card"
:class="{ expanded: expandedId === timer.id }"
>
<!-- Card header -->
<div class="card-header" @click="toggleExpand(timer.id)">
<div class="timer-icon">
<ion-icon :icon="timerOutline"></ion-icon>
</div>
<div class="timer-info">
<h3 class="timer-title">{{ timer.title }}</h3>
<p class="timer-subtitle">{{ timer.count_pomodoro }} помидоров по {{ timer.pomodoro_time }} минут</p>
</div>
<div class="expand-icon">
<ion-icon
:icon="expandedId === timer.id ? chevronUp : chevronDown"
></ion-icon>
</div>
</div>
<!-- Card content -->
<div v-show="expandedId === timer.id" class="card-content">
<div class="timer-details">
<div class="detail-row">
<span class="detail-label">Название:</span>
<span class="detail-value">{{ timer.title }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Длительность помидора:</span>
<span class="detail-value">{{ timer.pomodoro_time }} минут</span>
</div>
<div class="detail-row">
<span class="detail-label">Количество помидоров:</span>
<span class="detail-value">{{ timer.count_pomodoro }}</span>
</div>
</div>
<div class="card-actions">
<button @click="goToTimer(timer.id)" class="btn btn-secondary">
<ion-icon :icon="playCircleOutline"></ion-icon>
Открыть
</button>
<button class="btn btn-secondary">
<ion-icon :icon="pencilOutline"></ion-icon>
Редактировать
</button>
<button class="btn btn-danger">
<ion-icon :icon="trashOutline"></ion-icon>
Удалить
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</ion-content>
</ion-page>

View File

@@ -1,5 +1,6 @@
import { defineComponent, onMounted, ref } from 'vue'
import { IonIcon } from '@ionic/vue'
import { IonIcon, IonPage, IonContent } from '@ionic/vue'
import { useRouter } from 'vue-router';
import {
timerOutline,
chevronUp,
@@ -14,13 +15,19 @@ import type { TimersRow } from '@/types/timerType'
export default defineComponent({
name: 'TimersList',
components: { IonIcon },
components: { IonIcon, IonPage, IonContent },
setup() {
const timers = ref<TimersRow[]>([])
const loading = ref<boolean>(true)
const error = ref<string | null>(null)
const expandedId = ref<number | null>(null)
const router = useRouter();
const goToTimer = (id: number) => {
router.push(`/timer/${id}`);
};
const loadTimers = async (): Promise<void> => {
loading.value = true
error.value = null
@@ -55,6 +62,7 @@ export default defineComponent({
playCircleOutline,
pencilOutline,
trashOutline,
goToTimer
}
},
})