tete / app.py
Kgshop's picture
Update app.py
a91a123 verified
Raw
History Blame Contribute Delete
31.5 kB
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()">&#8592; Назад</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')">&#8592; Назад</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">&#9662;</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">&#9662;</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)