From e7b768c926fa19856dced89e8c5f8a4207fbbeb0 Mon Sep 17 00:00:00 2001 From: Arduinum Date: Thu, 23 Apr 2026 22:10:25 +0300 Subject: [PATCH 1/4] docs: updated .gitignore for db --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 6980809..705b1df 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ gen/ # Database *.db +*.db* *.sqlite *.sqlite3 From a431f70c2581addcf0537308ebe2e2ef499cbff5 Mon Sep 17 00:00:00 2001 From: Arduinum Date: Thu, 23 Apr 2026 22:12:29 +0300 Subject: [PATCH 2/4] feat: added dml and crud for get timer --- client/src/db/crud/timerCrud.ts | 10 ++++++++-- client/src/db/dml/timerDML.ts | 1 + 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/client/src/db/crud/timerCrud.ts b/client/src/db/crud/timerCrud.ts index 43b8535..a0170e8 100644 --- a/client/src/db/crud/timerCrud.ts +++ b/client/src/db/crud/timerCrud.ts @@ -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 { const db = await getDb(); return await db.select(SELECT_TIMERS); } + +/* Получает таймер по его id **/ +export async function getTimer(TimerId: number): Promise { + const db = await getDb(); + return await db.select(SELECT_TIMER, [TimerId]); +} diff --git a/client/src/db/dml/timerDML.ts b/client/src/db/dml/timerDML.ts index f1f4414..95f4014 100644 --- a/client/src/db/dml/timerDML.ts +++ b/client/src/db/dml/timerDML.ts @@ -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 = ?' From 46b8b181da2b3684ca2572ec5f4df4c832af3273 Mon Sep 17 00:00:00 2001 From: Arduinum Date: Thu, 23 Apr 2026 22:17:15 +0300 Subject: [PATCH 3/4] feat: added Timer screen --- client/src/composables/useTimer.ts | 73 ++++++++++++ client/src/types/timerType.ts | 31 ++++++ client/src/views/Timer/Timer.css | 173 +++++++++++++++++++++++++++++ client/src/views/Timer/Timer.html | 70 ++++++++++++ client/src/views/Timer/Timer.ts | 82 ++++++++++++++ client/src/views/Timer/Timer.vue | 4 + 6 files changed, 433 insertions(+) create mode 100644 client/src/composables/useTimer.ts create mode 100644 client/src/views/Timer/Timer.css create mode 100644 client/src/views/Timer/Timer.html create mode 100644 client/src/views/Timer/Timer.ts create mode 100644 client/src/views/Timer/Timer.vue diff --git a/client/src/composables/useTimer.ts b/client/src/composables/useTimer.ts new file mode 100644 index 0000000..f373c03 --- /dev/null +++ b/client/src/composables/useTimer.ts @@ -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(totalSeconds); + + /** Флаг активного состояния таймера */ + const isRunning = ref(false); + + let interval: ReturnType | null = null; + + /** Форматирует оставшееся время в строку ЧЧ:ММ:СС */ + const formatted = computed(() => { + 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(() => { + 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 + }; +} diff --git a/client/src/types/timerType.ts b/client/src/types/timerType.ts index 3ceedca..9b8a5f9 100644 --- a/client/src/types/timerType.ts +++ b/client/src/types/timerType.ts @@ -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; + isRunning: Ref; + formatted: ComputedRef; // ЧЧ:ММ:СС + progress: Ref; + start: () => void; + stop: () => void; + pause: () => void; +} diff --git a/client/src/views/Timer/Timer.css b/client/src/views/Timer/Timer.css new file mode 100644 index 0000000..b859379 --- /dev/null +++ b/client/src/views/Timer/Timer.css @@ -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; + } +} diff --git a/client/src/views/Timer/Timer.html b/client/src/views/Timer/Timer.html new file mode 100644 index 0000000..d9719cb --- /dev/null +++ b/client/src/views/Timer/Timer.html @@ -0,0 +1,70 @@ + + +
+
+ + +
+ + +
+ {{ timer.title }} + Помидор +
+ + +
+ {{ countdown?.formatted }} +
+ + +
+
+
+ + +
+ + + + + + + + + + +
+
+ +
+
+
+
diff --git a/client/src/views/Timer/Timer.ts b/client/src/views/Timer/Timer.ts new file mode 100644 index 0000000..a46a94c --- /dev/null +++ b/client/src/views/Timer/Timer.ts @@ -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(); + + /** Сообщение об ошибке при загрузке */ + const error = ref(null); + + /** Текущее состояние кнопок управления */ + const state = ref('idle'); + + /** Обновляет текущее состояние таймера */ + const setState = (newState: TimerState) => { + state.value = newState; + }; + + /** Shallow-ссылка на экземпляр composable обратного отсчёта */ + const countdown = shallowRef(null); + + /** Останавливает таймер при размонтировании компонента */ + onUnmounted(() => { + countdown.value?.stop(); + }); + + /** Загружает данные таймера из БД и инициализирует отсчёт */ + const loadTimer = async (): Promise => { + 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 + }; + } +}); diff --git a/client/src/views/Timer/Timer.vue b/client/src/views/Timer/Timer.vue new file mode 100644 index 0000000..27ce468 --- /dev/null +++ b/client/src/views/Timer/Timer.vue @@ -0,0 +1,4 @@ + + + + From 5b52549404360c85915dff12797c87fe20b6a07f Mon Sep 17 00:00:00 2001 From: Arduinum Date: Thu, 23 Apr 2026 22:18:54 +0300 Subject: [PATCH 4/4] feat: added router for Timer --- client/src/router/index.ts | 6 + client/src/views/TimersList/TimersList.html | 150 ++++++++++---------- client/src/views/TimersList/TimersList.ts | 12 +- 3 files changed, 93 insertions(+), 75 deletions(-) diff --git a/client/src/router/index.ts b/client/src/router/index.ts index 81a13c5..fbce953 100644 --- a/client/src/router/index.ts +++ b/client/src/router/index.ts @@ -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 } ]; diff --git a/client/src/views/TimersList/TimersList.html b/client/src/views/TimersList/TimersList.html index 56281fd..c656447 100644 --- a/client/src/views/TimersList/TimersList.html +++ b/client/src/views/TimersList/TimersList.html @@ -1,81 +1,85 @@ -
- + + +
+ -
- -
-

