добавил методы для конкурсов

This commit is contained in:
Мельников Данил 2025-06-01 23:25:12 +05:00
parent 00d17363f2
commit 203f6763d6
23 changed files with 624 additions and 41 deletions

View File

@ -0,0 +1,16 @@
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.models import Contest
class ContestStatusesRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def get_by_id(self, contest_status_id: int) -> Optional[Contest]:
stmt = select(Contest).filter_by(id=contest_status_id)
result = await self.db.execute(stmt)
return result.scalars().first()

View File

@ -0,0 +1,37 @@
from typing import Optional
from sqlalchemy import select, Sequence
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.models import Contest
class ContestsRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def get_all(self) -> Sequence[Contest]:
stmt = select(Contest)
result = await self.db.execute(stmt)
return result.scalars().all()
async def get_by_id(self, contest_id: int) -> Optional[Contest]:
stmt = select(Contest).filter_by(id=contest_id)
result = await self.db.execute(stmt)
return result.scalars().first()
async def create(self, contest: Contest) -> Contest:
self.db.add(contest)
await self.db.commit()
await self.db.refresh(contest)
return contest
async def update(self, contest: Contest) -> Contest:
await self.db.merge(contest)
await self.db.commit()
return contest
async def delete(self, contest: Contest) -> Contest:
await self.db.delete(contest)
await self.db.commit()
return contest

View File

@ -1,6 +1,6 @@
from typing import Optional
from sqlalchemy import select
from sqlalchemy import select, Sequence
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.models import Profile
@ -10,6 +10,11 @@ class ProfilesRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def get_all(self) -> Sequence[Profile]:
stmt = select(Profile)
result = await self.db.execute(stmt)
return result.scalars().all()
async def get_by_id(self, profile_id: int) -> Optional[Profile]:
stmt = select(Profile).filter_by(id=profile_id)
result = await self.db.execute(stmt)

View File

