primeiro commit

This commit is contained in:
Murilo Scolari 2026-09-16 19:58:18 -03:00
parent 0febca7c9b
commit e681295b55
9 changed files with 3037 additions and 2185 deletions

679
app.py
View file

@ -1,340 +1,445 @@
import os
import logging
import os
import secrets
from datetime import datetime
from functools import wraps
from flask import Flask, jsonify, request, render_template, session
from flask_cors import CORS
from apscheduler.schedulers.background import BackgroundScheduler
from flask import Flask, jsonify, render_template, request, session
from werkzeug.middleware.proxy_fix import ProxyFix
import click
import database
import ics_sync
import urllib3
from security import (
CATEGORIES,
RateLimiter,
ValidationError,
csrf_token,
integer,
iso_datetime,
require_json,
text,
valid_csrf,
validate_credentials,
validate_event,
validate_feed,
validate_public_url,
)
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
app = Flask(__name__, static_folder='static', template_folder='templates')
app.secret_key = os.environ.get('FLASK_SECRET_KEY', 'super-secret-key-calendar-study')
CORS(app, supports_credentials=True)
logging.basicConfig(level=logging.INFO)
logging.basicConfig(level=os.environ.get('LOG_LEVEL', 'INFO'))
logger = logging.getLogger(__name__)
scheduler = BackgroundScheduler()
def run_sync_job():
with app.app_context():
ics_sync.sync_all_feeds()
def _env_bool(name, default):
value = os.environ.get(name)
if value is None:
return default
if value.lower() not in {'true', 'false'}:
raise RuntimeError(f'{name} deve ser true ou false.')
return value.lower() == 'true'
# Inicializa banco de dados
database.init_db()
# Inicializa agendador apenas no processo principal (evita duplicação com Flask reloader)
if not os.environ.get("WERKZEUG_RUN_MAIN"):
scheduler.add_job(func=run_sync_job, trigger="interval", minutes=30, id='sync_feeds')
scheduler.start()
def _secret(environment, supplied=None):
value = supplied or os.environ.get('FLASK_SECRET_KEY')
if environment == 'production':
if (not value or len(value) < 32 or len(set(value)) < 8
or value in {'super-secret-key-calendar-study', 'changeme'}):
raise RuntimeError('FLASK_SECRET_KEY forte (mínimo de 32 caracteres) é obrigatória em produção.')
return value
return value or secrets.token_hex(32)
# Sincroniza feeds na primeira execução
try:
ics_sync.sync_all_feeds()
except Exception as e:
logger.warning(f"Erro na sincronização inicial: {e}")
def success_response(data=None, message="Operação realizada com sucesso."):
return jsonify({"success": True, "data": data, "message": message})
def create_app(test_config=None):
environment = os.environ.get('APP_ENV', 'production').lower()
if environment not in {'production', 'development', 'test'}:
raise RuntimeError('APP_ENV deve ser production, development ou test.')
supplied_secret = (test_config or {}).get('SECRET_KEY')
trusted_hosts_value = os.environ.get('TRUSTED_HOSTS', '')
trusted_hosts = [host.strip() for host in trusted_hosts_value.split(',') if host.strip()]
if environment == 'production' and not trusted_hosts and not (test_config or {}).get('TRUSTED_HOSTS'):
raise RuntimeError('TRUSTED_HOSTS é obrigatório em produção.')
application = Flask(__name__, static_folder='static', template_folder='templates')
application.config.update(
SECRET_KEY=_secret(environment, supplied_secret),
MAX_CONTENT_LENGTH=int(os.environ.get('MAX_CONTENT_LENGTH', str(64 * 1024))),
MAX_FORM_MEMORY_SIZE=int(os.environ.get('MAX_FORM_MEMORY_SIZE', str(16 * 1024))),
MAX_FORM_PARTS=20,
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SECURE=_env_bool('SESSION_COOKIE_SECURE', environment == 'production'),
SESSION_COOKIE_SAMESITE=os.environ.get('SESSION_COOKIE_SAMESITE', 'Lax'),
SESSION_COOKIE_NAME='__Host-studycalendar' if environment == 'production' else 'studycalendar',
SESSION_COOKIE_PATH='/',
SESSION_REFRESH_EACH_REQUEST=False,
PERMANENT_SESSION_LIFETIME=86400,
DATABASE_PATH=os.environ.get(
'DATABASE_PATH', os.path.join(application.root_path, 'calendario.db')
),
ICS_HTTPS_ONLY=_env_bool('ICS_HTTPS_ONLY', True),
ENABLE_HSTS=_env_bool('ENABLE_HSTS', environment == 'production'),
RATELIMIT_ENABLED=True,
ALLOW_REGISTRATION=_env_bool('ALLOW_REGISTRATION', environment != 'production'),
MAX_FEEDS_PER_USER=int(os.environ.get('MAX_FEEDS_PER_USER', '20')),
MAX_MANUAL_EVENTS_PER_USER=int(os.environ.get('MAX_MANUAL_EVENTS_PER_USER', '10000')),
MAX_NOTIFICATION_RULES_PER_USER=int(os.environ.get('MAX_NOTIFICATION_RULES_PER_USER', '20')),
MAX_EVENT_RESULTS=int(os.environ.get('MAX_EVENT_RESULTS', '5000')),
TRUSTED_HOSTS=trusted_hosts or None,
)
if test_config:
application.config.update(test_config)
def error_response(message="Ocorreu um erro.", status_code=400):
return jsonify({"success": False, "data": None, "message": message}), status_code
bounded_config = {
'MAX_FEEDS_PER_USER': (1, 100),
'MAX_MANUAL_EVENTS_PER_USER': (1, 100000),
'MAX_NOTIFICATION_RULES_PER_USER': (1, 100),
'MAX_EVENT_RESULTS': (1, 10000),
}
for name, (minimum, maximum) in bounded_config.items():
if not minimum <= application.config[name] <= maximum:
raise RuntimeError(f'{name} deve estar entre {minimum} e {maximum}.')
def login_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if 'user_id' not in session:
return error_response("Não autorizado.", 401)
return f(*args, **kwargs)
return decorated_function
same_site = application.config['SESSION_COOKIE_SAMESITE']
if same_site not in {'Strict', 'Lax', 'None'}:
raise RuntimeError('SESSION_COOKIE_SAMESITE deve ser Strict, Lax ou None.')
if same_site == 'None' and not application.config['SESSION_COOKIE_SECURE']:
raise RuntimeError('SESSION_COOKIE_SAMESITE=None exige SESSION_COOKIE_SECURE=true.')
if environment == 'production' and not application.config['SESSION_COOKIE_SECURE']:
raise RuntimeError('SESSION_COOKIE_SECURE deve permanecer true em produção.')
@app.route('/')
def index():
return render_template('index.html')
trusted_proxies = int(os.environ.get('TRUSTED_PROXY_COUNT', '0'))
if trusted_proxies < 0 or trusted_proxies > 5:
raise RuntimeError('TRUSTED_PROXY_COUNT deve estar entre 0 e 5.')
if trusted_proxies:
application.wsgi_app = ProxyFix(
application.wsgi_app, x_for=trusted_proxies, x_proto=trusted_proxies,
x_host=trusted_proxies, x_port=trusted_proxies
)
# ===================== Auth =====================
database.configure(application.config['DATABASE_PATH'])
database.init_db()
limiter = RateLimiter()
@app.route('/api/auth/register', methods=['POST'])
def register():
data = request.json
username = data.get('username')
password = data.get('password')
display_name = data.get('display_name', username)
if not username or not password:
return error_response("Usuário e senha são obrigatórios.")
user_id = database.create_user(username, password, display_name)
if user_id:
def success_response(data=None, message='Operação realizada com sucesso.', status_code=200):
return jsonify({'success': True, 'data': data, 'message': message}), status_code
def error_response(message='Ocorreu um erro.', status_code=400, headers=None):
response = jsonify({'success': False, 'data': None, 'message': message})
return (response, status_code, headers or {})
def login_required(function):
@wraps(function)
def decorated(*args, **kwargs):
user_id = session.get('user_id')
session_version = session.get('session_version')
if user_id is None or session_version is None:
return error_response('Não autorizado.', 401)
if database.get_user_session_version(user_id) != session_version:
session.clear()
return error_response('Sessão expirada.', 401)
return function(*args, **kwargs)
return decorated
def not_found_if_false(found, message):
return None if found else error_response(message, 404)
@application.before_request
def protect_request():
if request.path.startswith('/api/') and not valid_csrf(request.host_url):
return error_response('Token CSRF ausente ou origem inválida.', 403)
if not application.config['RATELIMIT_ENABLED'] or not request.path.startswith('/api/'):
return None
identity = session.get('user_id') or request.remote_addr or 'unknown'
limit = 120
bucket = 'global'
if request.path in {'/api/auth/login', '/api/auth/register'}:
identity = request.remote_addr or 'unknown'
limit, bucket = 10, 'auth'
elif '/sync' in request.path or (request.path == '/api/feeds' and request.method == 'POST'):
limit, bucket = 15, 'feed'
allowed, retry = limiter.check((bucket, identity), limit)
if not allowed:
return error_response('Muitas requisições. Tente novamente depois.', 429,
{'Retry-After': str(retry)})
return None
@application.after_request
def secure_headers(response):
response.headers['Content-Security-Policy'] = (
"default-src 'self'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; "
"form-action 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; connect-src 'self'; manifest-src 'self'"
)
response.headers['X-Content-Type-Options'] = 'nosniff'
response.headers['Referrer-Policy'] = 'no-referrer'
response.headers['X-Frame-Options'] = 'DENY'
response.headers['Permissions-Policy'] = (
'camera=(), microphone=(), geolocation=(), payment=(), usb=()'
)
if application.config['ENABLE_HSTS']:
response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
if request.path.startswith('/api/'):
response.headers['Cache-Control'] = 'no-store, max-age=0'
response.headers['Pragma'] = 'no-cache'
return response
@application.errorhandler(ValidationError)
def validation_error(error):
return error_response(str(error), 400)
@application.errorhandler(413)
def too_large(_error):
return error_response('Corpo da requisição excede o limite permitido.', 413)
@application.errorhandler(404)
def route_not_found(_error):
if request.path.startswith('/api/'):
return error_response('Recurso não encontrado.', 404)
return _error
@application.errorhandler(500)
def internal_error(error):
if request.path.startswith('/api/'):
logger.error('Erro interno na API (path=%s, tipo=%s).', request.path,
type(getattr(error, 'original_exception', error)).__name__)
return error_response('Erro interno do servidor.', 500)
return error
@application.route('/')
def index():
return render_template('index.html')
@application.cli.command('create-user')
@click.option('--username', prompt=True)
@click.option('--display-name', prompt='Nome de exibição')
@click.password_option(confirmation_prompt=True)
def create_user_command(username, display_name, password):
"""Cria um usuário sem habilitar cadastro público."""
try:
normalized, validated_password, validated_name = validate_credentials({
'username': username,
'password': password,
'display_name': display_name,
}, True)
except ValidationError as error:
raise click.ClickException(str(error)) from error
if not database.create_user(normalized, validated_password, validated_name):
raise click.ClickException('Não foi possível criar o usuário.')
click.echo('Usuário criado.')
@application.get('/api/auth/csrf')
def get_csrf():
return success_response({
'csrf_token': csrf_token(),
'registration_enabled': application.config['ALLOW_REGISTRATION'],
})
@application.post('/api/auth/register')
def register():
if not application.config['ALLOW_REGISTRATION']:
return error_response('Cadastro público desativado.', 403)
username, password, display_name = validate_credentials(require_json(), True)
user_id = database.create_user(username, password, display_name)
if not user_id:
return error_response('Não foi possível criar a conta com os dados informados.', 409)
request_csrf = session.get('csrf_token')
session.clear()
session['user_id'] = user_id
session['username'] = username
return success_response({"id": user_id, "username": username}, "Usuário registrado com sucesso.")
return error_response("Nome de usuário já existe.")
session['session_version'] = database.get_user_session_version(user_id)
session['csrf_token'] = request_csrf or csrf_token()
return success_response({'id': user_id, 'username': username}, 'Usuário registrado com sucesso.')
@app.route('/api/auth/login', methods=['POST'])
def login():
data = request.json
username = data.get('username')
password = data.get('password')
user = database.verify_user_password(username, password)
if user:
@application.post('/api/auth/login')
def login():
username, password, _ = validate_credentials(require_json())
user = database.verify_user_password(username, password)
if not user:
return error_response('Usuário ou senha inválidos.', 401)
request_csrf = session.get('csrf_token')
session.clear()
session['user_id'] = user['id']
session['username'] = user['username']
return success_response({"id": user['id'], "username": user['username'], "display_name": user['display_name']}, "Login realizado.")
return error_response("Usuário ou senha inválidos.", 401)
session['session_version'] = user['session_version']
session['csrf_token'] = request_csrf or csrf_token()
return success_response(
{'id': user['id'], 'username': user['username'], 'display_name': user['display_name']},
'Login realizado.'
)
@app.route('/api/auth/logout', methods=['POST'])
def logout():
session.clear()
return success_response(message="Logout realizado.")
@application.post('/api/auth/logout')
def logout():
user_id = session.get('user_id')
if user_id is not None:
database.revoke_user_sessions(user_id)
session.clear()
return success_response(message='Logout realizado.')
@app.route('/api/auth/me', methods=['GET'])
@login_required
def get_me():
user = database.get_user_by_id(session['user_id'])
if user:
return success_response(user)
return error_response("Usuário não encontrado.", 404)
@application.get('/api/auth/me')
@login_required
def get_me():
user = database.get_user_by_id(session['user_id'])
return success_response(user) if user else error_response('Usuário não encontrado.', 404)
# ===================== Eventos =====================
@app.route('/api/events', methods=['GET'])
@login_required
def list_events():
try:
start_iso = request.args.get('start')
end_iso = request.args.get('end')
@application.get('/api/events')
@login_required
def list_events():
start = iso_datetime(request.args.get('start'), 'start')
end = iso_datetime(request.args.get('end'), 'end')
if start and end:
start_value = datetime.fromisoformat(start)
end_value = datetime.fromisoformat(end)
if bool(start_value.tzinfo) != bool(end_value.tzinfo) or start_value > end_value:
raise ValidationError('end não pode ser anterior a start e deve usar o mesmo fuso.')
category = request.args.get('category')
if category and category not in CATEGORIES | {'todas'}:
raise ValidationError('category inválida.')
search = request.args.get('search')
events = database.get_events(session['user_id'], start_iso, end_iso, category, search)
return success_response(events)
except Exception as e:
logger.error(f"Erro listando eventos: {str(e)}")
return error_response("Erro ao listar eventos.", 500)
if search is not None and len(search) > 100:
raise ValidationError('search excede 100 caracteres.')
return success_response(database.get_events(
session['user_id'], start, end, category, search,
limit=application.config['MAX_EVENT_RESULTS']
))
@app.route('/api/events', methods=['POST'])
@login_required
def create_event():
try:
data = request.json
@application.post('/api/events')
@login_required
def create_event():
values = validate_event(require_json())
event_id = database.add_event(
user_id=session['user_id'],
title=data.get('title'),
start_datetime=data.get('start_datetime'),
end_datetime=data.get('end_datetime'),
description=data.get('description'),
location=data.get('location'),
all_day=data.get('all_day', False),
category=data.get('category', 'geral'),
priority=data.get('priority', 'normal')
user_id=session['user_id'], max_count=application.config['MAX_MANUAL_EVENTS_PER_USER'],
**values
)
return success_response({"id": event_id}, "Evento criado com sucesso.")
except Exception as e:
logger.error(f"Erro criando evento: {str(e)}")
return error_response("Erro ao criar evento.", 500)
if not event_id:
return error_response('Limite de eventos manuais atingido.', 409)
return success_response({'id': event_id}, 'Evento criado com sucesso.')
@app.route('/api/events/<int:event_id>', methods=['PUT'])
@login_required
def update_event_route(event_id):
try:
data = request.json
database.update_event(
event_id=event_id,
user_id=session['user_id'],
title=data.get('title'),
start_datetime=data.get('start_datetime'),
end_datetime=data.get('end_datetime'),
description=data.get('description'),
location=data.get('location'),
all_day=data.get('all_day', False),
category=data.get('category', 'geral'),
priority=data.get('priority', 'normal')
)
return success_response(message="Evento atualizado com sucesso.")
except Exception as e:
logger.error(f"Erro atualizando evento: {str(e)}")
return error_response("Erro ao atualizar evento.", 500)
@application.put('/api/events/<int:event_id>')
@login_required
def update_event_route(event_id):
values = validate_event(require_json())
if not database.update_event(event_id=event_id, user_id=session['user_id'], **values):
return error_response('Evento não encontrado.', 404)
return success_response(message='Evento atualizado com sucesso.')
@app.route('/api/events/<int:event_id>', methods=['DELETE'])
@login_required
def delete_event_route(event_id):
try:
database.delete_event(event_id, session['user_id'])
return success_response(message="Evento excluído com sucesso.")
except Exception as e:
logger.error(f"Erro excluindo evento: {str(e)}")
return error_response("Erro ao excluir evento.", 500)
@application.delete('/api/events/<int:event_id>')
@login_required
def delete_event_route(event_id):
missing = not_found_if_false(database.delete_event(event_id, session['user_id']), 'Evento não encontrado.')
return missing or success_response(message='Evento excluído com sucesso.')
@app.route('/api/events/<int:event_id>/toggle', methods=['PATCH'])
@login_required
def toggle_event(event_id):
try:
database.toggle_event_completed(event_id, session['user_id'])
return success_response(message="Status do evento alterado.")
except Exception as e:
logger.error(f"Erro alterando status do evento: {str(e)}")
return error_response("Erro ao alterar status do evento.", 500)
@application.patch('/api/events/<int:event_id>/toggle')
@login_required
def toggle_event(event_id):
if not database.toggle_event_completed(event_id, session['user_id']):
return error_response('Evento não encontrado.', 404)
return success_response(message='Status do evento alterado.')
# ===================== Feeds =====================
@application.get('/api/feeds')
@login_required
def list_feeds():
return success_response(database.get_feeds(session['user_id']))
@app.route('/api/feeds', methods=['GET'])
@login_required
def list_feeds():
try:
feeds = database.get_feeds(session['user_id'])
return success_response(feeds)
except Exception as e:
logger.error(f"Erro listando feeds: {str(e)}")
return error_response("Erro ao listar feeds.", 500)
@app.route('/api/feeds', methods=['POST'])
@login_required
def add_feed_route():
try:
data = request.json
@application.post('/api/feeds')
@login_required
def add_feed_route():
values = validate_feed(require_json(), application.config['ICS_HTTPS_ONLY'])
validate_public_url(values['url'], application.config['ICS_HTTPS_ONLY'])
feed_id = database.add_feed(
user_id=session['user_id'],
url=data.get('url'),
name=data.get('name'),
color=data.get('color', '#4A90D9'),
sync_interval_minutes=data.get('sync_interval_minutes', 30)
user_id=session['user_id'], max_count=application.config['MAX_FEEDS_PER_USER'],
**values
)
if not feed_id:
return error_response('Feed duplicado ou limite de feeds atingido.', 409)
return success_response(
{'id': feed_id, 'sync_queued': True}, 'Feed adicionado; sincronização agendada.', 202
)
if feed_id:
ics_sync.sync_feed(feed_id)
return success_response({"id": feed_id}, "Feed adicionado com sucesso.")
else:
return error_response("Feed com esta URL já existe para este usuário.", 400)
except Exception as e:
logger.error(f"Erro adicionando feed: {str(e)}")
return error_response("Erro ao adicionar feed.", 500)
@app.route('/api/feeds/<int:feed_id>', methods=['DELETE'])
@login_required
def delete_feed_route(feed_id):
try:
database.delete_feed(feed_id, session['user_id'])
return success_response(message="Feed excluído com sucesso.")
except Exception as e:
logger.error(f"Erro excluindo feed: {str(e)}")
return error_response("Erro ao excluir feed.", 500)
@application.delete('/api/feeds/<int:feed_id>')
@login_required
def delete_feed_route(feed_id):
missing = not_found_if_false(database.delete_feed(feed_id, session['user_id']), 'Feed não encontrado.')
return missing or success_response(message='Feed excluído com sucesso.')
@app.route('/api/feeds/<int:feed_id>/sync', methods=['POST'])
@login_required
def sync_feed_route(feed_id):
try:
# Check ownership
feed = database.get_feed(feed_id, session['user_id'])
if not feed:
return error_response("Feed não encontrado.", 404)
success = ics_sync.sync_feed(feed_id)
if success:
return success_response(message="Feed sincronizado com sucesso.")
else:
return error_response("Falha ao sincronizar feed.", 500)
except Exception as e:
logger.error(f"Erro sincronizando feed: {str(e)}")
return error_response("Erro ao sincronizar feed.", 500)
@application.post('/api/feeds/<int:feed_id>/sync')
@login_required
def sync_feed_route(feed_id):
if not database.request_feed_sync(feed_id, session['user_id']):
return error_response('Feed não encontrado.', 404)
return success_response(message='Sincronização agendada.', status_code=202)
@app.route('/api/feeds/sync-all', methods=['POST'])
@login_required
def sync_all_feeds_route():
try:
@application.post('/api/feeds/sync-all')
@login_required
def sync_all_feeds_route():
feeds = database.get_feeds(session['user_id'])
for feed in feeds:
ics_sync.sync_feed(feed['id'])
return success_response(message="Seus feeds foram sincronizados.")
except Exception as e:
logger.error(f"Erro sincronizando todos os feeds: {str(e)}")
return error_response("Erro ao sincronizar todos os feeds.", 500)
database.request_feed_sync(feed['id'], session['user_id'])
return success_response(message='Sincronizações agendadas.', status_code=202)
# ===================== Notificações =====================
@application.get('/api/notifications/upcoming')
@login_required
def get_upcoming_notifications():
return success_response(database.get_upcoming_notifications(session['user_id']))
@app.route('/api/notifications/upcoming', methods=['GET'])
@login_required
def get_upcoming_notifications():
try:
events = database.get_upcoming_notifications(session['user_id'])
return success_response(events)
except Exception as e:
logger.error(f"Erro obtendo notificações: {str(e)}")
return error_response("Erro ao obter notificações.", 500)
@application.post('/api/notifications/mark-notified')
@login_required
def mark_notified():
data = require_json()
unknown = set(data) - {'event_id', 'rule_id'}
if unknown:
raise ValidationError(f"Campos desconhecidos: {', '.join(sorted(unknown))}.")
event_id = integer(data, 'event_id', minimum=1, maximum=2 ** 63 - 1)
rule_id = integer(data, 'rule_id', minimum=1, maximum=2 ** 63 - 1)
if not database.mark_event_notified(event_id, rule_id, session['user_id']):
return error_response('Evento ou regra não encontrado.', 404)
return success_response(message='Evento marcado como notificado para a regra.')
@app.route('/api/notifications/mark-notified', methods=['POST'])
@login_required
def mark_notified():
try:
data = request.json
event_id = data.get('event_id')
rule_id = data.get('rule_id')
if not event_id or not rule_id:
return error_response("event_id e rule_id são obrigatórios.", 400)
database.mark_event_notified(event_id, rule_id)
return success_response(message="Evento marcado como notificado para a regra.")
except Exception as e:
logger.error(f"Erro marcando notificação: {str(e)}")
return error_response("Erro ao marcar notificação.", 500)
@application.get('/api/notifications/rules')
@login_required
def get_notification_rules_route():
return success_response(database.get_notification_rules(session['user_id']))
@app.route('/api/notifications/rules', methods=['GET'])
@login_required
def get_notification_rules_route():
try:
rules = database.get_notification_rules(session['user_id'])
return success_response(rules)
except Exception as e:
logger.error(f"Erro obtendo regras: {str(e)}")
return error_response("Erro ao obter regras.", 500)
@application.post('/api/notifications/rules')
@login_required
def add_notification_rule_route():
data = require_json()
unknown = set(data) - {'minutes_before', 'label'}
if unknown:
raise ValidationError(f"Campos desconhecidos: {', '.join(sorted(unknown))}.")
minutes = integer(data, 'minutes_before', minimum=1, maximum=10080, default=30)
label = text(data, 'label', required=True, minimum=1, maximum=100)
rule_id = database.add_notification_rule(
session['user_id'], minutes, label,
max_count=application.config['MAX_NOTIFICATION_RULES_PER_USER']
)
if not rule_id:
return error_response('Regra duplicada ou limite de regras atingido.', 409)
return success_response({'id': rule_id}, 'Regra criada.')
@app.route('/api/notifications/rules', methods=['POST'])
@login_required
def add_notification_rule_route():
try:
data = request.json
minutes = int(data.get('minutes_before', 30))
label = data.get('label', f"{minutes} minutos antes")
rule_id = database.add_notification_rule(session['user_id'], minutes, label)
if rule_id:
return success_response({"id": rule_id}, "Regra criada.")
return error_response("Regra com este tempo já existe.", 400)
except Exception as e:
logger.error(f"Erro criando regra: {str(e)}")
return error_response("Erro ao criar regra.", 500)
@application.delete('/api/notifications/rules/<int:rule_id>')
@login_required
def delete_notification_rule_route(rule_id):
if not database.delete_notification_rule(rule_id, session['user_id']):
return error_response('Regra não encontrada.', 404)
return success_response(message='Regra removida.')
@app.route('/api/notifications/rules/<int:rule_id>', methods=['DELETE'])
@login_required
def delete_notification_rule_route(rule_id):
try:
database.delete_notification_rule(rule_id, session['user_id'])
return success_response(message="Regra removida.")
except Exception as e:
return error_response("Erro ao remover regra.", 500)
@application.patch('/api/notifications/rules/<int:rule_id>/toggle')
@login_required
def toggle_notification_rule_route(rule_id):
if not database.toggle_notification_rule(rule_id, session['user_id']):
return error_response('Regra não encontrada.', 404)
return success_response(message='Status da regra alterado.')
@app.route('/api/notifications/rules/<int:rule_id>/toggle', methods=['PATCH'])
@login_required
def toggle_notification_rule_route(rule_id):
try:
database.toggle_notification_rule(rule_id, session['user_id'])
return success_response(message="Status da regra alterado.")
except Exception as e:
return error_response("Erro ao alterar regra.", 500)
@application.get('/api/stats')
@login_required
def get_stats_route():
return success_response(database.get_stats(session['user_id']))
# ===================== Estatísticas =====================
return application
app = create_app()
@app.route('/api/stats', methods=['GET'])
@login_required
def get_stats_route():
try:
stats = database.get_stats(session['user_id'])
return success_response(stats)
except Exception as e:
logger.error(f"Erro obtendo estatísticas: {str(e)}")
return error_response("Erro ao obter estatísticas.", 500)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=True)
if os.environ.get('APP_ENV') != 'development':
raise RuntimeError('O servidor embutido só pode ser usado com APP_ENV=development.')
app.run(host=os.environ.get('DEV_HOST', '127.0.0.1'), port=int(os.environ.get('PORT', '5000')), debug=False)