Загрузка таймеров...

-
- - -
-

{{ error }}

-
- - -
-

Нет таймеров

-
- - -
-
- -
-
- -
-
-

{{ timer.title }}

-

{{ timer.count_pomodoro }} помидоров по {{ timer.pomodoro_time }} минут

-
-
- -
+
+ +
+

Загрузка таймеров...

- -
-
-
- Название: - {{ timer.title }} -
-
- Длительность помидора: - {{ timer.pomodoro_time }} минут -
-
- Количество помидоров: - {{ timer.count_pomodoro }} -
-
+ +
+

{{ error }}

+
-
- - - + +
+

Нет таймеров

+
+ + +
+
+ +
+
+ +
+
+

{{ timer.title }}

+

{{ timer.count_pomodoro }} помидоров по {{ timer.pomodoro_time }} минут

+
+
+ +
+
+ + +
+
+
+ Название: + {{ timer.title }} +
+
+ Длительность помидора: + {{ timer.pomodoro_time }} минут +
+
+ Количество помидоров: + {{ timer.count_pomodoro }} +
+
+ +
+ + + +
+
-
-
+ + diff --git a/client/src/views/TimersList/TimersList.ts b/client/src/views/TimersList/TimersList.ts index 88057dd..61ea5dd 100644 --- a/client/src/views/TimersList/TimersList.ts +++ b/client/src/views/TimersList/TimersList.ts @@ -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([]) const loading = ref(true) const error = ref(null) const expandedId = ref(null) + const router = useRouter(); + + const goToTimer = (id: number) => { + router.push(`/timer/${id}`); + }; + const loadTimers = async (): Promise => { loading.value = true error.value = null @@ -55,6 +62,7 @@ export default defineComponent({ playCircleOutline, pencilOutline, trashOutline, + goToTimer } }, })