@ -0,0 +1,88 @@
from typing import Optional
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import get_db
from app.domain.entities.contest import ContestEntity
from app.infrastructure.contests_service import ContestsService
from app.infrastructure.dependencies import require_admin, get_current_user
router = APIRouter()
@router.get(
'/',
response_model=list[ContestEntity],
summary='Get all contests',
description='Returns all contests',
)
async def get_all_contests(
db: AsyncSession = Depends(get_db),
):
contests_service = ContestsService(db)
return await contests_service.get_all_contests()
@router.post(
'/',
response_model=Optional[ContestEntity],
summary='Create a new contest',
description='Creates a new contest',
)
async def create_contest(
contest: ContestEntity,
db: AsyncSession = Depends(get_db),
user=Depends(require_admin),
):
contests_service = ContestsService(db)
return await contests_service.create_contest(contest)
@router.put(
'/{contest_id}/',
response_model=Optional[ContestEntity],
summary='Update a contest',
description='Updates a contest',
)
async def update_contest(
contest_id: int,
contest: ContestEntity,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
contests_service = ContestsService(db)
return await contests_service.update_contest(contest_id, contest, user)
@router.delete(
'/{contest_id}/',
response_model=Optional[ContestEntity],
summary='Delete a contest',
description='Delete a contest',
)
async def delete_contest(
contest_id: int,
db: AsyncSession = Depends(get_db),
user=Depends(require_admin),
):
contests_service = ContestsService(db)
return await contests_service.delete(contest_id, user)
@router.get(
'/project/{project_id}/',
response_model=Optional[ContestEntity],
summary='Get project by contest ID',
description='Retrieve project data by contest ID',
)
async def get_project_by_contest_id(
project_id: int,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
contests_service = ContestsService(db)
contest = await contests_service.get_by_project(project_id)
return contest

View File

@ -1,6 +1,6 @@
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import get_db
@ -10,6 +10,18 @@ from app.infrastructure.profiles_service import ProfilesService
router = APIRouter()
@router.get(
'/',
response_model=list[ProfileEntity],
summary='Get all profiles',
description='Returns all profiles',
)
async def get_all_profiles(
db: AsyncSession = Depends(get_db),
):
profiles_service = ProfilesService(db)
return await profiles_service.get_all_profiles()
@router.post(
'/',

View File

@ -0,0 +1,13 @@
from typing import Optional
from pydantic import BaseModel
class ContestEntity(BaseModel):
id: int
title: str
description: Optional[str] = None
web_url: str
photo: Optional[str] = None
results: Optional[str] = None
is_win: Optional[bool] = None
project_id: int
status_id: int

View File

@ -0,0 +1,158 @@
from typing import Optional
from fastapi import HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.contest_statuses_repository import ContestStatusesRepository
from app.application.contests_repository import ContestsRepository
from app.application.projects_repository import ProjectsRepository
from app.application.users_repository import UsersRepository
from app.domain.entities.contest import ContestEntity
from app.domain.models import Contest, User
class ContestsService:
def __init__(self, db: AsyncSession):
self.contests_repository = ContestsRepository(db)
self.projects_repository = ProjectsRepository(db)
self.statuses_repository = ContestStatusesRepository(db)
self.users_repository = UsersRepository(db)
async def get_all_contests(self) -> list[ContestEntity]:
contests = await self.contests_repository.get_all()
return [
self.model_to_entity(contest)
for contest in contests
]
async def create_contest(self, contest: ContestEntity) -> Optional[ContestEntity]:
project = await self.projects_repository.get_by_id(contest.project_id)
if project is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='The project with this ID was not found',
)
status_contest = await self.statuses_repository.get_by_id(contest.status_id)
if status_contest is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='The status with this ID was not found',
)
contest_model = self.entity_to_model(contest)
contest_model = await self.contests_repository.create(contest_model)
return self.model_to_entity(contest_model)
async def update_contest(self, contest_id: int, contest: ContestEntity, user: User) -> Optional[
ContestEntity
]:
user = await self.users_repository.get_by_id(user.id)
if user is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='The user with this ID was not found',
)
elif user.profile.role.title != 'Администратор':
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Permission denied",
)
contest_model = await self.contests_repository.get_by_id(contest_id)
if contest_model is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='The contest with this ID was not found',
)
project = await self.projects_repository.get_by_id(contest.project_id)
if project is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='The project with this ID was not found',
)
status_contest = await self.statuses_repository.get_by_id(contest.role_id)
if status_contest is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='The status with this ID was not found',
)
contest_model.title = contest.title
contest_model.description = contest.description
contest_model.web_url = contest.web_url
contest_model.photo = contest.photo
contest_model.results = contest.results
contest_model.is_win = contest.is_win
contest_model.project_id = contest.project_id
contest_model.status_id = contest.status_id
contest_model = await self.contests_repository.update(contest_model)
return self.model_to_entity(contest_model)
async def delete(self, contest_id: int, user: User):
user = await self.users_repository.get_by_id(user.id)
if user is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='The user with this ID was not found',
)
contest_model = await self.contests_repository.get_by_id(contest_id)
if user.profile.role.title != 'Администратор':
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Permission denied',
)
result = await self.contests_repository.delete(contest_model)
return self.model_to_entity(result)
async def get_by_project(self, project_id: int) -> Optional[ContestEntity]:
project = await self.projects_repository.get_by_id(project_id)
if project is None:
raise HTTPException(status_code=404, detail='Project not found')
contest_model = await self.contests_repository.get_by_id(project.contest_id)
if not contest_model:
raise HTTPException(status_code=404, detail='Contest not found')
return self.model_to_entity(contest_model)
@staticmethod
def model_to_entity(contest_model: Contest) -> ContestEntity:
return ContestEntity(
id=contest_model.id,
title=contest_model.title,
description=contest_model.description,
web_url=contest_model.web_url,
photo=contest_model.photo,
results=contest_model.results,
is_win=contest_model.is_win,
project_id=contest_model.project_id,
status_id=contest_model.status_id,
)
@staticmethod
def entity_to_model(contest_entity: ContestEntity) -> Contest:
contest_model = Contest(
title=contest_entity.title,
description=contest_entity.description,
web_url=contest_entity.web_url,
photo=contest_entity.photo,
results=contest_entity.results,
is_win=contest_entity.is_win,
project_id=contest_entity.project_id,
status_id=contest_entity.status_id
)
if contest_entity.id is not None:
contest_model.id = contest_entity.id
return contest_model

