From 623bcf72492ea956544a10abc37106f86e8dd372 Mon Sep 17 00:00:00 2001 From: zzuse Date: Sat, 11 Jul 2026 11:10:54 -0500 Subject: [PATCH] slim down: remove ninja game, goals, weekly summary, unknown-metadata prompts, desktop notify, dead code and unused CSS Applied the ultraplan-generated patch (excluding a bogus package-lock rename): - Ninja game page + nav link - Goals cluster: routes, template, JS, LongTermGoal model, archive scheduler job - Weekly summary route + weekly_diff CLI + Discord summary channel - Unknown tags/status prompts + gentags CLI - notify_feedback / notify_current_task endpoints + send_hammerspoon_task - Dead files (executor.py, index.html, scripts/test.out) and unused CSS selectors - styles.css rebuilt locally with Tailwind v4.1.17 - Docs/CLAUDE.md updated; DB tables left in place (manual DROP noted in docs) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017WFRayXGGtZSsEjNFBSgFV --- .env.example | 1 - CLAUDE.md | 7 +- README.md | 4 +- config/settings.py | 4 - deploy/README.md | 4 +- docs/coverage-plan.md | 12 +- docs/implementation.md | 17 +- docs/todo-week-heatmap-design.md | 5 +- scripts/test.out | 34 - src/safe_family/app.py | 2 - src/safe_family/cli/gentags.py | 109 - src/safe_family/cli/weekly_diff.py | 211 - src/safe_family/core/models.py | 69 +- src/safe_family/notifications/notifier.py | 45 - src/safe_family/rules/executor.py | 0 src/safe_family/rules/scheduler.py | 68 +- src/safe_family/static/css/input.css | 499 --- src/safe_family/static/css/styles.css | 957 +++-- src/safe_family/static/js/goals.js | 404 -- src/safe_family/static/js/todo.js | 346 -- src/safe_family/templates/README.md | 2 +- src/safe_family/templates/base.html | 4 - src/safe_family/templates/index.html | 0 .../miscellaneous/ninja_nightmare.html | 3590 ----------------- src/safe_family/templates/todo/goals.html | 71 - src/safe_family/templates/todo/todo.html | 105 +- src/safe_family/todo/goals.py | 253 -- src/safe_family/todo/todo.py | 288 +- src/safe_family/urls/miscellaneous.py | 10 - tests/test_cli_reports.py | 48 +- tests/test_goals_route.py | 226 -- tests/test_models.py | 32 - tests/test_notifier.py | 44 +- tests/test_routes.py | 14 - tests/test_scheduler.py | 54 +- tests/test_todo_routes.py | 159 +- tests/test_units.py | 30 +- 37 files changed, 563 insertions(+), 7165 deletions(-) delete mode 100644 scripts/test.out delete mode 100644 src/safe_family/cli/gentags.py delete mode 100644 src/safe_family/cli/weekly_diff.py delete mode 100644 src/safe_family/rules/executor.py delete mode 100644 src/safe_family/static/js/goals.js delete mode 100644 src/safe_family/templates/index.html delete mode 100644 src/safe_family/templates/miscellaneous/ninja_nightmare.html delete mode 100644 src/safe_family/templates/todo/goals.html delete mode 100644 src/safe_family/todo/goals.py delete mode 100644 tests/test_goals_route.py diff --git a/.env.example b/.env.example index e8b7a29..326b385 100644 --- a/.env.example +++ b/.env.example @@ -17,7 +17,6 @@ GAS_STATION_SIDE_ID="gas_station_side_id" LONGTITUDE_LATITUDE="location longtitude,latitude" DISCORD_WEBHOOK_URL="discord_webhook_url" HAMMERSPOON_ALERT_URL="http://localhost:9181/alert" -HAMMERSPOON_TASK_URL="http://localhost:9181/task" MAIL_ACCOUNT="email account" MAIL_PASSWORD='email app engine password' MAIL_PERSON_LIST='["receiver email list"]' diff --git a/CLAUDE.md b/CLAUDE.md index ad8baae..d757b63 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,7 +73,7 @@ npx @tailwindcss/cli -i src/safe_family/static/css/input.css \ The project uses **two DB access styles in parallel**: -1. **SQLAlchemy ORM** (`src/safe_family/core/extensions.py` → `db`) — used for `users`, `token_blocklist`, `long_term_goals`, `notes`, `tags`, `media`, `note_sync_ops`, `auth_codes`, `agile_config`. +1. **SQLAlchemy ORM** (`src/safe_family/core/extensions.py` → `db`) — used for `users`, `token_blocklist`, `notes`, `tags`, `media`, `note_sync_ops`, `auth_codes`, `agile_config`. 2. **Raw psycopg2** (`get_db_connection()` in `extensions.py`) — used by logs, suspicious, block_list, filter_rule, schedule_rules, todo_list. These tables are not in SQLAlchemy models and must be created manually (see `scripts/notesync_schema.sql` and `docs/implementation.md`). Tests stub the raw connection with `FakeConnection`/`FakeCursor` (see `tests/conftest.py`). Notesync tests use a real SQLite DB via `notesync_app` fixture. @@ -95,7 +95,6 @@ All blueprints are registered in `src/safe_family/app.py`: | `auth_bp` | `core/auth.py` | `/auth` | | `user_bp` | `users/users.py` | `/users` | | `todo_bp` | `todo/todo.py` | — | -| `goals_bp` | `todo/goals.py` | — | | `api_bp` | `api/routes.py` | `/api` | ### Key subsystems @@ -104,7 +103,7 @@ All blueprints are registered in `src/safe_family/app.py`: **URL blocking pipeline** — `urls/receiver.py` ingests AdGuard Home log events at `POST /logs`; `urls/analyzer.py` aggregates them into `logs_daily` and derives `suspicious` entries; `urls/blocker.py` toggles AdGuard rule lists and router gateway traffic via HTTP calls. Cooldown logic prevents rapid toggling. -**Scheduler (`rules/scheduler.py`)** — APScheduler `BackgroundScheduler` loads cron rules from the `schedule_rules` table at startup. Uses Postgres advisory locks for leader election across multiple Gunicorn workers. Daily jobs archive tasks and run log analysis. Overdue tasks trigger Hammerspoon/email notifications. +**Scheduler (`rules/scheduler.py`)** — APScheduler `BackgroundScheduler` loads cron rules from the `schedule_rules` table at startup. Uses Postgres advisory locks for leader election across multiple Gunicorn workers. A daily job runs log analysis. Overdue tasks trigger Hammerspoon/email notifications. **Auto Git (`auto_git/auto_git.py`)** — exports block list and filter rules to files and auto-commits/pushes to a rules repository. SSH keys are required (mounted from `~/.ssh` in Docker). @@ -112,4 +111,4 @@ All blueprints are registered in `src/safe_family/app.py`: ### Raw SQL tables (not in ORM) -`logs`, `logs_daily`, `suspicious`, `block_list`, `filter_rule`, `schedule_rules`, `user_rule_assignment`, `todo_list`, `long_term_goals_his`, `block_types` — see `docs/implementation.md` for column details. `scripts/migrate.py` is the migration hook. +`logs`, `logs_daily`, `suspicious`, `block_list`, `filter_rule`, `schedule_rules`, `user_rule_assignment`, `todo_list`, `block_types` — see `docs/implementation.md` for column details. `scripts/migrate.py` is the migration hook. diff --git a/README.md b/README.md index 67d004f..761b4e6 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ integrations. - Rule toggles for AdGuard and router traffic control with cooldown safeguards. - Scheduler with APScheduler plus Postgres advisory locks for safe multi-process jobs. - Notesync API with LWW conflict handling, tags, and media attachments. -- Todo planner with time slots, long-term goals, and weekly summaries. +- Todo planner with time slots and a completion heatmap. - Notifications via email, Discord webhooks, and local Hammerspoon alerts. - Auto Git export/import for block list rules. @@ -149,7 +149,7 @@ Integrations (optional, feature-specific): - Router: `ROUTER_IP` - Email: `MAIL_ACCOUNT`, `MAIL_PASSWORD`, `MAIL_PERSON_LIST` - Discord: `DISCORD_WEBHOOK_URL` -- Hammerspoon: `HAMMERSPOON_ALERT_URL`, `HAMMERSPOON_TASK_URL` +- Hammerspoon: `HAMMERSPOON_ALERT_URL` - OAuth: `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_CLIENT_PROJECT_ID`, `GOOGLE_CALLBACK_ROUTE` diff --git a/config/settings.py b/config/settings.py index 936e472..106ea9b 100644 --- a/config/settings.py +++ b/config/settings.py @@ -72,10 +72,6 @@ class Settings: "HAMMERSPOON_ALERT_URL", "http://localhost:9181/alert", ) - HAMMERSPOON_TASK_URL = os.environ.get( - "HAMMERSPOON_TASK_URL", - "http://localhost:9181/task", - ) # Notesync settings NOTESYNC_API_KEY = os.environ.get("NOTESYNC_API_KEY", "") diff --git a/deploy/README.md b/deploy/README.md index 50b7d62..2464a55 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -16,10 +16,8 @@ The dump.sql file created most of the tables, but it missed the following table - auth_codes - token_blocklist - Todo & Goals Tables: + Todo Tables: - todo_list - - long_term_goals - - long_term_goals_his Rules & Scheduling Tables: - schedule_rules diff --git a/docs/coverage-plan.md b/docs/coverage-plan.md index 0ca1e4d..bd5b07e 100644 --- a/docs/coverage-plan.md +++ b/docs/coverage-plan.md @@ -14,7 +14,6 @@ src/safe_family/rules/scheduler.py | 363 | 73% | Background jobs and advisory lo src/safe_family/urls/blocker.py | 129 | 61% | AdGuard and router rule toggles src/safe_family/urls/analyzer.py | 87 | 75% | Log parsing and analysis src/safe_family/api/routes.py | 106 | 77% | Notesync API + notes list -src/safe_family/todo/goals.py | 115 | 92% | Separated long-term goals logic (New) src/safe_family/urls/notes.py | 68 | 86% | UI for notes viewer (pagination/media) Secondary modules close to 80 or above: @@ -84,17 +83,11 @@ Suggested files to extend: - [x] done_todo updates status - [ ] done_todo auto-complete after end time - [x] mark_status invalid + success paths -- [x] notify_feedback/notify_current_task routes - [x] split_slot success + forbidden paths -- [x] long-term goal create/update/reorder/start/stop/delete/update due (in goals.py) -- [x] update_tag success + forbidden paths -- [x] unknown_metadata filtering -- [x] goals page renders and admin user switching Suggested files to extend: - tests/test_todo.py - tests/test_units.py -- tests/test_goals_route.py ### Scheduler and automation - [x] _ensure_scheduler_leader returns False when lock not acquired @@ -137,7 +130,7 @@ Suggested files to extend: ### Notifications - [x] email notification formats and recipients - [x] Discord notification payloads and empty webhook behavior -- [x] Hammerspoon alert/task only sends when server reachable +- [x] Hammerspoon alert only sends when server reachable Suggested files to extend: - tests/test_notifier.py @@ -152,12 +145,9 @@ Suggested files to extend: ### CLI tools and reports - [ ] analyze: invalid args, custom ranges -- [x] weekly_diff output formatting -- [x] weekly_diff missing files - [x] weekly_metrics no data vs data - [x] weekly_metrics output file/dir paths - [x] weekly_metrics DB query path -- [ ] gentags: empty or malformed input Suggested files to extend: - tests/test_cli_analyze.py diff --git a/docs/implementation.md b/docs/implementation.md index d9586ac..0a7c84d 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -42,8 +42,12 @@ Files: - src/safe_family/core/schemas.py Scope: -- SQLAlchemy models: User, TokenBlocklist, LongTermGoal -- Raw SQL tables used by features: logs, logs_daily, suspicious, block_list, filter_rule, schedule_rules, user_rule_assignment, todo_list, long_term_goals_his, block_types +- SQLAlchemy models: User, TokenBlocklist +- Raw SQL tables used by features: logs, logs_daily, suspicious, block_list, filter_rule, schedule_rules, user_rule_assignment, todo_list, block_types + +Note: the long-term goals feature was removed. Its tables are not dropped +automatically; the operator can remove them by hand with: +`DROP TABLE long_term_goals; DROP TABLE long_term_goals_his;` Steps: 1) Keep model methods aligned with DB behavior (save/delete helpers). @@ -133,7 +137,7 @@ Files: Scope: - APScheduler jobs loaded from `schedule_rules` - Leader election and advisory locks -- Daily jobs: archive completed tasks, analyze logs +- Daily job: analyze logs - Realtime notifications: overdue task feedback Validation: @@ -141,7 +145,7 @@ Validation: --- -### Task 8: Todo system and long-term goals +### Task 8: Todo system Files: - src/safe_family/todo/todo.py @@ -151,8 +155,7 @@ Files: Scope: - Daily time slot planning (weekday/holiday/custom) - CRUD for tasks and completion status -- Split slots, tag updates, weekly summary, unknown metadata -- Long-term goals with tracking and reordering +- Split slots Validation: - `pytest tests/test_todo.py` @@ -168,7 +171,7 @@ Files: Scope: - Email via Flask-Mail - Discord webhook updates -- Hammerspoon alerts and task notifications +- Hammerspoon alerts Validation: - `pytest tests/test_notifier.py` diff --git a/docs/todo-week-heatmap-design.md b/docs/todo-week-heatmap-design.md index f7f93e3..6568531 100644 --- a/docs/todo-week-heatmap-design.md +++ b/docs/todo-week-heatmap-design.md @@ -7,9 +7,8 @@ reserved card** (`.sf-heatmap-reserve`). ## Key decision: no database schema change `todo_list` already retains full per-date history — the daily save in -`todo_page()` only deletes/rewrites *today's* rows, and the nightly -`archive_completed_tasks` job only touches `long_term_goals`, never -`todo_list`. Every column needed already exists: `username`, `date`, +`todo_page()` only deletes/rewrites *today's* rows, so historical +`todo_list` rows are never touched. Every column needed already exists: `username`, `date`, `time_slot`, `completed`, `completion_status`. Only DB change (optional, recommended): an index in `scripts/migrate.py`: diff --git a/scripts/test.out b/scripts/test.out deleted file mode 100644 index 8aa2052..0000000 --- a/scripts/test.out +++ /dev/null @@ -1,34 +0,0 @@ -pytest -# pytest tests/test_notesync_auth.py tests/test_notesync_lww.py tests/test_auth_exchange.py -q '--cov-fail-under=0' -........................................ -ERROR: Coverage failure: total of 44 is less than fail-under=80 - [100%] -================================ tests coverage ================================ -_______________ coverage: platform linux, python 3.11.7-final-0 ________________ - -Name Stmts Miss Branch BrPart Cover Missing ---------------------------------------------------------------------------------------- -src/safe_family/api/routes.py 53 14 10 3 70% 28-30, 48-49, 53, 57-59, 63-64, 81-82, 90 -src/safe_family/app.py 66 3 2 1 94% 70, 75, 79 -src/safe_family/auto_git/auto_git.py 77 26 18 0 62% 67-68, 75-77, 84-110 -src/safe_family/cli/analyze.py 19 2 4 2 83% 39, 53 -src/safe_family/cli/gentags.py 31 2 10 2 90% 83, 105 -src/safe_family/cli/weekly_diff.py 112 89 34 1 16% 24-25, 36, 40-46, 55-59, 63-65, 69-71, 75-177, 182-207, 211 -src/safe_family/cli/weekly_metrics.py 127 100 34 1 17% 42-49, 53-63, 67-70, 74-110, 120-136, 144-222, 226 -src/safe_family/core/auth.py 328 212 76 2 32% 99-101, 109-121, 133-143, 150-162, 169-170, 184-186, 195-200, 209-238, 244-272, 278, 284-288, 300-303, 311-324, 332-333, 339-342, 366-384, 388-389, 396-409, 414-427, 432-532, 537-577, 583-586 -src/safe_family/core/models.py 134 20 8 2 82% 36, 40, 56, 61, 85, 119, 123, 131-138, 142-146, 150-151, 237->239 -src/safe_family/notesync/service.py 97 7 34 4 92% 21, 46, 48-58, 92->95, 102-103 -src/safe_family/notifications/notifier.py 92 57 20 1 36% 35-36, 58-59, 64-85, 90-100, 105-119, 123-142 -src/safe_family/rules/scheduler.py 356 224 88 7 33% 41, 46, 77, 83-122, 128-135, 140-166, 172-177, 186-193, 200-202, 207-220, 226, 230, 241, 250-261, 266-271, 289-292, 320->319, 322->319, 386-391, 397-441, 448-557, 587-588, 616-617, 624-630 -src/safe_family/todo/todo.py 533 364 122 11 29% 47-64, 66->74, 105-106, 114, 125-126, 142->146, 149-150, 154-167, 180-184, 265-276, 300-301, 307-309, 319, 330-332, 339-419, 426-493, 500-510, 517-580, 594-647, 661-732, 745-774, 781-827, 837-855, 878-894, 904-931, 938-954, 961-977, 984-991, 1002-1015, 1021-1026 -src/safe_family/urls/analyzer.py 87 28 24 5 67% 19, 26-50, 76-77, 80-86, 93-94, 98-99, 103-109 -src/safe_family/urls/blocker.py 129 72 8 0 42% 189-190, 201-225, 230-272, 287, 297-351, 356-361, 366-371, 376-381, 388-394, 401-407, 414-432, 439-441, 448-450, 457-459 -src/safe_family/urls/miscellaneous.py 14 1 0 0 93% 36 -src/safe_family/urls/receiver.py 28 3 2 0 90% 70-72 -src/safe_family/urls/suspicious.py 158 90 14 4 42% 41, 87, 101, 118->121, 142-144, 154-186, 204-214, 220-237, 247-259, 276-283, 301-319 ---------------------------------------------------------------------------------------- -TOTAL 2535 1314 510 46 44% - -22 files skipped due to complete coverage. -FAIL Required test coverage of 80.0% not reached. Total coverage: 44.43% -40 passed, 2 warnings in 6.75s diff --git a/src/safe_family/app.py b/src/safe_family/app.py index b4539e5..34d60d9 100644 --- a/src/safe_family/app.py +++ b/src/safe_family/app.py @@ -12,7 +12,6 @@ from src.safe_family.core.extensions import db, jwt, mail from src.safe_family.core.models import User from src.safe_family.rules.scheduler import schedule_rules_bp -from src.safe_family.todo.goals import goals_bp from src.safe_family.todo.todo import todo_bp from src.safe_family.urls.analyzer import analyze_bp from src.safe_family.urls.blocker import rules_toggle_bp @@ -66,7 +65,6 @@ def inject_motto(): app.register_blueprint(auth_bp, url_prefix="/auth") app.register_blueprint(user_bp, url_prefix="/users") app.register_blueprint(todo_bp) - app.register_blueprint(goals_bp) # For debug # from flask import current_app diff --git a/src/safe_family/cli/gentags.py b/src/safe_family/cli/gentags.py deleted file mode 100644 index cc5b552..0000000 --- a/src/safe_family/cli/gentags.py +++ /dev/null @@ -1,109 +0,0 @@ -# CLI wrapper with __main__ -# python3 -m safe_family.cli.gentags -v -"""CLI for tag analysis.""" - -import argparse -import logging -import sys - -from src.safe_family.core.extensions import get_db_connection - -logger = logging.getLogger(__name__) - -# Keyword mapping for tag inference, is it possible to self grown based on the human marked tags? -KEYWORDS = { - "math": ["math", "algebra", "calculus", "tutor"], - "science": ["science", "biology", "chemistry", "physics", "lab"], - "language": [ - "english", - "essay", - "read", - "reading", - "history", - "geography", - "economics", - "writing", - "french", - "francais", - "langue", - "spanish", - "espanol", - "khan", - ], - "coding": [ - "unity", - "python", - "c++", - "js", - "twinery", - "roblox", - "dev", - "gemini", - "game", - "code", - "coding", - "programming", - ], - "leasure": [ - "sleep", - "clean", - "eat", - "exercise", - "meditate", - "rest", - "snow", - "tok", - "sledding", - "phone", - "haircut", - ], - "piano": ["piano", "flute", "violin"], -} - - -def infer_tag(task_name: str) -> str: - """Task name to tag inference.""" - task_lower = task_name.lower() - for tag, words in KEYWORDS.items(): - if any(w.lower() in task_lower for w in words): - logger.debug("Inferred tag '%s' for task '%s'", tag, task_name) - return tag - return "unknown" - - -def main(args: list[str] = None) -> int: - """Generate tags for tasks.""" - parser = argparse.ArgumentParser( - description="Analyze URLs for safety and metadata", - ) - parser.add_argument( - "-v", - "--verbose", - action="store_true", - help="Enable verbose logging", - ) - parsed_args = parser.parse_args(args) - if parsed_args.verbose: - logging.basicConfig(level=logging.DEBUG) - else: - logging.basicConfig(level=logging.INFO) - - conn = get_db_connection() - cur = conn.cursor() - - cur.execute( - "SELECT task, id from todo_list WHERE tags IS NULL OR tags = '' OR tags = 'unknown'", - ) - for task_name, task_id in cur.fetchall(): - inferred_tag = infer_tag(task_name) - logger.info("Inferred tag '%s' for task '%s'", inferred_tag, task_name) - cur.execute( - "UPDATE todo_list SET tags = %s WHERE id = %s", - (inferred_tag, task_id), - ) - conn.commit() - conn.close() - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/safe_family/cli/weekly_diff.py b/src/safe_family/cli/weekly_diff.py deleted file mode 100644 index 3db15d8..0000000 --- a/src/safe_family/cli/weekly_diff.py +++ /dev/null @@ -1,211 +0,0 @@ -# CLI wrapper with __main__ -# python3 -m safe_family.cli.weekly_diff --files metrics/2025-W52.txt metrics/2026-W01.txt -"""Generate a summary between two weekly metric files.""" - -import argparse -import json -import math -import sys -from dataclasses import dataclass -from pathlib import Path - - -@dataclass(frozen=True) -class WeekMetrics: - week: str - completion_rate: float | None - avg_tasks_per_day: float | None - avg_planned_minutes: float | None - by_category: dict[str, float] - by_category_minutes: dict[str, dict[str, float]] - - -def _read_metrics(path: Path) -> WeekMetrics: - data = json.loads(path.read_text(encoding="utf-8")) - return WeekMetrics( - week=str(data.get("week", "")), - completion_rate=data.get("completion_rate"), - avg_tasks_per_day=data.get("avg_tasks_per_day"), - avg_planned_minutes=data.get("avg_planned_minutes"), - by_category=data.get("by_category", {}), - by_category_minutes=data.get("by_category_minutes", {}), - ) - - -def _is_nan(value: float | None) -> bool: - return isinstance(value, float) and math.isnan(value) - - -def _trend_arrow(current: float | None, previous: float | None) -> str: - if current is None or previous is None or _is_nan(current) or _is_nan(previous): - return "→" - if current > previous: - return "↑" - if current < previous: - return "↓" - return "→" - - -def _delta_text( - current: float | None, - previous: float | None, - fmt: str, - scale: float = 1.0, -) -> str: - if current is None or previous is None or _is_nan(current) or _is_nan(previous): - return "(→)" - delta = (current - previous) * scale - sign = "+" if delta >= 0 else "" - return f"({sign}{fmt.format(delta)})" - - -def _fmt_pct(value: float | None) -> str: - if value is None or _is_nan(value): - return "n/a" - return f"{value * 100:.1f}%" - - -def _fmt_num(value: float | None, digits: int = 2) -> str: - if value is None or _is_nan(value): - return "n/a" - return f"{value:.{digits}f}" - - -def _format_output(current: WeekMetrics, previous: WeekMetrics) -> str: - study_tags = {"coding", "language", "math", "piano", "science"} - lines: list[str] = [] - lines.append(f"WEEK: {current.week}") - lines.append("") - lines.append("Overall:") - - comp_arrow = _trend_arrow(current.completion_rate, previous.completion_rate) - comp_delta = _delta_text( - current.completion_rate, - previous.completion_rate, - "{:.1f}%", - scale=100.0, - ) - lines.append( - f"- Completion rate: {_fmt_pct(current.completion_rate)} ({comp_arrow}) {comp_delta}", - ) - - tasks_arrow = _trend_arrow(current.avg_tasks_per_day, previous.avg_tasks_per_day) - tasks_delta = _delta_text( - current.avg_tasks_per_day, - previous.avg_tasks_per_day, - "{:.2f}", - ) - lines.append( - f"- Avg tasks/day: {_fmt_num(current.avg_tasks_per_day, 2)} ({tasks_arrow}) {tasks_delta}", - ) - - planned_arrow = _trend_arrow( - current.avg_planned_minutes, - previous.avg_planned_minutes, - ) - planned_delta = _delta_text( - current.avg_planned_minutes, - previous.avg_planned_minutes, - "{:.0f} min", - ) - planned_value = "n/a" - if current.avg_planned_minutes is not None and not _is_nan( - current.avg_planned_minutes, - ): - planned_value = f"{current.avg_planned_minutes:.0f} min" - lines.append( - f"- Avg planned task length: {planned_value} ({planned_arrow}) {planned_delta}", - ) - - total_study_minutes = 0.0 - total_study_effective_minutes = 0.0 - for tag in study_tags: - minutes = current.by_category_minutes.get(tag, {}) - total_study_minutes += minutes.get("planned_minutes", 0.0) - total_study_effective_minutes += minutes.get("effective_minutes", 0.0) - total_study_hours = total_study_minutes / 60.0 - total_study_effective_hours = total_study_effective_minutes / 60.0 - lines.append(f"- Total study hours (planned): {total_study_hours:.2f}h") - lines.append( - f"- Total study hours (effective): {total_study_effective_hours:.2f}h" - ) - - lines.append("") - lines.append("By category:") - if not current.by_category: - lines.append("- n/a") - else: - for tag, rate in sorted(current.by_category.items()): - prev_rate = previous.by_category.get(tag) - arrow = _trend_arrow(rate, prev_rate) - minutes = current.by_category_minutes.get(tag, {}) - planned_hours = minutes.get("planned_minutes", 0.0) / 60.0 - effective_hours = minutes.get("effective_minutes", 0.0) / 60.0 - lines.append( - f"- {tag}: completion {_fmt_pct(rate)} ({arrow}), planned {planned_hours:.2f}h, effective {effective_hours:.2f}h", - ) - - lines.append("") - lines.append("Interpretation hints:") - if ( - current.completion_rate is not None - and previous.completion_rate is not None - and current.completion_rate < previous.completion_rate - ): - lines.append("- Overall completion declined noticeably.") - if ( - current.avg_planned_minutes is not None - and previous.avg_planned_minutes is not None - and current.avg_planned_minutes > previous.avg_planned_minutes - ): - lines.append("- Tasks may be planned longer than before.") - if ( - current.avg_tasks_per_day is not None - and previous.avg_tasks_per_day is not None - and current.avg_tasks_per_day > previous.avg_tasks_per_day - ): - lines.append("- Daily workload increased significantly.") - leisure_minutes = current.by_category_minutes.get("leasure", {}) - leisure_hours = leisure_minutes.get("planned_minutes", 0.0) / 60.0 - if leisure_hours > 0: - lines.append( - f"- Leisure(sleep, phone, eat, etc.) time logged {leisure_hours:.2f}h; review if this should be planned study time.", - ) - if lines[-1] == "Interpretation hints:": - lines.append("- No major shifts detected.") - - return "\n".join(lines) - - -def main(args: list[str] | None = None) -> int: - """Render a summary between two metric files.""" - parser = argparse.ArgumentParser( - description="Generate a summary between two weekly metrics files", - ) - parser.add_argument( - "--files", - nargs=2, - metavar=("A", "B"), - required=True, - help="Two metric files to diff", - ) - parsed_args = parser.parse_args(args) - - file_a = Path(parsed_args.files[0]) - file_b = Path(parsed_args.files[1]) - - if not file_a.exists() or not file_b.exists(): - missing = [str(p) for p in (file_a, file_b) if not p.exists()] - print(f"Missing file(s): {', '.join(missing)}", file=sys.stderr) - return 1 - - metrics_a = _read_metrics(file_a) - metrics_b = _read_metrics(file_b) - - summary_text = _format_output(metrics_b, metrics_a) - print(summary_text) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/safe_family/core/models.py b/src/safe_family/core/models.py index e614bc2..0121687 100644 --- a/src/safe_family/core/models.py +++ b/src/safe_family/core/models.py @@ -5,7 +5,7 @@ from werkzeug.security import check_password_hash, generate_password_hash -from src.safe_family.core.extensions import db, local_tz +from src.safe_family.core.extensions import db class User(db.Model): @@ -24,12 +24,6 @@ class User(db.Model): default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC), ) - # Relationship to goals - goals = db.relationship( - "LongTermGoal", - back_populates="user", - cascade="all, delete-orphan", - ) def __repr__(self) -> str: """Return a string representation of the User.""" @@ -90,67 +84,6 @@ def save(self): db.session.commit() -class LongTermGoal(db.Model): - """ORM of LONG_TERM_GOALS.""" - - __tablename__ = "long_term_goals" - - goal_id = db.Column(db.Integer, primary_key=True, autoincrement=True) - user_id = db.Column( - db.String, - db.ForeignKey("users.id", onupdate="NO ACTION", ondelete="CASCADE"), - nullable=False, - ) - task_text = db.Column(db.Text, nullable=False) - priority = db.Column(db.Integer, default=3) - completed = db.Column(db.Boolean, default=False) - created_at = db.Column(db.DateTime, default=db.func.now()) - completed_at = db.Column(db.DateTime, nullable=True) - due_date = db.Column(db.DateTime, nullable=True) - time_spent = db.Column(db.Integer, default=0) - is_tracking = db.Column(db.Boolean, default=False) - tracking_start = db.Column(db.DateTime, nullable=True) - - # Relationship to user - user = db.relationship("User", back_populates="goals") - - def __repr__(self) -> str: - """Show String View.""" - return f"" - - def get_goal(self, goal_id: int): - """Retrieve a goal by ID.""" - return ( - db.session.query(LongTermGoal) - .filter(LongTermGoal.goal_id == goal_id) - .first() - ) - - def stop_tracking(self): - """Stop time tracking and update time_spent.""" - if self.is_tracking and self.tracking_start: - self.time_spent += ( - datetime.now(local_tz) - local_tz.localize(self.tracking_start) - ).seconds - self.is_tracking = False - self.tracking_start = None - db.session.commit() - return self - - def add_time_spent(self, goal_id: int, minutes: int): - """Add time spent on a goal.""" - goal = self.get_goal(goal_id) - if goal: - goal.time_spent += minutes * 60 - db.session.commit() - return goal - - def delete_goal(self): - """Delete goal.""" - db.session.delete(self) - db.session.commit() - - note_tags = db.Table( "note_tags", db.Column("note_id", db.String(), db.ForeignKey("notes.id"), primary_key=True), diff --git a/src/safe_family/notifications/notifier.py b/src/safe_family/notifications/notifier.py index e156590..e8b396a 100644 --- a/src/safe_family/notifications/notifier.py +++ b/src/safe_family/notifications/notifier.py @@ -59,32 +59,6 @@ def send_discord_notification(username, tasks): logger.exception("❌ Discord message failed:") -def send_discord_summary(username: str, summary: str, week: str, previous_week: str): - """Send a Discord notification for weekly summary.""" - if not settings.DISCORD_WEBHOOK_URL: - print("⚠️ Discord webhook URL not configured.") - return - - title = f"📊 Weekly Summary for {username}" - content = summary or "No summary data available." - footer_text = f"{previous_week} → {week}" if previous_week and week else None - embed = { - "title": title, - "description": content, - "color": 3447003, - } - if footer_text: - embed["footer"] = {"text": footer_text} - - data = {"embeds": [embed]} - - try: - requests.post(settings.DISCORD_WEBHOOK_URL, json=data, timeout=5) - logger.info(data) - except NotificationError: - logger.exception("❌ Discord summary failed:") - - def send_hammerspoon_alert(message: str): """Send a local desktop alert via Hammerspoon HTTP server.""" alert_url = settings.HAMMERSPOON_ALERT_URL @@ -100,25 +74,6 @@ def send_hammerspoon_alert(message: str): logger.exception("❌ Hammerspoon alert failed:") -def send_hammerspoon_task(doer: str, task_name: str, time_slot: str): - """Send a local desktop task notification via Hammerspoon HTTP server.""" - task_url = settings.HAMMERSPOON_TASK_URL - if not task_url: - return - if not _is_hammerspoon_available(task_url): - return - - payload = { - "doer": doer, - "task_name": task_name, - "time_slot": time_slot, - } - try: - requests.post(task_url, json=payload, timeout=2) - except requests.RequestException: - logger.exception("❌ Hammerspoon task notification failed:") - - def _is_hammerspoon_available(alert_url: str) -> bool: parsed = urlparse(alert_url) host = parsed.hostname diff --git a/src/safe_family/rules/executor.py b/src/safe_family/rules/executor.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/safe_family/rules/scheduler.py b/src/safe_family/rules/scheduler.py index 4090d61..5f38cda 100644 --- a/src/safe_family/rules/scheduler.py +++ b/src/safe_family/rules/scheduler.py @@ -8,7 +8,7 @@ import time import uuid import zlib -from datetime import datetime, timedelta +from datetime import datetime from apscheduler.events import EVENT_JOB_ERROR, EVENT_JOB_EXECUTED from apscheduler.schedulers.background import BackgroundScheduler @@ -351,16 +351,6 @@ def load_schedules(): conn.close() # Still prefer "cron" compare to ("interval", hours=24) - scheduler.add_job( - _wrap_job("archive_completed_tasks", archive_completed_tasks), - "cron", - id="archive_completed_tasks", - name="archive_completed_tasks", - hour=2, - minute=10, - day_of_week="*", - ) - active_job_ids.add("archive_completed_tasks") scheduler.add_job( _wrap_job("analyze_logs", analyze_logs), "cron", @@ -612,62 +602,6 @@ def schedule_rules(): ) -def archive_completed_tasks(): - """Move completed tasks to history table.""" - logger.info("Start archiving completed tasks...") - conn = get_db_connection() - cur = conn.cursor() - cur_his = conn.cursor() - - three_days_ago = datetime.now(local_tz) - timedelta(days=3) - - # 1. Select tasks completed 3+ days ago - cur.execute( - """ - SELECT goal_id, user_id, task_text, priority, completed_at, time_spent - FROM long_term_goals - WHERE completed = TRUE AND completed_at < %s - """, - (three_days_ago,), - ) - rows = cur.fetchall() - - if not rows: - conn.close() - return - - # 2. Insert them into history table - for row in rows: - try: - goal_id, user_id, task_text, priority, completed_at, time_spent = row - cur_his.execute( - """ - INSERT INTO long_term_goals_his (goal_id, user_id, task_text, priority, completed_at, time_spent) - VALUES (%s, %s, %s, %s, %s, %s) - """, - ( - goal_id, - user_id, - task_text, - priority, - completed_at.strftime("%Y-%m-%d %H:%M:%S"), - time_spent, - ), - ) - logger.info("Archived task: %s", str(row)) - # 3. Remove from active table - cur.execute( - "DELETE FROM long_term_goals WHERE goal_id = %s", - (goal_id,), - ) - logger.info("Move goal_id %d to history", goal_id) - conn.commit() - except Exception as e: - logger.info("move to history not success: %s", str(e)) - - conn.close() - - def analyze_logs(): """Analyze logs.""" now = datetime.now(local_tz) diff --git a/src/safe_family/static/css/input.css b/src/safe_family/static/css/input.css index 3b52d85..1e6f1f1 100644 --- a/src/safe_family/static/css/input.css +++ b/src/safe_family/static/css/input.css @@ -36,61 +36,7 @@ body { /* Centers the content */ } -.site-header { - /* Flexbox for layout: Logo on left, Nav on right */ - display: flex; - justify-content: space-between; - align-items: center; - padding: 20px 0; - margin-bottom: 20px; -} - -.logo { - font-size: 32px; - font-weight: bold; - color: var(--text-cyan); - /* Cyan Logo */ - text-transform: uppercase; - letter-spacing: 1px; -} - -.main-nav ul { - list-style: none; - /* Remove bullet points */ - display: flex; - /* Horizontal layout for links */ - gap: 30px; - /* Space between links */ -} - -.main-nav a { - text-decoration: none; - /* Remove underline */ - color: var(--text-cyan); - /* Cyan Links */ - font-weight: bold; - font-size: 16px; - text-transform: uppercase; - transition: color 0.3s ease; -} - -/* Hover effect for navigation links */ -.main-nav a:hover { - color: var(--highlight-orange); - /* Turn Orange on hover */ -} - /* Flash messages */ -.message { - color: #27ae60 !important; - /* Professional green for success message */ - margin-top: 15px; - font-weight: 600; - padding: 10px; - background-color: #e6ffed; - border-radius: 4px; -} - .flash { padding: 12px 20px; border-radius: 8px; @@ -128,56 +74,10 @@ body { /* Flash message ends */ -.hero-placeholder { - width: 100%; - height: 400px; - background-color: #020c1b; - /* Very dark blue placeholder */ - display: flex; - justify-content: center; - align-items: center; - margin-bottom: 30px; - border: 2px solid var(--bg-secondary); - /* Subtle border */ -} - -.content-grid { - display: grid; - /* Create three equal-width columns */ - grid-template-columns: repeat(3, 1fr); - gap: 30px; - padding-bottom: 50px; -} - -/* @media (max-width: 768px) { - .site-header { - flex-direction: column; - gap: 20px; - } - - .content-grid { - grid-template-columns: 1fr; - } - - .hero-placeholder { - height: 250px; - } -} */ - section { color: var(--text-light); } -.top-bar { - margin-bottom: 30px; - display: flex; - flex-direction: column; - /* Stack elements vertically on small screens */ - align-items: flex-start; - gap: 10px; - /* Space between the title and the note */ -} - .note { font-size: 0.9em; /* Slightly darker gray for better readability */ @@ -188,14 +88,6 @@ section { border-radius: 12px; } -.footer { - margin-top: 20px; - padding-top: 20px; - padding-bottom: 20px; - border-top: 1px solid; - text-align: center; -} - .card { @apply p-[25px] rounded-[12px] mb-[25px] border-[1px] border-[solid] border-[#eef1f4]; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); @@ -272,42 +164,6 @@ section { background-color: var(--bg-secondary); } -.signup-button { - display: flex; - justify-content: center; - align-items: center; - width: 100%; - /* High Attention Orange Background */ - background-color: var(--highlight-orange); - color: #ffffff; - /* White text for contrast */ - font-size: 24px; - font-weight: bold; - text-decoration: none; - text-transform: uppercase; - border-radius: 4px; - /* Slight rounded corners */ - box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3); - transition: background-color 0.3s ease, transform 0.1s ease; -} - -.signup-button:hover { - background-color: #e65c00; - /* Darker Orange on hover */ -} - -.signup-button:active { - transform: scale(0.98); - /* Slight click effect */ -} - -.content-container { - max-width: 1200px; - margin: 20px auto; - /* Center the content and add vertical margin */ - padding: 0 40px; -} - .dashboard-layout { @apply flex gap-[30px] min-h-[80vh]; } @@ -320,14 +176,6 @@ section { @apply min-w-[30] pt-[15px] overflow-y-auto; } -#longterm-task-text { - @apply w-full h-10 px-3 rounded-lg border-[1px] focus:outline-none focus:ring-1 focus:ring-green-300 focus:border-transparent transition-all shadow-sm; -} - -#longterm-priority { - @apply w-full h-10 pl-3 rounded-lg border-[1px] focus:outline-none focus:ring-1 focus:ring-green-300 focus:border-transparent shadow-sm appearance-none cursor-pointer; -} - input[type="email"], input[type="date"], input[type="time"], @@ -395,39 +243,15 @@ select:focus { @apply bg-[#ecf0f1] w-full text-[#2c3e50]; } -.btn-success { - @apply bg-[#2ecc71] w-fit text-[white]; -} - -.btn-failure { - @apply !bg-[#c0392b] !text-[white] text-[1.1em] w-fit; -} - -.btn-area { - @apply mx-[0] my-[25px] text-center; -} - .btn:hover { @apply hover:opacity-90 hover:-translate-y-px; box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); } -.timer-btn { - @apply p-2 hover:bg-purple-500/10 uppercase font-medium rounded-lg shadow-md hover:shadow-lg transition-all flex-col items-center justify-center gap-2 whitespace-nowrap; - background-color: var(--bg-secondary) -} - - td form button { @apply bg-[#e74c3c] text-[white] px-[10px] py-[5px] text-[0.9em] uppercase; } - - -.table-action-delete { - @apply bg-none border-[none] text-[#e74c3c] font-semibold cursor-pointer p-[5px]; -} - .link-active { @apply text-[#7f8c8d] text-[0.85rem]; } @@ -457,62 +281,6 @@ td form button { opacity: 0.6; } -.time-slot-cell { - --slot-hue: calc(var(--slot-progress) * 300deg); - position: relative; - overflow: hidden; - border-radius: 10px; - background: linear-gradient(90deg, - hsl(calc(var(--slot-hue)) 100% 70%) 0%, - hsl(calc(var(--slot-hue) + 55deg) 100% 60%) calc(var(--slot-fill) * 0.6), - hsl(calc(var(--slot-hue) + 110deg) 100% 55%) var(--slot-fill), - var(--bg-main) var(--slot-fill)); - color: var(--text-light); - text-shadow: 0 0 10px rgba(9, 9, 9, 0.55); - box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35), - 0 0 18px rgba(0, 255, 255, 0.25); -} - -.time-slot-cell::after { - content: ""; - position: absolute; - inset: 3px; - border-radius: 8px; - border: 1px solid rgba(255, 255, 255, 0.4); - mix-blend-mode: screen; - pointer-events: none; -} - -.time-slot-cell::before { - content: ""; - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; - background: linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.9) 45%, transparent 70%); - background-size: 200% 100%; - opacity: 0; - animation: none; - clip-path: inset(0 calc(100% - var(--slot-fill)) 0 0); - pointer-events: none; -} - -.time-slot-cell.is-active::before { - opacity: 0.85; - animation: slot-scan 1.75s linear infinite; -} - -@keyframes slot-scan { - 0% { - background-position: -100% 0; - } - - 100% { - background-position: 100% 0; - } -} - .task-status { margin-top: 6px; font-size: 0.7rem; @@ -545,10 +313,6 @@ td form button { color: #34d399; } -.task-cell { - position: relative; -} - .split-slot-btn { position: absolute; right: 12px; @@ -645,269 +409,6 @@ td form button { border: none; } -.weekly-summary-modal { - position: fixed; - inset: 0; - display: none; - align-items: center; - justify-content: center; - background: rgba(10, 16, 24, 0.65); - z-index: 55; -} - -.weekly-summary-modal.active { - display: flex; -} - -.weekly-summary-panel { - width: min(92vw, 720px); - max-height: 84vh; - overflow: hidden; - background: var(--bg-secondary); - border-radius: 18px; - padding: 20px 22px 18px; - box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35); - border: 1px solid rgba(255, 255, 255, 0.08); - display: grid; - gap: 12px; -} - -.weekly-summary-header { - display: flex; - align-items: center; - justify-content: space-between; -} - -.weekly-summary-title { - font-size: 1.1rem; - font-weight: var(--font-weight-semibold); - color: var(--text-cyan); -} - -.weekly-summary-subtitle { - font-size: 0.85rem; - color: #94a3b8; -} - -.weekly-summary-content { - background: rgba(15, 23, 42, 0.45); - border: 1px solid rgba(148, 163, 184, 0.3); - border-radius: 12px; - padding: 14px; - color: #e2e8f0; - font-size: 0.85rem; - line-height: 1.35; - white-space: pre-wrap; - overflow: auto; - min-height: 200px; -} - -.weekly-summary-close, -.weekly-summary-close-btn { - border-radius: 10px; - padding: 8px 12px; - font-weight: var(--font-weight-semibold); - background: rgba(15, 23, 42, 0.8); - color: #e2e8f0; - border: 1px solid rgba(148, 163, 184, 0.3); -} - -.weekly-summary-close { - padding: 6px 10px; - line-height: 1; -} - -.unknown-tags-modal, -.unknown-status-modal { - position: fixed; - inset: 0; - display: none; - align-items: center; - justify-content: center; - background: rgba(10, 16, 24, 0.65); - z-index: 56; -} - -.unknown-tags-modal.active, -.unknown-status-modal.active { - display: flex; -} - -.unknown-tags-panel, -.unknown-status-panel { - width: min(92vw, 720px); - max-height: 84vh; - overflow: hidden; - background: var(--bg-secondary); - border-radius: 18px; - padding: 20px 22px 18px; - box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35); - border: 1px solid rgba(255, 255, 255, 0.08); - display: grid; - gap: 12px; -} - -.unknown-tags-header, -.unknown-status-header { - display: flex; - align-items: center; - justify-content: space-between; -} - -.unknown-tags-title, -.unknown-status-title { - font-size: 1.1rem; - font-weight: var(--font-weight-semibold); - color: var(--text-cyan); -} - -.unknown-tags-subtitle, -.unknown-status-subtitle { - font-size: 0.85rem; - color: #94a3b8; -} - -.unknown-tags-list, -.unknown-status-list { - background: rgba(15, 23, 42, 0.45); - border: 1px solid rgba(148, 163, 184, 0.3); - border-radius: 12px; - padding: 12px; - display: grid; - gap: 10px; - overflow: auto; - min-height: 200px; - max-height: 45vh; -} - -.unknown-tags-row, -.unknown-status-row { - display: grid; - gap: 8px; - grid-template-columns: 1fr; - padding: 8px; - border-radius: 10px; - background: rgba(15, 23, 42, 0.7); - border: 1px solid rgba(148, 163, 184, 0.2); -} - -.unknown-row-title { - font-size: 0.85rem; - color: #e2e8f0; -} - -.unknown-tags-row select, -.unknown-status-row select { - width: 100%; - padding: 8px 10px; - border-radius: 10px; - background: rgba(15, 23, 42, 0.9); - color: #e2e8f0; - border: 1px solid rgba(148, 163, 184, 0.35); -} - -.unknown-tags-actions, -.unknown-status-actions { - display: flex; - justify-content: flex-end; - gap: 10px; -} - -.unknown-tags-close, -.unknown-status-close, -.unknown-tags-save, -.unknown-tags-skip, -.unknown-status-save, -.unknown-status-skip { - border-radius: 10px; - padding: 8px 12px; - font-weight: var(--font-weight-semibold); - background: rgba(15, 23, 42, 0.8); - color: #e2e8f0; - border: 1px solid rgba(148, 163, 184, 0.3); -} - -/* Priority Colors */ -.priority-1 { - @apply bg-red-500/10 text-red-300; -} - -.priority-2 { - @apply bg-orange-500/10 text-orange-300; -} - -/* Medium = Orange */ -.priority-3 { - @apply bg-yellow-400/10 text-yellow-200; -} - -.priority-4 { - @apply bg-green-500/10 text-green-300; -} - -/* Low = Green */ -.priority-5 { - @apply bg-blue-600/20 text-blue-300; -} - -/* Three dot popup menus */ -.menu-container { - position: relative; - display: inline-block; -} - -.menu-btn { - background: none; - border: none; - cursor: pointer; - font-size: 18px; - padding: 4px; -} - -.menu-popup { - position: absolute; - right: 0; - top: 25px; - background: var(--bg-secondary); - border: 1px solid #ccc; - border-radius: 6px; - padding: 5px 0; - min-width: 120px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); - z-index: 20; -} - -.menu-item { - padding: 8px 12px; - cursor: pointer; -} - -.menu-item:hover { - background: #100a0a; -} - -/* Helper popup */ -.help-btn { - cursor: pointer; - font-size: 14px; - margin-left: 6px; -} - -.help-btn:hover { - background-color: transparent; -} - -.help-popup { - position: absolute; - background: #fffdf5; - border: 1px solid #ccc; - border-radius: 8px; - padding: 10px 14px; - max-width: 240px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); - z-index: 50; -} - /* I want to keep this cause it makes whole page not editable */ #mobile-editor-overlay { position: fixed; diff --git a/src/safe_family/static/css/styles.css b/src/safe_family/static/css/styles.css index 6e5cd0a..7dc4c77 100644 --- a/src/safe_family/static/css/styles.css +++ b/src/safe_family/static/css/styles.css @@ -14,25 +14,50 @@ --color-yellow-200: oklch(94.5% 0.129 101.54); --color-yellow-400: oklch(85.2% 0.199 91.936); --color-green-300: oklch(87.1% 0.15 154.449); + --color-green-400: oklch(79.2% 0.209 151.711); --color-green-500: oklch(72.3% 0.219 149.579); + --color-green-600: oklch(62.7% 0.194 149.214); --color-blue-300: oklch(80.9% 0.105 251.813); --color-blue-600: oklch(54.6% 0.245 262.881); + --color-blue-700: oklch(48.8% 0.243 264.376); --color-purple-300: oklch(82.7% 0.119 306.383); + --color-purple-400: oklch(71.4% 0.203 305.504); --color-purple-500: oklch(62.7% 0.265 303.9); + --color-purple-600: oklch(55.8% 0.288 302.321); + --color-purple-700: oklch(49.6% 0.265 301.924); --color-slate-200: oklch(92.9% 0.013 255.508); + --color-slate-300: oklch(86.9% 0.022 252.894); --color-slate-600: oklch(44.6% 0.043 257.281); + --color-slate-900: oklch(20.8% 0.042 265.755); + --color-gray-200: oklch(92.8% 0.006 264.531); + --color-gray-300: oklch(87.2% 0.01 258.338); --color-gray-400: oklch(70.7% 0.022 261.325); --color-gray-600: oklch(44.6% 0.03 256.802); --color-gray-700: oklch(37.3% 0.034 259.733); + --color-black: #000; + --color-white: #fff; --spacing: 0.25rem; + --container-6xl: 72rem; --text-sm: 0.875rem; --text-sm--line-height: calc(1.25 / 0.875); + --text-lg: 1.125rem; + --text-lg--line-height: calc(1.75 / 1.125); + --text-xl: 1.25rem; + --text-xl--line-height: calc(1.75 / 1.25); + --text-2xl: 1.5rem; + --text-2xl--line-height: calc(2 / 1.5); + --text-5xl: 3rem; + --text-5xl--line-height: 1; --font-weight-medium: 500; --font-weight-semibold: 600; --font-weight-bold: 700; + --font-weight-black: 900; + --tracking-tighter: -0.05em; + --tracking-wider: 0.05em; --radius-md: 0.375rem; --radius-lg: 0.5rem; --radius-xl: 0.75rem; + --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); --default-transition-duration: 150ms; --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); --default-font-family: var(--font-sans); @@ -242,12 +267,30 @@ max-width: 96rem; } } + .mx-\[0\] { + margin-inline: 0; + } + .mx-auto { + margin-inline: auto; + } + .my-\[25px\] { + margin-block: 25px; + } .mt-1 { margin-top: calc(var(--spacing) * 1); } .mt-4 { margin-top: calc(var(--spacing) * 4); } + .mt-12 { + margin-top: calc(var(--spacing) * 12); + } + .mb-1 { + margin-bottom: calc(var(--spacing) * 1); + } + .mb-\[25px\] { + margin-bottom: 25px; + } .block { display: block; } @@ -260,6 +303,9 @@ .hidden { display: none; } + .inline { + display: inline; + } .table { display: table; } @@ -272,9 +318,15 @@ .h-10 { height: calc(var(--spacing) * 10); } + .w-1 { + width: calc(var(--spacing) * 1); + } .w-1\/11 { width: calc(1/11 * 100%); } + .w-3 { + width: calc(var(--spacing) * 3); + } .w-3\/11 { width: calc(3/11 * 100%); } @@ -290,18 +342,42 @@ .w-5\/11 { width: calc(5/11 * 100%); } + .w-9 { + width: calc(var(--spacing) * 9); + } .w-9\/41 { width: calc(9/41 * 100%); } + .w-10 { + width: calc(var(--spacing) * 10); + } .w-10\/41 { width: calc(10/41 * 100%); } + .w-14 { + width: calc(var(--spacing) * 14); + } + .w-20 { + width: calc(var(--spacing) * 20); + } + .w-21 { + width: calc(var(--spacing) * 21); + } .w-21\/41 { width: calc(21/41 * 100%); } + .w-32 { + width: calc(var(--spacing) * 32); + } + .w-fit { + width: fit-content; + } .w-full { width: 100%; } + .max-w-6xl { + max-width: var(--container-6xl); + } .max-w-\[70px\] { max-width: 70px; } @@ -317,18 +393,36 @@ .min-w-20 { min-width: calc(var(--spacing) * 20); } + .min-w-\[30\] { + min-width: 30; + } + .flex-shrink { + flex-shrink: 1; + } .shrink { flex-shrink: 1; } .flex-grow { flex-grow: 1; } + .border-collapse { + border-collapse: collapse; + } .transform { transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,); } + .cursor-pointer { + cursor: pointer; + } .resize { resize: both; } + .appearance-none { + appearance: none; + } + .grid-cols-1 { + grid-template-columns: repeat(1, minmax(0, 1fr)); + } .grid-cols-41 { grid-template-columns: repeat(41, minmax(0, 1fr)); } @@ -359,6 +453,25 @@ .gap-6 { gap: calc(var(--spacing) * 6); } + .gap-12 { + gap: calc(var(--spacing) * 12); + } + .gap-\[30px\] { + gap: 30px; + } + .space-x-6 { + :where(& > :not(:last-child)) { + --tw-space-x-reverse: 0; + margin-inline-start: calc(calc(var(--spacing) * 6) * var(--tw-space-x-reverse)); + margin-inline-end: calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-x-reverse))); + } + } + .rounded { + border-radius: 0.25rem; + } + .rounded-\[12px\] { + border-radius: 12px; + } .rounded-lg { border-radius: var(--radius-lg); } @@ -369,67 +482,263 @@ border-style: var(--tw-border-style); border-width: 1px; } + .border-2 { + border-style: var(--tw-border-style); + border-width: 2px; + } + .border-4 { + border-style: var(--tw-border-style); + border-width: 4px; + } + .border-\[1px\] { + border-style: var(--tw-border-style); + border-width: 1px; + } .border-b { border-bottom-style: var(--tw-border-style); border-bottom-width: 1px; } + .border-b-2 { + border-bottom-style: var(--tw-border-style); + border-bottom-width: 2px; + } + .border-b-4 { + border-bottom-style: var(--tw-border-style); + border-bottom-width: 4px; + } + .border-\[none\] { + border-color: none; + } + .border-\[solid\] { + border-color: solid; + } + .border-black { + border-color: var(--color-black); + } + .border-gray-200 { + border-color: var(--color-gray-200); + } + .border-gray-300 { + border-color: var(--color-gray-300); + } + .border-gray-400 { + border-color: var(--color-gray-400); + } + .border-orange-500 { + border-color: var(--color-orange-500); + } + .border-orange-500\/30 { + border-color: color-mix(in srgb, oklch(70.5% 0.213 47.604) 30%, transparent); + @supports (color: color-mix(in lab, red, red)) { + border-color: color-mix(in oklab, var(--color-orange-500) 30%, transparent); + } + } + .border-red-500 { + border-color: var(--color-red-500); + } + .border-red-500\/30 { + border-color: color-mix(in srgb, oklch(63.7% 0.237 25.331) 30%, transparent); + @supports (color: color-mix(in lab, red, red)) { + border-color: color-mix(in oklab, var(--color-red-500) 30%, transparent); + } + } + .\!bg-\[\#c0392b\] { + background-color: #c0392b !important; + } + .bg-\[\#2ecc71\] { + background-color: #2ecc71; + } .bg-\[\#172a45\] { background-color: #172a45; } + .bg-\[\#e74c3c\] { + background-color: #e74c3c; + } + .bg-\[\#ecf0f1\] { + background-color: #ecf0f1; + } + .bg-blue-600\/20 { + background-color: color-mix(in srgb, oklch(54.6% 0.245 262.881) 20%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-blue-600) 20%, transparent); + } + } + .bg-green-500\/10 { + background-color: color-mix(in srgb, oklch(72.3% 0.219 149.579) 10%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-green-500) 10%, transparent); + } + } + .bg-orange-500 { + background-color: var(--color-orange-500); + } .bg-orange-500\/10 { background-color: color-mix(in srgb, oklch(70.5% 0.213 47.604) 10%, transparent); @supports (color: color-mix(in lab, red, red)) { background-color: color-mix(in oklab, var(--color-orange-500) 10%, transparent); } } + .bg-red-500 { + background-color: var(--color-red-500); + } + .bg-red-500\/10 { + background-color: color-mix(in srgb, oklch(63.7% 0.237 25.331) 10%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-red-500) 10%, transparent); + } + } + .bg-slate-900 { + background-color: var(--color-slate-900); + } + .bg-white { + background-color: var(--color-white); + } + .bg-yellow-400\/10 { + background-color: color-mix(in srgb, oklch(85.2% 0.199 91.936) 10%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-yellow-400) 10%, transparent); + } + } + .bg-none { + background-image: none; + } .fill-current { fill: currentcolor; } + .p-2 { + padding: calc(var(--spacing) * 2); + } .p-4 { padding: calc(var(--spacing) * 4); } .p-6 { padding: calc(var(--spacing) * 6); } + .p-\[25px\] { + padding: 25px; + } .px-2 { padding-inline: calc(var(--spacing) * 2); } + .px-3 { + padding-inline: calc(var(--spacing) * 3); + } .px-6 { padding-inline: calc(var(--spacing) * 6); } + .px-\[10px\] { + padding-inline: 10px; + } + .py-1 { + padding-block: calc(var(--spacing) * 1); + } + .py-3 { + padding-block: calc(var(--spacing) * 3); + } + .py-\[5px\] { + padding-block: 5px; + } .pt-\[10px\] { padding-top: 10px; } + .pt-\[15px\] { + padding-top: 15px; + } .pr-2 { padding-right: calc(var(--spacing) * 2); } + .pb-2 { + padding-bottom: calc(var(--spacing) * 2); + } + .pl-3 { + padding-left: calc(var(--spacing) * 3); + } .text-center { text-align: center; } .text-left { text-align: left; } + .text-right { + text-align: right; + } + .text-2xl { + font-size: var(--text-2xl); + line-height: var(--tw-leading, var(--text-2xl--line-height)); + } + .text-5xl { + font-size: var(--text-5xl); + line-height: var(--tw-leading, var(--text-5xl--line-height)); + } + .text-lg { + font-size: var(--text-lg); + line-height: var(--tw-leading, var(--text-lg--line-height)); + } .text-sm { font-size: var(--text-sm); line-height: var(--tw-leading, var(--text-sm--line-height)); } + .text-xl { + font-size: var(--text-xl); + line-height: var(--tw-leading, var(--text-xl--line-height)); + } + .text-\[0\.9em\] { + font-size: 0.9em; + } + .text-\[1\.1em\] { + font-size: 1.1em; + } + .font-black { + --tw-font-weight: var(--font-weight-black); + font-weight: var(--font-weight-black); + } .font-bold { --tw-font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold); } + .font-medium { + --tw-font-weight: var(--font-weight-medium); + font-weight: var(--font-weight-medium); + } .font-semibold { --tw-font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold); } + .tracking-tighter { + --tw-tracking: var(--tracking-tighter); + letter-spacing: var(--tracking-tighter); + } + .tracking-wider { + --tw-tracking: var(--tracking-wider); + letter-spacing: var(--tracking-wider); + } .whitespace-nowrap { white-space: nowrap; } + .\!text-\[white\] { + color: white !important; + } + .text-\[\#7f8c8d\] { + color: #7f8c8d; + } + .text-\[\#e74c3c\] { + color: #e74c3c; + } .text-\[\#ecf0f1\] { color: #ecf0f1; } .text-\[\#ff6b00\] { color: #ff6b00; } + .text-\[white\] { + color: white; + } + .text-black { + color: var(--color-black); + } + .text-blue-700 { + color: var(--color-blue-700); + } .text-gray-400 { color: var(--color-gray-400); } @@ -439,16 +748,47 @@ .text-green-300 { color: var(--color-green-300); } + .text-green-400 { + color: var(--color-green-400); + } + .text-orange-500 { + color: var(--color-orange-500); + } .text-purple-300 { color: var(--color-purple-300); } + .text-purple-400 { + color: var(--color-purple-400); + } + .text-purple-700 { + color: var(--color-purple-700); + } + .text-red-500 { + color: var(--color-red-500); + } + .text-slate-300 { + color: var(--color-slate-300); + } + .text-white { + color: var(--color-white); + } + .text-yellow-400 { + color: var(--color-yellow-400); + } .uppercase { text-transform: uppercase; } + .underline { + text-decoration-line: underline; + } .shadow { --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } + .shadow-lg { + --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } .shadow-md { --tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); @@ -457,6 +797,10 @@ --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } + .outline { + outline-style: var(--tw-outline-style); + outline-width: 1px; + } .blur { --tw-blur: blur(8px); filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); @@ -483,6 +827,26 @@ transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); transition-duration: var(--tw-duration, var(--default-transition-duration)); } + .ease-in-out { + --tw-ease: var(--ease-in-out); + transition-timing-function: var(--ease-in-out); + } + .last\:border-0 { + &:last-child { + border-style: var(--tw-border-style); + border-width: 0px; + } + } + .hover\:bg-orange-500\/20 { + &:hover { + @media (hover: hover) { + background-color: color-mix(in srgb, oklch(70.5% 0.213 47.604) 20%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-orange-500) 20%, transparent); + } + } + } + } .hover\:bg-purple-500\/10 { &:hover { @media (hover: hover) { @@ -493,6 +857,16 @@ } } } + .hover\:bg-red-500\/20 { + &:hover { + @media (hover: hover) { + background-color: color-mix(in srgb, oklch(63.7% 0.237 25.331) 20%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-red-500) 20%, transparent); + } + } + } + } .hover\:text-gray-600 { &:hover { @media (hover: hover) { @@ -500,6 +874,13 @@ } } } + .hover\:opacity-90 { + &:hover { + @media (hover: hover) { + opacity: 90%; + } + } + } .hover\:shadow-lg { &:hover { @media (hover: hover) { @@ -508,6 +889,62 @@ } } } + .focus\:border-blue-600 { + &:focus { + border-color: var(--color-blue-600); + } + } + .focus\:border-green-600 { + &:focus { + border-color: var(--color-green-600); + } + } + .focus\:border-purple-600 { + &:focus { + border-color: var(--color-purple-600); + } + } + .focus\:border-transparent { + &:focus { + border-color: transparent; + } + } + .focus\:ring-0 { + &:focus { + --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + } + .focus\:ring-1 { + &:focus { + --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + } + .focus\:ring-green-300 { + &:focus { + --tw-ring-color: var(--color-green-300); + } + } + .focus\:outline-none { + &:focus { + --tw-outline-style: none; + outline-style: none; + } + } + .active\:scale-95 { + &:active { + --tw-scale-x: 95%; + --tw-scale-y: 95%; + --tw-scale-z: 95%; + scale: var(--tw-scale-x) var(--tw-scale-y); + } + } + .lg\:grid-cols-3 { + @media (width >= 64rem) { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + } } :root { --bg-main: #0a192f; @@ -533,44 +970,6 @@ body { max-width: 1200px; margin: 0 auto; } -.site-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 20px 0; - margin-bottom: 20px; -} -.logo { - font-size: 32px; - font-weight: bold; - color: var(--text-cyan); - text-transform: uppercase; - letter-spacing: 1px; -} -.main-nav ul { - list-style: none; - display: flex; - gap: 30px; -} -.main-nav a { - text-decoration: none; - color: var(--text-cyan); - font-weight: bold; - font-size: 16px; - text-transform: uppercase; - transition: color 0.3s ease; -} -.main-nav a:hover { - color: var(--highlight-orange); -} -.message { - color: #27ae60 !important; - margin-top: 15px; - font-weight: 600; - padding: 10px; - background-color: #e6ffed; - border-radius: 4px; -} .flash { padding: 12px 20px; border-radius: 8px; @@ -598,32 +997,9 @@ body { color: #b08d00; border-left-color: #b08d00; } -.hero-placeholder { - width: 100%; - height: 400px; - background-color: #020c1b; - display: flex; - justify-content: center; - align-items: center; - margin-bottom: 30px; - border: 2px solid var(--bg-secondary); -} -.content-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 30px; - padding-bottom: 50px; -} section { color: var(--text-light); } -.top-bar { - margin-bottom: 30px; - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 10px; -} .note { font-size: 0.9em; padding: 10px; @@ -631,13 +1007,6 @@ section { background-color: var(--bg-secondary); border-radius: 12px; } -.footer { - margin-top: 20px; - padding-top: 20px; - padding-bottom: 20px; - border-top: 1px solid; - text-align: center; -} .card { margin-bottom: 25px; border-radius: 12px; @@ -747,32 +1116,6 @@ section { color: var(--color-slate-200); background-color: var(--bg-secondary); } -.signup-button { - display: flex; - justify-content: center; - align-items: center; - width: 100%; - background-color: var(--highlight-orange); - color: #ffffff; - font-size: 24px; - font-weight: bold; - text-decoration: none; - text-transform: uppercase; - border-radius: 4px; - box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3); - transition: background-color 0.3s ease, transform 0.1s ease; -} -.signup-button:hover { - background-color: #e65c00; -} -.signup-button:active { - transform: scale(0.98); -} -.content-container { - max-width: 1200px; - margin: 20px auto; - padding: 0 40px; -} .dashboard-layout { display: flex; min-height: 80vh; @@ -789,59 +1132,6 @@ section { overflow-y: auto; padding-top: 15px; } -#longterm-task-text { - height: calc(var(--spacing) * 10); - width: 100%; - border-radius: var(--radius-lg); - border-style: var(--tw-border-style); - border-width: 1px; - padding-inline: calc(var(--spacing) * 3); - --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); - box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); - transition-property: all; - transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); - transition-duration: var(--tw-duration, var(--default-transition-duration)); - &:focus { - border-color: transparent; - } - &:focus { - --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); - box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); - } - &:focus { - --tw-ring-color: var(--color-green-300); - } - &:focus { - --tw-outline-style: none; - outline-style: none; - } -} -#longterm-priority { - height: calc(var(--spacing) * 10); - width: 100%; - cursor: pointer; - appearance: none; - border-radius: var(--radius-lg); - border-style: var(--tw-border-style); - border-width: 1px; - padding-left: calc(var(--spacing) * 3); - --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); - box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); - &:focus { - border-color: transparent; - } - &:focus { - --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); - box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); - } - &:focus { - --tw-ring-color: var(--color-green-300); - } - &:focus { - --tw-outline-style: none; - outline-style: none; - } -} input[type="email"], input[type="date"], input[type="time"], input[type="password"], input[type="text"], textarea, select { border-radius: 4px; border-style: var(--tw-border-style); @@ -908,22 +1198,6 @@ input[type="email"]:focus, input[type="date"]:focus, input[type="time"]:focus, i background-color: #ecf0f1; color: #2c3e50; } -.btn-success { - width: fit-content; - background-color: #2ecc71; - color: white; -} -.btn-failure { - width: fit-content; - background-color: #c0392b !important; - font-size: 1.1em; - color: white !important; -} -.btn-area { - margin-inline: 0; - margin-block: 25px; - text-align: center; -} .btn:hover { &:hover { @media (hover: hover) { @@ -938,38 +1212,6 @@ input[type="email"]:focus, input[type="date"]:focus, input[type="time"]:focus, i } box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); } -.timer-btn { - flex-direction: column; - align-items: center; - justify-content: center; - gap: calc(var(--spacing) * 2); - border-radius: var(--radius-lg); - padding: calc(var(--spacing) * 2); - --tw-font-weight: var(--font-weight-medium); - font-weight: var(--font-weight-medium); - white-space: nowrap; - text-transform: uppercase; - --tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); - box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); - transition-property: all; - transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); - transition-duration: var(--tw-duration, var(--default-transition-duration)); - &:hover { - @media (hover: hover) { - background-color: color-mix(in srgb, oklch(62.7% 0.265 303.9) 10%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-purple-500) 10%, transparent); - } - } - } - &:hover { - @media (hover: hover) { - --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); - box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); - } - } - background-color: var(--bg-secondary); -} td form button { background-color: #e74c3c; padding-inline: 10px; @@ -978,15 +1220,6 @@ td form button { color: white; text-transform: uppercase; } -.table-action-delete { - cursor: pointer; - border-color: none; - background-image: none; - padding: 5px; - --tw-font-weight: var(--font-weight-semibold); - font-weight: var(--font-weight-semibold); - color: #e74c3c; -} .link-active { font-size: 0.85rem; color: #7f8c8d; @@ -1010,51 +1243,6 @@ td form button { text-decoration: line-through; opacity: 0.6; } -.time-slot-cell { - --slot-hue: calc(var(--slot-progress) * 300deg); - position: relative; - overflow: hidden; - border-radius: 10px; - background: linear-gradient(90deg, hsl(calc(var(--slot-hue)) 100% 70%) 0%, hsl(calc(var(--slot-hue) + 55deg) 100% 60%) calc(var(--slot-fill) * 0.6), hsl(calc(var(--slot-hue) + 110deg) 100% 55%) var(--slot-fill), var(--bg-main) var(--slot-fill)); - color: var(--text-light); - text-shadow: 0 0 10px rgba(9, 9, 9, 0.55); - box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35), 0 0 18px rgba(0, 255, 255, 0.25); -} -.time-slot-cell::after { - content: ""; - position: absolute; - inset: 3px; - border-radius: 8px; - border: 1px solid rgba(255, 255, 255, 0.4); - mix-blend-mode: screen; - pointer-events: none; -} -.time-slot-cell::before { - content: ""; - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; - background: linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.9) 45%, transparent 70%); - background-size: 200% 100%; - opacity: 0; - animation: none; - clip-path: inset(0 calc(100% - var(--slot-fill)) 0 0); - pointer-events: none; -} -.time-slot-cell.is-active::before { - opacity: 0.85; - animation: slot-scan 1.75s linear infinite; -} -@keyframes slot-scan { - 0% { - background-position: -100% 0; - } - 100% { - background-position: 100% 0; - } -} .task-status { margin-top: 6px; font-size: 0.7rem; @@ -1080,9 +1268,6 @@ td form button { .task-status[data-status="done"] { color: #34d399; } -.task-cell { - position: relative; -} .split-slot-btn { position: absolute; right: 12px; @@ -1167,234 +1352,6 @@ td form button { background: transparent; border: none; } -.weekly-summary-modal { - position: fixed; - inset: 0; - display: none; - align-items: center; - justify-content: center; - background: rgba(10, 16, 24, 0.65); - z-index: 55; -} -.weekly-summary-modal.active { - display: flex; -} -.weekly-summary-panel { - width: min(92vw, 720px); - max-height: 84vh; - overflow: hidden; - background: var(--bg-secondary); - border-radius: 18px; - padding: 20px 22px 18px; - box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35); - border: 1px solid rgba(255, 255, 255, 0.08); - display: grid; - gap: 12px; -} -.weekly-summary-header { - display: flex; - align-items: center; - justify-content: space-between; -} -.weekly-summary-title { - font-size: 1.1rem; - font-weight: var(--font-weight-semibold); - color: var(--text-cyan); -} -.weekly-summary-subtitle { - font-size: 0.85rem; - color: #94a3b8; -} -.weekly-summary-content { - background: rgba(15, 23, 42, 0.45); - border: 1px solid rgba(148, 163, 184, 0.3); - border-radius: 12px; - padding: 14px; - color: #e2e8f0; - font-size: 0.85rem; - line-height: 1.35; - white-space: pre-wrap; - overflow: auto; - min-height: 200px; -} -.weekly-summary-close, .weekly-summary-close-btn { - border-radius: 10px; - padding: 8px 12px; - font-weight: var(--font-weight-semibold); - background: rgba(15, 23, 42, 0.8); - color: #e2e8f0; - border: 1px solid rgba(148, 163, 184, 0.3); -} -.weekly-summary-close { - padding: 6px 10px; - line-height: 1; -} -.unknown-tags-modal, .unknown-status-modal { - position: fixed; - inset: 0; - display: none; - align-items: center; - justify-content: center; - background: rgba(10, 16, 24, 0.65); - z-index: 56; -} -.unknown-tags-modal.active, .unknown-status-modal.active { - display: flex; -} -.unknown-tags-panel, .unknown-status-panel { - width: min(92vw, 720px); - max-height: 84vh; - overflow: hidden; - background: var(--bg-secondary); - border-radius: 18px; - padding: 20px 22px 18px; - box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35); - border: 1px solid rgba(255, 255, 255, 0.08); - display: grid; - gap: 12px; -} -.unknown-tags-header, .unknown-status-header { - display: flex; - align-items: center; - justify-content: space-between; -} -.unknown-tags-title, .unknown-status-title { - font-size: 1.1rem; - font-weight: var(--font-weight-semibold); - color: var(--text-cyan); -} -.unknown-tags-subtitle, .unknown-status-subtitle { - font-size: 0.85rem; - color: #94a3b8; -} -.unknown-tags-list, .unknown-status-list { - background: rgba(15, 23, 42, 0.45); - border: 1px solid rgba(148, 163, 184, 0.3); - border-radius: 12px; - padding: 12px; - display: grid; - gap: 10px; - overflow: auto; - min-height: 200px; - max-height: 45vh; -} -.unknown-tags-row, .unknown-status-row { - display: grid; - gap: 8px; - grid-template-columns: 1fr; - padding: 8px; - border-radius: 10px; - background: rgba(15, 23, 42, 0.7); - border: 1px solid rgba(148, 163, 184, 0.2); -} -.unknown-row-title { - font-size: 0.85rem; - color: #e2e8f0; -} -.unknown-tags-row select, .unknown-status-row select { - width: 100%; - padding: 8px 10px; - border-radius: 10px; - background: rgba(15, 23, 42, 0.9); - color: #e2e8f0; - border: 1px solid rgba(148, 163, 184, 0.35); -} -.unknown-tags-actions, .unknown-status-actions { - display: flex; - justify-content: flex-end; - gap: 10px; -} -.unknown-tags-close, .unknown-status-close, .unknown-tags-save, .unknown-tags-skip, .unknown-status-save, .unknown-status-skip { - border-radius: 10px; - padding: 8px 12px; - font-weight: var(--font-weight-semibold); - background: rgba(15, 23, 42, 0.8); - color: #e2e8f0; - border: 1px solid rgba(148, 163, 184, 0.3); -} -.priority-1 { - background-color: color-mix(in srgb, oklch(63.7% 0.237 25.331) 10%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-red-500) 10%, transparent); - } - color: var(--color-red-300); -} -.priority-2 { - background-color: color-mix(in srgb, oklch(70.5% 0.213 47.604) 10%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-orange-500) 10%, transparent); - } - color: var(--color-orange-300); -} -.priority-3 { - background-color: color-mix(in srgb, oklch(85.2% 0.199 91.936) 10%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-yellow-400) 10%, transparent); - } - color: var(--color-yellow-200); -} -.priority-4 { - background-color: color-mix(in srgb, oklch(72.3% 0.219 149.579) 10%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-green-500) 10%, transparent); - } - color: var(--color-green-300); -} -.priority-5 { - background-color: color-mix(in srgb, oklch(54.6% 0.245 262.881) 20%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-blue-600) 20%, transparent); - } - color: var(--color-blue-300); -} -.menu-container { - position: relative; - display: inline-block; -} -.menu-btn { - background: none; - border: none; - cursor: pointer; - font-size: 18px; - padding: 4px; -} -.menu-popup { - position: absolute; - right: 0; - top: 25px; - background: var(--bg-secondary); - border: 1px solid #ccc; - border-radius: 6px; - padding: 5px 0; - min-width: 120px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); - z-index: 20; -} -.menu-item { - padding: 8px 12px; - cursor: pointer; -} -.menu-item:hover { - background: #100a0a; -} -.help-btn { - cursor: pointer; - font-size: 14px; - margin-left: 6px; -} -.help-btn:hover { - background-color: transparent; -} -.help-popup { - position: absolute; - background: #fffdf5; - border: 1px solid #ccc; - border-radius: 8px; - padding: 10px 14px; - max-width: 240px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); - z-index: 50; -} #mobile-editor-overlay { position: fixed; top: 0; @@ -1493,6 +1450,11 @@ td form button { syntax: "*"; inherits: false; } +@property --tw-space-x-reverse { + syntax: "*"; + inherits: false; + initial-value: 0; +} @property --tw-border-style { syntax: "*"; inherits: false; @@ -1502,6 +1464,10 @@ td form button { syntax: "*"; inherits: false; } +@property --tw-tracking { + syntax: "*"; + inherits: false; +} @property --tw-shadow { syntax: "*"; inherits: false; @@ -1567,6 +1533,11 @@ td form button { inherits: false; initial-value: 0 0 #0000; } +@property --tw-outline-style { + syntax: "*"; + inherits: false; + initial-value: solid; +} @property --tw-blur { syntax: "*"; inherits: false; @@ -1656,6 +1627,25 @@ td form button { syntax: "*"; inherits: false; } +@property --tw-ease { + syntax: "*"; + inherits: false; +} +@property --tw-scale-x { + syntax: "*"; + inherits: false; + initial-value: 1; +} +@property --tw-scale-y { + syntax: "*"; + inherits: false; + initial-value: 1; +} +@property --tw-scale-z { + syntax: "*"; + inherits: false; + initial-value: 1; +} @property --tw-translate-x { syntax: "*"; inherits: false; @@ -1679,8 +1669,10 @@ td form button { --tw-rotate-z: initial; --tw-skew-x: initial; --tw-skew-y: initial; + --tw-space-x-reverse: 0; --tw-border-style: solid; --tw-font-weight: initial; + --tw-tracking: initial; --tw-shadow: 0 0 #0000; --tw-shadow-color: initial; --tw-shadow-alpha: 100%; @@ -1695,6 +1687,7 @@ td form button { --tw-ring-offset-width: 0px; --tw-ring-offset-color: #fff; --tw-ring-offset-shadow: 0 0 #0000; + --tw-outline-style: solid; --tw-blur: initial; --tw-brightness: initial; --tw-contrast: initial; @@ -1717,6 +1710,10 @@ td form button { --tw-backdrop-opacity: initial; --tw-backdrop-saturate: initial; --tw-backdrop-sepia: initial; + --tw-ease: initial; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-scale-z: 1; --tw-translate-x: 0; --tw-translate-y: 0; --tw-translate-z: 0; diff --git a/src/safe_family/static/js/goals.js b/src/safe_family/static/js/goals.js deleted file mode 100644 index 22b4b70..0000000 --- a/src/safe_family/static/js/goals.js +++ /dev/null @@ -1,404 +0,0 @@ - -const element = document.getElementById("bridge-element"); -const selected_user_row_id = element.dataset.value; // Access data-value -const selected_user_name = element.dataset.user; -const selected_user_role = element.dataset.role; - -function attachHelperPopups() { - document.querySelectorAll(".help-btn").forEach(btn => { - btn.addEventListener("click", (e) => { - e.stopPropagation(); - const popup = btn.nextElementSibling; - popup.classList.toggle("hidden"); - }); - btn.addEventListener("mouseenter", () => { - btn.nextElementSibling.classList.remove("hidden"); - }); - btn.addEventListener("mouseleave", () => { - btn.nextElementSibling.classList.add("hidden"); - }); - }); - - document.addEventListener("click", () => { - document.querySelectorAll(".help-popup").forEach(p => p.classList.add("hidden")); - }); -} - -const longtermList = document.getElementById("longterm-list"); -if (longtermList) { - new Sortable(longtermList, { - animation: 150, - delay: 400, - handle: ".drag-handle", - onEnd: function () { - saveNewOrder(); - refreshTaskPos(); - refreshTaskColors(); - } - }); -} - -function saveNewOrder() { - let ids = [...document.querySelectorAll("#longterm-list li")] - .map(li => li.dataset.id); - - fetch("/todo/long_term_reorder", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ order: ids }) - }); -} - -const PRIORITY_LABELS = { - 1: "High", - 2: "Medium-High", - 3: "Medium", - 4: "Medium-Low", - 5: "Low" -}; - -/** - * Map any priority range 1..maxValue into a 1..5 scale. - * 1 = highest priority - * 5 = lowest priority - */ -function mapPriorityToFiveLevels(priority, maxValue) { - if (maxValue <= 1) return 1; // only one level - - // Linear mapping to range [1..5] - const normalized = (priority - 1) / (maxValue - 1); // range 0..1 - const scaled = Math.round(normalized * 4) + 1; // range 1..5 - - return Math.min(5, Math.max(1, scaled)); // clamp safety -} - -function refreshTaskPos() { - const items = document.querySelectorAll("#longterm-list li select option[selected]"); - - items.forEach((el, index) => { - const pos = index + 1; // new position - - // Re-apply based on new pos - el.value = pos; - }); -} - -function refreshTaskColors() { - const items = document.querySelectorAll("#longterm-list li input[type='text']"); - color_length = items.length; - - items.forEach((el, index) => { - const pos = index + 1; // new position - - // Remove previous color classes - el.classList.remove("priority-1", "priority-2", "priority-3", "priority-4", "priority-5"); - // el.style.background = "#FFFFFF"; - // el.style.color = "#e6ffed"; - - // Re-apply based on new pos - level = mapPriorityToFiveLevels(pos, color_length) - el.classList.add(`priority-${level}`); - }); -} - -function renderShortDatePicker(container, pgTimestamp, options = {}, goal_id) { - options = Object.assign({ - format: 'MMM DDD', - locale: 'en', - emptyText: 'DUE' - }, options); - - const fakeSpan = document.createElement('span'); - fakeSpan.style.cssText = ` - cursor: pointer; - padding: 6px 12px; - border-radius: 6px; - font-size: 14px; - display: inline-block; - min-width: 60px; - text-align: center; - user-select: none; - `; - - container.dataset.goal_id = goal_id; - - let currentDate = null; - if (pgTimestamp) { - currentDate = new Date(pgTimestamp); - if (isNaN(currentDate)) currentDate = null; - } - - function updateDisplay() { - if (currentDate) { - const formatted = new Intl.DateTimeFormat(options.locale === 'en' ? 'en-US' : 'zh-CN', { - month: 'short', - day: 'numeric' - }).format(currentDate); - // console.log("formatted data ", formatted) - fakeSpan.textContent = options.format - .replace('MMM', formatted.split(' ')[0]) - .replace('DDD', formatted.split(' ')[1] || formatted.split(' ')[0]); - } else { - fakeSpan.textContent = options.emptyText; - } - // console.log("fakeSpan ", fakeSpan); - } - updateDisplay(); - - const fp = flatpickr(fakeSpan, { - dateFormat: "Y-m-d", - defaultDate: currentDate, - locale: options.locale === 'zh' ? "zh" : "default", - clickOpens: true, - allowInput: false, - onChange: function (selectedDates, dateStr) { - currentDate = selectedDates[0] || null; - updateDisplay(); - container.dataset.due = currentDate.toISOString().split('T')[0] + 'T12:00:00' - - if (container.dataset.goal_id) { - updateDueDateToServer(container.dataset.goal_id, container.dataset.due); - } - } - }); - - function updateDueDateToServer(goalId, dateStr) { - fetch(`/todo/longterm_update_due/${goalId}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ due_date: dateStr }) - }); - } - - container.innerHTML = ''; - container.appendChild(fakeSpan); - - return { - getValue: () => currentDate ? fp.formatDate(currentDate, "Y-m-d") : null, - setValue: (dateStr) => fp.setDate(dateStr, true) - }; -} - -async function loadLongTerm() { - // Only load if the container exists - if (!document.getElementById("longterm-list")) return; - - let r = await fetch("/todo/long_term_list/" + selected_user_row_id, { - headers: { "Authorization": "Bearer " + localStorage.getItem("access_token") } - }); - let tasks = await r.json(); - - let container = document.getElementById("longterm-list"); - container.innerHTML = ""; - - color_length = tasks.length; - - tasks.forEach(t => { - let li = document.createElement("li"); - - li.addEventListener("keydown", function (e) { - if (e.key === 'Enter') { - e.preventDefault(); - updateLongTerm(t.goal_id, color_length); - } - }); - - li.setAttribute("data-id", t.goal_id); - - li.innerHTML = ` -
-
- - - - - -
-
- - - - ${formatSeconds(t.time_spent)} - - - - -
-
- `; - - li.querySelectorAll('.short-date').forEach(el => { - renderShortDatePicker(el, el.dataset.due, { - format: 'MMM DDD', - locale: 'en', - emptyText: '-' - }, t.goal_id); - }); - - li.querySelectorAll('.timer-btn').forEach(el => { - startGoalTracking(el, t.is_tracking, t.goal_id); - }); - - // Toggle popup - li.querySelectorAll(".menu-btn").forEach(btn => { - btn.addEventListener("click", (e) => { - e.stopPropagation(); - const popup = btn.nextElementSibling; - popup.classList.toggle("hidden"); - }); - }); - - // Delete item - li.querySelectorAll(".delete-item").forEach(item => { - item.addEventListener("click", handleDeleteGoal); - }); - - // Close popups when clicking outside - document.addEventListener("click", () => { - document.querySelectorAll(".menu-popup").forEach(p => p.classList.add("hidden")); - }); - - container.appendChild(li); - }); -} - -async function updateLongTerm(goal_id, color_length) { - let body = { - goal_id, - color_length, - task: document.getElementById("task_" + goal_id).value, - priority: document.getElementById("prio_" + goal_id).value, - completed: document.querySelector(`#longterm-list input[type='checkbox'][onchange='updateLongTerm(${goal_id}, ${color_length})']`).checked, - }; - - await fetch("/todo/long_term_update", { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": "Bearer " + localStorage.getItem("access_token") - }, - body: JSON.stringify(body) - }); - - loadLongTerm(); -} - -const addLongTermForm = document.getElementById("add-longterm-form"); -if (addLongTermForm) { - addLongTermForm.addEventListener("submit", async e => { - e.preventDefault(); - const pattern = /^([^\[\]/]+\[[^\[\]]+\])(\s*\/\s*[^\[\]/]+\[[^\[\]]+\])*$/; // matches: main[sub] - input = document.getElementById("longterm-task-text") - if (input.value.trim() !== "" && !pattern.test(input.value.trim())) { - alert(`❌ Invalid format: "${input.value}". Use "Object[Problem]" format.`); - input.focus(); - return; - } else if (input.value.trim() === "") { - alert(`❌ Can't be NULL: "${input.value}". Use "Object[Problem]" format.`); - input.focus(); - return; - } - - await fetch("/todo/long_term_add", { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": "Bearer " + localStorage.getItem("access_token") - }, - body: JSON.stringify({ - user_id: selected_user_row_id, - task: document.getElementById("longterm-task-text").value, - priority: document.getElementById("longterm-priority").value - }) - }); - - document.getElementById("longterm-task-text").value = ""; - loadLongTerm(); - }); -} - -function formatSeconds(seconds) { - const h = Math.floor(seconds / 3600); - const m = Math.floor((seconds % 3600) / 60); - return `${h}h ${m}m`; -} - -function startGoalTracking(btn, is_tracking, goal_id) { - btn.dataset.goal_id = goal_id; - btn.dataset.is_tracking = String(is_tracking); - btn.addEventListener("click", () => { - const isTracking = btn.dataset.is_tracking === "true"; - // console.log("Before:", btn.dataset.is_tracking) - // update DB - const url = isTracking - ? `/todo/longterm_stop/${goal_id}` - : `/todo/longterm_start/${goal_id}`; - - fetch(url, { method: "POST" }) - .then(r => r.json()) - .then(data => { - // console.log("DB updated", data); - if (data.time_spent !== undefined) { - const timeSpan = document.querySelector(`#time-${goal_id}`); - timeSpan.textContent = formatSeconds(data.time_spent); - } - }); - // update UI - btn.dataset.is_tracking = String(!isTracking); - // console.log("After:", btn.dataset.is_tracking) - if (btn.dataset.is_tracking === "true") { - btn.classList.remove("start"); - btn.classList.remove("text-green-300"); - btn.classList.add("stop"); - btn.classList.add("text-purple-300"); - btn.innerText = "⏸ Pause"; - } else { - btn.classList.remove("stop"); - btn.classList.remove("text-purple-300"); - btn.classList.add("start"); - btn.classList.add("text-green-300"); - btn.innerText = "▶ Start"; - } - }); -} - -function handleDeleteGoal(e) { - const goalId = e.target.dataset.goalId; - - fetch(`/todo/longterm_delete/${goalId}`, { method: "POST" }) - .then(r => r.json()) - .then(data => { - if (data.success) { - const li = document.querySelector(`li[data-id="${goalId}"]`); - if (li) li.remove(); - } - }) - .catch(err => console.error("Delete error:", err)); -} - -loadLongTerm(); -attachHelperPopups(); diff --git a/src/safe_family/static/js/todo.js b/src/safe_family/static/js/todo.js index 46f04c7..40c705f 100644 --- a/src/safe_family/static/js/todo.js +++ b/src/safe_family/static/js/todo.js @@ -3,32 +3,6 @@ const selected_user_row_id = element.dataset.value; // Access data-value const selected_user_name = element.dataset.user; const selected_user_role = element.dataset.role; -const notifyCurrentTaskBtn = document.querySelector(".notify-current-task-btn"); -if (notifyCurrentTaskBtn) { - notifyCurrentTaskBtn.addEventListener("click", () => { - notifyCurrentTaskBtn.disabled = true; - const payload = selected_user_name ? { username: selected_user_name } : {}; - fetch("/todo/notify_current_task", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }) - .then(r => r.json()) - .then(data => { - if (!data.success) { - alert(data.error || "Notify failed."); - } - }) - .catch(err => { - console.error("Notify failed:", err); - alert("Notify failed."); - }) - .finally(() => { - notifyCurrentTaskBtn.disabled = false; - }); - }); -} - const todoForm = document.getElementById("todoForm"); if (todoForm) { todoForm.addEventListener("submit", function (e) { @@ -220,24 +194,8 @@ function setupTaskFeedbackModal() { const laterButton = modal.querySelector(".feedback-later"); const queue = []; const queuedIds = new Set(); - const serverNotifiedIds = new Set(); let activeItem = null; - function sendTaskFeedbackServerAlert(item) { - if (serverNotifiedIds.has(item.id)) return; - fetch("/todo/notify_feedback", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - time_slot: item.timeSlot, - task: item.task || "", - }), - }).catch(err => { - console.warn("Server alert failed:", err); - }); - serverNotifiedIds.add(item.id); - } - function collectOverdueTasks() { const now = new Date(); document.querySelectorAll(".todo-row").forEach(row => { @@ -263,7 +221,6 @@ function setupTaskFeedbackModal() { titleName.textContent = item.task ? `- ${item.task}` : ""; modal.classList.add("active"); modal.setAttribute("aria-hidden", "false"); - sendTaskFeedbackServerAlert(item); } function closeModal() { @@ -373,304 +330,7 @@ function setupTaskFeedbackModal() { }, 60000); } -function setupWeeklySummaryPopup() { - const modal = document.getElementById("weekly-summary-modal"); - if (!modal) return; - - const contentEl = modal.querySelector(".weekly-summary-content"); - const subtitleEl = modal.querySelector(".weekly-summary-subtitle"); - const closeButtons = modal.querySelectorAll(".weekly-summary-close, .weekly-summary-close-btn"); - const bridge = document.getElementById("bridge-element"); - const selectedUser = bridge ? bridge.dataset.user : ""; - - function pad2(value) { - return String(value).padStart(2, "0"); - } - - function localDateKey(date) { - return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`; - } - - - function closeModal() { - modal.classList.remove("active"); - modal.setAttribute("aria-hidden", "true"); - } - - function openModal() { - modal.classList.add("active"); - modal.setAttribute("aria-hidden", "false"); - } - - closeButtons.forEach(button => { - button.addEventListener("click", closeModal); - }); - - modal.addEventListener("click", event => { - if (event.target === modal) { - closeModal(); - } - }); - - function fetchSummary() { - const params = new URLSearchParams(); - if (selectedUser) { - params.set("user", selectedUser); - } - const url = params.toString() - ? `/todo/weekly_summary?${params.toString()}` - : "/todo/weekly_summary"; - - fetch(url) - .then(response => response.json()) - .then(data => { - if (!data.success) { - throw new Error(data.error || "Summary unavailable"); - } - subtitleEl.textContent = data.week ? `Week ${data.week}` : "Weekly summary"; - contentEl.textContent = data.summary || "No summary data available."; - openModal(); - localStorage.setItem("weeklySummaryShown", localDateKey(new Date())); - }) - .catch(err => { - console.warn("Weekly summary failed:", err); - subtitleEl.textContent = "Weekly summary"; - contentEl.textContent = "Summary unavailable right now."; - openModal(); - localStorage.setItem("weeklySummaryShown", localDateKey(new Date())); - }) - .finally(() => { - scheduleNext(); - }); - } - - function scheduleNext() { - const now = new Date(); - const target = new Date(now); - target.setHours(15, 30, 0, 0); - if (now >= target) { - target.setDate(target.getDate() + 1); - } - const delay = Math.max(target - now, 0); - window.setTimeout(fetchSummary, delay); - } - - const now = new Date(); - const todayKey = localDateKey(now); - const target = new Date(now); - target.setHours(15, 30, 0, 0); - if (now >= target && localStorage.getItem("weeklySummaryShown") !== todayKey) { - fetchSummary(); - return; - } - scheduleNext(); -} - -function setupUnknownMetadataFlow() { - const tagsModal = document.getElementById("unknown-tags-modal"); - const statusModal = document.getElementById("unknown-status-modal"); - if (!tagsModal || !statusModal) return; - - const tagsList = tagsModal.querySelector(".unknown-tags-list"); - const statusList = statusModal.querySelector(".unknown-status-list"); - const tagsSave = tagsModal.querySelector(".unknown-tags-save"); - const tagsSkip = tagsModal.querySelector(".unknown-tags-skip"); - const tagsClose = tagsModal.querySelector(".unknown-tags-close"); - const statusSave = statusModal.querySelector(".unknown-status-save"); - const statusSkip = statusModal.querySelector(".unknown-status-skip"); - const statusClose = statusModal.querySelector(".unknown-status-close"); - const bridge = document.getElementById("bridge-element"); - const selectedUser = bridge ? bridge.dataset.user : ""; - let pendingStatusItems = []; - - const tagOptions = ["math", "science", "language", "coding", "leasure", "piano", "unknown"]; - const statusOptions = ["skipped", "partially done", "half done", "mostly done", "done"]; - - function setModalState(modal, isOpen) { - if (isOpen) { - modal.classList.add("active"); - modal.setAttribute("aria-hidden", "false"); - } else { - modal.classList.remove("active"); - modal.setAttribute("aria-hidden", "true"); - } - } - - function renderTags(items) { - tagsList.innerHTML = ""; - items.forEach(item => { - const row = document.createElement("div"); - row.className = "unknown-tags-row"; - row.dataset.todoId = item.id; - const title = document.createElement("div"); - title.className = "unknown-row-title"; - title.textContent = item.time_slot - ? `${item.time_slot} - ${item.task}` - : item.task; - const select = document.createElement("select"); - tagOptions.forEach(option => { - const opt = document.createElement("option"); - opt.value = option; - opt.textContent = option; - if (option === (item.tags || "unknown")) { - opt.selected = true; - } - select.appendChild(opt); - }); - row.appendChild(title); - row.appendChild(select); - tagsList.appendChild(row); - }); - } - - function renderStatus(items) { - statusList.innerHTML = ""; - items.forEach(item => { - const row = document.createElement("div"); - row.className = "unknown-status-row"; - row.dataset.todoId = item.id; - const title = document.createElement("div"); - title.className = "unknown-row-title"; - title.textContent = item.time_slot - ? `${item.time_slot} - ${item.task}` - : item.task; - const select = document.createElement("select"); - const placeholder = document.createElement("option"); - placeholder.value = ""; - placeholder.textContent = "Select status"; - placeholder.selected = true; - select.appendChild(placeholder); - statusOptions.forEach(option => { - const opt = document.createElement("option"); - opt.value = option; - opt.textContent = option; - select.appendChild(opt); - }); - row.appendChild(title); - row.appendChild(select); - statusList.appendChild(row); - }); - } - - function openStatusIfNeeded() { - if (!pendingStatusItems.length) return; - renderStatus(pendingStatusItems); - setModalState(statusModal, true); - } - - function closeTagsModal() { - setModalState(tagsModal, false); - } - - function closeStatusModal() { - setModalState(statusModal, false); - } - - [tagsClose, tagsSkip].forEach(btn => { - if (btn) { - btn.addEventListener("click", () => { - closeTagsModal(); - openStatusIfNeeded(); - }); - } - }); - - [statusClose, statusSkip].forEach(btn => { - if (btn) { - btn.addEventListener("click", closeStatusModal); - } - }); - - if (tagsSave) { - tagsSave.addEventListener("click", () => { - const updates = []; - tagsList.querySelectorAll(".unknown-tags-row").forEach(row => { - const todoId = row.dataset.todoId; - const select = row.querySelector("select"); - const tag = select ? select.value : ""; - if (todoId && tag && tag !== "unknown") { - updates.push( - fetch("/todo/update_tag", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ id: todoId, tag }), - }).catch(err => { - console.warn("Tag update failed:", err); - }) - ); - } - }); - Promise.all(updates).finally(() => { - closeTagsModal(); - openStatusIfNeeded(); - }); - }); - } - - if (statusSave) { - statusSave.addEventListener("click", () => { - const updates = []; - statusList.querySelectorAll(".unknown-status-row").forEach(row => { - const todoId = row.dataset.todoId; - const select = row.querySelector("select"); - const status = select ? select.value : ""; - if (todoId && status) { - updates.push( - fetch("/todo/mark_status", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ id: todoId, status }), - }).catch(err => { - console.warn("Status update failed:", err); - }) - ); - } - }); - Promise.all(updates).finally(() => { - closeStatusModal(); - }); - }); - } - - function fetchUnknownMetadata() { - const params = new URLSearchParams(); - if (selectedUser) { - params.set("user", selectedUser); - } - const url = params.toString() - ? `/todo/unknown_metadata?${params.toString()}` - : "/todo/unknown_metadata"; - fetch(url) - .then(response => response.json()) - .then(data => { - if (!data.success) { - throw new Error(data.error || "Unknown metadata unavailable"); - } - const tags = data.unknown_tags || []; - const status = data.unknown_status || []; - pendingStatusItems = status; - if (tags.length) { - renderTags(tags); - setModalState(tagsModal, true); - return; - } - if (status.length) { - openStatusIfNeeded(); - } - }) - .catch(err => { - console.warn("Unknown metadata failed:", err); - }); - } - - fetchUnknownMetadata(); -} - setupTaskFeedbackModal(); -setupWeeklySummaryPopup(); -if (shouldRunUnknownMetadataFlow()) { - setupUnknownMetadataFlow(); -} setupAdminStatusDropdowns(); setupScheduleConfig(); setupTimeOptions(); @@ -681,12 +341,6 @@ setInterval(updateNowLine, 60000); updateNextMissionEta(); setInterval(updateNextMissionEta, 60000); -function shouldRunUnknownMetadataFlow(now = new Date()) { - if (selected_user_role !== "admin") return false; - if (now.getDay() !== 0) return false; - return now.getHours() >= 16; -} - function getSlotDurationMinutes(timeSlot) { const startTime = parseSlotStartTime(timeSlot); const endTime = parseSlotEndTime(timeSlot); diff --git a/src/safe_family/templates/README.md b/src/safe_family/templates/README.md index d9c877b..f1ff625 100644 --- a/src/safe_family/templates/README.md +++ b/src/safe_family/templates/README.md @@ -21,7 +21,7 @@ templates_redesign/ `src/safe_family/templates/`. 3. No Python changes needed — every Flask `url_for()` endpoint, form action, hidden input, and class/ID your JS depends on is preserved (`complete-checkbox`, - `admin-status-select`, `time-slot-cell` with `--slot-progress`, `bridge-element`, + `admin-status-select`, `bridge-element`, all the `*-modal` IDs, etc.). 4. Tailwind: your existing `styles.css` is still loaded, so anything else that uses utility classes keeps working unchanged. You do **not** need to rerun diff --git a/src/safe_family/templates/base.html b/src/safe_family/templates/base.html index 9e42c55..97d0b8a 100644 --- a/src/safe_family/templates/base.html +++ b/src/safe_family/templates/base.html @@ -110,7 +110,6 @@ code, pre, .sf-mono, - .time-slot-cell, .sf-rainbow-axis, .sf-eyebrow { font-family: var(--sf-mono) !important; @@ -534,15 +533,12 @@ {% if session.access_token %} Today - Goals Notes Timeline MGMT - Game Sheet Logout {% else %} diff --git a/src/safe_family/templates/index.html b/src/safe_family/templates/index.html deleted file mode 100644 index e69de29..0000000 diff --git a/src/safe_family/templates/miscellaneous/ninja_nightmare.html b/src/safe_family/templates/miscellaneous/ninja_nightmare.html deleted file mode 100644 index 0313f94..0000000 --- a/src/safe_family/templates/miscellaneous/ninja_nightmare.html +++ /dev/null @@ -1,3590 +0,0 @@ - - - - - - - Ninja's Nightmare: Cosmic Defense - - - - - -
- - - -
-
-
HEALTH -
-
-
DASH -
-
-
ATTACK CD -
-
-
USING ITEM -
-
Ammo: 10
-
Medkits: 1
-
Build: - Wall (F)
-
- F: Wall | G: Spike | C: Laser
V: Rocket | B: All-Seeing -
-
-
0
-
Kills: 0
-
Time: 0:00
-
-
Need More Score!
- - -
-