View file

@ -6,15 +6,27 @@ Suporte a múltiplos usuários, regras de notificação, e CRUD completo.
import sqlite3
import os
import secrets
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from werkzeug.security import generate_password_hash, check_password_hash
DATABASE_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'calendario.db')
_DUMMY_PASSWORD_HASH = generate_password_hash(secrets.token_urlsafe(32), method='scrypt')
DATABASE_PATH = os.environ.get(
'DATABASE_PATH', os.path.join(os.path.dirname(os.path.abspath(__file__)), 'calendario.db')
)
def configure(path):
"""Configura o arquivo SQLite antes de inicializar a aplicação."""
global DATABASE_PATH
DATABASE_PATH = os.path.abspath(path)
def get_db():
"""Retorna uma conexão com o banco de dados SQLite."""
conn = sqlite3.connect(DATABASE_PATH)
os.chmod(DATABASE_PATH, 0o600)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
@ -37,6 +49,10 @@ def init_db():
)
''')
user_columns = {row[1] for row in cur.execute("PRAGMA table_info(users)").fetchall()}
if 'session_version' not in user_columns:
cur.execute("ALTER TABLE users ADD COLUMN session_version INTEGER NOT NULL DEFAULT 0")
# Tabela de feeds ICS
cur.execute('''
CREATE TABLE IF NOT EXISTS feeds (
@ -52,6 +68,11 @@ def init_db():
UNIQUE(user_id, url)
)
''')
feed_columns = {row[1] for row in cur.execute("PRAGMA table_info(feeds)").fetchall()}
if 'sync_requested_at' not in feed_columns:
cur.execute("ALTER TABLE feeds ADD COLUMN sync_requested_at TIMESTAMP")
if 'last_attempted' not in feed_columns:
cur.execute("ALTER TABLE feeds ADD COLUMN last_attempted TIMESTAMP")
# Tabela de eventos
cur.execute('''
@ -78,6 +99,13 @@ def init_db():
)
''')
event_columns = {row[1] for row in cur.execute("PRAGMA table_info(events)").fetchall()}
current_feed_columns = {row[1] for row in cur.execute("PRAGMA table_info(feeds)").fetchall()}
if 'user_id' not in event_columns or 'user_id' not in current_feed_columns:
raise RuntimeError(
'Banco legado sem proprietário. Execute uma migração offline definindo user_id.'
)
# Regras de notificação (múltiplas por usuário)
cur.execute('''
CREATE TABLE IF NOT EXISTS notification_rules (
@ -92,6 +120,12 @@ def init_db():
)
''')
notification_columns = {
row[1] for row in cur.execute("PRAGMA table_info(notification_log)").fetchall()
}
if notification_columns and 'rule_id' not in notification_columns:
cur.execute("DROP TABLE notification_log")
# Log de notificações enviadas (por evento + regra)
cur.execute('''
CREATE TABLE IF NOT EXISTS notification_log (
@ -104,9 +138,7 @@ def init_db():
UNIQUE(event_id, rule_id)
)
''')
# Migração: tabelas antigas sem user_id
_migrate_legacy_tables(cur)
cur.execute("PRAGMA user_version = 3")
# Índices
cur.execute("CREATE INDEX IF NOT EXISTS idx_events_start ON events(start_datetime)")
@ -116,38 +148,30 @@ def init_db():
cur.execute("CREATE INDEX IF NOT EXISTS idx_notif_rules_user ON notification_rules(user_id)")
cur.execute("CREATE INDEX IF NOT EXISTS idx_notif_log_event ON notification_log(event_id)")
# Garante que evento e regra do log pertençam ao mesmo usuário.
cur.execute('''
CREATE TRIGGER IF NOT EXISTS notification_log_same_owner_insert
BEFORE INSERT ON notification_log
BEGIN
SELECT CASE WHEN NOT EXISTS (
SELECT 1 FROM events e JOIN notification_rules r ON r.id = NEW.rule_id
WHERE e.id = NEW.event_id AND e.user_id = r.user_id
) THEN RAISE(ABORT, 'notification ownership mismatch') END;
END
''')
cur.execute('''
DELETE FROM notification_log
WHERE NOT EXISTS (
SELECT 1 FROM events e JOIN notification_rules r
ON r.id = notification_log.rule_id
WHERE e.id = notification_log.event_id AND e.user_id = r.user_id
)
''')
conn.commit()
conn.close()
def _migrate_legacy_tables(cur):
"""Migra dados de tabelas antigas (sem user_id) para o novo schema."""
# Verificar se existem tabelas antigas sem user_id
try:
cur.execute("SELECT user_id FROM events LIMIT 1")
except sqlite3.OperationalError:
# Coluna user_id não existe - tabela antiga
try:
cur.execute("ALTER TABLE events ADD COLUMN user_id INTEGER DEFAULT 1")
except sqlite3.OperationalError:
pass
try:
cur.execute("SELECT user_id FROM feeds LIMIT 1")
except sqlite3.OperationalError:
try:
cur.execute("ALTER TABLE feeds ADD COLUMN user_id INTEGER DEFAULT 1")
except sqlite3.OperationalError:
pass
# Remover tabelas de notificação antigas
try:
cur.execute("SELECT notify_before_minutes FROM notification_settings LIMIT 1")
cur.execute("DROP TABLE IF EXISTS notification_settings")
except sqlite3.OperationalError:
pass
# ===================== Usuários =====================
def create_user(username, password, display_name):
@ -185,9 +209,10 @@ def create_user(username, password, display_name):
def get_user_by_username(username):
"""Retorna dados do usuário pelo username."""
conn = get_db()
user = conn.execute(
"SELECT * FROM users WHERE username = ?", (username.strip().lower(),)
).fetchone()
if not isinstance(username, str):
conn.close()
return None
user = conn.execute("SELECT * FROM users WHERE username = ?", (username.strip().lower(),)).fetchone()
conn.close()
return dict(user) if user else None
@ -200,10 +225,30 @@ def get_user_by_id(user_id):
return dict(user) if user else None
def get_user_session_version(user_id):
conn = get_db()
row = conn.execute("SELECT session_version FROM users WHERE id = ?", (user_id,)).fetchone()
conn.close()
return row['session_version'] if row else None
def revoke_user_sessions(user_id):
conn = get_db()
cur = conn.execute(
"UPDATE users SET session_version = session_version + 1 WHERE id = ?", (user_id,)
)
conn.commit()
conn.close()
return cur.rowcount > 0
def verify_user_password(username, password):
"""Verifica username e senha. Retorna o usuário ou None."""
user = get_user_by_username(username)
if user and check_password_hash(user['password_hash'], password):
if not user:
check_password_hash(_DUMMY_PASSWORD_HASH, password)
return None
if check_password_hash(user['password_hash'], password):
return user
return None
@ -214,12 +259,21 @@ def get_feeds(user_id):
"""Retorna todos os feeds de um usuário."""
conn = get_db()
feeds = conn.execute(
"SELECT * FROM feeds WHERE user_id = ? ORDER BY created_at DESC", (user_id,)
"""SELECT id, user_id, name, color, sync_interval_minutes, last_synced,
last_attempted, sync_requested_at, created_at
FROM feeds WHERE user_id = ? ORDER BY created_at DESC""", (user_id,)
).fetchall()
conn.close()
return [dict(f) for f in feeds]
def count_feeds(user_id):
conn = get_db()
count = conn.execute("SELECT COUNT(*) FROM feeds WHERE user_id = ?", (user_id,)).fetchone()[0]
conn.close()
return count
def get_feed(feed_id, user_id=None):
"""Retorna um feed pelo ID, verificando propriedade se user_id fornecido."""
conn = get_db()
@ -241,13 +295,24 @@ def get_all_feeds():
return [dict(f) for f in feeds]
def add_feed(user_id, url, name, color='#4A90D9', sync_interval_minutes=30):
def add_feed(user_id, url, name, color='#4A90D9', sync_interval_minutes=30, max_count=None):
"""Cria um novo feed ICS."""
conn = get_db()
try:
conn.execute("BEGIN IMMEDIATE")
if max_count is not None:
count = conn.execute(
"SELECT COUNT(*) FROM feeds WHERE user_id = ?", (user_id,)
).fetchone()[0]
if count >= max_count:
conn.rollback()
return None
now = datetime.now(timezone.utc).isoformat()
cur = conn.execute(
"INSERT INTO feeds (user_id, url, name, color, sync_interval_minutes) VALUES (?, ?, ?, ?, ?)",
(user_id, url, name, color, sync_interval_minutes)
"""INSERT INTO feeds
(user_id, url, name, color, sync_interval_minutes, sync_requested_at)
VALUES (?, ?, ?, ?, ?, ?)""",
(user_id, url, name, color, sync_interval_minutes, now)
)
conn.commit()
return cur.lastrowid
@ -260,53 +325,90 @@ def add_feed(user_id, url, name, color='#4A90D9', sync_interval_minutes=30):
def delete_feed(feed_id, user_id):
"""Remove um feed verificando propriedade."""
conn = get_db()
conn.execute("DELETE FROM feeds WHERE id = ? AND user_id = ?", (feed_id, user_id))
cur = conn.execute("DELETE FROM feeds WHERE id = ? AND user_id = ?", (feed_id, user_id))
conn.commit()
conn.close()
return cur.rowcount > 0
def update_feed_sync_time(feed_id):
"""Atualiza o timestamp de última sincronização."""
conn = get_db()
conn.execute(
"UPDATE feeds SET last_synced = ? WHERE id = ?",
(datetime.now().isoformat(), feed_id)
cur = conn.execute(
"UPDATE feeds SET last_synced = ?, sync_requested_at = NULL WHERE id = ?",
(datetime.now(timezone.utc).isoformat(), feed_id)
)
conn.commit()
conn.close()
return cur.rowcount > 0
def request_feed_sync(feed_id, user_id):
conn = get_db()
cur = conn.execute(
"UPDATE feeds SET sync_requested_at = ? WHERE id = ? AND user_id = ?",
(datetime.now(timezone.utc).isoformat(), feed_id, user_id)
)
conn.commit()
conn.close()
return cur.rowcount > 0
def mark_feed_sync_attempt(feed_id):
"""Registra uma tentativa e retira o pedido da fila para aplicar backoff pelo intervalo."""
conn = get_db()
cur = conn.execute(
"UPDATE feeds SET last_attempted = ?, sync_requested_at = NULL WHERE id = ?",
(datetime.now(timezone.utc).isoformat(), feed_id)
)
conn.commit()
conn.close()
return cur.rowcount > 0
# ===================== Eventos =====================
def get_events(user_id, start_iso=None, end_iso=None, category=None, search=None):
def get_events(user_id, start_iso=None, end_iso=None, category=None, search=None, limit=5000):
"""Retorna eventos de um usuário com filtros."""
conn = get_db()
query = "SELECT * FROM events WHERE user_id = ?"
query = """SELECT events.*, feeds.color AS feed_color
FROM events LEFT JOIN feeds ON feeds.id = events.feed_id
WHERE events.user_id = ?"""
params = [user_id]
if start_iso:
query += " AND (end_datetime >= ? OR (end_datetime IS NULL AND start_datetime >= ?))"
query += " AND (julianday(events.end_datetime) > julianday(?) OR (events.end_datetime IS NULL AND julianday(events.start_datetime) >= julianday(?)))"
params.extend([start_iso, start_iso])
if end_iso:
query += " AND start_datetime <= ?"
query += " AND julianday(events.start_datetime) < julianday(?)"
params.append(end_iso)
if category and category != 'todas':
query += " AND category = ?"
query += " AND events.category = ?"
params.append(category)
if search:
query += " AND (title LIKE ? OR description LIKE ?)"
query += " AND (events.title LIKE ? OR events.description LIKE ?)"
params.extend([f'%{search}%', f'%{search}%'])
query += " ORDER BY start_datetime ASC"
query += " ORDER BY julianday(events.start_datetime) ASC LIMIT ?"
params.append(limit)
events = conn.execute(query, params).fetchall()
conn.close()
return [dict(e) for e in events]
def count_manual_events(user_id):
conn = get_db()
count = conn.execute(
"SELECT COUNT(*) FROM events WHERE user_id = ? AND source = 'manual'", (user_id,)
).fetchone()[0]
conn.close()
return count
def get_events_for_day(user_id, date_str):
"""Retorna eventos de um dia específico (formato: YYYY-MM-DD)."""
day_start = f"{date_str}T00:00:00"
day_end = f"{date_str}T23:59:59"
day_end = (datetime.fromisoformat(date_str) + timedelta(days=1)).isoformat()
return get_events(user_id, start_iso=day_start, end_iso=day_end)
@ -321,10 +423,19 @@ def get_event(event_id, user_id):
def add_event(user_id, title, start_datetime, end_datetime=None, description=None,
location=None, all_day=False, category='geral', priority='normal'):
location=None, all_day=False, category='geral', priority='normal', max_count=None):
"""Cria um evento manual."""
conn = get_db()
now = datetime.now().isoformat()
conn.execute("BEGIN IMMEDIATE")
if max_count is not None:
count = conn.execute(
"SELECT COUNT(*) FROM events WHERE user_id = ? AND source = 'manual'", (user_id,)
).fetchone()[0]
if count >= max_count:
conn.rollback()
conn.close()
return None
now = datetime.now(timezone.utc).isoformat()
cur = conn.execute(
"""INSERT INTO events (user_id, title, start_datetime, end_datetime, description,
location, all_day, category, priority, source, created_at, updated_at)
@ -342,24 +453,26 @@ def update_event(event_id, user_id, title, start_datetime, end_datetime=None,
description=None, location=None, all_day=False, category='geral', priority='normal'):
"""Atualiza um evento verificando propriedade."""
conn = get_db()
conn.execute(
cur = conn.execute(
"""UPDATE events SET title=?, start_datetime=?, end_datetime=?, description=?,
location=?, all_day=?, category=?, priority=?, updated_at=?
WHERE id=? AND user_id=?""",
(title, start_datetime, end_datetime, description, location,
1 if all_day else 0, category, priority, datetime.now().isoformat(),
1 if all_day else 0, category, priority, datetime.now(timezone.utc).isoformat(),
event_id, user_id)
)
conn.commit()
conn.close()
return cur.rowcount > 0
def delete_event(event_id, user_id):
"""Remove um evento verificando propriedade."""
conn = get_db()
conn.execute("DELETE FROM events WHERE id = ? AND user_id = ?", (event_id, user_id))
cur = conn.execute("DELETE FROM events WHERE id = ? AND user_id = ?", (event_id, user_id))
conn.commit()
conn.close()
return cur.rowcount > 0
def toggle_event_completed(event_id, user_id):
@ -367,7 +480,7 @@ def toggle_event_completed(event_id, user_id):
conn = get_db()
conn.execute(
"UPDATE events SET completed = NOT completed, updated_at = ? WHERE id = ? AND user_id = ?",
(datetime.now().isoformat(), event_id, user_id)
(datetime.now(timezone.utc).isoformat(), event_id, user_id)
)
conn.commit()
event = conn.execute("SELECT completed FROM events WHERE id = ? AND user_id = ?", (event_id, user_id)).fetchone()
@ -379,7 +492,7 @@ def upsert_ics_event(feed_id, user_id, uid, title, start_datetime, end_datetime,
description, location, all_day):
"""Insere ou atualiza evento de feed ICS."""
conn = get_db()
now = datetime.now().isoformat()
now = datetime.now(timezone.utc).isoformat()
existing = conn.execute(
"SELECT id FROM events WHERE uid = ? AND feed_id = ?", (uid, feed_id)
).fetchone()
@ -403,6 +516,55 @@ def upsert_ics_event(feed_id, user_id, uid, title, start_datetime, end_datetime,
conn.close()
def replace_ics_events(feed_id, user_id, events):
"""Atualiza um feed inteiro em uma única transação e remove itens ausentes."""
conn = get_db()
now = datetime.now(timezone.utc).isoformat()
try:
owner = conn.execute(
"SELECT 1 FROM feeds WHERE id = ? AND user_id = ?", (feed_id, user_id)
).fetchone()
if not owner:
return False
uids = []
for event in events:
uids.append(event['uid'])
conn.execute(
"""INSERT INTO events
(user_id, feed_id, uid, title, start_datetime, end_datetime, description,
location, all_day, source, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'ics', ?, ?)
ON CONFLICT(uid, feed_id) DO UPDATE SET
title=excluded.title, start_datetime=excluded.start_datetime,
end_datetime=excluded.end_datetime, description=excluded.description,
location=excluded.location, all_day=excluded.all_day,
updated_at=excluded.updated_at""",
(user_id, feed_id, event['uid'], event['title'], event['start_datetime'],
event['end_datetime'], event['description'], event['location'],
1 if event['all_day'] else 0, now, now)
)
if uids:
placeholders = ','.join('?' for _ in uids)
conn.execute(
f"DELETE FROM events WHERE feed_id = ? AND uid NOT IN ({placeholders})",
[feed_id, *uids]
)
else:
conn.execute("DELETE FROM events WHERE feed_id = ?", (feed_id,))
conn.execute(
"""UPDATE feeds SET last_synced = ?, last_attempted = ?, sync_requested_at = NULL
WHERE id = ?""",
(now, now, feed_id)
)
conn.commit()
return True
except Exception:
conn.rollback()
raise
finally:
conn.close()
# ===================== Regras de Notificação =====================
def get_notification_rules(user_id):
@ -416,10 +578,18 @@ def get_notification_rules(user_id):
return [dict(r) for r in rules]
def add_notification_rule(user_id, minutes_before, label):
def add_notification_rule(user_id, minutes_before, label, max_count=None):
"""Adiciona uma regra de notificação."""
conn = get_db()
try:
conn.execute("BEGIN IMMEDIATE")
if max_count is not None:
count = conn.execute(
"SELECT COUNT(*) FROM notification_rules WHERE user_id = ?", (user_id,)
).fetchone()[0]
if count >= max_count:
conn.rollback()
return None
cur = conn.execute(
"INSERT INTO notification_rules (user_id, minutes_before, label) VALUES (?, ?, ?)",
(user_id, minutes_before, label)
@ -435,11 +605,12 @@ def add_notification_rule(user_id, minutes_before, label):
def delete_notification_rule(rule_id, user_id):
"""Remove uma regra de notificação."""
conn = get_db()
conn.execute(
cur = conn.execute(
"DELETE FROM notification_rules WHERE id = ? AND user_id = ?", (rule_id, user_id)
)
conn.commit()
conn.close()
return cur.rowcount > 0
def toggle_notification_rule(rule_id, user_id):
@ -464,7 +635,7 @@ def get_upcoming_notifications(user_id):
que ainda não foram notificados para essa regra específica.
"""
conn = get_db()
now = datetime.now()
now = datetime.now(timezone.utc)
# Buscar regras ativas do usuário
rules = conn.execute(
@ -481,8 +652,8 @@ def get_upcoming_notifications(user_id):
"""SELECT e.*, ? as rule_id, ? as rule_label, ? as rule_minutes
FROM events e
WHERE e.user_id = ?
AND e.start_datetime >= ?
AND e.start_datetime <= ?
AND julianday(e.start_datetime) >= julianday(?)
AND julianday(e.start_datetime) <= julianday(?)
AND e.completed = 0
AND e.id NOT IN (
SELECT event_id FROM notification_log WHERE rule_id = ?
@ -509,15 +680,23 @@ def get_upcoming_notifications(user_id):
return unique
def mark_event_notified(event_id, rule_id):
def mark_event_notified(event_id, rule_id, user_id):
"""Registra que uma notificação foi enviada para evento+regra."""
conn = get_db()
try:
conn.execute(
"INSERT OR IGNORE INTO notification_log (event_id, rule_id) VALUES (?, ?)",
(event_id, rule_id)
cur = conn.execute(
"""INSERT OR IGNORE INTO notification_log (event_id, rule_id)
SELECT e.id, r.id FROM events e JOIN notification_rules r
ON r.id = ? AND r.user_id = ? WHERE e.id = ? AND e.user_id = ?""",
(rule_id, user_id, event_id, user_id)
)
conn.commit()
return cur.rowcount > 0 or conn.execute(
"""SELECT 1 FROM notification_log l JOIN events e ON e.id=l.event_id
JOIN notification_rules r ON r.id=l.rule_id
WHERE l.event_id=? AND l.rule_id=? AND e.user_id=? AND r.user_id=?""",
(event_id, rule_id, user_id, user_id)
).fetchone() is not None
finally:
conn.close()
@ -527,18 +706,18 @@ def mark_event_notified(event_id, rule_id):
def get_stats(user_id):
"""Retorna estatísticas dos eventos do usuário."""
conn = get_db()
now = datetime.now()
now = datetime.now(timezone.utc)
week_end = now + timedelta(days=7)
total = conn.execute(
"SELECT COUNT(*) FROM events WHERE user_id = ?", (user_id,)
).fetchone()[0]
upcoming_week = conn.execute(
"SELECT COUNT(*) FROM events WHERE user_id = ? AND start_datetime >= ? AND start_datetime <= ? AND completed = 0",
"SELECT COUNT(*) FROM events WHERE user_id = ? AND julianday(start_datetime) >= julianday(?) AND julianday(start_datetime) <= julianday(?) AND completed = 0",
(user_id, now.isoformat(), week_end.isoformat())
).fetchone()[0]
overdue = conn.execute(
"SELECT COUNT(*) FROM events WHERE user_id = ? AND start_datetime < ? AND completed = 0",
"SELECT COUNT(*) FROM events WHERE user_id = ? AND julianday(start_datetime) < julianday(?) AND completed = 0",
(user_id, now.isoformat())
).fetchone()[0]
completed = conn.execute(

View file

@ -1,111 +1,212 @@
"""
ics_sync.py - Sincronização de feeds ICS para o StudyCalendar.
"""
"""Sincronização segura de feeds iCalendar."""
import logging
import os
import ipaddress
import socket
import threading
import time
from contextlib import contextmanager
from datetime import datetime
from urllib.parse import urljoin
import requests
import icalendar
import pytz
from datetime import datetime
import requests
import database
import logging
import urllib3
from security import ValidationError, validate_public_url
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
sp_tz = pytz.timezone('America/Sao_Paulo')
MAX_REDIRECTS = int(os.environ.get('ICS_MAX_REDIRECTS', '3'))
MAX_BYTES = int(os.environ.get('ICS_MAX_BYTES', str(5 * 1024 * 1024)))
MAX_EVENTS = int(os.environ.get('ICS_MAX_EVENTS', '5000'))
CONNECT_TIMEOUT = float(os.environ.get('ICS_CONNECT_TIMEOUT', '5'))
READ_TIMEOUT = float(os.environ.get('ICS_READ_TIMEOUT', '15'))
TOTAL_TIMEOUT = float(os.environ.get('ICS_TOTAL_TIMEOUT', '30'))
HTTPS_ONLY = os.environ.get('ICS_HTTPS_ONLY', 'true').lower() == 'true'
_DNS_LOCK = threading.Lock()
if not 0 <= MAX_REDIRECTS <= 5:
raise RuntimeError('ICS_MAX_REDIRECTS deve estar entre 0 e 5.')
if not 1024 <= MAX_BYTES <= 10 * 1024 * 1024:
raise RuntimeError('ICS_MAX_BYTES deve estar entre 1 KiB e 10 MiB.')
if not 1 <= MAX_EVENTS <= 10000:
raise RuntimeError('ICS_MAX_EVENTS deve estar entre 1 e 10000.')
if not 0 < CONNECT_TIMEOUT <= 30 or not 0 < READ_TIMEOUT <= 30 or not 1 <= TOTAL_TIMEOUT <= 120:
raise RuntimeError('Timeouts ICS fora dos limites permitidos.')
def parse_ics_datetime(dt_prop):
"""Converte valor de data/hora iCalendar para ISO string no fuso de São Paulo."""
"""Converte data iCalendar para ISO no fuso de São Paulo."""
if not dt_prop:
return None, False
dt = dt_prop.dt
if isinstance(dt, datetime):
if dt.tzinfo is None:
dt = pytz.utc.localize(dt)
dt = dt.astimezone(sp_tz)
return dt.isoformat(), False
else:
# Date only = dia inteiro
dt_start = datetime(dt.year, dt.month, dt.day)
return sp_tz.localize(dt_start).isoformat(), True
value = dt_prop.dt
if isinstance(value, datetime):
if value.tzinfo is None:
value = sp_tz.localize(value)
return value.astimezone(sp_tz).isoformat(), False
value = datetime(value.year, value.month, value.day)
return sp_tz.localize(value).isoformat(), True
def _peer_address(response):
"""Obtém o peer conectado antes de consumir o corpo, quando exposto pelo urllib3."""
raw = response.raw
candidates = [
getattr(getattr(raw, '_connection', None), 'sock', None),
getattr(getattr(raw, 'connection', None), 'sock', None),
getattr(getattr(getattr(getattr(raw, '_fp', None), 'fp', None), 'raw', None), '_sock', None),
]
for sock in candidates:
if sock:
return str(ipaddress.ip_address(sock.getpeername()[0].split('%', 1)[0]))
return None
@contextmanager
def _pinned_dns(hostname, address):
"""Fixa a resolução durante a conexão para eliminar a janela de DNS rebinding."""
original = socket.getaddrinfo
def canonical_host(value):
if isinstance(value, bytes):
value = value.decode('ascii')
return str(value).rstrip('.').encode('idna').decode('ascii').lower()
normalized_host = canonical_host(hostname)
ip = ipaddress.ip_address(address)
def resolve(host, port, family=0, type=0, proto=0, flags=0):
candidate = canonical_host(host)
if candidate != normalized_host:
return original(host, port, family, type, proto, flags)
selected_family = socket.AF_INET6 if ip.version == 6 else socket.AF_INET
sockaddr = (address, port, 0, 0) if ip.version == 6 else (address, port)
return [(selected_family, socket.SOCK_STREAM, socket.IPPROTO_TCP, '', sockaddr)]
with _DNS_LOCK:
socket.getaddrinfo = resolve
try:
yield
finally:
socket.getaddrinfo = original
def fetch_ics(url, session_factory=requests.Session):
"""Baixa um ICS validando DNS, redirects, peer, TLS e tamanho."""
client = session_factory()
client.trust_env = False
current = url
deadline = time.monotonic() + TOTAL_TIMEOUT
try:
for redirect_count in range(MAX_REDIRECTS + 1):
parsed, approved_addresses = validate_public_url(current, HTTPS_ONLY)
approved_address = sorted(approved_addresses)[0]
remaining = deadline - time.monotonic()
if remaining <= 0:
raise ValidationError('Tempo total de download excedido.')
with _pinned_dns(parsed.hostname, approved_address):
response = client.get(
current,
headers={'User-Agent': 'StudyCalendar/2.0', 'Accept': 'text/calendar,*/*;q=0.1'},
timeout=(min(CONNECT_TIMEOUT, remaining), min(READ_TIMEOUT, remaining)),
allow_redirects=False,
stream=True,
verify=True,
)
try:
peer = _peer_address(response)
if not peer:
raise ValidationError('Não foi possível validar o endereço conectado.')
if peer not in approved_addresses:
raise ValidationError('Endereço conectado diverge do DNS validado.')
if response.is_redirect or response.is_permanent_redirect:
if redirect_count >= MAX_REDIRECTS:
raise ValidationError('Limite de redirecionamentos excedido.')
location = response.headers.get('Location')
if not location:
raise ValidationError('Redirecionamento sem destino.')
current = urljoin(current, location)
validate_public_url(current, HTTPS_ONLY)
continue
response.raise_for_status()
declared = response.headers.get('Content-Length')
if declared and int(declared) > MAX_BYTES:
raise ValidationError('Feed excede o tamanho permitido.')
body = bytearray()
for chunk in response.iter_content(64 * 1024):
if time.monotonic() > deadline:
raise ValidationError('Tempo total de download excedido.')
body.extend(chunk)
if len(body) > MAX_BYTES:
raise ValidationError('Feed excede o tamanho permitido.')
return bytes(body)
finally:
response.close()
finally:
client.close()
raise ValidationError('Não foi possível obter o feed.')
def _field(component, name, maximum, default=''):
value = component.get(name)
if value is None:
return default
return str(value).replace('\\n', '\n').replace('\\,', ',')[:maximum]
def parse_ics(content):
calendar = icalendar.Calendar.from_ical(content)
events = []
seen_uids = set()
for component in calendar.walk('VEVENT'):
if len(events) >= MAX_EVENTS:
raise ValidationError('Feed excede o número de eventos permitido.')
uid = _field(component, 'uid', 512).strip()
if not uid or uid in seen_uids:
continue
start_iso, all_day = parse_ics_datetime(component.get('dtstart'))
if not start_iso:
continue
end_iso, _ = parse_ics_datetime(component.get('dtend'))
if end_iso and datetime.fromisoformat(end_iso) <= datetime.fromisoformat(start_iso):
end_iso = None
seen_uids.add(uid)
events.append({
'uid': uid,
'title': _field(component, 'summary', 200, 'Sem Título') or 'Sem Título',
'description': _field(component, 'description', 4000),
'location': _field(component, 'location', 300),
'start_datetime': start_iso,
'end_datetime': end_iso,
'all_day': all_day,
})
return events
def sync_feed(feed_id):
"""Sincroniza um feed ICS específico."""
"""Sincroniza um feed sem registrar sua URL ou tokens em logs."""
feed = database.get_feed(feed_id)
if not feed:
logger.error(f"Feed {feed_id} não encontrado.")
if not feed or not feed.get('user_id'):
logger.warning('Feed inexistente ou sem proprietário (id=%s).', feed_id)
return False
user_id = feed.get('user_id')
if not user_id:
logger.error(f"Feed {feed_id} sem user_id.")
return False
logger.info(f"Sincronizando feed: {feed['name']} ({feed['url']})")
try:
headers = {'User-Agent': 'StudyCalendar/1.0'}
response = requests.get(feed['url'], headers=headers, timeout=30, verify=False)
response.raise_for_status()
cal = icalendar.Calendar.from_ical(response.content)
count = 0
for component in cal.walk():
if component.name == "VEVENT":
uid = str(component.get('uid', ''))
if not uid:
continue
title = str(component.get('summary', 'Sem Título'))
description = component.get('description')
if description:
description = str(description).replace('\\n', '\n').replace('\\,', ',')
if len(description) > 2000:
description = description[:2000] + '...'
else:
description = ""
location = component.get('location')
location = str(location) if location else ""
dtstart = component.get('dtstart')
dtend = component.get('dtend')
start_iso, all_day = parse_ics_datetime(dtstart)
end_iso, _ = parse_ics_datetime(dtend)
if start_iso:
database.upsert_ics_event(
feed_id=feed_id,
user_id=user_id,
uid=uid,
title=title,
start_datetime=start_iso,
end_datetime=end_iso,
description=description,
location=location,
all_day=all_day
)
count += 1
database.update_feed_sync_time(feed_id)
logger.info(f"Feed {feed['name']}: {count} eventos sincronizados.")
return True
except Exception as e:
logger.error(f"Erro ao sincronizar feed {feed['name']}: {str(e)}")
events = parse_ics(fetch_ics(feed['url']))
success = database.replace_ics_events(feed_id, feed['user_id'], events)
if success:
logger.info('Feed sincronizado (id=%s, eventos=%s).', feed_id, len(events))
return success
except Exception as exc:
database.mark_feed_sync_attempt(feed_id)
logger.warning('Falha ao sincronizar feed (id=%s, tipo=%s).', feed_id, type(exc).__name__)
return False
def sync_all_feeds():
"""Sincroniza todos os feeds de todos os usuários."""
logger.info("Iniciando sincronização de todos os feeds...")
feeds = database.get_all_feeds()
for feed in feeds:
"""Sincroniza todos os feeds; deve ser chamado apenas pelo processo scheduler."""
for feed in database.get_all_feeds():
sync_feed(feed['id'])
logger.info("Sincronização concluída.")

View file

@ -1,6 +1,346 @@
Flask==3.1.1
Flask-Cors==5.0.1
icalendar==6.1.3
requests==2.32.3
APScheduler==3.11.0
pytz==2025.2
#
# This file is autogenerated by pip-compile with Python 3.14
# by the following command:
#
# pip-compile --generate-hashes --no-index --output-file=requirements.txt requirements.in
#
apscheduler==3.11.3 \
--hash=sha256:bbeb2ec02d23d3c06a6c07ed7f0f3939ada6680eb121fae809a69bb42c537a30 \
--hash=sha256:cd2fcc9330039a81a5893472ad49facf23a6d5604cbe1d918c835c6de7834d5a
# via -r requirements.in
blinker==1.9.0 \
--hash=sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf \
--hash=sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc
# via flask
certifi==2026.7.22 \
--hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \
--hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55
# via requests
charset-normalizer==3.5.1 \
--hash=sha256:00668ebb0609751758682eb0b5857e7c35b9f00e84dfdef062e103244ec94d45 \
--hash=sha256:012a22b88a77ca2e59b98ac5889b0deb604147666032f45e6d6e217634d2550d \
--hash=sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5 \
--hash=sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b \
--hash=sha256:0722590aabf9dc6a6c0343d523c05458fa2b5047dbe6302fd526bb570600753f \
--hash=sha256:07ffd07412fc5d5e84cd8952acf9ff7e4ed7a708e69d1bada19d8ba91711353f \
--hash=sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5 \
--hash=sha256:0b2b1b3fa5670c127b246df1d0c059defd41f689a868a3b9d79df9b1cac42d22 \
--hash=sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5 \
--hash=sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac \
--hash=sha256:13e3afe97712e8887cd516e960c63f0b93122971e5b5e4b2622fe7701771e838 \
--hash=sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90 \
--hash=sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626 \
--hash=sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4 \
--hash=sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369 \
--hash=sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b \
--hash=sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e \
--hash=sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee \
--hash=sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1 \
--hash=sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102 \
--hash=sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8 \
--hash=sha256:29880d17a8eb0b5cfdfd8944b468322928059aa35f1f5fa8ff22b149ec0b42f8 \
--hash=sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9 \
--hash=sha256:2e9cf9253119d8e5d111f05d71626786fd3d6193817316eab1ca088cdb8593cf \
--hash=sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0 \
--hash=sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031 \
--hash=sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e \
--hash=sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235 \
--hash=sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072 \
--hash=sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb \
--hash=sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c \
--hash=sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950 \
--hash=sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2 \
--hash=sha256:366ec70f5547c640d3ce1985722490f23faf4eb5216a7eeba78277490e78dacb \
--hash=sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e \
--hash=sha256:3d27167433c0d5f18dc850f07d0b3816221984fecdc405d6c157a6f0b8f8e9e6 \
--hash=sha256:3e5e1224c0a6a90e05843e07adfec669edebec17801c67072f51e59561d63c0b \
--hash=sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2 \
--hash=sha256:433c5a81eade63b47e522303bad236f59dba55ea6951746f5558355eeed8c75d \
--hash=sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa \
--hash=sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2 \
--hash=sha256:494b70049a4d69aec6e8137c13af4cf8db8c9f9820a1392ac293b0dd2987a818 \
--hash=sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032 \
--hash=sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71 \
--hash=sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96 \
--hash=sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687 \
--hash=sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8 \
--hash=sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3 \
--hash=sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61 \
--hash=sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9 \
--hash=sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1 \
--hash=sha256:55261ac0d2941c42f196dd576f543d87a8ee03cd6f5e30dfb4d807b2e3b9121a \
--hash=sha256:56490c595a28b1bb27dfc583e816152a9767721ef58b2c03b13f954d2f707420 \
--hash=sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4 \
--hash=sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65 \
--hash=sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663 \
--hash=sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f \
--hash=sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591 \
--hash=sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a \
--hash=sha256:5ca0555312ae2fe82715cada7fac375530c2f3349e1eaa1bcb33d0283ac79a18 \
--hash=sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e \
--hash=sha256:5e2d0e146dcb57034f8b97dc58d2d512cb90aba253960ce449f695fec6a82c6f \
--hash=sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7 \
--hash=sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3 \
--hash=sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c \
--hash=sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3 \
--hash=sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7 \
--hash=sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96 \
--hash=sha256:6ba32c4d2abf1d2fe7cf27d280f4cca5664233b0f885549c7761719eb977f486 \
--hash=sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3 \
--hash=sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6 \
--hash=sha256:6e2912d4babbc65196ac13c2f53468dc57fb8b9c25ef913e8c59ddf7c6dc0e1b \
--hash=sha256:6e5e4d73d588ca5ed09df1b7dcd1b203d1df3c542e3f50d126c947d432b10731 \
--hash=sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959 \
--hash=sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9 \
--hash=sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf \
--hash=sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8 \
--hash=sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e \
--hash=sha256:789b8982559ae28dad2356519f841655756cdcd96616410590ae0b17454ee64f \
--hash=sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885 \
--hash=sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0 \
--hash=sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506 \
--hash=sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2 \
--hash=sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0 \
--hash=sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e \
--hash=sha256:85de3134b5379856e323ba37c19c9256d39425f7b76a63af52b09fb4664c2e8f \
--hash=sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e \
--hash=sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491 \
--hash=sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a \
--hash=sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20 \
--hash=sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449 \
--hash=sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af \
--hash=sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c \
--hash=sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712 \
--hash=sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7 \
--hash=sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a \
--hash=sha256:94fbf1c0c6cc0d3d5e50f9a9313a8cdca90dd696d34b381cd1704f8c9e939f20 \
--hash=sha256:950f23cb393f85543777b0433f082cddd25b51ab398eac7971146495679efe5f \
--hash=sha256:96eefc178f8636b9c760c5829345307fd81cfae9ab1e80997dbddeb0f54ee9a3 \
--hash=sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9 \
--hash=sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e \
--hash=sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5 \
--hash=sha256:994e883d17c559cdfd38c84003c8b27d25424a1077272a17e7cd27bfe0bf57b2 \
--hash=sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36 \
--hash=sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263 \
--hash=sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4 \
--hash=sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11 \
--hash=sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a \
--hash=sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3 \
--hash=sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375 \
--hash=sha256:a545775cfe815855ea32d7c27731d79da358ef2055b4a25830231b1622dd18aa \
--hash=sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d \
--hash=sha256:a6d095662e73e74f0a49988e0593373e243e3a52e27bfeea0a859e88acf4a0f5 \
--hash=sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99 \
--hash=sha256:a951ad59cad9145664a730d3036b40b844e74d2d3683da40111463cd3a83845d \
--hash=sha256:aa1099b956fb795e686d073568f6dc002a0bb89765ea6d5b055dd7d9bf1b116c \
--hash=sha256:aa2bb0b37202dca27175591f761108b5d34096ade1191ffe4808bdf6b1571488 \
--hash=sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6 \
--hash=sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc \
--hash=sha256:ac00177c4831ffa650f8609e4bdddd5fe09c03b1c0c47acece7e6ea20421598b \
--hash=sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f \
--hash=sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00 \
--hash=sha256:ae31a1a1db2ee6cc2942fccaf695c934bc7f3db9f2133a3fef1f367cf1a4ab10 \
--hash=sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598 \
--hash=sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6 \
--hash=sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962 \
--hash=sha256:b54e7e13267d49ffbfe68e25b3cbd774dab38fa37238f71265e91b36146eb21c \
--hash=sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08 \
--hash=sha256:ba2f37ee79e6338845261a3c5b1784e5d1acdff2c0785b284f1b633033d136ab \
--hash=sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573 \
--hash=sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90 \
--hash=sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5 \
--hash=sha256:bd6c173f04743d483881bffa1478d5a4624475b8cd1d2194956a75548e191c18 \
--hash=sha256:be47f99644b208bff7766314013f9acf57b056b04191d570d68ad14022cf5b1d \
--hash=sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af \
--hash=sha256:c1dcc36dcb96abc02236e182d17e0f71430152a6c2c7447421da2d2dc144edea \
--hash=sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c \
--hash=sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b \
--hash=sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6 \
--hash=sha256:c7b742bf31c88566b4bb6335a7f393bb322e580b6bb98df7bd0c25e6e3519ce8 \
--hash=sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774 \
--hash=sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004 \
--hash=sha256:ce854f5f478050ade5a238731c4ca985a7d3b3cb53ff600a9b5c3b689b5f0a7a \
--hash=sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a \
--hash=sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2 \
--hash=sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2 \
--hash=sha256:d1ee1e296209fdce05b81b663250eefa02213a2da7b41bf26f7829b8ba3545aa \
--hash=sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe \
--hash=sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3 \
--hash=sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc \
--hash=sha256:e06efa066f7dbadbc84ebc126a97c452a6451dfcf589d89d788484949e1cf795 \
--hash=sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d \
--hash=sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc \
--hash=sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893 \
--hash=sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef \
--hash=sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d \
--hash=sha256:e9fbdce1e47394b09bc9f26ab117dfc8d6491977a11d86f592bb42c779db2fda \
--hash=sha256:eb12fb2ba69ffa05f8695f61c69e591dc4b4a12ac3757ac8af8adb259bf56d17 \
--hash=sha256:eda059b6bc8bc0812d626fd91a7ce01bf583df0a61296eff390fd94141a34e30 \
--hash=sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7 \
--hash=sha256:f298e218441525d3794428b4c8b8fb8662c6d3ea79925d4807ee6b9a96a3bca5 \
--hash=sha256:f5542f9b941279d82d41eb0aa9f98eba36fe4df5c7086c651df7944935b37182 \
--hash=sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f \
--hash=sha256:f9b1e28d0e8dbfa858abdba91d6b547beaf2df1a59bec6da6faae7b96a4991a9 \
--hash=sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada \
--hash=sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876 \
--hash=sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a \
--hash=sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348 \
--hash=sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3 \
--hash=sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f \
--hash=sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0 \
--hash=sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f
# via requests
click==8.5.0 \
--hash=sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360 \
--hash=sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34
# via flask
flask==3.1.3 \
--hash=sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb \
--hash=sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c
# via -r requirements.in
gunicorn==26.2.0 \
--hash=sha256:62b864895d9ebff0b2f9867ba04fe811c93121596540830c9c916d0769668447 \
--hash=sha256:bd249d0b3f7972f7432f0a6b6ff3b3ee2d129f70cd1ff6c09a9dd9e29a2b88e3
# via -r requirements.in
icalendar==7.3.0 \
--hash=sha256:7bd001c8e648205e1bde5c6a5b77096598e8d0893dcf57755c6c597635620132 \
--hash=sha256:8355acfe17be81b368f0b1e3740817cea9b56ea889931f8f1a87c62f2d28db0b
# via -r requirements.in
idna==3.19 \
--hash=sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15 \
--hash=sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4
# via requests
itsdangerous==2.2.0 \
--hash=sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef \
--hash=sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173
# via flask
jinja2==3.1.6 \
--hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \
--hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67
# via flask
markupsafe==3.0.3 \
--hash=sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f \
--hash=sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a \
--hash=sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf \
--hash=sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19 \
--hash=sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf \
--hash=sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c \
--hash=sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175 \
--hash=sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219 \
--hash=sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb \
--hash=sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6 \
--hash=sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab \
--hash=sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26 \
--hash=sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1 \
--hash=sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce \
--hash=sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218 \
--hash=sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634 \
--hash=sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695 \
--hash=sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad \
--hash=sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73 \
--hash=sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c \
--hash=sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe \
--hash=sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa \
--hash=sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559 \
--hash=sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa \
--hash=sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37 \
--hash=sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758 \
--hash=sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f \
--hash=sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8 \
--hash=sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d \
--hash=sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c \
--hash=sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97 \
--hash=sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a \
--hash=sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19 \
--hash=sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9 \
--hash=sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9 \
--hash=sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc \
--hash=sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2 \
--hash=sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4 \
--hash=sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354 \
--hash=sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50 \
--hash=sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698 \
--hash=sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9 \
--hash=sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b \
--hash=sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc \
--hash=sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115 \
--hash=sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e \
--hash=sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485 \
--hash=sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f \
--hash=sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12 \
--hash=sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025 \
--hash=sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009 \
--hash=sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d \
--hash=sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b \
--hash=sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a \
--hash=sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5 \
--hash=sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f \
--hash=sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d \
--hash=sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1 \
--hash=sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287 \
--hash=sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6 \
--hash=sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f \
--hash=sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581 \
--hash=sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed \
--hash=sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b \
--hash=sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c \
--hash=sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026 \
--hash=sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8 \
--hash=sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676 \
--hash=sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6 \
--hash=sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e \
--hash=sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d \
--hash=sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d \
--hash=sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01 \
--hash=sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7 \
--hash=sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419 \
--hash=sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795 \
--hash=sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1 \
--hash=sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5 \
--hash=sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d \
--hash=sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42 \
--hash=sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe \
--hash=sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda \
--hash=sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e \
--hash=sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737 \
--hash=sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523 \
--hash=sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591 \
--hash=sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc \
--hash=sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a \
--hash=sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50
# via
# flask
# jinja2
# werkzeug
python-dateutil==2.9.0.post0 \
--hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \
--hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427
# via icalendar
pytz==2025.2 \
--hash=sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3 \
--hash=sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00
# via -r requirements.in
requests==2.34.2 \
--hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \
--hash=sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed
# via -r requirements.in
six==1.17.0 \
--hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \
--hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81
# via python-dateutil
tzdata==2026.3 \
--hash=sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415 \
--hash=sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931
# via icalendar
tzlocal==5.4.4 \
--hash=sha256:8dbb8660838688a7b6ba4fed31d18dedf842afb4d47ca050d6d891c2c15f3be4 \
--hash=sha256:aae09f0126a8a86fa736be266eb4a471380d26a0de3bc14844e7821fee3e2a15
# via apscheduler
urllib3==2.7.0 \
--hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \
--hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897
# via requests
werkzeug==3.1.8 \
--hash=sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50 \
--hash=sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44
# via flask

View file

@ -1,490 +1,437 @@
/* ===== StudyCalendar - CSS ===== */
:root {
--bg-primary: #0f0f1a;
--bg-secondary: #1a1a2e;
--bg-tertiary: #252540;
--bg-card: #16213e;
--text-primary: #e0e0e0;
--text-secondary: #a0a0b0;
--text-muted: #6a6a7a;
--accent: #4A90D9;
--accent-hover: #5BA0E9;
--accent-light: rgba(74, 144, 217, 0.15);
--success: #4CAF50;
--warning: #FF9800;
--danger: #f44336;
--urgent: #e91e63;
--border: #2a2a4a;
--shadow: rgba(0, 0, 0, 0.3);
--radius: 12px;
--radius-sm: 8px;
--sidebar-width: 300px;
--header-height: 60px;
--cat-estudos: #4A90D9;
--cat-trabalho: #FF9800;
--cat-pessoal: #4CAF50;
--cat-provas: #e91e63;
--cat-entregas: #9C27B0;
--cat-geral: #607D8B;
color-scheme: dark;
--bg: #0d0e19;
--surface: #17182b;
--surface-raised: #20223a;
--surface-hover: #292c49;
--text: #f4f5fb;
--text-soft: #c3c7d9;
--text-muted: #9ba2ba;
--accent: #6caaf0;
--accent-strong: #286fae;
--accent-soft: rgb(108 170 240 / 16%);
--success: #55c875;
--warning: #f5b942;
--danger: #ff6b6b;
--border: #353852;
--overlay: rgb(2 3 10 / 78%);
--shadow: 0 20px 60px rgb(0 0 0 / 45%);
--radius: 14px;
--radius-small: 9px;
--header-height: 64px;
--sidebar-width: 304px;
--cat-estudos: #4a90d9;
--cat-trabalho: #d97706;
--cat-pessoal: #2f9e44;
--cat-provas: #db2777;
--cat-entregas: #9333c7;
--cat-geral: #64748b;
}
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: var(--bg-primary); }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
*, *::before, *::after { box-sizing: border-box; }
html { min-height: 100%; background: var(--bg); }
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
min-height: 100dvh;
margin: 0;
overflow: hidden;
background: var(--bg);
color: var(--text);
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
line-height: 1.45;
}
button, input, textarea, select { font: inherit; }
button { color: inherit; }
button:disabled { cursor: wait; opacity: .58; }
:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
::selection { background: var(--accent); color: #08101d; }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: #41455f; border: 2px solid var(--bg); border-radius: 99px; }
/* ===== Header ===== */
.app-header {
display: flex; align-items: center; justify-content: space-between;
height: var(--header-height); padding: 0 20px;
background: var(--bg-secondary); border-bottom: 1px solid var(--border);
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
.skip-link {
position: fixed;
top: 8px;
left: 8px;
z-index: 2000;
padding: 10px 14px;
border-radius: var(--radius-small);
background: var(--text);
color: var(--bg);
transform: translateY(-150%);
}
.header-left, .header-right { display: flex; align-items: center; gap: 12px; }
.app-title { font-size: 1.25rem; font-weight: 700; letter-spacing: -0.5px; }
/* ===== Botões ===== */
.btn-icon {
display: flex; align-items: center; justify-content: center;
width: 40px; height: 40px; border: none; border-radius: var(--radius-sm);
background: transparent; color: var(--text-secondary); cursor: pointer; transition: all 0.2s;
}
.btn-icon:hover { background: var(--bg-tertiary); color: var(--text-primary); }
.btn-icon.btn-sm { width: 32px; height: 32px; }
.btn-primary {
display: inline-flex; align-items: center; gap: 6px;
padding: 8px 16px; border: none; border-radius: var(--radius-sm);
background: var(--accent); color: white; font-family: inherit;
font-size: 0.875rem; font-weight: 500; cursor: pointer; transition: all 0.2s;
}
.btn-primary:hover { background: var(--accent-hover); transform: translateY(-1px); }
.btn-primary .material-icons-round { font-size: 18px; }
.btn-secondary {
display: inline-flex; align-items: center; gap: 6px;
padding: 8px 16px; border: 1px solid var(--border); border-radius: var(--radius-sm);
background: var(--bg-tertiary); color: var(--text-primary); font-family: inherit;
font-size: 0.875rem; font-weight: 500; cursor: pointer; transition: all 0.2s;
}
.btn-secondary:hover { background: var(--bg-card); border-color: var(--accent); }
.btn-secondary .material-icons-round { font-size: 18px; }
.btn-danger {
display: inline-flex; align-items: center; gap: 6px;
padding: 8px 16px; border: none; border-radius: var(--radius-sm);
background: var(--danger); color: white; font-family: inherit;
font-size: 0.875rem; font-weight: 500; cursor: pointer; transition: all 0.2s;
}
.btn-danger:hover { opacity: 0.9; }
.btn-danger .material-icons-round { font-size: 18px; }
.btn-sm { padding: 5px 10px; font-size: 0.8rem; }
.btn-install {
background: linear-gradient(135deg, var(--accent), #7c4dff);
animation: pulse-glow 2s infinite;
}
@keyframes pulse-glow {
0%, 100% { box-shadow: 0 0 5px rgba(74,144,217,0.3); }
50% { box-shadow: 0 0 15px rgba(74,144,217,0.6); }
}
/* ===== Layout ===== */
.app-container {
display: flex; margin-top: var(--header-height);
height: calc(100vh - var(--header-height));
}
/* ===== Sidebar ===== */
.sidebar {
width: var(--sidebar-width); min-width: var(--sidebar-width);
background: var(--bg-secondary); border-right: 1px solid var(--border);
overflow-y: auto; height: 100%; transition: transform 0.3s ease;
}
.sidebar-overlay { display: none; }
.sidebar-content { padding: 16px; }
.sidebar-section { margin-bottom: 20px; padding-bottom: 16px; border-bottom: 1px solid var(--border); }
.sidebar-section:last-child { border-bottom: none; }
.sidebar-title {
font-size: 0.8rem; font-weight: 600; color: var(--text-secondary);
text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 10px;
}
/* Mini Calendário */
.mini-calendar { user-select: none; }
.mini-cal-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.mini-cal-title { font-size: 0.9rem; font-weight: 600; text-transform: capitalize; }
.mini-cal-weekdays {
display: grid; grid-template-columns: repeat(7, 1fr); text-align: center;
font-size: 0.7rem; font-weight: 500; color: var(--text-muted); margin-bottom: 4px;
}
.mini-cal-days { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; }
.mini-cal-day {
display: flex; align-items: center; justify-content: center; position: relative;
width: 32px; height: 32px; margin: 0 auto; border-radius: 50%;
font-size: 0.8rem; cursor: pointer; transition: all 0.15s; color: var(--text-secondary);
}
.mini-cal-day:hover { background: var(--bg-tertiary); color: var(--text-primary); }
.mini-cal-day.today { background: var(--accent); color: white; font-weight: 600; }
.mini-cal-day.other-month { opacity: 0.3; }
/* Busca */
.search-box {
display: flex; align-items: center; gap: 8px; padding: 8px 12px;
background: var(--bg-tertiary); border-radius: var(--radius-sm);
border: 1px solid var(--border); transition: border-color 0.2s;
}
.search-box:focus-within { border-color: var(--accent); }
.search-icon { color: var(--text-muted); font-size: 20px; }
.search-box input {
flex: 1; border: none; background: transparent; color: var(--text-primary);
font-family: inherit; font-size: 0.875rem; outline: none;
}
.search-box input::placeholder { color: var(--text-muted); }
/* Categorias */
.category-filters { display: flex; flex-direction: column; gap: 4px; }
.category-btn {
display: flex; align-items: center; gap: 8px; width: 100%; padding: 8px 12px;
border: none; border-radius: var(--radius-sm); background: transparent;
color: var(--text-secondary); font-family: inherit; font-size: 0.85rem;
cursor: pointer; transition: all 0.2s; text-align: left;
}
.category-btn:hover { background: var(--bg-tertiary); color: var(--text-primary); }
.category-btn.active { background: var(--accent-light); color: var(--accent); font-weight: 500; }
.cat-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
/* Lista Próximos */
.upcoming-list { display: flex; flex-direction: column; gap: 6px; max-height: 200px; overflow-y: auto; }
.upcoming-item {
display: flex; align-items: flex-start; gap: 8px; padding: 8px;
border-radius: var(--radius-sm); background: var(--bg-tertiary);
cursor: pointer; transition: all 0.2s;
}
.upcoming-item:hover { background: var(--bg-card); }
.upcoming-dot { width: 8px; height: 8px; border-radius: 50%; margin-top: 5px; flex-shrink: 0; }
.upcoming-info { flex: 1; min-width: 0; }
.upcoming-title {
font-size: 0.8rem; font-weight: 500; white-space: nowrap;
overflow: hidden; text-overflow: ellipsis;
}
.upcoming-date { font-size: 0.7rem; color: var(--text-muted); }
/* Estatísticas */
.stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.stat-card { padding: 12px; background: var(--bg-tertiary); border-radius: var(--radius-sm); text-align: center; }
.stat-value { display: block; font-size: 1.5rem; font-weight: 700; color: var(--accent); }
.stat-danger .stat-value { color: var(--danger); }
.stat-success .stat-value { color: var(--success); }
.stat-label { font-size: 0.7rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.3px; }
/* ===== Main Content ===== */
.main-content { flex: 1; overflow-y: auto; padding: 16px 20px; display: flex; flex-direction: column; }
.calendar-controls {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 16px; flex-shrink: 0;
}
.view-nav { display: flex; align-items: center; gap: 8px; }
.current-period { font-size: 1.2rem; font-weight: 600; min-width: 200px; text-align: center; text-transform: capitalize; }
.btn-today { margin-left: 8px; }
.view-tabs {
display: flex; background: var(--bg-secondary); border-radius: var(--radius-sm);
border: 1px solid var(--border); overflow: hidden;
}
.view-tab {
padding: 8px 20px; border: none; background: transparent;
color: var(--text-secondary); font-family: inherit; font-size: 0.85rem;
font-weight: 500; cursor: pointer; transition: all 0.2s;
}
.view-tab:hover { color: var(--text-primary); background: var(--bg-tertiary); }
.view-tab.active { background: var(--accent); color: white; }
/* ===== Visão Mensal ===== */
.calendar-weekdays {
display: grid; grid-template-columns: repeat(7, 1fr); text-align: center;
font-size: 0.75rem; font-weight: 600; color: var(--text-muted);
text-transform: uppercase; letter-spacing: 0.5px; padding: 8px 0;
border-bottom: 1px solid var(--border);
}
.calendar-grid {
display: grid; grid-template-columns: repeat(7, 1fr); flex: 1;
gap: 1px; background: var(--border); border-radius: var(--radius); overflow: hidden;
}
.calendar-day {
background: var(--bg-primary); padding: 6px; min-height: 100px;
cursor: pointer; transition: background 0.15s; display: flex; flex-direction: column;
}
.calendar-day:hover { background: var(--bg-secondary); }
.calendar-day.today { background: var(--accent-light); }
.calendar-day.other-month { opacity: 0.4; }
.day-number { font-size: 0.85rem; font-weight: 500; color: var(--text-secondary); margin-bottom: 4px; }
.calendar-day.today .day-number {
background: var(--accent); color: white; width: 26px; height: 26px;
display: flex; align-items: center; justify-content: center;
border-radius: 50%; font-weight: 600;
}
.day-events { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 2px; }
.day-event {
padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 500;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
cursor: pointer; transition: all 0.15s; border-left: 3px solid;
background: var(--bg-tertiary); color: var(--text-primary);
}
.day-event:hover { filter: brightness(1.2); transform: translateX(2px); }
.day-event.completed { text-decoration: line-through; opacity: 0.5; }
.day-event.cat-estudos { border-color: var(--cat-estudos); }
.day-event.cat-trabalho { border-color: var(--cat-trabalho); }
.day-event.cat-pessoal { border-color: var(--cat-pessoal); }
.day-event.cat-provas { border-color: var(--cat-provas); }
.day-event.cat-entregas { border-color: var(--cat-entregas); }
.day-event.cat-geral { border-color: var(--cat-geral); }
.priority-badge {
display: inline-block; width: 6px; height: 6px; border-radius: 50%; margin-right: 3px;
}
.priority-baixa { background: var(--success); }
.priority-normal { background: var(--accent); }
.priority-alta { background: var(--warning); }
.priority-urgente { background: var(--urgent); animation: blink 1s infinite; }
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
.day-more { font-size: 0.65rem; color: var(--accent); text-align: center; padding: 2px; cursor: pointer; font-weight: 500; }
/* ===== Visão Semanal ===== */
.week-grid { display: flex; flex-direction: column; flex: 1; overflow-y: auto; }
.week-header {
display: grid; grid-template-columns: 60px repeat(7, 1fr);
border-bottom: 1px solid var(--border); position: sticky; top: 0;
background: var(--bg-primary); z-index: 5;
}
.week-header-cell { padding: 8px; text-align: center; font-size: 0.75rem; color: var(--text-secondary); }
.week-header-cell.today { color: var(--accent); font-weight: 600; }
.week-header-cell .day-num { display: block; font-size: 1.1rem; font-weight: 600; }
.week-body { display: grid; grid-template-columns: 60px repeat(7, 1fr); flex: 1; }
.week-time-label {
padding: 4px 8px; font-size: 0.7rem; color: var(--text-muted);
text-align: right; height: 48px; border-right: 1px solid var(--border);
}
.week-day-column { border-right: 1px solid var(--border); position: relative; }
.week-hour-slot { height: 48px; border-bottom: 1px solid var(--border); position: relative; }
.week-hour-slot:hover { background: var(--accent-light); }
.week-event {
position: absolute; left: 2px; right: 2px; padding: 2px 4px;
border-radius: 4px; font-size: 0.7rem; overflow: hidden;
cursor: pointer; z-index: 2; border-left: 3px solid; min-height: 20px;
}
/* ===== Visão Lista ===== */
.list-container { flex: 1; overflow-y: auto; }
.list-day-group { margin-bottom: 20px; }
.list-day-header {
font-size: 0.85rem; font-weight: 600; color: var(--text-secondary);
padding: 8px 0; border-bottom: 1px solid var(--border); margin-bottom: 8px;
text-transform: capitalize; position: sticky; top: 0; background: var(--bg-primary); z-index: 3;
}
.list-event {
display: flex; align-items: flex-start; gap: 12px; padding: 12px;
margin-bottom: 6px; border-radius: var(--radius-sm); background: var(--bg-secondary);
border-left: 4px solid; cursor: pointer; transition: all 0.2s;
}
.list-event:hover { background: var(--bg-tertiary); transform: translateX(4px); }
.list-event.completed { opacity: 0.5; }
.list-event.completed .list-event-title { text-decoration: line-through; }
.list-event.cat-estudos { border-color: var(--cat-estudos); }
.list-event.cat-trabalho { border-color: var(--cat-trabalho); }
.list-event.cat-pessoal { border-color: var(--cat-pessoal); }
.list-event.cat-provas { border-color: var(--cat-provas); }
.list-event.cat-entregas { border-color: var(--cat-entregas); }
.list-event.cat-geral { border-color: var(--cat-geral); }
.list-event-time { font-size: 0.8rem; font-weight: 600; color: var(--accent); min-width: 50px; flex-shrink: 0; }
.list-event-content { flex: 1; min-width: 0; }
.list-event-title { font-size: 0.9rem; font-weight: 500; margin-bottom: 2px; }
.list-event-desc { font-size: 0.75rem; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.list-event-meta { display: flex; gap: 8px; margin-top: 4px; font-size: 0.7rem; color: var(--text-muted); }
.list-event-actions { display: flex; gap: 4px; flex-shrink: 0; }
.list-check-btn {
width: 28px; height: 28px; border: 2px solid var(--border); border-radius: 50%;
background: transparent; color: var(--text-muted); display: flex; align-items: center;
justify-content: center; cursor: pointer; transition: all 0.2s; flex-shrink: 0; margin-top: 2px;
}
.list-check-btn:hover { border-color: var(--success); color: var(--success); }
.list-check-btn.checked { background: var(--success); border-color: var(--success); color: white; }
.list-check-btn .material-icons-round { font-size: 16px; }
/* ===== FAB ===== */
.fab {
position: fixed; bottom: 24px; right: 24px; width: 56px; height: 56px;
border-radius: 50%; border: none; background: var(--accent); color: white;
display: flex; align-items: center; justify-content: center;
cursor: pointer; box-shadow: 0 4px 12px rgba(74,144,217,0.4);
transition: all 0.3s; z-index: 50;
}
.fab:hover { transform: scale(1.1); box-shadow: 0 6px 20px rgba(74,144,217,0.6); }
.fab .material-icons-round { font-size: 28px; }
/* ===== Modais ===== */
.modal-overlay {
display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.6);
backdrop-filter: blur(4px); z-index: 200; align-items: center;
justify-content: center; padding: 20px;
}
.modal-overlay.active { display: flex; }
.modal {
background: var(--bg-secondary); border-radius: var(--radius);
border: 1px solid var(--border); width: 100%; max-width: 520px;
max-height: 85vh; display: flex; flex-direction: column;
box-shadow: 0 20px 60px rgba(0,0,0,0.5); animation: modal-in 0.2s ease-out;
}
.modal-wide { max-width: 640px; }
@keyframes modal-in {
from { transform: scale(0.95) translateY(10px); opacity: 0; }
to { transform: scale(1) translateY(0); opacity: 1; }
}
.modal-header {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 20px; border-bottom: 1px solid var(--border);
}
.modal-header h3 { font-size: 1.1rem; font-weight: 600; }
.modal-body { padding: 20px; overflow-y: auto; flex: 1; }
.modal-footer {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 20px; border-top: 1px solid var(--border);
}
.modal-footer-right { display: flex; gap: 8px; margin-left: auto; }
/* Formulários */
.form-group { margin-bottom: 14px; }
.form-group label { display: block; font-size: 0.8rem; font-weight: 500; color: var(--text-secondary); margin-bottom: 4px; }
.form-group input[type="text"], .form-group input[type="url"],
.form-group input[type="date"], .form-group input[type="time"],
.form-group input[type="number"], .form-group textarea, .form-group select {
width: 100%; padding: 10px 12px; border: 1px solid var(--border);
border-radius: var(--radius-sm); background: var(--bg-tertiary);
color: var(--text-primary); font-family: inherit; font-size: 0.9rem;
outline: none; transition: border-color 0.2s;
}
.form-group input:focus, .form-group textarea:focus, .form-group select:focus { border-color: var(--accent); }
.form-group input[type="color"] {
width: 100%; height: 40px; padding: 4px; border: 1px solid var(--border);
border-radius: var(--radius-sm); background: var(--bg-tertiary); cursor: pointer;
}
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.checkbox-label { display: flex !important; align-items: center; gap: 8px; cursor: pointer; }
.checkbox-label input[type="checkbox"] { width: 18px; height: 18px; accent-color: var(--accent); }
/* Configurações */
.settings-section { margin-bottom: 24px; padding-bottom: 20px; border-bottom: 1px solid var(--border); }
.settings-section:last-child { border-bottom: none; }
.settings-section h4 { font-size: 1rem; font-weight: 600; margin-bottom: 12px; }
.add-feed-form { margin-top: 16px; padding: 16px; background: var(--bg-tertiary); border-radius: var(--radius); }
.add-feed-form h5 { font-size: 0.9rem; font-weight: 600; margin-bottom: 12px; }
.feeds-list { display: flex; flex-direction: column; gap: 8px; }
.feed-item { display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: var(--bg-tertiary); border-radius: var(--radius-sm); }
.feed-color-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }
.feed-info { flex: 1; min-width: 0; }
.feed-name { font-size: 0.85rem; font-weight: 500; }
.feed-meta { font-size: 0.7rem; color: var(--text-muted); }
.feed-actions { display: flex; gap: 4px; }
/* Detalhe de evento */
.detail-field { margin-bottom: 12px; }
.detail-label { font-size: 0.75rem; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 2px; }
.detail-value { font-size: 0.9rem; line-height: 1.5; }
.detail-value.description {
white-space: pre-wrap; word-break: break-word; max-height: 200px;
overflow-y: auto; padding: 8px; background: var(--bg-tertiary);
border-radius: var(--radius-sm); font-size: 0.85rem;
}
/* ===== Toast ===== */
.toast-container { position: fixed; top: 72px; right: 16px; z-index: 300; display: flex; flex-direction: column; gap: 8px; }
.toast {
display: flex; align-items: center; gap: 10px; padding: 12px 16px;
border-radius: var(--radius-sm); background: var(--bg-secondary);
border: 1px solid var(--border); box-shadow: 0 8px 24px rgba(0,0,0,0.4);
font-size: 0.85rem; min-width: 280px; max-width: 400px; animation: toast-in 0.3s ease-out;
}
.toast.toast-out { animation: toast-out 0.3s ease-in forwards; }
.toast.success { border-left: 4px solid var(--success); }
.toast.error { border-left: 4px solid var(--danger); }
.toast.warning { border-left: 4px solid var(--warning); }
.toast.info { border-left: 4px solid var(--accent); }
.toast .material-icons-round { font-size: 20px; }
.toast.success .material-icons-round { color: var(--success); }
.toast.error .material-icons-round { color: var(--danger); }
.toast.warning .material-icons-round { color: var(--warning); }
.toast.info .material-icons-round { color: var(--accent); }
@keyframes toast-in { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
@keyframes toast-out { from { transform: translateX(0); opacity: 1; } to { transform: translateX(100%); opacity: 0; } }
.text-muted { color: var(--text-muted); font-size: 0.85rem; }
.skip-link:focus { transform: none; }
.hidden { display: none !important; }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
.spinning { animation: spin 1s linear infinite; }
.no-events { text-align: center; padding: 40px; color: var(--text-muted); }
.no-events .material-icons-round { font-size: 48px; margin-bottom: 12px; display: block; }
.visually-hidden {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
}
/* ===== Responsivo ===== */
@media (max-width: 768px) {
.app-title { font-size: 1rem; }
.app-shell { min-height: 100dvh; }
.app-header {
position: fixed;
inset: 0 0 auto;
z-index: 100;
display: flex;
align-items: center;
justify-content: space-between;
height: calc(var(--header-height) + env(safe-area-inset-top));
padding: env(safe-area-inset-top) max(16px, env(safe-area-inset-right)) 0 max(16px, env(safe-area-inset-left));
border-bottom: 1px solid var(--border);
background: rgb(23 24 43 / 96%);
backdrop-filter: blur(14px);
}
.header-left, .header-right { display: flex; align-items: center; gap: 8px; min-width: 0; }
.app-title { margin: 0; font-size: 1.15rem; letter-spacing: -.025em; white-space: nowrap; }
.user-display { max-width: 220px; overflow: hidden; color: var(--text-soft); font-size: .82rem; text-overflow: ellipsis; white-space: nowrap; }
.connection-status { padding: 3px 7px; border: 1px solid #347e4d; border-radius: 99px; color: #83e59e; font-size: .7rem; }
.connection-status.offline { border-color: #936d1e; color: var(--warning); }
.icon-button, .button, .view-tab, .category-button, .text-button, .fab, .mini-cal-day, .upcoming-item, .day-event, .week-event, .week-all-day-event, .week-hour-slot, .list-check-button {
border: 0;
cursor: pointer;
}
.icon-button {
display: inline-grid;
flex: 0 0 auto;
width: 44px;
height: 44px;
place-items: center;
border-radius: var(--radius-small);
background: transparent;
color: var(--text-soft);
font-size: 1.4rem;
}
.icon-button:hover { background: var(--surface-hover); color: var(--text); }
.icon-button.small { width: 36px; height: 36px; min-width: 36px; font-size: 1.15rem; }
.danger-text { color: var(--danger); }
.button {
display: inline-flex;
min-height: 44px;
align-items: center;
justify-content: center;
gap: 7px;
padding: 9px 16px;
border-radius: var(--radius-small);
font-weight: 650;
}
.button.primary { background: var(--accent-strong); color: #fff; }
.button.primary:hover { background: #3177b6; }
.button.secondary { border: 1px solid var(--border); background: var(--surface-raised); color: var(--text); }
.button.secondary:hover { border-color: var(--accent); background: var(--surface-hover); }
.button.danger { background: #b8343b; color: #fff; }
.button.compact { min-height: 38px; padding: 6px 12px; font-size: .82rem; }
.button.full { width: 100%; }
.text-button { padding: 3px; background: transparent; color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }
.app-container {
display: flex;
height: 100vh;
height: 100dvh;
padding-top: calc(var(--header-height) + env(safe-area-inset-top));
}
.sidebar {
z-index: 90;
width: var(--sidebar-width);
min-width: var(--sidebar-width);
height: 100%;
overflow-y: auto;
border-right: 1px solid var(--border);
background: var(--surface);
}
.sidebar-content { padding: 18px 16px max(24px, env(safe-area-inset-bottom)); }
.sidebar-section { padding-bottom: 18px; margin-bottom: 18px; border-bottom: 1px solid var(--border); }
.sidebar-section:last-child { border: 0; }
.sidebar-title { margin: 0 0 10px; color: var(--text-soft); font-size: .75rem; letter-spacing: .08em; text-transform: uppercase; }
.sidebar-backdrop { display: none; }
.mini-cal-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.mini-cal-title { margin: 0; font-size: .9rem; text-transform: capitalize; }
.mini-cal-weekdays, .mini-cal-days { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); }
.mini-cal-weekdays { color: var(--text-muted); font-size: .7rem; text-align: center; }
.mini-cal-day {
width: 36px;
height: 36px;
margin: 1px auto;
border-radius: 50%;
background: transparent;
color: var(--text-soft);
font-size: .78rem;
}
.mini-cal-day:hover { background: var(--surface-hover); color: var(--text); }
.mini-cal-day.today { background: var(--accent-strong); color: #fff; font-weight: 700; }
.mini-cal-day.other-month { color: #70768f; }
.search-box { display: flex; min-height: 44px; align-items: center; gap: 9px; padding: 0 12px; border: 1px solid var(--border); border-radius: var(--radius-small); background: var(--surface-raised); }
.search-box:focus-within { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
.search-box input { width: 100%; min-width: 0; border: 0; outline: 0; background: transparent; color: var(--text); }
.search-box input::placeholder { color: var(--text-muted); }
.category-filters { display: grid; gap: 3px; }
.category-button { display: flex; min-height: 40px; align-items: center; gap: 9px; padding: 7px 10px; border-radius: var(--radius-small); background: transparent; color: var(--text-soft); text-align: left; }
.category-button:hover { background: var(--surface-hover); color: var(--text); }
.category-button.active { background: var(--accent-soft); color: #9dcbff; font-weight: 700; }
.category-dot, .upcoming-dot { flex: 0 0 auto; width: 10px; height: 10px; border-radius: 50%; background: var(--cat-geral); }
.cat-todas { background: linear-gradient(135deg, var(--cat-estudos) 50%, var(--cat-entregas) 50%); }
.cat-geral { background: var(--cat-geral); }
.cat-estudos { background: var(--cat-estudos); }
.cat-trabalho { background: var(--cat-trabalho); }
.cat-pessoal { background: var(--cat-pessoal); }
.cat-provas { background: var(--cat-provas); }
.cat-entregas { background: var(--cat-entregas); }
.upcoming-list { display: grid; max-height: 230px; gap: 6px; overflow-y: auto; }
.upcoming-item { display: flex; width: 100%; min-height: 48px; align-items: flex-start; gap: 9px; padding: 9px; border-radius: var(--radius-small); background: var(--surface-raised); color: var(--text); text-align: left; }
.upcoming-item:hover { background: var(--surface-hover); }
.upcoming-dot { margin-top: 6px; }
.upcoming-info { display: grid; min-width: 0; }
.upcoming-title { overflow: hidden; font-size: .8rem; text-overflow: ellipsis; white-space: nowrap; }
.upcoming-date { color: var(--text-muted); font-size: .7rem; }
.stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.stat-card { display: grid; padding: 11px; border-radius: var(--radius-small); background: var(--surface-raised); text-align: center; }
.stat-card strong { color: var(--accent); font-size: 1.35rem; }
.stat-card span { color: var(--text-muted); font-size: .68rem; text-transform: uppercase; }
.stat-card.danger strong { color: var(--danger); }
.stat-card.success strong { color: var(--success); }
.main-content { display: flex; min-width: 0; flex: 1; flex-direction: column; overflow: auto; padding: 18px 20px max(20px, env(safe-area-inset-bottom)); }
.calendar-controls { display: flex; flex: 0 0 auto; align-items: center; justify-content: space-between; gap: 14px; margin-bottom: 12px; }
.view-nav { display: flex; align-items: center; gap: 6px; }
.current-period { min-width: 230px; margin: 0; font-size: 1.12rem; text-align: center; text-transform: capitalize; }
.view-tabs { display: flex; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-small); background: var(--surface); }
.view-tab { min-height: 42px; padding: 8px 18px; background: transparent; color: var(--text-soft); font-weight: 650; }
.view-tab:hover { background: var(--surface-hover); color: var(--text); }
.view-tab.active { background: var(--accent-strong); color: #fff; }
.content-status { display: none; margin: 0 0 12px; padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius-small); background: var(--surface); color: var(--text-soft); }
.content-status.visible { display: block; }
.content-status.error { border-color: #8c3e45; color: #ffb4b4; }
.calendar-view { min-height: 0; flex: 1; }
.calendar-weekdays { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); padding: 7px 0; color: var(--text-muted); font-size: .72rem; font-weight: 700; letter-spacing: .06em; text-align: center; text-transform: uppercase; }
.calendar-grid { display: grid; min-height: 620px; grid-template-columns: repeat(7, minmax(0, 1fr)); grid-auto-rows: minmax(100px, 1fr); gap: 1px; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius); background: var(--border); }
.calendar-day { min-width: 0; padding: 6px; background: var(--bg); }
.calendar-day:hover { background: #121424; }
.calendar-day.today { background: var(--accent-soft); }
.calendar-day.other-month { background: #10111d; color: #777d94; }
.day-number { display: grid; width: 27px; height: 27px; margin-bottom: 3px; place-items: center; color: var(--text-soft); font-size: .8rem; }
.calendar-day.today .day-number { border-radius: 50%; background: var(--accent-strong); color: #fff; font-weight: 700; }
.day-events { display: grid; gap: 3px; }
.day-event {
width: 100%;
min-width: 0;
overflow: hidden;
padding: 4px 6px;
border-left: 4px solid var(--event-color, var(--cat-geral));
border-radius: 4px;
background: color-mix(in srgb, var(--event-color, var(--cat-geral)) 22%, var(--surface-raised));
color: var(--text);
font-size: .7rem;
font-weight: 650;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.day-event:hover { filter: brightness(1.18); }
.day-event.completed { opacity: .64; text-decoration: line-through; }
.day-more { color: #9dcbff; font-size: .68rem; text-align: center; }
.week-scroll { height: 100%; overflow: auto; overscroll-behavior: contain; border: 1px solid var(--border); border-radius: var(--radius); }
.week-grid { min-width: 920px; background: var(--bg); }
.week-header, .all-day-row, .week-body { display: grid; grid-template-columns: 64px repeat(7, minmax(120px, 1fr)); }
.week-header { position: sticky; top: 0; z-index: 8; min-height: 58px; border-bottom: 1px solid var(--border); background: var(--surface); }
.week-corner, .week-header-cell, .all-day-label { display: grid; place-content: center; border-right: 1px solid var(--border); color: var(--text-muted); font-size: .7rem; text-align: center; }
.week-header-cell { gap: 2px; padding: 7px; text-transform: capitalize; }
.week-header-cell.today { color: #9dcbff; }
.day-num { font-size: 1rem; }
.all-day-row { position: sticky; top: 58px; z-index: 7; min-height: 48px; border-bottom: 1px solid var(--border); background: var(--surface); }
.all-day-cell { display: grid; align-content: start; gap: 2px; min-width: 0; padding: 4px; border-right: 1px solid var(--border); }
.week-all-day-event { overflow: hidden; padding: 3px 5px; border-left: 3px solid var(--event-color); border-radius: 4px; background: color-mix(in srgb, var(--event-color) 25%, var(--surface-raised)); color: var(--text); font-size: .67rem; text-align: left; text-overflow: ellipsis; white-space: nowrap; }
.week-all-day-event.completed { opacity: .65; text-decoration: line-through; }
.week-time-column { border-right: 1px solid var(--border); }
.week-time-label { height: 48px; padding: 3px 7px; color: var(--text-muted); font-size: .68rem; text-align: right; }
.week-day-column { position: relative; height: 1152px; border-right: 1px solid var(--border); }
.week-hour-slot { display: block; width: 100%; height: 48px; border-bottom: 1px solid var(--border); background: transparent; }
.week-hour-slot:hover, .week-hour-slot:focus-visible { background: var(--accent-soft); }
.week-event {
position: absolute;
z-index: 2;
top: var(--event-top);
left: calc((100% / var(--event-columns)) * var(--event-column) + 2px);
width: calc(100% / var(--event-columns) - 4px);
height: var(--event-height);
min-height: 24px;
overflow: hidden;
padding: 3px 5px;
border-left: 4px solid var(--event-color);
border-radius: 5px;
background: color-mix(in srgb, var(--event-color) 35%, #20223a);
color: #fff;
font-size: .68rem;
font-weight: 650;
text-align: left;
}
.week-event:hover { z-index: 4; filter: brightness(1.2); }
.week-event.completed { opacity: .6; text-decoration: line-through; }
.list-container { height: 100%; overflow-y: auto; }
.list-day-group { margin-bottom: 20px; }
.list-day-header { position: sticky; top: 0; z-index: 3; margin: 0 0 7px; padding: 8px 2px; border-bottom: 1px solid var(--border); background: var(--bg); color: var(--text-soft); font-size: .85rem; text-transform: capitalize; }
.list-event { display: flex; align-items: flex-start; gap: 11px; margin-bottom: 7px; padding: 12px; border-left: 4px solid var(--event-color, var(--cat-geral)); border-radius: var(--radius-small); background: var(--surface); }
.list-event:hover { background: var(--surface-raised); }
.list-event.completed { opacity: .65; }
.list-event.completed .list-event-title { text-decoration: line-through; }
.list-event-open { align-self: flex-start; margin-top: 5px; border: 0; background: transparent; color: var(--accent); font: inherit; font-size: .72rem; text-decoration: underline; cursor: pointer; }
.list-check-button { display: grid; flex: 0 0 auto; width: 36px; height: 36px; margin-top: 1px; place-items: center; border: 2px solid #555b74; border-radius: 50%; background: transparent; color: #fff; }
.list-check-button:hover { border-color: var(--success); }
.list-check-button.checked { border-color: var(--success); background: #287b40; }
.list-event-time { min-width: 66px; padding-top: 8px; color: #9dcbff; font-size: .78rem; font-weight: 700; }
.list-event-content { min-width: 0; flex: 1; }
.list-event-title { margin: 0 0 2px; font-size: .92rem; }
.list-event-description { overflow: hidden; margin: 0; color: var(--text-soft); font-size: .78rem; text-overflow: ellipsis; white-space: nowrap; }
.list-event-meta { display: flex; flex-wrap: wrap; gap: 5px 12px; margin: 5px 0 0; color: var(--text-muted); font-size: .72rem; }
.priority::before { display: inline-block; width: 7px; height: 7px; margin-right: 5px; border-radius: 50%; background: var(--accent); content: ""; }
.priority-baixa::before { background: var(--success); }
.priority-alta::before { background: var(--warning); }
.priority-urgente::before { background: var(--danger); }
.fab { position: fixed; right: max(24px, env(safe-area-inset-right)); bottom: max(24px, env(safe-area-inset-bottom)); z-index: 50; display: grid; width: 58px; height: 58px; place-items: center; border-radius: 50%; background: var(--accent-strong); box-shadow: 0 8px 28px rgb(67 142 220 / 42%); color: #fff; font-size: 1.8rem; }
.fab:hover { background: #3177b6; transform: translateY(-2px); }
.auth-overlay, .modal-overlay { position: fixed; inset: 0; z-index: 1000; display: none; align-items: center; justify-content: center; padding: max(16px, env(safe-area-inset-top)) max(16px, env(safe-area-inset-right)) max(16px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left)); background: var(--overlay); }
.auth-overlay.active, .modal-overlay.active { display: flex; }
.auth-overlay { background: radial-gradient(circle at 50% 15%, #20284b 0, var(--bg) 52%); }
.auth-container { width: min(100%, 410px); padding: 34px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); }
.auth-title { margin: 0 0 5px; color: #8fc4ff; font-size: 1.65rem; text-align: center; }
.auth-subtitle { margin: 0 0 24px; color: var(--text-muted); font-size: .88rem; text-align: center; }
.auth-form h2 { margin: 0 0 17px; font-size: 1.15rem; }
.auth-switch { margin: 20px 0 0; color: var(--text-muted); font-size: .84rem; text-align: center; }
.modal-overlay { z-index: 500; backdrop-filter: blur(5px); }
.modal { display: flex; width: min(100%, 540px); max-height: min(88vh, 88dvh); flex-direction: column; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); }
.modal.wide { width: min(100%, 680px); }
.modal-header { display: flex; min-height: 62px; flex: 0 0 auto; align-items: center; justify-content: space-between; padding: 9px 12px 9px 20px; border-bottom: 1px solid var(--border); }
.modal-header h2 { margin: 0; font-size: 1.08rem; }
.modal-body { min-height: 0; flex: 1; overflow-y: auto; padding: 20px; }
.modal-footer { display: flex; flex: 0 0 auto; align-items: center; justify-content: space-between; gap: 10px; padding: 12px 20px; border-top: 1px solid var(--border); }
.modal-footer.centered { justify-content: center; }
.modal-footer-actions { display: flex; gap: 8px; margin-left: auto; }
.form-group { margin-bottom: 14px; }
.form-group label { display: block; margin-bottom: 5px; color: var(--text-soft); font-size: .8rem; font-weight: 650; }
.form-group input, .form-group textarea, .form-group select {
width: 100%;
min-height: 44px;
padding: 9px 11px;
border: 1px solid #444862;
border-radius: var(--radius-small);
outline: 0;
background: var(--surface-raised);
color: var(--text);
}
.form-group textarea { resize: vertical; }
.form-group input:focus, .form-group textarea:focus, .form-group select:focus { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
.form-group input:disabled { color: var(--text-muted); opacity: .7; }
.form-group input[type="color"] { padding: 4px; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.checkbox-label { display: flex; min-height: 44px; align-items: center; gap: 9px; color: var(--text-soft); cursor: pointer; }
.checkbox-label input { width: 20px; height: 20px; accent-color: var(--accent-strong); }
.form-error { display: none; margin: 0 0 12px; padding: 9px 11px; border-left: 3px solid var(--danger); border-radius: 4px; background: rgb(255 107 107 / 10%); color: #ffb4b4; font-size: .82rem; }
.form-error.visible { display: block; }
.settings-section { padding-bottom: 24px; margin-bottom: 24px; border-bottom: 1px solid var(--border); }
.settings-section:last-child { padding-bottom: 0; margin-bottom: 0; border: 0; }
.settings-section h3, .add-feed-form h4 { margin: 0 0 10px; font-size: 1rem; }
.help-text, .state-message { margin: 6px 0 12px; color: var(--text-muted); font-size: .82rem; }
.state-message.error { color: #ffb4b4; }
.settings-list { display: grid; gap: 7px; margin: 14px 0; }
.settings-item { display: flex; min-height: 54px; align-items: center; gap: 10px; padding: 9px 11px; border-radius: var(--radius-small); background: var(--surface-raised); }
.feed-color-dot { flex: 0 0 auto; width: 13px; height: 13px; border-radius: 50%; }
.feed-info { display: grid; min-width: 0; flex: 1; }
.feed-name { overflow: hidden; font-size: .84rem; text-overflow: ellipsis; white-space: nowrap; }
.feed-meta { color: var(--text-muted); font-size: .7rem; }
.feed-actions { display: flex; gap: 2px; }
.rule-label { flex: 1; }
.inline-form { display: flex; align-items: end; gap: 10px; }
.inline-form .form-group { flex: 1; margin: 0; }
.add-feed-form { margin-top: 16px; padding: 16px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface-raised); }
.add-feed-form .form-group input, .add-feed-form .form-group select { background: var(--surface); }
.detail-field { margin-bottom: 16px; }
.detail-label { margin: 0 0 3px; color: var(--text-muted); font-size: .7rem; letter-spacing: .07em; text-transform: uppercase; }
.detail-value { margin: 0; overflow-wrap: anywhere; font-size: .9rem; }
.detail-value.description { max-height: 220px; overflow-y: auto; padding: 10px; border-radius: var(--radius-small); background: var(--surface-raised); white-space: pre-wrap; }
.toast-container { position: fixed; top: calc(var(--header-height) + env(safe-area-inset-top) + 10px); right: max(14px, env(safe-area-inset-right)); z-index: 1500; display: grid; gap: 8px; width: min(380px, calc(100vw - 28px)); pointer-events: none; }
.toast { display: flex; align-items: center; gap: 10px; padding: 12px 14px; border: 1px solid var(--border); border-left: 4px solid var(--accent); border-radius: var(--radius-small); background: var(--surface); box-shadow: var(--shadow); font-size: .84rem; animation: toast-in .2s ease-out; }
.toast.success { border-left-color: var(--success); }
.toast.error { border-left-color: var(--danger); }
.toast.warning { border-left-color: var(--warning); }
.toast-icon { display: grid; flex: 0 0 auto; width: 22px; height: 22px; place-items: center; border-radius: 50%; background: var(--surface-hover); font-weight: 800; }
.toast.toast-out { opacity: 0; transform: translateX(20px); transition: .2s; }
@keyframes toast-in { from { opacity: 0; transform: translateY(-8px); } }
@keyframes spin { to { transform: rotate(360deg); } }
.spinning { display: inline-block; animation: spin .8s linear infinite; }
@media (max-width: 800px) {
:root { --header-height: 60px; }
.app-header { padding-right: max(8px, env(safe-area-inset-right)); padding-left: max(8px, env(safe-area-inset-left)); }
.header-left, .header-right { gap: 2px; }
.app-title { font-size: .98rem; }
.user-display, .connection-status, .install-label { display: none; }
.sidebar {
position: fixed; top: var(--header-height); left: 0; bottom: 0;
z-index: 90; transform: translateX(-100%); width: 85vw; max-width: 320px;
position: fixed;
top: calc(var(--header-height) + env(safe-area-inset-top));
bottom: 0;
left: 0;
z-index: 110;
width: min(88vw, 340px);
min-width: 0;
height: auto;
transform: translateX(-105%);
transition: transform .22s ease;
box-shadow: var(--shadow);
}
.sidebar.open { transform: translateX(0); }
.sidebar-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: -1; }
.sidebar.open .sidebar-overlay { display: block; }
.main-content { padding: 12px; }
.calendar-controls { flex-direction: column; gap: 8px; align-items: stretch; }
.sidebar.open { transform: none; }
.sidebar-backdrop { position: fixed; inset: calc(var(--header-height) + env(safe-area-inset-top)) 0 0; z-index: 105; background: var(--overlay); }
.sidebar-backdrop.active { display: block; }
.main-content { padding: 12px 10px max(78px, env(safe-area-inset-bottom)); }
.calendar-controls { align-items: stretch; flex-direction: column; gap: 8px; }
.view-nav { justify-content: center; }
.view-tabs { justify-content: center; }
.current-period { font-size: 1rem; min-width: auto; }
.calendar-weekdays span { font-size: 0.6rem; }
.calendar-day { min-height: 70px; padding: 3px; }
.day-number { font-size: 0.75rem; }
.day-event { font-size: 0.6rem; padding: 1px 3px; }
.calendar-day.today .day-number { width: 22px; height: 22px; font-size: 0.7rem; }
.form-row { grid-template-columns: 1fr; }
.modal { max-height: 90vh; margin: 10px; }
.fab { bottom: 16px; right: 16px; width: 48px; height: 48px; }
.fab .material-icons-round { font-size: 24px; }
.btn-install span:not(.material-icons-round) { display: none; }
}
@media (max-width: 480px) {
.calendar-weekdays span { font-size: 0.55rem; }
.calendar-day { min-height: 55px; }
.current-period { font-size: 0.9rem; }
.current-period { min-width: 0; flex: 1; font-size: .95rem; }
.view-tabs { width: 100%; }
.view-tab { flex: 1; padding-inline: 8px; }
.calendar-grid { min-height: 500px; grid-auto-rows: minmax(72px, 1fr); }
.calendar-day { padding: 3px; }
.day-number { width: 23px; height: 23px; font-size: .72rem; }
.day-event { padding: 3px; border-left-width: 3px; font-size: .6rem; }
.day-more { font-size: .58rem; }
.week-scroll { min-height: 580px; }
.fab { right: max(16px, env(safe-area-inset-right)); bottom: max(16px, env(safe-area-inset-bottom)); width: 54px; height: 54px; }
.modal-overlay { align-items: flex-end; padding: max(8px, env(safe-area-inset-top)) 0 0; }
.modal { max-height: calc(100dvh - max(8px, env(safe-area-inset-top))); border-radius: var(--radius) var(--radius) 0 0; }
.modal-footer { padding-bottom: max(12px, env(safe-area-inset-bottom)); }
.form-row { grid-template-columns: 1fr; gap: 0; }
.auth-container { padding: 26px 22px; }
}
/* ===== Auth Overlay ===== */
.auth-overlay {
display: none; position: fixed; inset: 0; background: var(--bg-primary);
z-index: 1000; align-items: center; justify-content: center; padding: 20px;
@media (max-width: 430px) {
.app-title { max-width: 145px; overflow: hidden; text-overflow: ellipsis; }
.header-right .icon-button { width: 40px; }
.btn-today { margin: 0; }
.calendar-weekdays { font-size: .62rem; }
.calendar-grid { min-height: 430px; grid-auto-rows: minmax(62px, 1fr); }
.inline-form { align-items: stretch; flex-direction: column; }
.modal-footer { align-items: stretch; flex-direction: column-reverse; }
.modal-footer-actions { width: 100%; margin: 0; }
.modal-footer-actions .button { flex: 1; }
.modal-footer > .button { width: 100%; }
.list-event { gap: 7px; padding: 10px 8px; }
.list-event-time { min-width: 53px; font-size: .7rem; }
}
.auth-overlay.active { display: flex; }
.auth-container {
background: var(--bg-secondary); padding: 40px; border-radius: var(--radius);
border: 1px solid var(--border); box-shadow: 0 10px 40px rgba(0,0,0,0.5);
width: 100%; max-width: 400px; text-align: center;
}
.auth-title { font-size: 1.8rem; font-weight: 700; margin-bottom: 8px; color: var(--accent); }
.auth-subtitle { font-size: 0.9rem; color: var(--text-muted); margin-bottom: 30px; }
.auth-form-container h2 { font-size: 1.3rem; margin-bottom: 20px; text-align: left; }
.auth-form-container .form-group { text-align: left; margin-bottom: 16px; }
.w-100 { width: 100%; justify-content: center; }
.mt-2 { margin-top: 16px; }
.auth-switch { margin-top: 24px; font-size: 0.85rem; color: var(--text-muted); }
.auth-switch a { color: var(--accent); text-decoration: none; font-weight: 500; }
.auth-switch a:hover { text-decoration: underline; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
}

File diff suppressed because it is too large Load diff

View file

@ -2,13 +2,17 @@
"name": "StudyCalendar - Agenda de Estudos",
"short_name": "StudyCalendar",
"description": "Calendário inteligente para organizar estudos e entregas",
"id": "/",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#0f0f1a",
"theme_color": "#1a1a2e",
"lang": "pt-BR",
"dir": "ltr",
"background_color": "#0d0e19",
"theme_color": "#17182b",
"orientation": "any",
"categories": ["education", "productivity"],
"icons": [
{"src": "/static/icons/icon.svg", "sizes": "512x512", "type": "image/svg+xml", "purpose": "any maskable"}
{"src": "/static/icons/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any"}
]
}

View file

@ -1,51 +1,58 @@
const CACHE_NAME = 'studycalendar-v1';
const urlsToCache = [
'/',
'/static/css/style.css',
'/static/js/app.js',
'/static/manifest.json',
'/static/icons/icon.svg',
'https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap',
'https://fonts.googleapis.com/icon?family=Material+Icons'
'use strict';
const CACHE_NAME = 'studycalendar-shell-v4';
const APP_SHELL = [
'/',
'/static/css/style.css',
'/static/js/app.js',
'/static/manifest.json',
'/static/icons/icon.svg'
];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => {
return cache.addAll(urlsToCache);
})
);
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => Promise.allSettled(APP_SHELL.map(url => cache.add(new Request(url, { cache: 'reload' })))))
.then(() => self.skipWaiting())
);
});
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cacheName => {
if (cacheName !== CACHE_NAME) {
return caches.delete(cacheName);
}
})
);
})
);
event.waitUntil(
caches.keys()
.then(names => Promise.all(names.filter(name => name !== CACHE_NAME).map(name => caches.delete(name))))
.then(() => self.clients.claim())
);
});
self.addEventListener('fetch', event => {
// Para API, Network First
if (event.request.url.includes('/api/')) {
event.respondWith(
fetch(event.request).catch(() => caches.match(event.request))
);
} else {
// Para estáticos, Cache First, Network Fallback
event.respondWith(
caches.match(event.request)
.then(response => {
if (response) return response;
return fetch(event.request);
})
);
}
const request = event.request;
const url = new URL(request.url);
if (request.method !== 'GET' || url.origin !== self.location.origin || url.pathname.startsWith('/api/')) return;
if (request.mode === 'navigate') {
event.respondWith(
fetch(request)
.then(response => {
if (response.ok) caches.open(CACHE_NAME).then(cache => cache.put('/', response.clone()));
return response;
})
.catch(() => caches.match('/'))
);
return;
}
if (url.pathname.startsWith('/static/')) {
event.respondWith(
caches.match(request).then(cached => {
const update = fetch(request)
.then(response => {
if (response.ok) caches.open(CACHE_NAME).then(cache => cache.put(request, response.clone()));
return response;
})
.catch(() => cached);
return cached || update;
})
);
}
});

View file

@ -1,437 +1,219 @@
<!DOCTYPE html>
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#1a1a2e">
<meta name="description" content="Calendário inteligente para organizar estudos e entregas">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#17182b">
<meta name="description" content="Calendário para organizar estudos, compromissos e entregas">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="StudyCalendar">
<link rel="manifest" href="/static/manifest.json">
<link rel="icon" type="image/svg+xml" href="/static/icons/icon.svg">
<link rel="apple-touch-icon" href="/static/icons/icon.svg">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons+Round" rel="stylesheet">
<link rel="stylesheet" href="/static/css/style.css">
<title>StudyCalendar - Agenda de Estudos</title>
<title>StudyCalendar - Agenda de estudos</title>
</head>
<body>
<!-- Overlay de Autenticação -->
<div id="auth-overlay" class="auth-overlay active">
<div class="auth-container">
<h1 class="auth-title">📚 StudyCalendar</h1>
<p class="auth-subtitle">Organize seus estudos e não perca prazos</p>
<div id="login-form-container" class="auth-form-container">
<h2>Entrar</h2>
<div class="form-group">
<label>Usuário</label>
<input type="text" id="login-username" placeholder="Seu nome de usuário">
</div>
<div class="form-group">
<label>Senha</label>
<input type="password" id="login-password" placeholder="Sua senha">
</div>
<button class="btn-primary w-100 mt-2" onclick="login()">Entrar</button>
<p class="auth-switch">Não tem uma conta? <a href="#" onclick="toggleAuth('register')">Cadastre-se</a></p>
<a class="skip-link" href="#main-content">Ir para o calendário</a>
<div id="app-shell" class="app-shell">
<header class="app-header">
<div class="header-left">
<button class="icon-button sidebar-toggle" id="sidebar-toggle" type="button" aria-label="Abrir menu" aria-controls="sidebar" aria-expanded="false"><span aria-hidden="true">☰</span></button>
<h1 class="app-title"><span aria-hidden="true">▦</span> StudyCalendar</h1>
<span id="user-display" class="user-display"></span>
</div>
<div id="register-form-container" class="auth-form-container hidden">
<h2>Cadastrar</h2>
<div class="form-group">
<label>Nome Completo</label>
<input type="text" id="register-displayname" placeholder="Como quer ser chamado?">
</div>
<div class="form-group">
<label>Nome de Usuário</label>
<input type="text" id="register-username" placeholder="Escolha um usuário">
</div>
<div class="form-group">
<label>Senha</label>
<input type="password" id="register-password" placeholder="Crie uma senha">
</div>
<button class="btn-primary w-100 mt-2" onclick="register()">Criar Conta</button>
<p class="auth-switch">Já tem uma conta? <a href="#" onclick="toggleAuth('login')">Faça login</a></p>
<div class="header-right">
<span id="connection-status" class="connection-status" role="status">Online</span>
<button class="icon-button" id="sync-all-btn" type="button" aria-label="Sincronizar todos os feeds" title="Sincronizar feeds"><span id="sync-icon" aria-hidden="true">↻</span></button>
<button class="icon-button" id="settings-btn" type="button" aria-label="Abrir configurações" title="Configurações"><span aria-hidden="true">⚙</span></button>
<button class="icon-button" id="logout-btn" type="button" aria-label="Sair da conta" title="Sair"><span aria-hidden="true">⇥</span></button>
<button class="button primary install-button hidden" id="install-btn" type="button"><span aria-hidden="true">⇩</span><span class="install-label">Instalar</span></button>
</div>
</div>
</div>
</header>
<!-- Header -->
<header class="app-header">
<div class="header-left">
<button class="btn-icon sidebar-toggle" onclick="toggleSidebar()" title="Menu">
<span class="material-icons-round">menu</span>
</button>
<h1 class="app-title">📚 StudyCalendar <span id="user-display" style="font-size: 0.8rem; font-weight: normal; margin-left: 10px; color: var(--text-muted);"></span></h1>
</div>
<div class="header-right">
<button class="btn-icon" onclick="syncAllFeeds()" title="Sincronizar feeds">
<span class="material-icons-round" id="sync-icon">sync</span>
</button>
<button class="btn-icon" onclick="openSettings()" title="Configurações">
<span class="material-icons-round">settings</span>
</button>
<button class="btn-icon" onclick="logout()" title="Sair">
<span class="material-icons-round">logout</span>
</button>
<button class="btn-primary btn-install" id="install-btn" onclick="installPWA()" style="display:none">
<span class="material-icons-round">install_mobile</span>
Instalar
</button>
</div>
</header>
<div class="app-container">
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-overlay" onclick="toggleSidebar()"></div>
<div class="sidebar-content">
<!-- Mini Calendário -->
<div class="sidebar-section">
<div class="mini-calendar">
<div class="app-container">
<aside class="sidebar" id="sidebar" aria-label="Navegação e filtros">
<div class="sidebar-content">
<section class="sidebar-section" aria-labelledby="mini-calendar-title">
<div class="mini-cal-header">
<button class="btn-icon btn-sm" onclick="miniCalPrev()">
<span class="material-icons-round">chevron_left</span>
</button>
<span class="mini-cal-title" id="mini-cal-title"></span>
<button class="btn-icon btn-sm" onclick="miniCalNext()">
<span class="material-icons-round">chevron_right</span>
</button>
<button class="icon-button small" id="mini-prev-btn" type="button" aria-label="Mês anterior no minicalendário"><span aria-hidden="true">‹</span></button>
<h2 class="mini-cal-title" id="mini-calendar-title"></h2>
<button class="icon-button small" id="mini-next-btn" type="button" aria-label="Próximo mês no minicalendário"><span aria-hidden="true">›</span></button>
</div>
<div class="mini-cal-weekdays">
<span>D</span><span>S</span><span>T</span><span>Q</span><span>Q</span><span>S</span><span>S</span>
<div class="mini-cal-weekdays" aria-hidden="true"><span>D</span><span>S</span><span>T</span><span>Q</span><span>Q</span><span>S</span><span>S</span></div>
<div class="mini-cal-days" id="mini-cal-days" role="grid" aria-label="Minicalendário"></div>
</section>
<section class="sidebar-section">
<label class="visually-hidden" for="search-input">Buscar em todos os eventos</label>
<div class="search-box"><span aria-hidden="true">⌕</span><input type="search" id="search-input" placeholder="Buscar em todos os eventos" autocomplete="off"></div>
</section>
<section class="sidebar-section" aria-labelledby="category-title">
<h2 class="sidebar-title" id="category-title">Categorias</h2>
<div class="category-filters" id="category-filters" role="radiogroup" aria-label="Filtrar por categoria">
<button class="category-button active" type="button" data-category="todas" role="radio" aria-checked="true"><span class="category-dot cat-todas" aria-hidden="true"></span>Todas</button>
<button class="category-button" type="button" data-category="geral" role="radio" aria-checked="false" tabindex="-1"><span class="category-dot cat-geral" aria-hidden="true"></span>Geral</button>
<button class="category-button" type="button" data-category="estudos" role="radio" aria-checked="false" tabindex="-1"><span class="category-dot cat-estudos" aria-hidden="true"></span>Estudos</button>
<button class="category-button" type="button" data-category="trabalho" role="radio" aria-checked="false" tabindex="-1"><span class="category-dot cat-trabalho" aria-hidden="true"></span>Trabalho</button>
<button class="category-button" type="button" data-category="pessoal" role="radio" aria-checked="false" tabindex="-1"><span class="category-dot cat-pessoal" aria-hidden="true"></span>Pessoal</button>
<button class="category-button" type="button" data-category="provas" role="radio" aria-checked="false" tabindex="-1"><span class="category-dot cat-provas" aria-hidden="true"></span>Provas</button>
<button class="category-button" type="button" data-category="entregas" role="radio" aria-checked="false" tabindex="-1"><span class="category-dot cat-entregas" aria-hidden="true"></span>Entregas</button>
</div>
<div class="mini-cal-days" id="mini-cal-days"></div>
</section>
<section class="sidebar-section" aria-labelledby="upcoming-title">
<h2 class="sidebar-title" id="upcoming-title">Próximos 7 dias</h2>
<div class="upcoming-list" id="upcoming-list" aria-busy="true"><p class="state-message">Carregando próximos eventos...</p></div>
</section>
<section class="sidebar-section" aria-labelledby="stats-title">
<h2 class="sidebar-title" id="stats-title">Resumo</h2>
<div class="stats-grid" id="stats-grid">
<div class="stat-card"><strong id="stat-total">0</strong><span>Total</span></div>
<div class="stat-card"><strong id="stat-week">0</strong><span>Esta semana</span></div>
<div class="stat-card danger"><strong id="stat-overdue">0</strong><span>Atrasados</span></div>
<div class="stat-card success"><strong id="stat-completed">0</strong><span>Concluídos</span></div>
</div>
</section>
</div>
</aside>
<button class="sidebar-backdrop" id="sidebar-backdrop" type="button" aria-label="Fechar menu" tabindex="-1"></button>
<main class="main-content" id="main-content" tabindex="-1">
<div class="calendar-controls">
<div class="view-nav">
<button class="icon-button" id="prev-btn" type="button" aria-label="Período anterior"><span aria-hidden="true">‹</span></button>
<h2 class="current-period" id="current-period"></h2>
<button class="icon-button" id="next-btn" type="button" aria-label="Próximo período"><span aria-hidden="true">›</span></button>
<button class="button secondary compact" id="today-btn" type="button">Hoje</button>
</div>
<div class="view-tabs" role="tablist" aria-label="Visualização do calendário">
<button class="view-tab active" id="month-tab" type="button" role="tab" aria-selected="true" aria-controls="month-view" data-view="month">Mês</button>
<button class="view-tab" id="week-tab" type="button" role="tab" aria-selected="false" aria-controls="week-view" data-view="week" tabindex="-1">Semana</button>
<button class="view-tab" id="list-tab" type="button" role="tab" aria-selected="false" aria-controls="list-view" data-view="list" tabindex="-1">Lista</button>
</div>
</div>
<!-- Busca -->
<div class="sidebar-section">
<div class="search-box">
<span class="material-icons-round search-icon">search</span>
<input type="text" id="search-input" placeholder="Buscar eventos..." oninput="debounceSearch()">
</div>
</div>
<div id="content-status" class="content-status" role="status" aria-live="polite"></div>
<section class="calendar-view" id="month-view" role="tabpanel" aria-labelledby="month-tab">
<div class="calendar-weekdays" aria-hidden="true"><span>Dom</span><span>Seg</span><span>Ter</span><span>Qua</span><span>Qui</span><span>Sex</span><span>Sáb</span></div>
<div class="calendar-grid" id="calendar-grid" role="grid" aria-label="Calendário mensal"></div>
</section>
<section class="calendar-view hidden" id="week-view" role="tabpanel" aria-labelledby="week-tab">
<div class="week-scroll" tabindex="0" aria-label="Agenda semanal, role horizontalmente para ver todos os dias"><div class="week-grid" id="week-grid"></div></div>
</section>
<section class="calendar-view hidden" id="list-view" role="tabpanel" aria-labelledby="list-tab">
<div class="list-container" id="list-container"></div>
</section>
</main>
</div>
<!-- Filtros de Categoria -->
<div class="sidebar-section">
<h3 class="sidebar-title">Categorias</h3>
<div class="category-filters" id="category-filters">
<button class="category-btn active" data-category="todas" onclick="filterCategory('todas')">
<span class="cat-dot" style="background:#607D8B"></span> Todas
</button>
<button class="category-btn" data-category="estudos" onclick="filterCategory('estudos')">
<span class="cat-dot" style="background:#4A90D9"></span> Estudos
</button>
<button class="category-btn" data-category="trabalho" onclick="filterCategory('trabalho')">
<span class="cat-dot" style="background:#FF9800"></span> Trabalho
</button>
<button class="category-btn" data-category="pessoal" onclick="filterCategory('pessoal')">
<span class="cat-dot" style="background:#4CAF50"></span> Pessoal
</button>
<button class="category-btn" data-category="provas" onclick="filterCategory('provas')">
<span class="cat-dot" style="background:#e91e63"></span> Provas
</button>
<button class="category-btn" data-category="entregas" onclick="filterCategory('entregas')">
<span class="cat-dot" style="background:#9C27B0"></span> Entregas
</button>
</div>
</div>
<!-- Próximas Entregas -->
<div class="sidebar-section">
<h3 class="sidebar-title">📅 Próximos 7 dias</h3>
<div class="upcoming-list" id="upcoming-list">
<p class="text-muted">Carregando...</p>
</div>
</div>
<!-- Estatísticas -->
<div class="sidebar-section">
<h3 class="sidebar-title">📊 Estatísticas</h3>
<div class="stats-grid" id="stats-grid">
<div class="stat-card">
<span class="stat-value" id="stat-total">0</span>
<span class="stat-label">Total</span>
</div>
<div class="stat-card">
<span class="stat-value" id="stat-week">0</span>
<span class="stat-label">Esta semana</span>
</div>
<div class="stat-card stat-danger">
<span class="stat-value" id="stat-overdue">0</span>
<span class="stat-label">Atrasados</span>
</div>
<div class="stat-card stat-success">
<span class="stat-value" id="stat-completed">0</span>
<span class="stat-label">Concluídos</span>
</div>
</div>
</div>
</div>
</aside>
<!-- Conteúdo Principal -->
<main class="main-content">
<div class="calendar-controls">
<div class="view-nav">
<button class="btn-icon" onclick="navigatePrev()">
<span class="material-icons-round">chevron_left</span>
</button>
<h2 class="current-period" id="current-period"></h2>
<button class="btn-icon" onclick="navigateNext()">
<span class="material-icons-round">chevron_right</span>
</button>
<button class="btn-secondary btn-sm btn-today" onclick="goToToday()">Hoje</button>
</div>
<div class="view-tabs">
<button class="view-tab active" data-view="month" onclick="switchView('month')">Mês</button>
<button class="view-tab" data-view="week" onclick="switchView('week')">Semana</button>
<button class="view-tab" data-view="list" onclick="switchView('list')">Lista</button>
</div>
</div>
<!-- Visão Mensal -->
<div class="calendar-view" id="month-view">
<div class="calendar-weekdays">
<span>Domingo</span><span>Segunda</span><span>Terça</span><span>Quarta</span><span>Quinta</span><span>Sexta</span><span>Sábado</span>
</div>
<div class="calendar-grid" id="calendar-grid"></div>
</div>
<!-- Visão Semanal -->
<div class="calendar-view hidden" id="week-view">
<div class="week-grid" id="week-grid"></div>
</div>
<!-- Visão Lista -->
<div class="calendar-view hidden" id="list-view">
<div class="list-container" id="list-container"></div>
</div>
</main>
<button class="fab" id="new-event-btn" type="button" aria-label="Criar novo evento"><span aria-hidden="true">+</span></button>
</div>
<!-- FAB -->
<button class="fab" onclick="openEventModal()" title="Novo evento">
<span class="material-icons-round">add</span>
</button>
<div id="auth-overlay" class="auth-overlay active" role="dialog" aria-modal="true" aria-labelledby="auth-title">
<div class="auth-container">
<h1 class="auth-title" id="auth-title"><span aria-hidden="true">▦</span> StudyCalendar</h1>
<p class="auth-subtitle">Organize seus estudos e não perca prazos</p>
<p class="form-error" id="auth-error" role="alert"></p>
<!-- Modal de Evento -->
<div class="modal-overlay" id="event-modal">
<div class="modal">
<div class="modal-header">
<h3 id="event-modal-title">Novo Evento</h3>
<button class="btn-icon" onclick="closeModal('event-modal')">
<span class="material-icons-round">close</span>
</button>
</div>
<form id="login-form" class="auth-form">
<h2>Entrar</h2>
<div class="form-group"><label for="login-username">Usuário</label><input type="text" id="login-username" name="username" autocomplete="username" required minlength="2"></div>
<div class="form-group"><label for="login-password">Senha</label><input type="password" id="login-password" name="password" autocomplete="current-password" required></div>
<button class="button primary full" type="submit">Entrar</button>
<p class="auth-switch" id="register-switch">Não tem uma conta? <button class="text-button" type="button" data-auth-view="register">Cadastre-se</button></p>
</form>
<form id="register-form" class="auth-form hidden">
<h2>Cadastrar</h2>
<div class="form-group"><label for="register-displayname">Nome completo</label><input type="text" id="register-displayname" name="name" autocomplete="name" required minlength="2"></div>
<div class="form-group"><label for="register-username">Nome de usuário</label><input type="text" id="register-username" name="username" autocomplete="username" required minlength="3" maxlength="32" pattern="[A-Za-z0-9_.-]+"></div>
<div class="form-group"><label for="register-password">Senha</label><input type="password" id="register-password" name="new-password" autocomplete="new-password" required minlength="12" maxlength="128"></div>
<button class="button primary full" type="submit">Criar conta</button>
<p class="auth-switch">Já tem uma conta? <button class="text-button" type="button" data-auth-view="login">Faça login</button></p>
</form>
</div>
</div>
<div class="modal-overlay" id="event-modal" aria-hidden="true">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="event-modal-title">
<div class="modal-header"><h2 id="event-modal-title">Novo evento</h2><button class="icon-button modal-close" type="button" data-close-modal="event-modal" aria-label="Fechar"><span aria-hidden="true">×</span></button></div>
<form id="event-form" novalidate>
<div class="modal-body">
<input type="hidden" id="event-id">
<p class="form-error" id="event-form-error" role="alert"></p>
<div class="form-group"><label for="event-title">Título <span aria-hidden="true">*</span></label><input type="text" id="event-title" required maxlength="200" autocomplete="off"></div>
<div class="form-group"><label for="event-description">Descrição</label><textarea id="event-description" rows="3" maxlength="4000"></textarea></div>
<div class="form-row">
<div class="form-group"><label for="event-start-date">Data de início <span aria-hidden="true">*</span></label><input type="date" id="event-start-date" required></div>
<div class="form-group"><label for="event-start-time">Hora de início</label><input type="time" id="event-start-time" value="08:00"></div>
</div>
<div class="form-row">
<div class="form-group"><label for="event-end-date">Data de término</label><input type="date" id="event-end-date"></div>
<div class="form-group"><label for="event-end-time">Hora de término</label><input type="time" id="event-end-time" value="09:00"></div>
</div>
<label class="checkbox-label"><input type="checkbox" id="event-all-day"><span>Dia inteiro</span></label>
<div class="form-row">
<div class="form-group"><label for="event-category">Categoria</label><select id="event-category"><option value="geral">Geral</option><option value="estudos">Estudos</option><option value="trabalho">Trabalho</option><option value="pessoal">Pessoal</option><option value="provas">Provas</option><option value="entregas">Entregas</option></select></div>
<div class="form-group"><label for="event-priority">Prioridade</label><select id="event-priority"><option value="baixa">Baixa</option><option value="normal" selected>Normal</option><option value="alta">Alta</option><option value="urgente">Urgente</option></select></div>
</div>
<div class="form-group"><label for="event-location">Local</label><input type="text" id="event-location" maxlength="300" autocomplete="off"></div>
</div>
<div class="modal-footer"><button class="button danger hidden" id="event-delete-btn" type="button">Excluir</button><div class="modal-footer-actions"><button class="button secondary" type="button" data-close-modal="event-modal">Cancelar</button><button class="button primary" type="submit">Salvar</button></div></div>
</form>
</div>
</div>
<div class="modal-overlay" id="settings-modal" aria-hidden="true">
<div class="modal wide" role="dialog" aria-modal="true" aria-labelledby="settings-title">
<div class="modal-header"><h2 id="settings-title">Configurações</h2><button class="icon-button modal-close" type="button" data-close-modal="settings-modal" aria-label="Fechar"><span aria-hidden="true">×</span></button></div>
<div class="modal-body">
<input type="hidden" id="event-id">
<div class="form-group">
<label for="event-title">Título *</label>
<input type="text" id="event-title" placeholder="Nome do evento" required>
</div>
<div class="form-group">
<label for="event-description">Descrição</label>
<textarea id="event-description" placeholder="Detalhes do evento" rows="3"></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label for="event-start-date">Data Início *</label>
<input type="date" id="event-start-date" required>
</div>
<div class="form-group">
<label for="event-start-time">Hora Início</label>
<input type="time" id="event-start-time" value="08:00">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="event-end-date">Data Fim</label>
<input type="date" id="event-end-date">
</div>
<div class="form-group">
<label for="event-end-time">Hora Fim</label>
<input type="time" id="event-end-time" value="09:00">
</div>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="event-all-day" onchange="toggleAllDay()">
<span>Dia inteiro</span>
</label>
</div>
<div class="form-row">
<div class="form-group">
<label for="event-category">Categoria</label>
<select id="event-category">
<option value="geral">Geral</option>
<option value="estudos">Estudos</option>
<option value="trabalho">Trabalho</option>
<option value="pessoal">Pessoal</option>
<option value="provas">Provas</option>
<option value="entregas">Entregas</option>
</select>
</div>
<div class="form-group">
<label for="event-priority">Prioridade</label>
<select id="event-priority">
<option value="baixa">Baixa</option>
<option value="normal" selected>Normal</option>
<option value="alta">Alta</option>
<option value="urgente">Urgente</option>
</select>
</div>
</div>
<div class="form-group">
<label for="event-location">Local</label>
<input type="text" id="event-location" placeholder="Local do evento">
</div>
</div>
<div class="modal-footer">
<button class="btn-danger" id="event-delete-btn" onclick="deleteCurrentEvent()" style="display:none">
<span class="material-icons-round">delete</span> Excluir
</button>
<div class="modal-footer-right">
<button class="btn-secondary" onclick="closeModal('event-modal')">Cancelar</button>
<button class="btn-primary" onclick="saveEvent()">
<span class="material-icons-round">save</span> Salvar
</button>
</div>
<section class="settings-section" aria-labelledby="notifications-title">
<h3 id="notifications-title">Notificações</h3>
<p class="help-text" id="notification-permission-status">Permissão do navegador ainda não solicitada.</p>
<button class="button secondary" id="enable-notifications-btn" type="button">Ativar notificações neste dispositivo</button>
<div id="notif-rules-list" class="settings-list"></div>
<form id="notification-rule-form" class="inline-form">
<div class="form-group"><label for="new-rule-minutes">Notificar antes</label><select id="new-rule-minutes"><option value="5">5 minutos</option><option value="15">15 minutos</option><option value="30">30 minutos</option><option value="60">1 hora</option><option value="120">2 horas</option><option value="1440">1 dia</option><option value="2880">2 dias</option><option value="10080">1 semana</option></select></div>
<button class="button primary" type="submit">Adicionar regra</button>
</form>
</section>
<section class="settings-section" aria-labelledby="feeds-title">
<h3 id="feeds-title">Feeds de calendário (ICS)</h3>
<div class="settings-list" id="feeds-list"><p class="state-message">Carregando feeds...</p></div>
<form id="feed-form" class="add-feed-form" novalidate>
<h4>Adicionar feed</h4><p class="form-error" id="feed-form-error" role="alert"></p>
<div class="form-group"><label for="feed-name">Nome</label><input type="text" id="feed-name" required maxlength="100" autocomplete="off"></div>
<div class="form-group"><label for="feed-url">URL HTTPS do feed ICS</label><input type="url" id="feed-url" required inputmode="url" placeholder="https://exemplo.com/calendario.ics"></div>
<div class="form-row"><div class="form-group"><label for="feed-color">Cor</label><input type="color" id="feed-color" value="#4a90d9"></div><div class="form-group"><label for="feed-interval">Intervalo (minutos)</label><input type="number" id="feed-interval" value="30" min="5" max="1440" required></div></div>
<button class="button primary" type="submit">Adicionar feed</button>
</form>
</section>
</div>
</div>
</div>
<!-- Modal de Configurações -->
<div class="modal-overlay" id="settings-modal">
<div class="modal modal-wide">
<div class="modal-header">
<h3>⚙️ Configurações</h3>
<button class="btn-icon" onclick="closeModal('settings-modal')">
<span class="material-icons-round">close</span>
</button>
</div>
<div class="modal-body">
<div class="settings-section">
<h4>🔔 Notificações</h4>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="notif-enabled" checked>
<span>Ativar notificações</span>
</label>
</div>
<div class="form-group">
<label for="notif-minutes">Notificar antes (minutos)</label>
<select id="notif-minutes">
<option value="5">5 minutos</option>
<option value="10">10 minutos</option>
<option value="15">15 minutos</option>
<option value="30" selected>30 minutos</option>
<option value="60">1 hora</option>
<option value="120">2 horas</option>
<option value="1440">1 dia</option>
<option value="2880">2 dias</option>
</select>
</div>
<button class="btn-primary btn-sm" onclick="saveNotificationSettings()">
<span class="material-icons-round">save</span> Salvar notificações
</button>
</div>
<div class="settings-section">
<h4>📡 Feeds de Calendário (ICS)</h4>
<div class="feeds-list" id="feeds-list">
<p class="text-muted">Carregando feeds...</p>
</div>
<div class="add-feed-form">
<h5>Adicionar novo feed</h5>
<div class="form-group">
<label for="feed-name">Nome</label>
<input type="text" id="feed-name" placeholder="Ex: Unicep Virtual">
</div>
<div class="form-group">
<label for="feed-url">URL do feed ICS</label>
<input type="url" id="feed-url" placeholder="https://exemplo.com/calendar.ics">
</div>
<div class="form-row">
<div class="form-group">
<label for="feed-color">Cor</label>
<input type="color" id="feed-color" value="#4A90D9">
</div>
<div class="form-group">
<label for="feed-interval">Intervalo de sync (min)</label>
<input type="number" id="feed-interval" value="30" min="5" max="1440">
</div>
</div>
<button class="btn-primary" onclick="addFeed()">
<span class="material-icons-round">add</span> Adicionar Feed
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Modal de Detalhe de Evento -->
<div class="modal-overlay" id="event-detail-modal">
<div class="modal">
<div class="modal-header">
<h3 id="detail-title">Detalhes do Evento</h3>
<button class="btn-icon" onclick="closeModal('event-detail-modal')">
<span class="material-icons-round">close</span>
</button>
</div>
<div class="modal-overlay" id="event-detail-modal" aria-hidden="true">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="detail-title">
<div class="modal-header"><h2 id="detail-title">Detalhes do evento</h2><button class="icon-button modal-close" type="button" data-close-modal="event-detail-modal" aria-label="Fechar"><span aria-hidden="true">×</span></button></div>
<div class="modal-body" id="detail-body"></div>
<div class="modal-footer">
<button class="btn-secondary" id="detail-toggle-btn" onclick="toggleDetailComplete()">
<span class="material-icons-round">check_circle</span> Concluir
</button>
<div class="modal-footer-right">
<button class="btn-secondary" id="detail-edit-btn" onclick="editDetailEvent()">
<span class="material-icons-round">edit</span> Editar
</button>
</div>
</div>
<div class="modal-footer"><button class="button secondary" id="detail-toggle-btn" type="button">Concluir</button><div class="modal-footer-actions"><button class="button secondary" id="detail-edit-btn" type="button">Editar</button></div></div>
</div>
</div>
<!-- Modal de Eventos do Dia -->
<div class="modal-overlay" id="day-events-modal">
<div class="modal">
<div class="modal-header">
<h3 id="day-events-title">Eventos do Dia</h3>
<button class="btn-icon" onclick="closeModal('day-events-modal')">
<span class="material-icons-round">close</span>
</button>
</div>
<div class="modal-body">
<div id="day-events-list" class="list-container" style="max-height: 400px; overflow-y: auto;">
<!-- Eventos serão injetados aqui -->
</div>
</div>
<div class="modal-footer">
<div class="modal-footer-right" style="width: 100%; justify-content: center;">
<button class="btn-primary" onclick="openNewEventForDay()">
<span class="material-icons-round">add</span> Adicionar Evento
</button>
</div>
</div>
<div class="modal-overlay" id="day-events-modal" aria-hidden="true">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="day-events-title">
<div class="modal-header"><h2 id="day-events-title">Eventos do dia</h2><button class="icon-button modal-close" type="button" data-close-modal="day-events-modal" aria-label="Fechar"><span aria-hidden="true">×</span></button></div>
<div class="modal-body"><div id="day-events-list" class="list-container"></div></div>
<div class="modal-footer centered"><button class="button primary" id="new-day-event-btn" type="button">Adicionar evento</button></div>
</div>
</div>
<!-- Toast Container -->
<div class="toast-container" id="toast-container"></div>
<script src="/static/js/app.js"></script>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/static/sw.js')
.then(reg => console.log('SW registrado:', reg.scope))
.catch(err => console.log('Erro ao registrar SW:', err));
});
}
</script>
<div class="toast-container" id="toast-container" role="status" aria-live="polite" aria-atomic="true"></div>
<script src="/static/js/app.js" defer></script>
</body>
</html>