View File

@ -18,6 +18,13 @@ class ProfilesService:
self.roles_repository = RolesRepository(db)
self.users_repository = UsersRepository(db)
async def get_all_profiles(self) -> list[ProfileEntity]:
profiles = await self.profiles_repository.get_all()
return [
self.model_to_entity(profile)
for profile in profiles
]
async def create_profile(self, profile: ProfileEntity) -> Optional[ProfileEntity]:
team = await self.teams_repository.get_by_id(profile.team_id)
if team is None:

View File

@ -11,6 +11,7 @@ from app.contollers.register_router import router as register_router
from app.contollers.rss_router import router as rss_router
from app.contollers.teams_router import router as team_router
from app.contollers.users_router import router as users_router
from app.contollers.contests_router import router as contest_router
from app.settings import settings
@ -35,6 +36,7 @@ def start_app():
api_app.include_router(rss_router, prefix=f'{settings.PREFIX}/rss', tags=['rss_router'])
api_app.include_router(team_router, prefix=f'{settings.PREFIX}/teams', tags=['teams'])
api_app.include_router(users_router, prefix=f'{settings.PREFIX}/users', tags=['users'])
api_app.include_router(contest_router,prefix=f'{settings.PREFIX}/contests', tags=['contests'])
return api_app

View File

@ -0,0 +1,24 @@
import axios from 'axios'
import CONFIG from '@/core/config.js'
const createProfile = async (profile) => {
console.log(profile)
try {
const token = localStorage.getItem('access_token') // или другой способ получения токена
const response = await axios.post(
`${CONFIG.BASE_URL}/profiles`,
profile,
{
withCredentials: true,
headers: {
Authorization: `Bearer ${token}`
}
}
)
return response.data
} catch (error) {
throw new Error(error.response?.data?.detail || error.message)
}
}
export default createProfile

View File

@ -0,0 +1,22 @@
import axios from 'axios'
import CONFIG from '@/core/config.js'
const deleteProfile = async (profileId) => {
try {
const token = localStorage.getItem('access_token') // получение токена
const response = await axios.delete(
`${CONFIG.BASE_URL}/profiles/${profileId}`,
{
withCredentials: true,
headers: {
Authorization: `Bearer ${token}`
}
}
)
return response.data
} catch (error) {
throw new Error(error.response?.data?.detail || error.message)
}
}
export default deleteProfile

View File

