| import os |
| import json |
| import threading |
| import time |
| import uuid |
| from datetime import datetime, timedelta |
| import zoneinfo |
| from flask import Flask, render_template_string, request, jsonify |
| from huggingface_hub import HfApi, hf_hub_download |
| from huggingface_hub.utils import RepositoryNotFoundError |
|
|
| app = Flask(__name__) |
| app.secret_key = os.getenv("FLASK_SECRET_KEY", "med_tracker_secret_key") |
| DATA_FILE = 'medications_data.json' |
| REPO_ID = os.getenv("REPO_ID", "Kgshop/clients") |
| HF_TOKEN_WRITE = os.getenv("HF_TOKEN") |
| HF_TOKEN_READ = os.getenv("HF_TOKEN_READ") |
|
|
| def load_data(): |
| try: |
| hf_hub_download( |
| repo_id=REPO_ID, |
| filename=DATA_FILE, |
| repo_type="dataset", |
| token=HF_TOKEN_READ, |
| local_dir=".", |
| local_dir_use_symlinks=False |
| ) |
| with open(DATA_FILE, 'r', encoding='utf-8') as f: |
| data = json.load(f) |
| if not isinstance(data, dict) or 'schedules' not in data: |
| return {'schedules': []} |
| return data |
| except Exception: |
| return {'schedules': []} |
|
|
| def save_data(data): |
| with open(DATA_FILE, 'w', encoding='utf-8') as f: |
| json.dump(data, f, ensure_ascii=False, indent=4) |
| try: |
| api = HfApi() |
| api.upload_file( |
| path_or_fileobj=DATA_FILE, |
| path_in_repo=DATA_FILE, |
| repo_id=REPO_ID, |
| repo_type="dataset", |
| token=HF_TOKEN_WRITE |
| ) |
| except Exception: |
| pass |
|
|
| def periodic_backup(): |
| while True: |
| try: |
| data = load_data() |
| save_data(data) |
| except Exception: |
| pass |
| time.sleep(800) |
|
|
| HTML_CONTENT = ''' |
| <!DOCTYPE html> |
| <html lang="ru"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> |
| <title>MedTracker</title> |
| <style> |
| :root { |
| --bg: #F2F2F7; |
| --surface: #FFFFFF; |
| --text: #000000; |
| --text-sec: #8A8A8E; |
| --acc: #007AFF; |
| --acc-hov: #005bb5; |
| --border: #E5E5EA; |
| --red: #FF3B30; |
| --green: #34C759; |
| } |
| @media (prefers-color-scheme: dark) { |
| :root { |
| --bg: #000000; |
| --surface: #1C1C1E; |
| --text: #FFFFFF; |
| --text-sec: #EBEBF599; |
| --acc: #0A84FF; |
| --acc-hov: #0060df; |
| --border: #38383A; |
| } |
| } |
| * { |
| box-sizing: border-box; |
| -webkit-tap-highlight-color: transparent; |
| } |
| body { |
| margin: 0; |
| padding: 0; |
| font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; |
| background-color: var(--bg); |
| color: var(--text); |
| -webkit-font-smoothing: antialiased; |
| } |
| .app-container { |
| max-width: 600px; |
| margin: 0 auto; |
| min-height: 100vh; |
| position: relative; |
| background: var(--bg); |
| overflow-x: hidden; |
| } |
| .nav-bar { |
| position: sticky; |
| top: 0; |
| z-index: 10; |
| background: rgba(255, 255, 255, 0.85); |
| backdrop-filter: blur(12px); |
| -webkit-backdrop-filter: blur(12px); |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| padding: 16px 20px; |
| border-bottom: 1px solid var(--border); |
| } |
| @media (prefers-color-scheme: dark) { |
| .nav-bar { |
| background: rgba(28, 28, 30, 0.85); |
| } |
| } |
| .nav-title { |
| font-size: 20px; |
| font-weight: 600; |
| flex: 1; |
| text-align: center; |
| } |
| .nav-btn { |
| background: none; |
| border: none; |
| color: var(--acc); |
| font-size: 17px; |
| cursor: pointer; |
| padding: 0; |
| font-weight: 500; |
| width: 80px; |
| text-align: left; |
| } |
| .nav-btn.right { |
| text-align: right; |
| } |
| .view { |
| display: none; |
| animation: fade 0.3s cubic-bezier(0.25, 0.8, 0.25, 1); |
| padding: 20px; |
| padding-bottom: 100px; |
| } |
| .view.active { |
| display: block; |
| } |
| @keyframes fade { |
| from { opacity: 0; transform: translateY(15px); } |
| to { opacity: 1; transform: translateY(0); } |
| } |
| .card { |
| background: var(--surface); |
| border-radius: 16px; |
| padding: 18px; |
| margin-bottom: 16px; |
| box-shadow: 0 4px 12px rgba(0,0,0,0.03); |
| cursor: pointer; |
| transition: transform 0.15s; |
| } |
| .card:active { |
| transform: scale(0.98); |
| } |
| .card-title { |
| font-size: 19px; |
| font-weight: 600; |
| margin-bottom: 6px; |
| margin-top: 0; |
| } |
| .card-sub { |
| font-size: 15px; |
| color: var(--text-sec); |
| margin: 0; |
| } |
| .progress-bar { |
| height: 8px; |
| background: var(--border); |
| border-radius: 4px; |
| margin-top: 14px; |
| overflow: hidden; |
| } |
| .progress-fill { |
| height: 100%; |
| background: var(--acc); |
| transition: width 0.4s ease; |
| } |
| .fab { |
| position: fixed; |
| bottom: 30px; |
| left: 50%; |
| transform: translateX(-50%); |
| width: 60px; |
| height: 60px; |
| border-radius: 30px; |
| background: var(--acc); |
| color: white; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| font-size: 28px; |
| font-weight: 300; |
| box-shadow: 0 6px 16px rgba(0,122,255,0.35); |
| border: none; |
| cursor: pointer; |
| transition: transform 0.2s, background 0.2s; |
| z-index: 20; |
| } |
| .fab:active { |
| transform: translateX(-50%) scale(0.92); |
| background: var(--acc-hov); |
| } |
| .day-header { |
| font-size: 17px; |
| font-weight: 600; |
| padding: 16px 18px; |
| background: var(--surface); |
| border-radius: 14px; |
| margin-bottom: 10px; |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| cursor: pointer; |
| user-select: none; |
| box-shadow: 0 2px 8px rgba(0,0,0,0.02); |
| scroll-margin-top: 80px; |
| } |
| .day-content { |
| display: none; |
| padding: 4px 0 16px 0; |
| } |
| .day-content.open { |
| display: block; |
| animation: fade 0.2s ease; |
| } |
| .dose-row { |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| padding: 16px; |
| background: var(--surface); |
| border-radius: 14px; |
| margin-bottom: 10px; |
| border: 1px solid var(--border); |
| } |
| .dose-left { |
| display: flex; |
| align-items: center; |
| gap: 14px; |
| } |
| .med-avatar { |
| width: 44px; |
| height: 44px; |
| border-radius: 50%; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| color: white; |
| font-weight: 600; |
| font-size: 20px; |
| flex-shrink: 0; |
| } |
| .dose-info { |
| display: flex; |
| flex-direction: column; |
| } |
| .dose-name { |
| font-size: 17px; |
| font-weight: 600; |
| } |
| .dose-time { |
| font-size: 14px; |
| color: var(--text-sec); |
| margin-top: 4px; |
| } |
| .checkbox { |
| width: 32px; |
| height: 32px; |
| border-radius: 16px; |
| border: 2.5px solid var(--border); |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| cursor: pointer; |
| transition: all 0.25s cubic-bezier(0.25, 0.8, 0.25, 1); |
| background: transparent; |
| flex-shrink: 0; |
| } |
| .checkbox.checked { |
| background: var(--green); |
| border-color: var(--green); |
| } |
| .checkbox.checked::after { |
| content: ''; |
| width: 7px; |
| height: 14px; |
| border: solid white; |
| border-width: 0 2.5px 2.5px 0; |
| transform: rotate(45deg); |
| margin-bottom: 2px; |
| } |
| .input-group { |
| margin-bottom: 18px; |
| } |
| .input-group label { |
| display: block; |
| font-size: 15px; |
| font-weight: 500; |
| color: var(--text); |
| margin-bottom: 8px; |
| padding-left: 4px; |
| } |
| .input-group input { |
| width: 100%; |
| padding: 16px; |
| border-radius: 14px; |
| border: 1px solid var(--border); |
| background: var(--surface); |
| color: var(--text); |
| font-size: 17px; |
| outline: none; |
| -webkit-appearance: none; |
| } |
| .input-group input:focus { |
| border-color: var(--acc); |
| } |
| .btn { |
| display: block; |
| width: 100%; |
| padding: 16px; |
| border-radius: 14px; |
| border: none; |
| font-size: 17px; |
| font-weight: 600; |
| cursor: pointer; |
| text-align: center; |
| transition: opacity 0.2s; |
| } |
| .btn:active { |
| opacity: 0.8; |
| } |
| .btn-primary { |
| background: var(--acc); |
| color: white; |
| } |
| .btn-secondary { |
| background: var(--surface); |
| color: var(--text); |
| border: 1px solid var(--border); |
| margin-top: 12px; |
| } |
| .btn-danger { |
| background: var(--red); |
| color: white; |
| margin-top: 24px; |
| } |
| .med-row { |
| display: flex; |
| gap: 12px; |
| margin-bottom: 12px; |
| } |
| .med-row input { |
| flex: 1; |
| padding: 14px; |
| border-radius: 12px; |
| border: 1px solid var(--border); |
| background: var(--surface); |
| color: var(--text); |
| font-size: 16px; |
| outline: none; |
| } |
| .med-row input[type="number"] { |
| flex: 0 0 100px; |
| } |
| .med-row input:focus { |
| border-color: var(--acc); |
| } |
| .empty-state { |
| text-align: center; |
| color: var(--text-sec); |
| margin-top: 60px; |
| font-size: 17px; |
| } |
| .header-icon { |
| font-size: 18px; |
| transition: transform 0.2s; |
| } |
| .open .header-icon { |
| transform: rotate(180deg); |
| } |
| </style> |
| </head> |
| <body> |
| <div class="app-container"> |
| <div id="view-home" class="view active"> |
| <div class="nav-bar"> |
| <span class="nav-btn"></span> |
| <span class="nav-title">Курсы</span> |
| <span class="nav-btn right"></span> |
| </div> |
| <div id="schedules-list"></div> |
| <button class="fab" onclick="openAdd()">+</button> |
| </div> |
| |
| <div id="view-schedule" class="view"> |
| <div class="nav-bar"> |
| <button class="nav-btn" onclick="renderHome()">← Назад</button> |
| <span class="nav-title" id="nav-sched-title" style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 60%;"></span> |
| <span class="nav-btn right"></span> |
| </div> |
| <div id="days-list"></div> |
| </div> |
| |
| <div id="view-add" class="view"> |
| <div class="nav-bar"> |
| <button class="nav-btn" onclick="renderHome()">Отмена</button> |
| <span class="nav-title">Новый курс</span> |
| <span class="nav-btn right"></span> |
| </div> |
| <div class="input-group"> |
| <label>Дата начала</label> |
| <input type="date" id="add-start-date"> |
| </div> |
| <button class="btn btn-primary" onclick="createSpecial()">Создать Пробиотик + Нистатин (42 дня)</button> |
| <button class="btn btn-secondary" onclick="openAddCustom()">Создать другой курс</button> |
| </div> |
| |
| <div id="view-add-custom" class="view"> |
| <div class="nav-bar"> |
| <button class="nav-btn" onclick="switchView('view-add')">← Назад</button> |
| <span class="nav-title">Свой курс</span> |
| <span class="nav-btn right"></span> |
| </div> |
| <div class="input-group"> |
| <label>Название курса</label> |
| <input type="text" id="custom-name" placeholder="Мой курс"> |
| </div> |
| <div class="input-group"> |
| <label>Дата начала</label> |
| <input type="date" id="custom-start-date"> |
| </div> |
| <div class="input-group"> |
| <label>Длительность (дней)</label> |
| <input type="number" id="custom-duration" min="1" placeholder="10"> |
| </div> |
| <div class="input-group"> |
| <label>Лекарства (название и раз в день)</label> |
| <div id="custom-meds"></div> |
| <button class="btn btn-secondary" style="margin-top: 8px;" onclick="addCustomMed()">+ Добавить препарат</button> |
| </div> |
| <button class="btn btn-primary" style="margin-top: 30px;" onclick="createCustom()">Сохранить курс</button> |
| </div> |
| </div> |
| |
| <script> |
| let currentData = { schedules: [] }; |
| |
| async function fetchData() { |
| try { |
| const res = await fetch('/api/data'); |
| currentData = await res.json(); |
| renderHome(); |
| } catch (e) { |
| console.error(e); |
| } |
| } |
| |
| function switchView(viewId) { |
| document.querySelectorAll('.view').forEach(el => el.classList.remove('active')); |
| document.getElementById(viewId).classList.add('active'); |
| window.scrollTo(0, 0); |
| } |
| |
| function renderHome() { |
| const list = document.getElementById('schedules-list'); |
| list.innerHTML = ''; |
| if (currentData.schedules.length === 0) { |
| list.innerHTML = '<div class="empty-state">Нет активных курсов. Нажмите + чтобы добавить.</div>'; |
| } else { |
| currentData.schedules.forEach(sched => { |
| let total = 0; |
| let done = 0; |
| sched.days.forEach(d => { |
| d.doses.forEach(dose => { |
| total++; |
| if(dose.taken) done++; |
| }); |
| }); |
| const pct = total === 0 ? 0 : (done / total) * 100; |
| |
| const card = document.createElement('div'); |
| card.className = 'card'; |
| card.onclick = () => openSchedule(sched.id); |
| card.innerHTML = ` |
| <h3 class="card-title">${sched.name}</h3> |
| <p class="card-sub">Начало: ${sched.start_date}</p> |
| <div class="progress-bar"><div class="progress-fill" style="width: ${pct}%"></div></div> |
| <p class="card-sub" style="margin-top:12px; font-size:13px; font-weight: 500;">Выполнено ${done} из ${total}</p> |
| `; |
| list.appendChild(card); |
| }); |
| } |
| switchView('view-home'); |
| } |
| |
| function getLocalDateString() { |
| const d = new Date(); |
| const tzOffset = d.getTimezoneOffset() * 60000; |
| return new Date(d.getTime() - tzOffset).toISOString().split('T')[0]; |
| } |
| |
| function getAvatarColor(text) { |
| let hash = 0; |
| for(let i = 0; i < text.length; i++) { |
| hash += text.charCodeAt(i); |
| } |
| const colors = ['#FF3B30', '#FF9500', '#FFCC00', '#34C759', '#5AC8FA', '#007AFF', '#5856D6', '#FF2D55']; |
| return colors[hash % colors.length]; |
| } |
| |
| function openSchedule(id) { |
| const sched = currentData.schedules.find(s => s.id === id); |
| if (!sched) return; |
| document.getElementById('nav-sched-title').innerText = sched.name; |
| |
| const container = document.getElementById('days-list'); |
| container.innerHTML = ''; |
| |
| const todayStr = getLocalDateString(); |
| let dayToOpen = -1; |
| let targetElement = null; |
| |
| let todayIdx = sched.days.findIndex(d => d.date === todayStr); |
| if (todayIdx !== -1) { |
| dayToOpen = sched.days[todayIdx].day_number; |
| } else { |
| let firstIncomplete = sched.days.find(d => d.doses.some(dose => !dose.taken)); |
| if (firstIncomplete) { |
| dayToOpen = firstIncomplete.day_number; |
| } else if (sched.days.length > 0) { |
| dayToOpen = sched.days[sched.days.length - 1].day_number; |
| } |
| } |
| |
| sched.days.forEach(day => { |
| const header = document.createElement('div'); |
| header.className = 'day-header'; |
| |
| let dayDone = 0; |
| day.doses.forEach(d => { if(d.taken) dayDone++; }); |
| const dayTotal = day.doses.length; |
| const checkMark = dayDone === dayTotal && dayTotal > 0 ? ' ✓' : ''; |
| |
| header.innerHTML = `<span>День ${day.day_number} (${day.date})${checkMark}</span> <span class="header-icon">▾</span>`; |
| |
| const content = document.createElement('div'); |
| content.className = 'day-content'; |
| |
| if (day.day_number === dayToOpen) { |
| content.classList.add('open'); |
| header.classList.add('open'); |
| targetElement = header; |
| } |
| |
| header.onclick = () => { |
| content.classList.toggle('open'); |
| header.classList.toggle('open'); |
| }; |
| |
| day.doses.forEach(dose => { |
| const row = document.createElement('div'); |
| row.className = 'dose-row'; |
| |
| const leftContainer = document.createElement('div'); |
| leftContainer.className = 'dose-left'; |
| |
| const letter = dose.medication.charAt(0).toUpperCase(); |
| const bgColor = getAvatarColor(dose.medication); |
| |
| leftContainer.innerHTML = ` |
| <div class="med-avatar" style="background-color: ${bgColor}">${letter}</div> |
| <div class="dose-info"> |
| <span class="dose-name">${dose.medication}</span> |
| <span class="dose-time">${dose.label}${dose.taken_at ? ' • ' + dose.taken_at : ''}</span> |
| </div> |
| `; |
| |
| const check = document.createElement('div'); |
| check.className = 'checkbox ' + (dose.taken ? 'checked' : ''); |
| check.onclick = async (e) => { |
| e.stopPropagation(); |
| const newVal = !check.classList.contains('checked'); |
| check.classList.toggle('checked'); |
| |
| try { |
| const res = await fetch('/api/toggle', { |
| method: 'POST', |
| headers: {'Content-Type': 'application/json'}, |
| body: JSON.stringify({ schedule_id: id, dose_id: dose.id, value: newVal }) |
| }); |
| const rData = await res.json(); |
| |
| dose.taken = newVal; |
| dose.taken_at = rData.taken_at; |
| |
| leftContainer.innerHTML = ` |
| <div class="med-avatar" style="background-color: ${bgColor}">${letter}</div> |
| <div class="dose-info"> |
| <span class="dose-name">${dose.medication}</span> |
| <span class="dose-time">${dose.label}${dose.taken_at ? ' • ' + dose.taken_at : ''}</span> |
| </div> |
| `; |
| |
| let nDone = 0; |
| day.doses.forEach(d => { if(d.taken) nDone++; }); |
| const nCheckMark = nDone === dayTotal && dayTotal > 0 ? ' ✓' : ''; |
| header.innerHTML = `<span>День ${day.day_number} (${day.date})${nCheckMark}</span> <span class="header-icon">▾</span>`; |
| if (content.classList.contains('open')) { |
| header.classList.add('open'); |
| } |
| |
| } catch(err) { |
| check.classList.toggle('checked'); |
| } |
| }; |
| |
| row.appendChild(leftContainer); |
| row.appendChild(check); |
| content.appendChild(row); |
| }); |
| |
| container.appendChild(header); |
| container.appendChild(content); |
| }); |
| |
| const delBtn = document.createElement('button'); |
| delBtn.className = 'btn btn-danger'; |
| delBtn.innerText = 'Удалить курс'; |
| delBtn.onclick = async () => { |
| if(confirm('Точно удалить этот курс?')) { |
| await fetch('/api/schedules/delete', { |
| method: 'POST', |
| headers: {'Content-Type': 'application/json'}, |
| body: JSON.stringify({ schedule_id: id }) |
| }); |
| fetchData(); |
| } |
| }; |
| container.appendChild(delBtn); |
| |
| switchView('view-schedule'); |
| |
| if (targetElement) { |
| setTimeout(() => { |
| targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); |
| }, 200); |
| } |
| } |
| |
| function openAdd() { |
| switchView('view-add'); |
| document.getElementById('add-start-date').value = getLocalDateString(); |
| } |
| |
| async function createSpecial() { |
| const date = document.getElementById('add-start-date').value; |
| if(!date) return; |
| const btn = document.querySelector('#view-add .btn-primary'); |
| btn.disabled = true; |
| btn.innerText = 'Создание...'; |
| await fetch('/api/schedules/special', { |
| method: 'POST', |
| headers: {'Content-Type': 'application/json'}, |
| body: JSON.stringify({ start_date: date }) |
| }); |
| btn.disabled = false; |
| btn.innerText = 'Создать Пробиотик + Нистатин (42 дня)'; |
| fetchData(); |
| } |
| |
| function openAddCustom() { |
| switchView('view-add-custom'); |
| document.getElementById('custom-start-date').value = getLocalDateString(); |
| document.getElementById('custom-name').value = ''; |
| document.getElementById('custom-duration').value = ''; |
| document.getElementById('custom-meds').innerHTML = ''; |
| addCustomMed(); |
| } |
| |
| function addCustomMed() { |
| const div = document.createElement('div'); |
| div.className = 'med-row'; |
| div.innerHTML = ` |
| <input type="text" placeholder="Препарат" class="med-name"> |
| <input type="number" placeholder="Раз/день" class="med-times" min="1" value="1"> |
| `; |
| document.getElementById('custom-meds').appendChild(div); |
| } |
| |
| async function createCustom() { |
| const name = document.getElementById('custom-name').value; |
| const start_date = document.getElementById('custom-start-date').value; |
| const duration = document.getElementById('custom-duration').value; |
| |
| const meds = []; |
| document.querySelectorAll('.med-row').forEach(row => { |
| const mName = row.querySelector('.med-name').value.trim(); |
| const mTimes = row.querySelector('.med-times').value; |
| if(mName && mTimes) { |
| meds.push({ name: mName, times: mTimes }); |
| } |
| }); |
| |
| if(!name || !duration || meds.length === 0) { |
| alert('Заполните все поля и добавьте хотя бы один препарат'); |
| return; |
| } |
| |
| const btn = document.querySelector('#view-add-custom .btn-primary'); |
| btn.disabled = true; |
| btn.innerText = 'Сохранение...'; |
| |
| await fetch('/api/schedules/custom', { |
| method: 'POST', |
| headers: {'Content-Type': 'application/json'}, |
| body: JSON.stringify({ name, start_date, duration, meds }) |
| }); |
| |
| btn.disabled = false; |
| btn.innerText = 'Сохранить курс'; |
| fetchData(); |
| } |
| |
| window.onload = fetchData; |
| </script> |
| </body> |
| </html> |
| ''' |
|
|
| @app.route('/') |
| def index(): |
| return render_template_string(HTML_CONTENT) |
|
|
| @app.route('/api/data', methods=['GET']) |
| def get_data(): |
| return jsonify(load_data()) |
|
|
| @app.route('/api/schedules/special', methods=['POST']) |
| def create_special(): |
| data_req = request.json |
| start_date_str = data_req.get('start_date') |
| start_date = datetime.strptime(start_date_str, "%Y-%m-%d").date() |
| schedule = { |
| "id": str(uuid.uuid4()), |
| "name": "Пробиотик + Нистатин (42 дня)", |
| "start_date": start_date_str, |
| "days": [] |
| } |
| |
| for day_idx in range(42): |
| current_date = start_date + timedelta(days=day_idx) |
| day_data = { |
| "day_number": day_idx + 1, |
| "date": current_date.strftime("%Y-%m-%d"), |
| "doses": [] |
| } |
| |
| day_data["doses"].append({ |
| "id": str(uuid.uuid4()), "medication": "Пробиотик", "label": "Утро", "taken": False, "taken_at": None |
| }) |
| day_data["doses"].append({ |
| "id": str(uuid.uuid4()), "medication": "Пробиотик", "label": "Вечер", "taken": False, "taken_at": None |
| }) |
| |
| if (7 <= day_idx < 21) or (28 <= day_idx < 42): |
| day_data["doses"].append({ |
| "id": str(uuid.uuid4()), "medication": "Нистатин", "label": "Утро", "taken": False, "taken_at": None |
| }) |
| day_data["doses"].append({ |
| "id": str(uuid.uuid4()), "medication": "Нистатин", "label": "День", "taken": False, "taken_at": None |
| }) |
| day_data["doses"].append({ |
| "id": str(uuid.uuid4()), "medication": "Нистатин", "label": "Вечер", "taken": False, "taken_at": None |
| }) |
| |
| schedule["days"].append(day_data) |
| |
| db = load_data() |
| db['schedules'].append(schedule) |
| save_data(db) |
| return jsonify({"status": "ok"}) |
|
|
| @app.route('/api/schedules/custom', methods=['POST']) |
| def create_custom(): |
| data_req = request.json |
| start_date_str = data_req.get('start_date') |
| name = data_req.get('name') |
| duration = int(data_req.get('duration')) |
| meds = data_req.get('meds') |
| |
| start_date = datetime.strptime(start_date_str, "%Y-%m-%d").date() |
| schedule = { |
| "id": str(uuid.uuid4()), |
| "name": name, |
| "start_date": start_date_str, |
| "days": [] |
| } |
| |
| for day_idx in range(duration): |
| current_date = start_date + timedelta(days=day_idx) |
| day_data = { |
| "day_number": day_idx + 1, |
| "date": current_date.strftime("%Y-%m-%d"), |
| "doses": [] |
| } |
| |
| for med in meds: |
| times = int(med['times']) |
| for t in range(times): |
| day_data["doses"].append({ |
| "id": str(uuid.uuid4()), |
| "medication": med['name'], |
| "label": f"Прием {t+1}", |
| "taken": False, |
| "taken_at": None |
| }) |
| |
| schedule["days"].append(day_data) |
| |
| db = load_data() |
| db['schedules'].append(schedule) |
| save_data(db) |
| return jsonify({"status": "ok"}) |
|
|
| @app.route('/api/toggle', methods=['POST']) |
| def toggle_dose(): |
| data_req = request.json |
| sched_id = data_req.get('schedule_id') |
| dose_id = data_req.get('dose_id') |
| val = data_req.get('value') |
| |
| db = load_data() |
| tz = zoneinfo.ZoneInfo("Asia/Almaty") |
| now_str = datetime.now(tz).strftime("%d.%m.%Y %H:%M") |
| |
| for s in db['schedules']: |
| if s['id'] == sched_id: |
| for d in s['days']: |
| for dose in d['doses']: |
| if dose['id'] == dose_id: |
| dose['taken'] = val |
| dose['taken_at'] = now_str if val else None |
| save_data(db) |
| return jsonify({"status": "ok", "taken_at": dose['taken_at']}) |
| |
| return jsonify({"status": "error"}), 404 |
| |
| @app.route('/api/schedules/delete', methods=['POST']) |
| def delete_schedule(): |
| data_req = request.json |
| sched_id = data_req.get('schedule_id') |
| db = load_data() |
| db['schedules'] = [s for s in db['schedules'] if s['id'] != sched_id] |
| save_data(db) |
| return jsonify({"status": "ok"}) |
|
|
| if __name__ == '__main__': |
| backup_thread = threading.Thread(target=periodic_backup, daemon=True) |
| backup_thread.start() |
| try: |
| load_data() |
| except Exception: |
| pass |
| app.run(debug=False, host='0.0.0.0', port=7860) |