Ninja's Nightmare

-
Made by McGee Zhang
- - - - - - - -
- - - - -
- -

INDEX

-
- - - - - -
-

Learn - the world.

-
-
- - -
- -
- -

PREPARE FOR BATTLE

-
Gold: 0
- -
-
CAMPAIGN
-
TOWER DEFENSE
-
ENDLESS (Locked)
-
- -
-
Primary Weapon
-
-
-
-
Utility
-
-
-
-
Cosmetics
-
-
-
-
Building (Campaign/Endless)
-
-
- - -
-
- -
-

SETTINGS

- - - - -
- -
-

PAUSED

- - -
- -
-

VICTORY!

-

Score: 0

-

Kills: 0

-

Time: 0:00

-

Shots: 0 · Buildings: 0 · Boulders: 0 · - Indirect Kills: 0

-

Gold Earned: 0

-

Total Gold: 0

-

Endless Mode Unlocked!

- -
- -
-

GAME OVER

-

Score: 0

-

Kills: 0

-

Time: 0:00

-

Shots: 0 · Buildings: 0 · Boulders: 0 - · Indirect Kills: 0

-

Gold Earned: 0

-

Total Gold: 0

- -
- - - - - - \ No newline at end of file diff --git a/src/safe_family/templates/todo/goals.html b/src/safe_family/templates/todo/goals.html deleted file mode 100644 index 789eaf9..0000000 --- a/src/safe_family/templates/todo/goals.html +++ /dev/null @@ -1,71 +0,0 @@ -{% extends "base.html" %} -{% block content %} - -
-
-
Mid-Term & Long-Term Goals for {{ selected_user }}
-
- -
- -
-
-
-
Track tasks more than 3 days to finish. You can long press to drag to sort the long - term goals. Toggle checkbox to finished tasks. Finished tasks will automatically dispear after 3 - days. -
-
-
- -
-
-
- -
- - - -
-
-
- -
-
-
- ☑️ - -
-
Task Name
-
- Due - Track - Actions -
-
-
-
    -
    -
    -
    -
    - - -
    -{% endblock %} diff --git a/src/safe_family/templates/todo/todo.html b/src/safe_family/templates/todo/todo.html index f3695f9..8f6c1e8 100644 --- a/src/safe_family/templates/todo/todo.html +++ b/src/safe_family/templates/todo/todo.html @@ -389,17 +389,6 @@ color: var(--sf-accent); } - .notify-current-task-btn { - background: var(--sf-accent-soft); - color: var(--sf-accent); - border: 0; - padding: 4px 8px; - border-radius: 5px; - font-size: 11px; - font-weight: 600; - cursor: pointer; - } - .sf-drawer-toggle-btn { background: var(--sf-surface-2); color: var(--sf-ink); @@ -536,10 +525,7 @@ } /* modals (keep IDs/classes for JS) */ - .task-feedback-modal, - .weekly-summary-modal, - .unknown-tags-modal, - .unknown-status-modal { + .task-feedback-modal { position: fixed; inset: 0; background: rgba(10, 14, 26, .72); @@ -549,17 +535,11 @@ z-index: 50; } - .task-feedback-modal[aria-hidden="false"], - .weekly-summary-modal[aria-hidden="false"], - .unknown-tags-modal[aria-hidden="false"], - .unknown-status-modal[aria-hidden="false"] { + .task-feedback-modal[aria-hidden="false"] { display: flex; } - .task-feedback-panel, - .weekly-summary-panel, - .unknown-tags-panel, - .unknown-status-panel { + .task-feedback-panel { background: var(--sf-surface); border: 1px solid var(--sf-border-strong); border-radius: var(--sf-radius); @@ -570,10 +550,7 @@ overflow: auto; } - .task-feedback-title, - .weekly-summary-title, - .unknown-tags-title, - .unknown-status-title { + .task-feedback-title { font-family: var(--sf-display); font-weight: 600; font-size: 18px; @@ -588,9 +565,7 @@ gap: 12px; } - .task-feedback-actions, - .unknown-tags-actions, - .unknown-status-actions { + .task-feedback-actions { display: flex; gap: 6px; flex-wrap: wrap; @@ -598,13 +573,7 @@ } .feedback-option, - .feedback-later, - .unknown-tags-save, - .unknown-tags-skip, - .unknown-status-save, - .unknown-status-skip, - .weekly-summary-close, - .weekly-summary-close-btn { + .feedback-later { background: var(--sf-surface-2); color: var(--sf-ink); border: 1px solid var(--sf-border-strong); @@ -627,16 +596,6 @@ margin-top: 8px; } - .weekly-summary-content { - background: var(--sf-surface-3); - padding: 12px; - border-radius: var(--sf-radius-sm); - font-family: var(--sf-mono); - font-size: 12px; - color: var(--sf-ink); - white-space: pre-wrap; - } - /* shared orange shade scale — used by the week strip fill and heatmap cells */ .sf-lvl[data-level="none"] { background: var(--sf-surface-3); @@ -965,10 +924,6 @@

    Hey {{ selected_user }} -- your quest is ready.

    - {% if today_tasks %} - - {% endif %}
    @@ -1269,54 +1224,6 @@

    Hey {{ selected_user }} -- your quest is ready.

    {% endif %} - - - - - - {# bridge element — kept for JS #}
    diff --git a/src/safe_family/todo/goals.py b/src/safe_family/todo/goals.py deleted file mode 100644 index 7274654..0000000 --- a/src/safe_family/todo/goals.py +++ /dev/null @@ -1,253 +0,0 @@ -"""Long-term goals management routes and logic.""" - -import logging -from datetime import datetime - -from flask import Blueprint, flash, jsonify, redirect, render_template, request - -from src.safe_family.core.auth import get_current_username, login_required -from src.safe_family.core.extensions import get_db_connection, local_tz -from src.safe_family.core.models import LongTermGoal - -logger = logging.getLogger(__name__) -goals_bp = Blueprint("goals", __name__) - - -@goals_bp.route("/goals", methods=["GET"]) -@login_required -def goals_page(): - """Render the long-term goals page.""" - user = get_current_username() - if user is None: - flash("Please log in to access the Goals page.", "warning") - return redirect("/auth/login-ui") - - username = user.username - role = user.role - - selected_user = username - if role == "admin" and request.args.get("view_user"): - selected_user = request.args.get("view_user") - - conn = get_db_connection() - cur = conn.cursor() - - # Get selected user ID - cur.execute("SELECT id FROM users WHERE username = %s", (selected_user,)) - selected_user_row = cur.fetchone() - if not selected_user_row: - flash("Target user not found", "warning") - selected_user = username - cur.execute("SELECT id FROM users WHERE username = %s", (selected_user,)) - selected_user_row = cur.fetchone() - - selected_user_id = selected_user_row[0] - cur.close() - conn.close() - - return render_template( - "todo/goals.html", - user_name=username, - role=role, - selected_user=selected_user, - selected_user_row_id=selected_user_id, - ) - - -# ────────────────────────────── -# GET all long-term tasks -# ────────────────────────────── -@goals_bp.get("/todo/long_term_list/") -@login_required -def get_long_term(selected_user_id: str): - """Get all long-term tasks for the selected user.""" - user_id = selected_user_id - conn = get_db_connection() - cur = conn.cursor() - - cur.execute( - """ - SELECT goal_id, task_text, priority, completed, to_char(due_date, 'YYYY-MM-DD"T"HH24:MI:SS') AS datetime_local, time_spent, is_tracking - FROM long_term_goals - WHERE user_id = %s - ORDER BY priority ASC, goal_id DESC - """, - (user_id,), - ) - rows = cur.fetchall() - if rows: - logger.info("Long-term tasks: %s", rows[0]) - conn.close() - - return jsonify( - [ - { - "goal_id": r[0], - "task": r[1], - "priority": r[2], - "completed": r[3], - "due_date": r[4], - "time_spent": r[5], - "is_tracking": r[6], - } - for r in rows - ], - ) - - -# ────────────────────────────── -# ADD long-term task -# ────────────────────────────── -@goals_bp.post("/todo/long_term_add") -@login_required -def add_long_term(): - """Add a new long-term task for the selected user.""" - data = request.get_json() - - conn = get_db_connection() - cur = conn.cursor() - - cur.execute( - """ - INSERT INTO long_term_goals (user_id, task_text, priority) - VALUES (%s, %s, %s) - """, - (data["user_id"], data["task"], data["priority"]), - ) - - conn.commit() - conn.close() - - return jsonify({"success": True}) - - -# ────────────────────────────── -# UPDATE completion or priority -# ────────────────────────────── -@goals_bp.post("/todo/long_term_update") -@login_required -def update_long_term_complete(): - """Update long-term task's completion status or priority.""" - data = request.get_json() - - conn = get_db_connection() - cur = conn.cursor() - completed_at = None - logger.debug("Updating long-term task: %s", data) - if data["completed"]: - completed_at = datetime.now(local_tz) - data["priority"] = data["color_length"] + 1 - cur.execute( - """ - UPDATE long_term_goals - SET task_text = %s, priority = %s, completed = %s, completed_at = %s - WHERE goal_id = %s - """, - ( - data["task"], - data["priority"], - data["completed"], - completed_at, - data["goal_id"], - ), - ) - - conn.commit() - conn.close() - - return jsonify({"success": True}) - - -@goals_bp.post("/todo/long_term_reorder") -@login_required -def reorder_long_term(): - """Reorder long-term tasks based on provided list of IDs.""" - data = request.get_json() - id_list = data["order"] - - conn = get_db_connection() - cur = conn.cursor() - - # priority 1 = top item - for index, goal_id in enumerate(id_list, start=1): - cur.execute( - "UPDATE long_term_goals SET priority = %s WHERE goal_id = %s", - (index, goal_id), - ) - - conn.commit() - conn.close() - - return jsonify({"status": "ok"}) - - -@goals_bp.post("/todo/longterm_start/") -@login_required -def start_goal_tracking(goal_id: int): - """Start goal tracking.""" - conn = get_db_connection() - cur = conn.cursor() - - cur.execute( - """ - UPDATE long_term_goals - SET is_tracking = TRUE, - tracking_start = NOW() - WHERE goal_id = %s AND is_tracking = FALSE - """, - (goal_id,), - ) - - conn.commit() - conn.close() - - return jsonify({"status": "started"}) - - -@goals_bp.post("/todo/longterm_stop/") -@login_required -def stop_goal_tracking(goal_id: int): - """Stop goal tracking.""" - goal = LongTermGoal.query.get(goal_id) - - if not goal.tracking_start: - return jsonify({"error": "not tracking"}), 400 - - goal = goal.stop_tracking() - - return jsonify( - { - "status": "stopped", - "time_spent": goal.time_spent, - }, - ) - - -@goals_bp.post("/todo/longterm_update_due/") -def update_due(goal_id: int): - """Update long-term task's due date.""" - due = request.json.get("due_date") - conn = get_db_connection() - cur = conn.cursor() - cur.execute( - """ - UPDATE long_term_goals - SET due_date = %s - WHERE goal_id = %s - """, - (due, goal_id), - ) - conn.commit() - conn.close() - return jsonify({"status": "ok"}) - - -@goals_bp.post("/todo/longterm_delete/") -def longterm_delete(goal_id: int): - """Delete goal.""" - goal = LongTermGoal.query.get(goal_id) - if goal is None: - return jsonify({"success": False, "error": "Not found"}), 404 - - goal.delete_goal() - return jsonify({"success": True}) diff --git a/src/safe_family/todo/todo.py b/src/safe_family/todo/todo.py index a2f7179..50d8abf 100644 --- a/src/safe_family/todo/todo.py +++ b/src/safe_family/todo/todo.py @@ -7,15 +7,12 @@ from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for -from src.safe_family.cli import gentags, weekly_diff, weekly_metrics +from src.safe_family.cli import weekly_metrics from src.safe_family.core.auth import get_current_username, login_required from src.safe_family.core.extensions import get_db_connection, local_tz from src.safe_family.notifications.notifier import ( send_discord_notification, - send_discord_summary, send_email_notification, - send_hammerspoon_alert, - send_hammerspoon_task, ) from src.safe_family.rules.scheduler import ( RULE_FUNCTIONS, @@ -693,289 +690,6 @@ def mark_todo_status(): return jsonify({"success": False, "error": str(e)}), 500 -@todo_bp.post("/todo/notify_feedback") -@login_required -def notify_feedback(): - """Send a local desktop alert when task feedback is due.""" - data = request.get_json() or {} - time_slot = (data.get("time_slot") or "").strip() - task = (data.get("task") or "").strip() - - if time_slot and task: - message = f"{time_slot} - {task}" - else: - message = time_slot or task or "Task feedback needed" - - send_hammerspoon_alert(message) - return jsonify({"success": True}) - - -@todo_bp.post("/todo/notify_current_task") -@login_required -def notify_current_task(): - """Send a local desktop alert for the current time slot task.""" - user = get_current_username() - if user is None: - return jsonify({"success": False, "error": "not logged in"}), 401 - - data = request.get_json() or {} - requested_user = (data.get("username") or "").strip() - target_username = user.username - if requested_user: - if user.role != "admin" and requested_user != user.username: - return jsonify({"success": False, "error": "forbidden"}), 403 - target_username = requested_user - - now = datetime.now(local_tz) - conn = get_db_connection() - cur = conn.cursor() - today_date = now.date() - cur.execute( - """ - SELECT time_slot, task - FROM todo_list - WHERE date = %s - AND username = %s - AND COALESCE(task, '') <> '' - ORDER BY time_slot - """, - (today_date, target_username), - ) - rows = cur.fetchall() - cur.close() - conn.close() - - current_task = None - for time_slot, task in rows: - try: - start_str, end_str = [t.strip() for t in time_slot.split("-")] - start_time = datetime.strptime(start_str, "%H:%M").time() - end_time = datetime.strptime(end_str, "%H:%M").time() - except (ValueError, AttributeError): - continue - - start_dt = now.replace( - hour=start_time.hour, - minute=start_time.minute, - second=0, - microsecond=0, - ) - end_dt = now.replace( - hour=end_time.hour, - minute=end_time.minute, - second=0, - microsecond=0, - ) - if end_dt <= start_dt: - end_dt += timedelta(days=1) - - if start_dt <= now < end_dt: - current_task = (time_slot, task) - break - - if not current_task: - return jsonify({"success": False, "error": "no current task"}), 404 - - time_slot, task_name = current_task - send_hammerspoon_task(target_username, task_name, time_slot) - return jsonify( - { - "success": True, - "username": target_username, - "time_slot": time_slot, - "task": task_name, - }, - ) - - -@todo_bp.get("/todo/weekly_summary") -@login_required -def weekly_summary(): - """Return a weekly summary for the current or selected user.""" - user = get_current_username() - if user is None: - return jsonify({"success": False, "error": "not logged in"}), 401 - - requested_user = (request.args.get("user") or "").strip() - username = user.username - if user.role == "admin" and requested_user: - username = requested_user - - gentags.main([]) - - today = datetime.now(local_tz).date() - current_iso = today.isocalendar() - current_week = f"{current_iso.year}-W{current_iso.week:02d}" - previous_date = today - timedelta(days=7) - previous_iso = previous_date.isocalendar() - previous_week = f"{previous_iso.year}-W{previous_iso.week:02d}" - - current_start, current_end = weekly_metrics._parse_iso_week(current_week) - previous_start, previous_end = weekly_metrics._parse_iso_week(previous_week) - - current_df = weekly_metrics._fetch_week_df(current_start, current_end, username) - previous_df = weekly_metrics._fetch_week_df(previous_start, previous_end, username) - current_metrics = weekly_metrics._compute_metrics( - current_df, - current_start, - current_end, - ) - previous_metrics = weekly_metrics._compute_metrics( - previous_df, - previous_start, - previous_end, - ) - - current_payload = weekly_diff.WeekMetrics( - week=current_week, - completion_rate=current_metrics.completion_rate, - avg_tasks_per_day=current_metrics.avg_tasks_per_day, - avg_planned_minutes=current_metrics.avg_planned_minutes, - by_category=current_metrics.by_category, - by_category_minutes=current_metrics.by_category_minutes, - ) - previous_payload = weekly_diff.WeekMetrics( - week=previous_week, - completion_rate=previous_metrics.completion_rate, - avg_tasks_per_day=previous_metrics.avg_tasks_per_day, - avg_planned_minutes=previous_metrics.avg_planned_minutes, - by_category=previous_metrics.by_category, - by_category_minutes=previous_metrics.by_category_minutes, - ) - - summary = weekly_diff._format_output(current_payload, previous_payload) - send_discord_summary(username, summary, current_week, previous_week) - return jsonify( - { - "success": True, - "summary": summary, - "week": current_week, - "previous_week": previous_week, - }, - ) - - -@todo_bp.get("/todo/unknown_metadata") -@login_required -def unknown_metadata(): - """Return tasks with unknown tags or missing completion status.""" - user = get_current_username() - if user is None: - return jsonify({"success": False, "error": "not logged in"}), 401 - - requested_user = (request.args.get("user") or "").strip() - username = user.username - if user.role == "admin" and requested_user: - username = requested_user - - gentags.main([]) - - conn = get_db_connection() - cur = conn.cursor() - today_date = datetime.now(local_tz).date() - cur.execute( - """ - SELECT id, time_slot, task, tags, date, completion_status - FROM todo_list - WHERE username = %s - AND date <= %s - AND (tags IS NULL OR tags = '' OR tags = 'unknown' OR completion_status IS NULL OR completion_status = '') - ORDER BY date DESC, time_slot - """, - (username, today_date), - ) - rows = cur.fetchall() - cur.close() - conn.close() - - today = today_date - now = datetime.now(local_tz) - unknown_tags = [] - unknown_status = [] - for todo_id, time_slot, task, tags, task_date, completion_status in rows: - tag_missing = ( - tags is None or not str(tags).strip() or str(tags).strip() == "unknown" - ) - status_missing = completion_status is None or not str(completion_status).strip() - if tag_missing and task_date < today: - unknown_tags.append( - { - "id": todo_id, - "time_slot": time_slot, - "task": task, - "tags": tags or "unknown", - }, - ) - - if status_missing: - include_status = task_date < today - if task_date == today and time_slot: - try: - _, end_str = [t.strip() for t in time_slot.split("-")] - end_time = datetime.strptime(end_str, "%H:%M").time() - end_dt = now.replace( - hour=end_time.hour, - minute=end_time.minute, - second=0, - microsecond=0, - ) - include_status = now >= end_dt - except (ValueError, AttributeError): - include_status = False - if include_status: - unknown_status.append( - { - "id": todo_id, - "time_slot": time_slot, - "task": task, - }, - ) - - return jsonify( - { - "success": True, - "unknown_tags": unknown_tags, - "unknown_status": unknown_status, - }, - ) - - -@todo_bp.post("/todo/update_tag") -@login_required -def update_tag(): - """Update task tags for a specific todo item.""" - user = get_current_username() - if user is None: - return jsonify({"success": False, "error": "not logged in"}), 401 - - data = request.get_json() or {} - todo_id = data.get("id") - tag = (data.get("tag") or "").strip().lower() - if not todo_id or not tag: - return jsonify({"success": False, "error": "missing data"}), 400 - - conn = get_db_connection() - cur = conn.cursor() - cur.execute("SELECT username FROM todo_list WHERE id = %s", (todo_id,)) - row = cur.fetchone() - if not row: - cur.close() - conn.close() - return jsonify({"success": False, "error": "not found"}), 404 - - task_owner = row[0] - if user.role != "admin" and task_owner != user.username: - cur.close() - conn.close() - return jsonify({"success": False, "error": "forbidden"}), 403 - - cur.execute("UPDATE todo_list SET tags = %s WHERE id = %s", (tag, todo_id)) - conn.commit() - cur.close() - conn.close() - return jsonify({"success": True}) - - @todo_bp.route("/exec_rules/", methods=["POST"]) @login_required def exec_rules(selected_user_id: str): diff --git a/src/safe_family/urls/miscellaneous.py b/src/safe_family/urls/miscellaneous.py index 3ad10ed..a9a9def 100644 --- a/src/safe_family/urls/miscellaneous.py +++ b/src/safe_family/urls/miscellaneous.py @@ -34,13 +34,3 @@ def store_sum_static(): This route renders the original static accounting page. """ return render_template("miscellaneous/store_sum.html") - - -@root_bp.route("/ninja") -@login_required -def ninja_nightmare(): - """Redirect to the store_sum page. - - This route redirects users to the store_sum page. - """ - return render_template("miscellaneous/ninja_nightmare.html") diff --git a/tests/test_cli_reports.py b/tests/test_cli_reports.py index f35b49f..d143769 100644 --- a/tests/test_cli_reports.py +++ b/tests/test_cli_reports.py @@ -1,12 +1,11 @@ -"""Tests for weekly metrics and diff CLIs.""" +"""Tests for weekly metrics CLI.""" -import json from pathlib import Path import pandas as pd import pytest -from src.safe_family.cli import weekly_diff, weekly_metrics +from src.safe_family.cli import weekly_metrics def test_parse_iso_week_invalid(): @@ -132,46 +131,3 @@ def test_weekly_metrics_main_writes_output_file(monkeypatch, tmp_path, capsys): assert code == 0 assert Path(captured.out.strip()).exists() - -def test_weekly_diff_main_missing_files(tmp_path, capsys): - missing_a = tmp_path / "missing-a.json" - missing_b = tmp_path / "missing-b.json" - code = weekly_diff.main(["--files", str(missing_a), str(missing_b)]) - captured = capsys.readouterr() - assert code == 1 - assert "Missing file" in captured.err - - -def test_weekly_diff_formats_output(): - current = weekly_diff.WeekMetrics( - week="2025-W01", - completion_rate=0.5, - avg_tasks_per_day=2.0, - avg_planned_minutes=30.0, - by_category={"math": 0.5}, - by_category_minutes={"math": {"planned_minutes": 60.0, "effective_minutes": 30.0}}, - ) - previous = weekly_diff.WeekMetrics( - week="2024-W52", - completion_rate=0.4, - avg_tasks_per_day=1.0, - avg_planned_minutes=20.0, - by_category={}, - by_category_minutes={}, - ) - output = weekly_diff._format_output(current, previous) - assert "Completion rate" in output - assert "math" in output - - -def test_weekly_diff_main_success(tmp_path, capsys): - file_a = tmp_path / "a.json" - file_b = tmp_path / "b.json" - file_a.write_text(json.dumps({"week": "2024-W52"}), encoding="utf-8") - file_b.write_text(json.dumps({"week": "2025-W01"}), encoding="utf-8") - - code = weekly_diff.main(["--files", str(file_a), str(file_b)]) - captured = capsys.readouterr() - - assert code == 0 - assert "WEEK:" in captured.out diff --git a/tests/test_goals_route.py b/tests/test_goals_route.py deleted file mode 100644 index 8e3bd47..0000000 --- a/tests/test_goals_route.py +++ /dev/null @@ -1,226 +0,0 @@ -"""Tests for the goals route.""" - -from datetime import datetime -from types import SimpleNamespace -from src.safe_family.core import auth -from src.safe_family.todo import goals - -class SeqCursor: - """Cursor that returns queued values for fetchone/fetchall.""" - - def __init__(self, fetchone_values=None, fetchall_values=None): - self.fetchone_values = list(fetchone_values or []) - self.fetchall_values = list(fetchall_values or []) - self.queries = [] - - def execute(self, sql, params=None): - self.queries.append((sql, params)) - - def fetchone(self): - return self.fetchone_values.pop(0) if self.fetchone_values else None - - def fetchall(self): - return self.fetchall_values.pop(0) if self.fetchall_values else [] - - def close(self): - return None - - -class SeqConnection: - """Connection wrapper for SeqCursor.""" - - def __init__(self, cursor): - self.cursor_obj = cursor - self.commits = 0 - self.closed = False - - def cursor(self): - return self.cursor_obj - - def commit(self): - self.commits += 1 - - def close(self): - self.closed = True - -def _login_session(client, monkeypatch): - monkeypatch.setattr(auth, "decode_token", lambda token: {"sub": "user"}) - with client.session_transaction() as sess: - sess["access_token"] = "token" - -def test_goals_page_renders(client, monkeypatch): - cursor = SeqCursor( - fetchone_values=[("u1",)], # for get user id - ) - conn = SeqConnection(cursor) - monkeypatch.setattr(goals, "get_db_connection", lambda: conn) - - monkeypatch.setattr( - goals, - "get_current_username", - lambda: SimpleNamespace(username="alice", role="user"), - ) - - captured_templates = [] - def _capture_render(template, **context): - captured_templates.append((template, context)) - return "ok" - - monkeypatch.setattr(goals, "render_template", _capture_render) - _login_session(client, monkeypatch) - - resp = client.get("/goals") - - assert resp.status_code == 200 - assert len(captured_templates) == 1 - template, context = captured_templates[0] - assert template == "todo/goals.html" - assert context["user_name"] == "alice" - assert context["selected_user"] == "alice" - assert context["selected_user_row_id"] == "u1" - -def test_goals_page_admin_view_user(client, monkeypatch): - cursor = SeqCursor( - fetchone_values=[("u2",)], # for get user id - ) - conn = SeqConnection(cursor) - monkeypatch.setattr(goals, "get_db_connection", lambda: conn) - - monkeypatch.setattr( - goals, - "get_current_username", - lambda: SimpleNamespace(username="admin", role="admin"), - ) - - captured_templates = [] - def _capture_render(template, **context): - captured_templates.append((template, context)) - return "ok" - - monkeypatch.setattr(goals, "render_template", _capture_render) - _login_session(client, monkeypatch) - - resp = client.get("/goals?view_user=bob") - - assert resp.status_code == 200 - assert len(captured_templates) == 1 - template, context = captured_templates[0] - assert context["user_name"] == "admin" - assert context["selected_user"] == "bob" - assert context["selected_user_row_id"] == "u2" - -def test_get_long_term_returns_rows(client, monkeypatch): - cursor = SeqCursor(fetchall_values=[[(1, "Task", 1, False, "2025-01-01T00:00:00", 0, False)]]) - conn = SeqConnection(cursor) - monkeypatch.setattr(goals, "get_db_connection", lambda: conn) - _login_session(client, monkeypatch) - - resp = client.get("/todo/long_term_list/u1") - - assert resp.status_code == 200 - payload = resp.get_json() - assert payload[0]["goal_id"] == 1 - - -def test_add_long_term_inserts(client, monkeypatch): - cursor = SeqCursor() - conn = SeqConnection(cursor) - monkeypatch.setattr(goals, "get_db_connection", lambda: conn) - _login_session(client, monkeypatch) - - resp = client.post( - "/todo/long_term_add", - json={"user_id": "u1", "task": "Task", "priority": 2}, - ) - - assert resp.status_code == 200 - assert any("INSERT INTO long_term_goals" in sql for sql, _ in cursor.queries) - - -def test_update_long_term_complete_updates(client, monkeypatch): - cursor = SeqCursor() - conn = SeqConnection(cursor) - monkeypatch.setattr(goals, "get_db_connection", lambda: conn) - _login_session(client, monkeypatch) - - resp = client.post( - "/todo/long_term_update", - json={"goal_id": 1, "task": "Task", "priority": 2, "completed": True, "color_length": 3}, - ) - - assert resp.status_code == 200 - assert any("UPDATE long_term_goals" in sql for sql, _ in cursor.queries) - - -def test_reorder_long_term_updates(client, monkeypatch): - cursor = SeqCursor() - conn = SeqConnection(cursor) - monkeypatch.setattr(goals, "get_db_connection", lambda: conn) - _login_session(client, monkeypatch) - - resp = client.post("/todo/long_term_reorder", json={"order": [2, 1]}) - - assert resp.status_code == 200 - assert len([sql for sql, _ in cursor.queries if "UPDATE long_term_goals" in sql]) == 2 - - -def test_start_goal_tracking_updates(client, monkeypatch): - cursor = SeqCursor() - conn = SeqConnection(cursor) - monkeypatch.setattr(goals, "get_db_connection", lambda: conn) - _login_session(client, monkeypatch) - - resp = client.post("/todo/longterm_start/1") - - assert resp.status_code == 200 - assert any("UPDATE long_term_goals" in sql for sql, _ in cursor.queries) - - -def test_update_due_updates(client, monkeypatch): - cursor = SeqCursor() - conn = SeqConnection(cursor) - monkeypatch.setattr(goals, "get_db_connection", lambda: conn) - - resp = client.post("/todo/longterm_update_due/1", json={"due_date": "2025-01-01"}) - - assert resp.status_code == 200 - assert any("UPDATE long_term_goals" in sql for sql, _ in cursor.queries) - - -def test_stop_goal_tracking_not_tracking(client, monkeypatch): - goal = SimpleNamespace(tracking_start=None) - monkeypatch.setattr(goals.LongTermGoal, "query", SimpleNamespace(get=lambda gid: goal)) - _login_session(client, monkeypatch) - - resp = client.post("/todo/longterm_stop/1") - - assert resp.status_code == 400 - - -def test_stop_goal_tracking_success(client, monkeypatch): - goal = SimpleNamespace(tracking_start=datetime.utcnow(), time_spent=120) - goal.stop_tracking = lambda: goal - monkeypatch.setattr(goals.LongTermGoal, "query", SimpleNamespace(get=lambda gid: goal)) - _login_session(client, monkeypatch) - - resp = client.post("/todo/longterm_stop/1") - - assert resp.status_code == 200 - assert resp.get_json()["time_spent"] == 120 - - -def test_longterm_delete_not_found(client, monkeypatch): - monkeypatch.setattr(goals.LongTermGoal, "query", SimpleNamespace(get=lambda gid: None)) - - resp = client.post("/todo/longterm_delete/1") - - assert resp.status_code == 404 - - -def test_longterm_delete_success(client, monkeypatch): - goal = SimpleNamespace(delete_goal=lambda: None) - monkeypatch.setattr(goals.LongTermGoal, "query", SimpleNamespace(get=lambda gid: goal)) - - resp = client.post("/todo/longterm_delete/1") - - assert resp.status_code == 200 \ No newline at end of file diff --git a/tests/test_models.py b/tests/test_models.py index 1193b50..02cec82 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,11 +1,8 @@ """Tests for core models.""" -from datetime import datetime, timedelta from types import SimpleNamespace from src.safe_family.core import models -from src.safe_family.core.extensions import db, local_tz -from src.safe_family.core.models import LongTermGoal, User class FakeSession: @@ -53,32 +50,3 @@ def test_token_blocklist_save(monkeypatch): assert fake_session.added[0] is token assert fake_session.commits == 1 - - -def test_long_term_goal_tracking(notesync_app): - with notesync_app.app_context(): - user = User(id="u-goal", username="alice", email="a@example.com") - user.set_password("secret") - db.session.add(user) - db.session.commit() - - started = datetime.now(local_tz) - timedelta(minutes=10) - goal = LongTermGoal( - user_id=user.id, - task_text="Learn", - priority=1, - is_tracking=True, - tracking_start=started.replace(tzinfo=None), - ) - db.session.add(goal) - db.session.commit() - - updated = goal.stop_tracking() - assert updated.is_tracking is False - assert updated.time_spent > 0 - - updated.add_time_spent(updated.goal_id, 5) - assert updated.time_spent >= 5 * 60 - - updated.delete_goal() - assert LongTermGoal.query.get(updated.goal_id) is None diff --git a/tests/test_notifier.py b/tests/test_notifier.py index 6dbad0f..3ab70c4 100644 --- a/tests/test_notifier.py +++ b/tests/test_notifier.py @@ -36,20 +36,12 @@ def test_send_discord_notification_posts(monkeypatch, patch_requests): assert patch_requests[0].args[0] == "http://example.com" -def test_send_discord_summary_posts(monkeypatch, patch_requests): - monkeypatch.setattr( - notifier.settings, - "DISCORD_WEBHOOK_URL", - "http://example.com", - ) - notifier.send_discord_summary("alice", "summary", "2025-W01", "2024-W52") - assert patch_requests - assert patch_requests[0].args[0] == "http://example.com" - - -def test_send_discord_summary_skips_when_disabled(monkeypatch, patch_requests): +def test_send_discord_notification_skips_when_disabled(monkeypatch, patch_requests): monkeypatch.setattr(notifier.settings, "DISCORD_WEBHOOK_URL", "") - notifier.send_discord_summary("alice", "summary", "2025-W01", "2024-W52") + notifier.send_discord_notification( + "bob", + [{"time_slot": "09:00 - 10:00", "task": "Study"}], + ) assert patch_requests == [] @@ -61,12 +53,28 @@ def test_send_hammerspoon_alert_posts(monkeypatch, patch_requests): assert patch_requests[0].args[0] == "http://localhost:9181/alert" -def test_send_hammerspoon_task_posts(monkeypatch, patch_requests): - monkeypatch.setattr(notifier.settings, "HAMMERSPOON_TASK_URL", "http://localhost:9181/task") +def test_send_hammerspoon_alert_skips_without_url(monkeypatch, patch_requests): + monkeypatch.setattr(notifier.settings, "HAMMERSPOON_ALERT_URL", "") + notifier.send_hammerspoon_alert("hello") + assert patch_requests == [] + + +def test_send_hammerspoon_alert_skips_when_unavailable(monkeypatch, patch_requests): + monkeypatch.setattr(notifier.settings, "HAMMERSPOON_ALERT_URL", "http://localhost:9181/alert") + monkeypatch.setattr(notifier, "_is_hammerspoon_available", lambda url: False) + notifier.send_hammerspoon_alert("hello") + assert patch_requests == [] + + +def test_send_hammerspoon_alert_swallows_request_error(monkeypatch): + monkeypatch.setattr(notifier.settings, "HAMMERSPOON_ALERT_URL", "http://localhost:9181/alert") monkeypatch.setattr(notifier, "_is_hammerspoon_available", lambda url: True) - notifier.send_hammerspoon_task("alice", "Read", "09:00 - 10:00") - assert patch_requests - assert patch_requests[0].args[0] == "http://localhost:9181/task" + + def _raise(*args, **kwargs): + raise notifier.requests.RequestException("boom") + + monkeypatch.setattr(notifier.requests, "post", _raise) + notifier.send_hammerspoon_alert("hello") def test_is_hammerspoon_available_missing_host(): diff --git a/tests/test_routes.py b/tests/test_routes.py index 1a3042d..07993d9 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -1,8 +1,5 @@ """Route and CLI tests using shared fixtures.""" -from src.safe_family.cli import gentags -from .conftest import FakeConnection - def test_root_redirects_to_todo(client): resp = client.get("/") @@ -35,14 +32,3 @@ def test_store_sum_static_renders_when_logged_in(client, monkeypatch): resp = client.get("/store_sum_static") assert resp.status_code == 200 - -def test_gentags_main_updates_tags(monkeypatch): - fake_conn = FakeConnection(rows=[("math study", 1)]) - monkeypatch.setattr(gentags, "get_db_connection", lambda: fake_conn) - - gentags.main([]) - - queries = fake_conn.cursor_obj.queries - assert any("UPDATE todo_list" in q[0] for q in queries) - assert fake_conn.commits == 1 - diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index b28d8c6..858ca95 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -1,6 +1,6 @@ """Tests for scheduler utilities.""" -from datetime import datetime, time, timedelta +from datetime import time from types import SimpleNamespace from src.safe_family.rules import scheduler @@ -71,54 +71,6 @@ def test_load_schedules_adds_jobs(monkeypatch): scheduler.load_schedules() assert fake_scheduler.removed - # One DB job + archive_completed_tasks + analyze_logs + notify_overdue_task_feedback + run_adguard_pull - assert len(fake_scheduler.jobs) == 5 + # One DB job + analyze_logs + notify_overdue_task_feedback + run_adguard_pull + assert len(fake_scheduler.jobs) == 4 assert fake_scheduler.jobs[0].id == "rule_1" - - -class DualCursor: - def __init__(self, rows): - self.rows = rows - self.executed = [] - - def execute(self, sql, params=None): - self.executed.append((sql, params)) - - def fetchall(self): - return list(self.rows) - - def close(self): - return None - - -class DualConn: - def __init__(self, rows): - self.cursors = [DualCursor(rows), DualCursor(rows)] - self.index = 0 - self.closed = False - - def cursor(self): - cur = self.cursors[self.index] - self.index = (self.index + 1) % len(self.cursors) - return cur - - def commit(self): - return None - - def close(self): - self.closed = True - - -def test_archive_completed_tasks_moves_rows(monkeypatch): - completed_at = datetime.now(scheduler.local_tz) - timedelta(days=4) - rows = [(1, "user", "task", 1, completed_at, 60)] - conn = DualConn(rows) - monkeypatch.setattr(scheduler, "get_db_connection", lambda: conn) - monkeypatch.setattr(scheduler.logger, "info", lambda *a, **k: None) - - scheduler.archive_completed_tasks() - - cur_main = conn.cursors[0] - assert any("DELETE FROM long_term_goals" in sql for sql, _ in cur_main.executed) - cur_his = conn.cursors[1] - assert any("INSERT INTO long_term_goals_his" in sql for sql, _ in cur_his.executed) diff --git a/tests/test_todo_routes.py b/tests/test_todo_routes.py index 3bc8760..6066646 100644 --- a/tests/test_todo_routes.py +++ b/tests/test_todo_routes.py @@ -1,10 +1,8 @@ """Additional tests for todo routes.""" -from datetime import datetime, timedelta +from datetime import datetime from types import SimpleNamespace -import pandas as pd - from src.safe_family.core import auth from src.safe_family.todo import todo @@ -202,117 +200,6 @@ def test_mark_todo_status_success(client, monkeypatch): assert any("UPDATE todo_list" in sql for sql, _ in cursor.queries) -def test_notify_current_task_success(client, monkeypatch): - fixed_now = todo.local_tz.localize(datetime(2025, 1, 1, 9, 5)) - - class FixedDateTime(datetime): - @classmethod - def now(cls, tz=None): - if tz is None: - return fixed_now.replace(tzinfo=None) - return fixed_now - - monkeypatch.setattr(todo, "datetime", FixedDateTime) - time_slot = "09:00 - 09:10" - cursor = SeqCursor(fetchall_values=[[(time_slot, "Read")]]) - conn = SeqConnection(cursor) - monkeypatch.setattr(todo, "get_db_connection", lambda: conn) - monkeypatch.setattr(todo, "get_agile_config", lambda k, d="": d) - monkeypatch.setattr( - todo, - "get_current_username", - lambda: SimpleNamespace(username="alice", role="user"), - ) - sent = {} - monkeypatch.setattr( - todo, - "send_hammerspoon_task", - lambda *a, **k: sent.__setitem__("task", a), - ) - _login_session(client, monkeypatch) - - resp = client.post("/todo/notify_current_task", json={}) - - assert resp.status_code == 200 - assert resp.get_json()["success"] is True - assert "task" in sent - - -def test_notify_current_task_requires_login(client, monkeypatch): - monkeypatch.setattr(todo, "get_current_username", lambda: None) - _login_session(client, monkeypatch) - - resp = client.post("/todo/notify_current_task", json={}) - - assert resp.status_code == 401 - assert resp.get_json()["error"] == "not logged in" - - -def test_unknown_metadata_filters(client, monkeypatch): - yesterday = datetime.now(todo.local_tz).date() - timedelta(days=1) - cursor = SeqCursor( - fetchall_values=[ - [ - (1, "09:00 - 10:00", "Task", "", yesterday, ""), - (2, "09:00 - 10:00", "Task2", "unknown", yesterday, None), - ] - ], - ) - conn = SeqConnection(cursor) - monkeypatch.setattr(todo, "get_db_connection", lambda: conn) - monkeypatch.setattr(todo, "gentags", SimpleNamespace(main=lambda *_: None)) - monkeypatch.setattr( - todo, - "get_current_username", - lambda: SimpleNamespace(username="alice", role="user"), - ) - _login_session(client, monkeypatch) - - resp = client.get("/todo/unknown_metadata") - - assert resp.status_code == 200 - data = resp.get_json() - assert len(data["unknown_tags"]) == 2 - assert len(data["unknown_status"]) == 2 - - -def test_update_tag_forbidden(client, monkeypatch): - cursor = SeqCursor(fetchone_values=[("bob",)]) - conn = SeqConnection(cursor) - monkeypatch.setattr(todo, "get_db_connection", lambda: conn) - monkeypatch.setattr(todo, "get_agile_config", lambda k, d="": d) - monkeypatch.setattr( - todo, - "get_current_username", - lambda: SimpleNamespace(username="alice", role="user"), - ) - _login_session(client, monkeypatch) - - resp = client.post("/todo/update_tag", json={"id": 1, "tag": "math"}) - - assert resp.status_code == 403 - assert resp.get_json()["error"] == "forbidden" - - -def test_update_tag_success(client, monkeypatch): - cursor = SeqCursor(fetchone_values=[("alice",)]) - conn = SeqConnection(cursor) - monkeypatch.setattr(todo, "get_db_connection", lambda: conn) - monkeypatch.setattr(todo, "get_agile_config", lambda k, d="": d) - monkeypatch.setattr( - todo, - "get_current_username", - lambda: SimpleNamespace(username="alice", role="admin"), - ) - _login_session(client, monkeypatch) - - resp = client.post("/todo/update_tag", json={"id": 1, "tag": "math"}) - - assert resp.status_code == 200 - assert resp.get_json()["success"] is True - assert conn.commits == 1 - - def test_exec_rules_no_lock(client, monkeypatch): class DummyLock: def acquire(self, blocking=False): @@ -381,46 +268,4 @@ def strptime(cls, *args, **kwargs): return datetime.strptime(*args, **kwargs) assert resp.status_code == 302 assert called["load"] == 1 assert called["notify"] == 1 - assert any("UPDATE schedule_rules" in sql for sql, _ in cursor.queries) - - -def test_notify_feedback_sends_alert(client, monkeypatch): - monkeypatch.setattr(todo, "send_hammerspoon_alert", lambda *a, **k: None) - _login_session(client, monkeypatch) - - resp = client.post("/todo/notify_feedback", json={"time_slot": "09:00 - 10:00", "task": "Read"}) - - assert resp.status_code == 200 - assert resp.get_json()["success"] is True - - -def test_weekly_summary_returns_payload(client, monkeypatch): - today = datetime(2025, 1, 8) - monkeypatch.setattr(todo, "gentags", SimpleNamespace(main=lambda *_: None)) - monkeypatch.setattr( - todo, - "get_current_username", - lambda: SimpleNamespace(username="alice", role="user"), - ) - monkeypatch.setattr(todo.weekly_metrics, "_parse_iso_week", lambda week: (today.date(), today.date())) - monkeypatch.setattr(todo.weekly_metrics, "_fetch_week_df", lambda *a, **k: pd.DataFrame()) - monkeypatch.setattr( - todo.weekly_metrics, - "_compute_metrics", - lambda *a, **k: SimpleNamespace( - completion_rate=0.5, - avg_tasks_per_day=1.0, - avg_planned_minutes=30.0, - by_category={}, - by_category_minutes={}, - ), - ) - monkeypatch.setattr(todo.weekly_diff, "_format_output", lambda *a, **k: "summary") - monkeypatch.setattr(todo, "send_discord_summary", lambda *a, **k: None) - _login_session(client, monkeypatch) - - resp = client.get("/todo/weekly_summary") - - assert resp.status_code == 200 - data = resp.get_json() - assert data["summary"] == "summary" \ No newline at end of file + assert any("UPDATE schedule_rules" in sql for sql, _ in cursor.queries) \ No newline at end of file diff --git a/tests/test_units.py b/tests/test_units.py index 021c874..e762fc2 100644 --- a/tests/test_units.py +++ b/tests/test_units.py @@ -4,7 +4,6 @@ import pytest -from src.safe_family.cli.gentags import infer_tag from src.safe_family.todo.todo import generate_time_slots from src.safe_family.urls.analyzer import get_time_range @@ -49,6 +48,30 @@ def test_generate_time_slots_custom_invalid_falls_back(): assert slots +def test_generate_time_slots_custom_bad_format_falls_back(): + weekday = datetime(2025, 1, 6, 12, 0) # Monday + slots = generate_time_slots( + slot_type="60", + schedule_mode="custom", + custom_start="08:00:00", + custom_end="09:00", + today=weekday, + ) + assert slots[0] == "18:30 - 19:30" + + +def test_generate_time_slots_custom_end_before_start_falls_back(): + weekday = datetime(2025, 1, 6, 12, 0) # Monday + slots = generate_time_slots( + slot_type="60", + schedule_mode="custom", + custom_start="10:00", + custom_end="09:00", + today=weekday, + ) + assert slots[0] == "18:30 - 19:30" + + def test_generate_time_slots_custom_valid(): weekday = datetime(2025, 1, 6, 12, 0) # Monday slots = generate_time_slots( @@ -80,8 +103,3 @@ def test_get_time_range_custom_valid(): def test_get_time_range_invalid_raises(): with pytest.raises(ValueError): get_time_range(custom=("2025-01-02T10:00:00", "2025-01-01T09:00:00")) - - -def test_infer_tag_matches_keywords_and_unknown(): - assert infer_tag("Finish calculus problems") == "math" - assert infer_tag("Unrelated task name") == "unknown"