@ -0,0 +1,25 @@
import axios from 'axios'
import CONFIG from '../../core/config.js'
const fetchProfile = async () => {
try {
const token = localStorage.getItem("access_token");
const response = await axios.get(`${CONFIG.BASE_URL}/profiles`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
return response.data;
} catch (error) {
if (error.response?.status === 401) {
throw new Error("Нет доступа к пользователям");
} else if (error.response?.status === 403) {
throw new Error("Доступ запрещён");
}
throw new Error(error.message);
}
};
export default fetchProfile;

View File

@ -0,0 +1,31 @@
import axios from 'axios'
import CONFIG from '@/core/config.js'
const updateProfile = async (profile) => {
try {
const token = localStorage.getItem('access_token')
// Убираем id из тела запроса, он идет в URL
const { id, ...profileData } = profile
console.log('Отправляем на сервер:', profileData)
const response = await axios.put(
`${CONFIG.BASE_URL}/profiles/${id}`,
profileData,
{
withCredentials: true,
headers: {
Authorization: `Bearer ${token}`
}
}
)
console.log('Ответ от сервера:', response.data)
return response.data
} catch (error) {
throw new Error(error.response?.data?.detail || error.message)
}
}
export default updateProfile

View File

@ -0,0 +1,24 @@
import axios from 'axios'
import CONFIG from '@/core/config.js'
const createProfile = async (profile) => {
console.log(profile)
try {
const token = localStorage.getItem('access_token') // или другой способ получения токена
const response = await axios.post(
`${CONFIG.BASE_URL}/profiles`,
profile,
{
withCredentials: true,
headers: {
Authorization: `Bearer ${token}`
}
}
)
return response.data
} catch (error) {
throw new Error(error.response?.data?.detail || error.message)
}
}
export default createProfile

View File

@ -0,0 +1,22 @@
import axios from 'axios'
import CONFIG from '@/core/config.js'
const deleteProfile = async (profileId) => {
try {
const token = localStorage.getItem('access_token') // получение токена
const response = await axios.delete(
`${CONFIG.BASE_URL}/profiles/${profileId}`,
{
withCredentials: true,
headers: {
Authorization: `Bearer ${token}`
}
}
)
return response.data
} catch (error) {
throw new Error(error.response?.data?.detail || error.message)
}
}
export default deleteProfile

View File

@ -0,0 +1,25 @@
import axios from 'axios'
import CONFIG from '../../core/config.js'
const fetchProfile = async () => {
try {
const token = localStorage.getItem("access_token");
const response = await axios.get(`${CONFIG.BASE_URL}/profiles`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
return response.data;
} catch (error) {
if (error.response?.status === 401) {
throw new Error("Нет доступа к пользователям");
} else if (error.response?.status === 403) {
throw new Error("Доступ запрещён");
}
throw new Error(error.message);
}
};
export default fetchProfile;

View File

@ -1,17 +0,0 @@
import axios from 'axios'
import CONFIG from '../../core/config.js'
const getUserProfile = async (user_id, token) => {
try {
const response = await axios.get(`${CONFIG.BASE_URL}/profiles/${user_id}/`, {
headers: {
Authorization: `Bearer ${token}`
}
})
return response.data
} catch (error) {
throw new Error(error.response?.data?.detail || 'Ошибка получения профиля')
}
}
export default getUserProfile

View File

@ -0,0 +1,31 @@
import axios from 'axios'
import CONFIG from '@/core/config.js'
const updateProfile = async (profile) => {
try {
const token = localStorage.getItem('access_token')
// Убираем id из тела запроса, он идет в URL
const { id, ...profileData } = profile
console.log('Отправляем на сервер:', profileData)
const response = await axios.put(
`${CONFIG.BASE_URL}/profiles/${id}`,
profileData,
{
withCredentials: true,
headers: {
Authorization: `Bearer ${token}`
}
}
)
console.log('Ответ от сервера:', response.data)
return response.data
} catch (error) {
throw new Error(error.response?.data?.detail || error.message)
}
}
export default updateProfile

View File

@ -2,6 +2,7 @@ import axios from 'axios'
import CONFIG from '@/core/config.js'
const createProject = async (project) => {
console.log(project)
try {
const token = localStorage.getItem('access_token') // или другой способ получения токена
const response = await axios.post(

View File

@ -8,7 +8,6 @@ const updateProject = async (project) => {
// Убираем id из тела запроса, он идет в URL
const { id, ...projectData } = project
console.log('Отправляем на сервер:', projectData)
const response = await axios.put(
`${CONFIG.BASE_URL}/projects/${id}`,
@ -21,7 +20,6 @@ const updateProject = async (project) => {
}
)
console.log('Ответ от сервера:', response.data)
return response.data
} catch (error) {
throw new Error(error.response?.data?.detail || error.message)

View File

@ -8,7 +8,6 @@ const updateTeam = async (team) => {
// Убираем id из тела запроса, он идет в URL
const { id, ...teamData } = team
console.log('Отправляем на сервер:', teamData)
const response = await axios.put(
`${CONFIG.BASE_URL}/teams/${id}`,
@ -21,7 +20,6 @@ const updateTeam = async (team) => {
}
)
console.log('Ответ от сервера:', response.data)
return response.data
} catch (error) {
throw new Error(error.response?.data?.detail || error.message)

View File

@ -11,7 +11,7 @@
animated
@update:model-value="loadData"
>
<q-tab name="users" label="Пользователи" />
<q-tab name="profiles" label="Пользователи" />
<q-tab name="teams" label="Команды" />
<q-tab name="projects" label="Проекты" />
<q-tab name="contests" label="Конкурсы" />
@ -22,7 +22,7 @@
<q-tab-panels v-model="tab" animated transition-prev="slide-right" transition-next="slide-left">
<!-- Пользователи -->
<q-tab-panel name="users">
<q-tab-panel name="profiles">
<div class="violet-card q-pa-md">
<div class="q-gutter-sm q-mb-sm row items-center justify-between">
<q-btn
@ -33,11 +33,11 @@
</div>
<q-table
title="Пользователи"
:rows="users"
:columns="userColumns"
:rows="profiles"
:columns="profileColumns"
row-key="id"
@row-click="onRowClick"
:loading="loadingUsers"
:loading="loadingProfiles"
dense
flat
/>
@ -125,7 +125,7 @@
<template v-if="dialogType === 'teams'">
Редактирование команды {{ dialogData.title || '' }}
</template>
<template v-else-if="dialogType === 'users'">
<template v-else-if="dialogType === 'profiles'">
Редактирование пользователя {{ dialogData.name || dialogData.login || '' }}
</template>
<template v-else-if="dialogType === 'projects'">
@ -151,19 +151,23 @@
<q-input v-model="dialogData.git_url" label="Git URL" dense clearable class="q-mt-sm" />
</template>
<!-- Users -->
<template v-else-if="dialogType === 'users'">
<q-input v-model="dialogData.login" label="Логин" dense autofocus clearable />
<q-input v-model="dialogData.name" label="Имя" dense clearable class="q-mt-sm" />
<q-input v-model="dialogData.email" label="Email" dense clearable class="q-mt-sm" />
<!-- Добавь другие поля, которые нужны для пользователя -->
<!-- Profiles -->
<template v-else-if="dialogType === 'profiles'">
<q-input v-model="dialogData.first_name" label="Имя" dense autofocus clearable />
<q-input v-model="dialogData.last_name" label="Фамилия" dense clearable class="q-mt-sm" />
<q-input v-model="dialogData.patronymic" label="Отчество" dense clearable class="q-mt-sm" />
<q-input v-model="dialogData.birthday" label="День рождения" dense clearable class="q-mt-sm" type="date" />
<q-input v-model="dialogData.email" label="Почта" dense clearable class="q-mt-sm" />
<q-input v-model="dialogData.phone" label="Телефон" dense clearable class="q-mt-sm" />
<q-input v-model="dialogData.role_id" label="Роль" dense clearable class="q-mt-sm" />
<q-input v-model="dialogData.team_id" label="Команда" dense clearable class="q-mt-sm" />
</template>
<!-- Projects -->
<template v-else-if="dialogType === 'projects'">
<q-input v-model="dialogData.title" label="Название проекта" dense autofocus clearable />
<q-input v-model="dialogData.description" label="Описание" dense clearable type="textarea" class="q-mt-sm" />
<q-input v-model="dialogData.repo_url" label="URL репозитория" dense clearable class="q-mt-sm" />
<q-input v-model="dialogData.repository_url" label="URL репозитория" dense clearable class="q-mt-sm" />
<!-- Другие поля проекта -->
</template>
@ -190,7 +194,7 @@
@click="deleteItem"
/>
<q-btn
v-if="dialogType === 'users'"
v-if="dialogType === 'profiles'"
flat
label="Удалить"
color="negative"
@ -213,7 +217,7 @@
<q-space />
<q-btn flat label="Закрыть" color="primary" @click="closeDialog" />
<q-btn
v-if="['teams', 'users', 'projects', 'contests'].includes(dialogType)"
v-if="['teams', 'profiles', 'projects', 'contests'].includes(dialogType)"
flat
label="Сохранить"
color="primary"
@ -239,8 +243,32 @@ import updateProject from '@/api/projects/updateProject.js'
import deleteProjectById from '@/api/projects/deleteProject.js'
import createProject from '@/api/projects/createProject.js'
import fetchProfiles from '@/api/profiles/getProfiles.js'
import updateProfile from '@/api/profiles/updateProfile.js'
import deleteProfileById from '@/api/profiles/deleteProfile.js'
import createProfile from '@/api/profiles/createProfile.js'
// import fetchContests from '@/api/contests/getContests.js'
// import updateContest from '@/api/contests/updateContest.js'
// import deleteContestById from '@/api/contests/deleteContest.js'
// import createContest from '@/api/contests/createContest.js'
// Текущая вкладка 'teams' или 'projects'
const tab = ref('teams')
const tab = ref('profiles')
// --- Profiles ---
const profiles = ref([])
const loadingProfiles = ref(false)
const profileColumns = [
{ name: 'first_name', label: 'Имя', field: 'first_name', sortable: true },
{ name: 'last_name', label: 'Фамилия', field: 'last_name', sortable: true },
{ name: 'patronymic', label: 'Отчество', field: 'patronymic', sortable: true },
{ name: 'birthday', label: 'День рождения', field: 'birthday', sortable: true },
{ name: 'email', label: 'Почта', field: 'email', sortable: true },
{ name: 'phone', label: 'Телефон', field: 'phone', sortable: true },
{ name: 'role_id', label: 'Роль', field: 'role_id', sortable: true },
{ name: 'team_id', label: 'Команда', field: 'team_id', sortable: true },
]
// --- Teams ---
const teams = ref([])
@ -261,6 +289,17 @@ const projectColumns = [
{ name: 'repository_url', label: 'Репозиторий', field: 'repository_url', sortable: true },
]
// --- Contests ---
const contests = ref([])
const loadingContests = ref(false)
const contestColumns = [
{ name: 'title', label: 'Название проекта', field: 'title', sortable: true },
{ name: 'description', label: 'Описание', field: 'description', sortable: true },
{ name: 'repository_url', label: 'Репозиторий', field: 'repository_url', sortable: true },
]
// Общие состояния для диалогов
const dialogVisible = ref(false)
const dialogData = ref({})
@ -308,6 +347,15 @@ async function saveChanges() {
const newProject = await createProject(dialogData.value)
projects.value.push(newProject)
}
} else if (dialogType.value === 'profiles') {
if (dialogData.value.id) {
await updateProfile(dialogData.value)
const idx = profiles.value.findIndex(p => p.id === dialogData.value.id)
if (idx !== -1) profiles.value[idx] = JSON.parse(JSON.stringify(dialogData.value))
} else {
const newProfile = await createProfile(dialogData.value)
profiles.value.push(newProfile)
}
}
closeDialog()
} catch (error) {
@ -336,6 +384,16 @@ async function loadData(name) {
} finally {
loadingProjects.value = false
}
} else if (name === 'profiles') {
loadingProfiles.value = true
try {
profiles.value = await fetchProfiles() || []
} catch (error) {
projects.value = []
console.error(error.message)
} finally {
loadingProfiles.value = false
}
}
}
@ -348,6 +406,9 @@ async function deleteItem() {
} else if (dialogType.value === 'projects') {
await deleteProjectById(dialogData.value.id)
projects.value = projects.value.filter(p => p.id !== dialogData.value.id)
} else if (dialogType.value === 'profiles') {
await deleteProfileById(dialogData.value.id)
profiles.value = profiles.value.filter(p => p.id !== dialogData.value.id)
}
closeDialog()
} catch (error) {

View File

@ -186,7 +186,7 @@ const contests = ref([
// --- Активность ---
const activityData = ref([]);
const dayHeight = 14;
const squareSize = ref(14);
const squareSize = ref(12);
// Подписи месяцев (с июня 2024 по май 2025, чтобы соответствовать текущему году)
const monthLabels = ['июн.', 'июл.', 'авг.', 'сент.', 'окт.', 'нояб.', 'дек.', 'янв.', 'февр.', 'март', 'апр.', 'май'];
@ -241,7 +241,7 @@ function getMonthMargin(idx) {
}
// Загрузка активности из API
const username = 'andrei';
const username = 'Numerum';
async function loadActivity() {
try {