diff --git a/memory-bank/API.md b/memory-bank/API.md index 3d2e16d..f4ab7ba 100644 --- a/memory-bank/API.md +++ b/memory-bank/API.md @@ -20,6 +20,7 @@ http://localhost:8044/smoke-tracker - [Получить список сигарет](#get-cigarettes) 3. [Статистика](#статистика) - [Дневная статистика](#get-statsdaily) + - [Сводная статистика](#get-statssummary) --- @@ -399,6 +400,208 @@ const chartData = { --- +### `GET /stats/summary` + +**Описание**: Получить расширенную статистику для текущего пользователя и общую по всем пользователям + +**Требуется авторизация**: ✅ Да (Bearer token) + +**Query-параметры** (все необязательные): + +| Параметр | Тип | Описание | Пример | По умолчанию | +|----------|-----|----------|--------|--------------| +| `from` | string (ISO 8601) | Начало периода | `2024-01-01T00:00:00.000Z` | 30 дней назад от текущей даты | +| `to` | string (ISO 8601) | Конец периода | `2024-01-31T23:59:59.999Z` | Текущая дата и время | + +**Пример запроса**: + +```bash +curl -X GET http://localhost:8044/smoke-tracker/stats/summary \ + -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +``` + +**Ответ при успехе** (200 OK): + +```json +{ + "success": true, + "body": { + "user": { + "daily": [ + { + "date": "2024-01-15", + "count": 8 + }, + { + "date": "2024-01-16", + "count": 12 + } + ], + "averagePerDay": 10.5, + "weekday": [ + { + "dayOfWeek": 2, + "dayName": "Понедельник", + "count": 25, + "average": "6.25" + }, + { + "dayOfWeek": 3, + "dayName": "Вторник", + "count": 30, + "average": "7.50" + } + ], + "total": 315, + "daysWithData": 30 + }, + "global": { + "daily": [ + { + "date": "2024-01-15", + "count": 45 + }, + { + "date": "2024-01-16", + "count": 52 + } + ], + "averagePerDay": 48.5, + "weekday": [ + { + "dayOfWeek": 2, + "dayName": "Понедельник", + "count": 120, + "average": "30.00" + }, + { + "dayOfWeek": 3, + "dayName": "Вторник", + "count": 135, + "average": "33.75" + } + ], + "total": 1455, + "daysWithData": 30, + "activeUsers": 5 + }, + "period": { + "from": "2024-01-01T00:00:00.000Z", + "to": "2024-01-31T23:59:59.999Z" + } + } +} +``` + +**Структура ответа**: + +**`user`** — статистика текущего пользователя: +- `daily` — массив с количеством сигарет по дням + - `date` — дата в формате YYYY-MM-DD + - `count` — количество сигарет +- `averagePerDay` — среднее количество сигарет в день (число с плавающей точкой) +- `weekday` — статистика по дням недели + - `dayOfWeek` — номер дня недели (1 = воскресенье, 2 = понедельник, ..., 7 = суббота) + - `dayName` — название дня недели + - `count` — общее количество сигарет в этот день недели за весь период + - `average` — среднее количество за один такой день недели (строка) +- `total` — общее количество сигарет за период +- `daysWithData` — количество дней, в которые были записи + +**`global`** — общая статистика по всем пользователям: +- `daily` — массив с суммарным количеством сигарет всех пользователей по дням +- `averagePerDay` — среднее количество сигарет в день (все пользователи) +- `weekday` — статистика по дням недели (все пользователи) +- `total` — общее количество сигарет всех пользователей за период +- `daysWithData` — количество дней с записями +- `activeUsers` — количество уникальных пользователей, записывавших сигареты в период + +**`period`** — информация о запрошенном периоде: +- `from` — начало периода (ISO 8601) +- `to` — конец периода (ISO 8601) + +**Особенности**: + +- Дни недели нумеруются по стандарту MongoDB: 1 = Воскресенье, 2 = Понедельник, ..., 7 = Суббота +- `average` для дней недели рассчитывается делением общего количества на количество таких дней в периоде +- Дни без записей **не включаются** в массив `daily` +- Глобальная статистика позволяет сравнить свои результаты с другими пользователями + +**Примеры использования**: + +```javascript +// Получение сводной статистики +const response = await fetch('http://localhost:8044/smoke-tracker/stats/summary', { + headers: { + 'Authorization': `Bearer ${token}` + } +}); + +const { body } = await response.json(); + +console.log(`Вы в среднем выкуриваете ${body.user.averagePerDay} сигарет в день`); +console.log(`Общее среднее по всем пользователям: ${body.global.averagePerDay} сигарет в день`); +console.log(`Активных пользователей в периоде: ${body.global.activeUsers}`); + +// Поиск самого "тяжёлого" дня недели +const maxWeekday = body.user.weekday.reduce((max, day) => + parseFloat(day.average) > parseFloat(max.average) ? day : max +); +console.log(`Больше всего вы курите в ${maxWeekday.dayName} (в среднем ${maxWeekday.average} сигарет)`); +``` + +**Визуализация данных по дням недели**: + +```javascript +// Данные для круговой диаграммы (Chart.js) +const weekdayChartData = { + labels: body.user.weekday.map(d => d.dayName), + datasets: [{ + label: 'Сигарет в день недели', + data: body.user.weekday.map(d => d.count), + backgroundColor: [ + 'rgba(255, 99, 132, 0.6)', + 'rgba(54, 162, 235, 0.6)', + 'rgba(255, 206, 86, 0.6)', + 'rgba(75, 192, 192, 0.6)', + 'rgba(153, 102, 255, 0.6)', + 'rgba(255, 159, 64, 0.6)', + 'rgba(199, 199, 199, 0.6)' + ] + }] +}; +``` + +**Сравнение с глобальной статистикой**: + +```javascript +// Сравнительный график (ваши данные vs общие данные) +const comparisonData = { + labels: body.user.weekday.map(d => d.dayName), + datasets: [ + { + label: 'Вы', + data: body.user.weekday.map(d => parseFloat(d.average)), + borderColor: 'rgb(255, 99, 132)', + backgroundColor: 'rgba(255, 99, 132, 0.2)', + }, + { + label: 'Среднее по пользователям', + data: body.global.weekday.map(d => parseFloat(d.average)), + borderColor: 'rgb(54, 162, 235)', + backgroundColor: 'rgba(54, 162, 235, 0.2)', + } + ] +}; +``` + +**Возможные ошибки**: + +- **401 Unauthorized**: `"Требуется авторизация"` — не передан токен +- **401 Unauthorized**: `"Неверный или истекший токен авторизации"` — токен невалидный/просрочен + +--- + ## Общая структура ответов Все эндпоинты возвращают JSON в следующем формате: diff --git a/src/api/client.ts b/src/api/client.ts index 4c0a174..6efacb2 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -11,6 +11,8 @@ import type { GetCigarettesParams, DailyStat, GetDailyStatsParams, + SummaryStats, + GetSummaryStatsParams, } from '../types/api' const TOKEN_KEY = 'smokeToken' @@ -99,5 +101,10 @@ export const statsApi = { const response = await apiClient.get('/stats/daily', { params }) return response.data }, + + getSummary: async (params?: GetSummaryStatsParams): Promise> => { + const response = await apiClient.get('/stats/summary', { params }) + return response.data + }, } diff --git a/src/pages/stats/stats.tsx b/src/pages/stats/stats.tsx index 6e12179..1409395 100644 --- a/src/pages/stats/stats.tsx +++ b/src/pages/stats/stats.tsx @@ -10,11 +10,14 @@ import { Card, Input, Stack, + Table, } from '@chakra-ui/react' import { Field } from '../../components/ui/field' import { LineChart, Line, + BarChart, + Bar, XAxis, YAxis, CartesianGrid, @@ -25,7 +28,7 @@ import { import { format, subDays, eachDayOfInterval, parseISO } from 'date-fns' import { statsApi } from '../../api/client' import { URLs } from '../../__data__/urls' -import type { DailyStat } from '../../types/api' +import type { DailyStat, SummaryStats } from '../../types/api' interface FilledDailyStat { date: string @@ -33,7 +36,8 @@ interface FilledDailyStat { displayDate: string } -export const StatsPage: React.FC = () => { +// Daily Stats Tab Component +const DailyStatsTab: React.FC = () => { const [stats, setStats] = useState([]) const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(null) @@ -78,10 +82,11 @@ export const StatsPage: React.FC = () => { const filled = fillMissingDates(response.body, fromDate, toDate) setStats(filled) } - } catch (err: any) { + } catch (err) { + const error = err as { response?: { data?: { errors?: string; message?: string } } } const errorMessage = - err?.response?.data?.errors || - err?.response?.data?.message || + error?.response?.data?.errors || + error?.response?.data?.message || 'Ошибка при загрузке статистики' setError(errorMessage) } finally { @@ -97,6 +102,438 @@ export const StatsPage: React.FC = () => { const averagePerDay = stats.length > 0 ? (totalCigarettes / stats.length).toFixed(1) : 0 const maxPerDay = Math.max(...stats.map((s) => s.count), 0) + return ( + + {/* Date range selector */} + + + + Выберите период + + + + setFromDate(e.target.value)} + /> + + + + setToDate(e.target.value)} + /> + + + + + + + + + + + {error && ( + + + + {error} + + + + )} + + {/* Summary statistics */} + + + + + + {totalCigarettes} + + + Всего сигарет + + + + + + {averagePerDay} + + + В среднем в день + + + + + + {maxPerDay} + + + Максимум в день + + + + + + + {/* Chart */} + + + + График по дням + + {isLoading ? ( + + Загрузка... + + ) : stats.length === 0 ? ( + + Нет данных за выбранный период + + ) : ( + + + + + + + `Дата: ${label}`} + formatter={(value: number) => [value, 'Сигарет']} + /> + + + + + + )} + + + + + ) +} + +// Summary Stats Tab Component +const SummaryStatsTab: React.FC = () => { + const [summaryStats, setSummaryStats] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + + // Default: last 30 days + const [fromDate, setFromDate] = useState( + format(subDays(new Date(), 30), 'yyyy-MM-dd') + ) + const [toDate, setToDate] = useState(format(new Date(), 'yyyy-MM-dd')) + + const fetchSummary = async () => { + setIsLoading(true) + setError(null) + + try { + const fromISO = new Date(fromDate).toISOString() + const toISO = new Date(toDate + 'T23:59:59').toISOString() + + const response = await statsApi.getSummary({ + from: fromISO, + to: toISO, + }) + + if (response.success) { + setSummaryStats(response.body) + } + } catch (err) { + const error = err as { response?: { data?: { errors?: string; message?: string } } } + const errorMessage = + error?.response?.data?.errors || + error?.response?.data?.message || + 'Ошибка при загрузке сводной статистики' + setError(errorMessage) + } finally { + setIsLoading(false) + } + } + + useEffect(() => { + fetchSummary() + }, []) + + // Prepare data for weekday bar chart + const prepareWeekdayData = () => { + if (!summaryStats) return [] + + // Create a map for quick lookup + const userMap = new Map(summaryStats.user.weekday.map(w => [w.dayOfWeek, parseFloat(w.average)])) + const globalMap = new Map(summaryStats.global.weekday.map(w => [w.dayOfWeek, parseFloat(w.average)])) + + // Get all unique days and sort them (starting from Monday = 2) + const allDays = new Set([ + ...summaryStats.user.weekday.map(w => w.dayOfWeek), + ...summaryStats.global.weekday.map(w => w.dayOfWeek) + ]) + + const sortedDays = Array.from(allDays).sort((a, b) => { + // Sort: Monday(2) to Sunday(1), so 1 goes to end + if (a === 1) return 1 + if (b === 1) return -1 + return a - b + }) + + return sortedDays.map(dayOfWeek => { + const dayName = summaryStats.user.weekday.find(w => w.dayOfWeek === dayOfWeek)?.dayName || + summaryStats.global.weekday.find(w => w.dayOfWeek === dayOfWeek)?.dayName || '' + + return { + dayName, + user: userMap.get(dayOfWeek) || 0, + global: globalMap.get(dayOfWeek) || 0, + } + }) + } + + const weekdayChartData = prepareWeekdayData() + + return ( + + {/* Date range selector */} + + + + Выберите период + + + + setFromDate(e.target.value)} + /> + + + + setToDate(e.target.value)} + /> + + + + + + + + + + + {error && ( + + + + {error} + + + + )} + + {isLoading ? ( + Загрузка... + ) : !summaryStats ? ( + Нет данных + ) : ( + <> + {/* Key Metrics */} + + + + Ключевые показатели + + + + + {summaryStats.user.averagePerDay.toFixed(1)} + + + Ваш средний в день + + + + + + {summaryStats.global.averagePerDay.toFixed(1)} + + + Средний по пользователям + + + + + + {summaryStats.user.total} + + + Всего за период + + + + + + {summaryStats.global.activeUsers} + + + Активных пользователей + + + + + + {summaryStats.user.daysWithData} + + + Дней с данными + + + + + + + + {/* Weekday Bar Chart */} + + + + Статистика по дням недели + + {weekdayChartData.length === 0 ? ( + + Нет данных за выбранный период + + ) : ( + + + + + + + value.toFixed(2)} + /> + + + + + + + )} + + + + + {/* Detailed Weekday Table */} + + + + Детализация по дням недели + + + + + + День недели + Ваше среднее + Глобальное среднее + Разница, % + + + + {weekdayChartData.map((day) => { + const diff = day.global > 0 + ? ((day.user - day.global) / day.global * 100).toFixed(1) + : '0' + const diffColor = parseFloat(diff) > 0 ? 'red.600' : parseFloat(diff) < 0 ? 'green.600' : 'gray.600' + + return ( + + {day.dayName} + {day.user.toFixed(2)} + {day.global.toFixed(2)} + + {parseFloat(diff) > 0 ? '+' : ''}{diff}% + + + ) + })} + + + + + + + + )} + + ) +} + +// Main Stats Page with Tabs +export const StatsPage: React.FC = () => { + const [activeTab, setActiveTab] = useState<'daily' | 'summary'>('daily') + return ( @@ -115,141 +552,34 @@ export const StatsPage: React.FC = () => { - {/* Date range selector */} - - - - Выберите период + {/* Tab Selector */} + + + + - - - setFromDate(e.target.value)} - /> - - - - setToDate(e.target.value)} - /> - - - - - - - - - - - {error && ( - - - - {error} - - - - )} - - {/* Summary statistics */} - - - - - - {totalCigarettes} - - - Всего сигарет - - - - - - {averagePerDay} - - - В среднем в день - - - - - - {maxPerDay} - - - Максимум в день - - - - - - - {/* Chart */} - - - - График по дням - - {isLoading ? ( - - Загрузка... - - ) : stats.length === 0 ? ( - - Нет данных за выбранный период - - ) : ( - - - - - - - `Дата: ${label}`} - formatter={(value: number) => [value, 'Сигарет']} - /> - - - - - - )} - - - + {/* Tab Content */} + + {activeTab === 'daily' ? : } + ) diff --git a/src/types/api.ts b/src/types/api.ts index 5e54ffc..8e074bb 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -62,3 +62,42 @@ export interface GetDailyStatsParams { to?: string } +// Summary statistics types +export interface WeekdayStat { + dayOfWeek: number // 1 = Sunday, 2 = Monday, ..., 7 = Saturday + dayName: string + count: number + average: string +} + +export interface UserSummary { + daily: DailyStat[] + averagePerDay: number + weekday: WeekdayStat[] + total: number + daysWithData: number +} + +export interface GlobalSummary { + daily: DailyStat[] + averagePerDay: number + weekday: WeekdayStat[] + total: number + daysWithData: number + activeUsers: number +} + +export interface SummaryStats { + user: UserSummary + global: GlobalSummary + period: { + from: string + to: string + } +} + +export interface GetSummaryStatsParams { + from?: string + to?: string +} + diff --git a/stubs/api/index.js b/stubs/api/index.js index bea4f20..11f718f 100644 --- a/stubs/api/index.js +++ b/stubs/api/index.js @@ -5,9 +5,16 @@ const timer = (time = 300) => (req, res, next) => setTimeout(next, time); router.use(timer()); // In-memory storage for demo -const users = []; +const users = [ + { + id: '1', + login: 'testuser', + password: 'test1234', + created: new Date('2024-01-01T00:00:00.000Z').toISOString() + } +]; const cigarettes = []; -let userIdCounter = 1; +let userIdCounter = 2; let cigaretteIdCounter = 1; // Simple token generation (for demo purposes only) @@ -191,4 +198,108 @@ router.get('/stats/daily', authMiddleware, (req, res) => { }); }); +// GET /stats/summary +router.get('/stats/summary', authMiddleware, (req, res) => { + const { from, to } = req.query; + + // Default: 30 days ago to now + const fromDate = from ? new Date(from) : new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + const toDate = to ? new Date(to) : new Date(); + + // Helper function to get day name in Russian + const getDayName = (dayOfWeek) => { + const names = ['', 'Воскресенье', 'Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота']; + return names[dayOfWeek]; + }; + + // Helper function to calculate stats for a set of cigarettes + const calculateStats = (cigs) => { + // Daily stats + const dailyStats = {}; + cigs.forEach(c => { + const date = c.smokedAt.split('T')[0]; + dailyStats[date] = (dailyStats[date] || 0) + 1; + }); + + const daily = Object.entries(dailyStats) + .map(([date, count]) => ({ date, count })) + .sort((a, b) => a.date.localeCompare(b.date)); + + // Weekday stats (1=Sunday, 2=Monday, ..., 7=Saturday) + const weekdayStats = {}; + cigs.forEach(c => { + const date = new Date(c.smokedAt); + const dayOfWeek = date.getDay() + 1; // JS getDay() returns 0-6, we want 1-7 + weekdayStats[dayOfWeek] = (weekdayStats[dayOfWeek] || 0) + 1; + }); + + // Count occurrences of each weekday in the period + const weekdayCounts = {}; + let current = new Date(fromDate); + while (current <= toDate) { + const dayOfWeek = current.getDay() + 1; + weekdayCounts[dayOfWeek] = (weekdayCounts[dayOfWeek] || 0) + 1; + current.setDate(current.getDate() + 1); + } + + const weekday = Object.entries(weekdayStats) + .map(([dayOfWeek, count]) => { + const dow = parseInt(dayOfWeek); + const occurrences = weekdayCounts[dow] || 1; + return { + dayOfWeek: dow, + dayName: getDayName(dow), + count, + average: (count / occurrences).toFixed(2) + }; + }) + .sort((a, b) => a.dayOfWeek - b.dayOfWeek); + + const total = cigs.length; + const daysWithData = Object.keys(dailyStats).length; + const averagePerDay = daysWithData > 0 ? total / daysWithData : 0; + + return { + daily, + averagePerDay: parseFloat(averagePerDay.toFixed(2)), + weekday, + total, + daysWithData + }; + }; + + // User cigarettes + const userCigarettes = cigarettes.filter(c => { + if (c.userId !== req.userId) return false; + const smokedDate = new Date(c.smokedAt); + return smokedDate >= fromDate && smokedDate <= toDate; + }); + + // Global cigarettes (all users) + const globalCigarettes = cigarettes.filter(c => { + const smokedDate = new Date(c.smokedAt); + return smokedDate >= fromDate && smokedDate <= toDate; + }); + + // Count active users + const activeUsers = new Set(globalCigarettes.map(c => c.userId)).size; + + // Calculate stats + const userStats = calculateStats(userCigarettes); + const globalStats = calculateStats(globalCigarettes); + globalStats.activeUsers = activeUsers; + + res.json({ + success: true, + body: { + user: userStats, + global: globalStats, + period: { + from: fromDate.toISOString(), + to: toDate.toISOString() + } + } + }); +}); + module.exports = router;