diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..f2d93ca --- /dev/null +++ b/alembic.ini @@ -0,0 +1,149 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/migrations + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = sqlite:///./data/timmy_calm.db + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..e0d0858 --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration with an async dbapi. \ No newline at end of file diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..85bb5b3 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,80 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +from src.dashboard.models.database import Base +from src.dashboard.models.calm import Task, JournalEntry +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/0093c15b4bbf_create_task_and_journal_entry_tables.py b/migrations/versions/0093c15b4bbf_create_task_and_journal_entry_tables.py new file mode 100644 index 0000000..9f89edb --- /dev/null +++ b/migrations/versions/0093c15b4bbf_create_task_and_journal_entry_tables.py @@ -0,0 +1,67 @@ +"""Create task and journal_entry tables + +Revision ID: 0093c15b4bbf +Revises: +Create Date: 2026-03-02 10:57:55.537090 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '0093c15b4bbf' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('journal_entries', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('entry_date', sa.Date(), nullable=False), + sa.Column('mit_task_ids', sa.JSON(), nullable=True), + sa.Column('evening_reflection', sa.String(length=2000), nullable=True), + sa.Column('gratitude', sa.String(length=500), nullable=True), + sa.Column('energy_level', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_journal_entries_entry_date'), 'journal_entries', ['entry_date'], unique=True) + op.create_index(op.f('ix_journal_entries_id'), 'journal_entries', ['id'], unique=False) + op.create_table('tasks', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=255), nullable=False), + sa.Column('description', sa.String(length=1000), nullable=True), + sa.Column('state', sa.Enum('LATER', 'NEXT', 'NOW', 'DONE', 'DEFERRED', name='taskstate'), nullable=False), + sa.Column('certainty', sa.Enum('FUZZY', 'SOFT', 'HARD', name='taskcertainty'), nullable=False), + sa.Column('is_mit', sa.Boolean(), nullable=False), + sa.Column('sort_order', sa.Integer(), nullable=False), + sa.Column('started_at', sa.DateTime(), nullable=True), + sa.Column('completed_at', sa.DateTime(), nullable=True), + sa.Column('deferred_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('ix_task_state_order', 'tasks', ['state', 'sort_order'], unique=False) + op.create_index(op.f('ix_tasks_id'), 'tasks', ['id'], unique=False) + op.create_index(op.f('ix_tasks_state'), 'tasks', ['state'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_tasks_state'), table_name='tasks') + op.drop_index(op.f('ix_tasks_id'), table_name='tasks') + op.drop_index('ix_task_state_order', table_name='tasks') + op.drop_table('tasks') + op.drop_index(op.f('ix_journal_entries_id'), table_name='journal_entries') + op.drop_index(op.f('ix_journal_entries_entry_date'), table_name='journal_entries') + op.drop_table('journal_entries') + # ### end Alembic commands ### diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/dashboard/app.py b/src/dashboard/app.py index df5731e..ab326f2 100644 --- a/src/dashboard/app.py +++ b/src/dashboard/app.py @@ -21,35 +21,36 @@ from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates -from config import settings -from dashboard.routes.agents import router as agents_router -from dashboard.routes.health import router as health_router -from dashboard.routes.swarm import router as swarm_router -from dashboard.routes.marketplace import router as marketplace_router -from dashboard.routes.voice import router as voice_router -from dashboard.routes.mobile import router as mobile_router -from dashboard.routes.briefing import router as briefing_router -from dashboard.routes.telegram import router as telegram_router -from dashboard.routes.tools import router as tools_router -from dashboard.routes.spark import router as spark_router -from dashboard.routes.creative import router as creative_router -from dashboard.routes.discord import router as discord_router -from dashboard.routes.events import router as events_router -from dashboard.routes.ledger import router as ledger_router -from dashboard.routes.memory import router as memory_router -from dashboard.routes.router import router as router_status_router -from dashboard.routes.upgrades import router as upgrades_router -from dashboard.routes.tasks import router as tasks_router -from dashboard.routes.scripture import router as scripture_router -from dashboard.routes.self_coding import router as self_coding_router -from dashboard.routes.self_coding import self_modify_router -from dashboard.routes.hands import router as hands_router -from dashboard.routes.grok import router as grok_router -from dashboard.routes.models import router as models_router -from dashboard.routes.models import api_router as models_api_router -from dashboard.routes.chat_api import router as chat_api_router -from dashboard.routes.thinking import router as thinking_router -from dashboard.routes.bugs import router as bugs_router +from src.config import settings +from src.dashboard.routes.agents import router as agents_router +from src.dashboard.routes.health import router as health_router +from src.dashboard.routes.swarm import router as swarm_router +from src.dashboard.routes.marketplace import router as marketplace_router +from src.dashboard.routes.voice import router as voice_router +from src.dashboard.routes.mobile import router as mobile_router +from src.dashboard.routes.briefing import router as briefing_router +from src.dashboard.routes.telegram import router as telegram_router +from src.dashboard.routes.tools import router as tools_router +from src.dashboard.routes.spark import router as spark_router +from src.dashboard.routes.creative import router as creative_router +from src.dashboard.routes.discord import router as discord_router +from src.dashboard.routes.events import router as events_router +from src.dashboard.routes.ledger import router as ledger_router +from src.dashboard.routes.memory import router as memory_router +from src.dashboard.routes.router import router as router_status_router +from src.dashboard.routes.upgrades import router as upgrades_router +from src.dashboard.routes.tasks import router as tasks_router +from src.dashboard.routes.scripture import router as scripture_router +from src.dashboard.routes.self_coding import router as self_coding_router +from src.dashboard.routes.self_coding import self_modify_router +from src.dashboard.routes.hands import router as hands_router +from src.dashboard.routes.grok import router as grok_router +from src.dashboard.routes.models import router as models_router +from src.dashboard.routes.models import api_router as models_api_router +from src.dashboard.routes.chat_api import router as chat_api_router +from src.dashboard.routes.thinking import router as thinking_router +from src.dashboard.routes.bugs import router as bugs_router +from src.dashboard.routes.calm import router as calm_router from infrastructure.router.api import router as cascade_router @@ -682,6 +683,7 @@ app.include_router(models_api_router) app.include_router(chat_api_router) app.include_router(thinking_router) app.include_router(bugs_router) +app.include_router(calm_router) app.include_router(cascade_router) diff --git a/src/dashboard/models/calm.py b/src/dashboard/models/calm.py new file mode 100644 index 0000000..bc0cb67 --- /dev/null +++ b/src/dashboard/models/calm.py @@ -0,0 +1,60 @@ + +from datetime import datetime, date +from enum import Enum as PyEnum +from sqlalchemy import ( + Column, Integer, String, DateTime, Boolean, Enum as SQLEnum, + Date, ForeignKey, Index, JSON +) +from sqlalchemy.orm import relationship +from .database import Base # Assuming a shared Base in models/database.py + +class TaskState(str, PyEnum): + LATER = "LATER" + NEXT = "NEXT" + NOW = "NOW" + DONE = "DONE" + DEFERRED = "DEFERRED" # Task pushed to tomorrow + +class TaskCertainty(str, PyEnum): + FUZZY = "FUZZY" # An intention without a time + SOFT = "SOFT" # A flexible task with a time + HARD = "HARD" # A fixed meeting/appointment + +class Task(Base): + __tablename__ = "tasks" + + id = Column(Integer, primary_key=True, index=True) + title = Column(String(255), nullable=False) + description = Column(String(1000), nullable=True) + + state = Column(SQLEnum(TaskState), default=TaskState.LATER, nullable=False, index=True) + certainty = Column(SQLEnum(TaskCertainty), default=TaskCertainty.SOFT, nullable=False) + is_mit = Column(Boolean, default=False, nullable=False) # 1-3 per day + + sort_order = Column(Integer, default=0, nullable=False) + + # Time tracking + started_at = Column(DateTime, nullable=True) + completed_at = Column(DateTime, nullable=True) + deferred_at = Column(DateTime, nullable=True) + + # Timestamps + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + __table_args__ = (Index('ix_task_state_order', 'state', 'sort_order'),) + +class JournalEntry(Base): + __tablename__ = "journal_entries" + + id = Column(Integer, primary_key=True, index=True) + entry_date = Column(Date, unique=True, nullable=False, index=True, default=date.today) + + # Relationships to the 1-3 MITs for the day + mit_task_ids = Column(JSON, nullable=True) + + evening_reflection = Column(String(2000), nullable=True) + gratitude = Column(String(500), nullable=True) + energy_level = Column(Integer, nullable=True) # User-reported, 1-10 + + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) diff --git a/src/dashboard/models/database.py b/src/dashboard/models/database.py new file mode 100644 index 0000000..10877b0 --- /dev/null +++ b/src/dashboard/models/database.py @@ -0,0 +1,20 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker, Session + +SQLALCHEMY_DATABASE_URL = "sqlite:///./data/timmy_calm.db" + +engine = create_engine( + SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False} +) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/src/dashboard/routes/agents.py b/src/dashboard/routes/agents.py index 8dda6d8..c6df593 100644 --- a/src/dashboard/routes/agents.py +++ b/src/dashboard/routes/agents.py @@ -7,7 +7,7 @@ from fastapi import APIRouter, Form, Request from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates -from timmy.session import chat as timmy_chat +from src.timmy.session import chat as timmy_chat from dashboard.store import message_log logger = logging.getLogger(__name__) diff --git a/src/dashboard/routes/calm.py b/src/dashboard/routes/calm.py new file mode 100644 index 0000000..a6ae704 --- /dev/null +++ b/src/dashboard/routes/calm.py @@ -0,0 +1,380 @@ + +import logging +from datetime import date, datetime +from typing import List, Optional + +from fastapi import APIRouter, Depends, Form, HTTPException, Request +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates +from sqlalchemy.orm import Session + +from src.dashboard.models.calm import JournalEntry, Task, TaskCertainty, TaskState +from src.dashboard.models.database import SessionLocal, engine, get_db + +# Create database tables (if not already created by Alembic) +# This is typically handled by Alembic migrations in a production environment +# from src.dashboard.models.database import Base +# Base.metadata.create_all(bind=engine) + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["calm"]) +templates = Jinja2Templates(directory="src/dashboard/templates") + + +# Helper functions for state machine logic +def get_now_task(db: Session) -> Optional[Task]: + return db.query(Task).filter(Task.state == TaskState.NOW).first() + +def get_next_task(db: Session) -> Optional[Task]: + return db.query(Task).filter(Task.state == TaskState.NEXT).first() + +def get_later_tasks(db: Session) -> List[Task]: + return db.query(Task).filter(Task.state == TaskState.LATER).order_by(Task.is_mit.desc(), Task.sort_order).all() + +def promote_tasks(db: Session): + # Ensure only one NOW task exists. If multiple, demote extras to NEXT. + now_tasks = db.query(Task).filter(Task.state == TaskState.NOW).all() + if len(now_tasks) > 1: + # Keep the one with highest priority/sort_order, demote others to NEXT + now_tasks.sort(key=lambda t: (t.is_mit, t.sort_order), reverse=True) + for task_to_demote in now_tasks[1:]: + task_to_demote.state = TaskState.NEXT + db.add(task_to_demote) + db.flush() # Make changes visible + + # If no NOW task, promote NEXT to NOW + current_now = db.query(Task).filter(Task.state == TaskState.NOW).first() + if not current_now: + next_task = db.query(Task).filter(Task.state == TaskState.NEXT).first() + if next_task: + next_task.state = TaskState.NOW + db.add(next_task) + db.flush() # Make changes visible + + # If no NEXT task, promote highest priority LATER to NEXT + current_next = db.query(Task).filter(Task.state == TaskState.NEXT).first() + if not current_next: + later_tasks = db.query(Task).filter(Task.state == TaskState.LATER).order_by(Task.is_mit.desc(), Task.sort_order).all() + if later_tasks: + later_tasks[0].state = TaskState.NEXT + db.add(later_tasks[0]) + + db.commit() + + + +# Endpoints +@router.get("/calm", response_class=HTMLResponse) +async def get_calm_view(request: Request, db: Session = Depends(get_db)): + now_task = get_now_task(db) + next_task = get_next_task(db) + later_tasks_count = len(get_later_tasks(db)) + return templates.TemplateResponse( + "calm/calm_view.html", + { + "request": request, + "now_task": now_task, + "next_task": next_task, + "later_tasks_count": later_tasks_count, + }, + ) + + +@router.get("/calm/ritual/morning", response_class=HTMLResponse) +async def get_morning_ritual_form(request: Request): + return templates.TemplateResponse( + "calm/morning_ritual_form.html", {"request": request} + ) + + +@router.post("/calm/ritual/morning", response_class=HTMLResponse) +async def post_morning_ritual( + request: Request, + db: Session = Depends(get_db), + mit1_title: str = Form(None), + mit2_title: str = Form(None), + mit3_title: str = Form(None), + other_tasks: str = Form(""), +): + # Create Journal Entry + mit_task_ids = [] + journal_entry = JournalEntry(entry_date=date.today()) + db.add(journal_entry) + db.commit() + db.refresh(journal_entry) + + # Create MIT tasks + for mit_title in [mit1_title, mit2_title, mit3_title]: + if mit_title: + task = Task( + title=mit_title, + is_mit=True, + state=TaskState.LATER, # Initially LATER, will be promoted + certainty=TaskCertainty.SOFT, + ) + db.add(task) + db.commit() + db.refresh(task) + mit_task_ids.append(task.id) + + journal_entry.mit_task_ids = mit_task_ids + db.add(journal_entry) + + # Create other tasks + for task_title in other_tasks.split('\n'): + task_title = task_title.strip() + if task_title: + task = Task( + title=task_title, + state=TaskState.LATER, + certainty=TaskCertainty.FUZZY, + ) + db.add(task) + + db.commit() + + # Set initial NOW/NEXT states + # Set initial NOW/NEXT states after all tasks are created + if not get_now_task(db) and not get_next_task(db): + later_tasks = db.query(Task).filter(Task.state == TaskState.LATER).order_by(Task.is_mit.desc(), Task.sort_order).all() + if later_tasks: + # Set the highest priority LATER task to NOW + later_tasks[0].state = TaskState.NOW + db.add(later_tasks[0]) + db.flush() # Flush to make the change visible for the next query + + # Set the next highest priority LATER task to NEXT + if len(later_tasks) > 1: + later_tasks[1].state = TaskState.NEXT + db.add(later_tasks[1]) + db.commit() # Commit changes after initial NOW/NEXT setup + + return templates.TemplateResponse( + "calm/calm_view.html", + { + "request": request, + "now_task": get_now_task(db), + "next_task": get_next_task(db), + "later_tasks_count": len(get_later_tasks(db)), + }, + ) + + +@router.get("/calm/ritual/evening", response_class=HTMLResponse) +async def get_evening_ritual_form(request: Request, db: Session = Depends(get_db)): + journal_entry = db.query(JournalEntry).filter(JournalEntry.entry_date == date.today()).first() + if not journal_entry: + raise HTTPException(status_code=404, detail="No journal entry for today") + return templates.TemplateResponse( + "calm/evening_ritual_form.html", {"request": request, "journal_entry": journal_entry} + ) + + +@router.post("/calm/ritual/evening", response_class=HTMLResponse) +async def post_evening_ritual( + request: Request, + db: Session = Depends(get_db), + evening_reflection: str = Form(None), + gratitude: str = Form(None), + energy_level: int = Form(None), +): + journal_entry = db.query(JournalEntry).filter(JournalEntry.entry_date == date.today()).first() + if not journal_entry: + raise HTTPException(status_code=404, detail="No journal entry for today") + + journal_entry.evening_reflection = evening_reflection + journal_entry.gratitude = gratitude + journal_entry.energy_level = energy_level + db.add(journal_entry) + + # Archive any remaining active tasks + active_tasks = db.query(Task).filter(Task.state.in_([TaskState.NOW, TaskState.NEXT, TaskState.LATER])).all() + for task in active_tasks: + task.state = TaskState.DEFERRED # Or DONE, depending on desired archiving logic + task.deferred_at = datetime.utcnow() + db.add(task) + + db.commit() + + return templates.TemplateResponse( + "calm/evening_ritual_complete.html", {"request": request} + ) + + +@router.post("/calm/tasks", response_class=HTMLResponse) +async def create_new_task( + request: Request, + db: Session = Depends(get_db), + title: str = Form(...), + description: Optional[str] = Form(None), + is_mit: bool = Form(False), + certainty: TaskCertainty = Form(TaskCertainty.SOFT), +): + task = Task( + title=title, + description=description, + is_mit=is_mit, + certainty=certainty, + state=TaskState.LATER, + ) + db.add(task) + db.commit() + db.refresh(task) + # After creating a new task, we might need to re-evaluate NOW/NEXT/LATER, but for simplicity + # and given the spec, new tasks go to LATER. Promotion happens on completion/deferral. + return templates.TemplateResponse( + "calm/partials/later_count.html", + {"request": request, "later_tasks_count": len(get_later_tasks(db))}, + ) + + +@router.post("/calm/tasks/{task_id}/start", response_class=HTMLResponse) +async def start_task( + request: Request, + task_id: int, + db: Session = Depends(get_db), +): + current_now_task = get_now_task(db) + if current_now_task and current_now_task.id != task_id: + current_now_task.state = TaskState.NEXT # Demote current NOW to NEXT + db.add(current_now_task) + + task = db.query(Task).filter(Task.id == task_id).first() + if not task: + raise HTTPException(status_code=404, detail="Task not found") + + task.state = TaskState.NOW + task.started_at = datetime.utcnow() + db.add(task) + db.commit() + + # Re-evaluate NEXT from LATER if needed + promote_tasks(db) + + return templates.TemplateResponse( + "calm/partials/now_next_later.html", + { + "request": request, + "now_task": get_now_task(db), + "next_task": get_next_task(db), + "later_tasks_count": len(get_later_tasks(db)), + }, + ) + + +@router.post("/calm/tasks/{task_id}/complete", response_class=HTMLResponse) +async def complete_task( + request: Request, + task_id: int, + db: Session = Depends(get_db), +): + task = db.query(Task).filter(Task.id == task_id).first() + if not task: + raise HTTPException(status_code=404, detail="Task not found") + + task.state = TaskState.DONE + task.completed_at = datetime.utcnow() + db.add(task) + db.commit() + + promote_tasks(db) + + return templates.TemplateResponse( + "calm/partials/now_next_later.html", + { + "request": request, + "now_task": get_now_task(db), + "next_task": get_next_task(db), + "later_tasks_count": len(get_later_tasks(db)), + }, + ) + + +@router.post("/calm/tasks/{task_id}/defer", response_class=HTMLResponse) +async def defer_task( + request: Request, + task_id: int, + db: Session = Depends(get_db), +): + task = db.query(Task).filter(Task.id == task_id).first() + if not task: + raise HTTPException(status_code=404, detail="Task not found") + + task.state = TaskState.DEFERRED + task.deferred_at = datetime.utcnow() + db.add(task) + db.commit() + + promote_tasks(db) + + return templates.TemplateResponse( + "calm/partials/now_next_later.html", + { + "request": request, + "now_task": get_now_task(db), + "next_task": get_next_task(db), + "later_tasks_count": len(get_later_tasks(db)), + }, + ) + + +@router.get("/calm/partials/later_tasks_list", response_class=HTMLResponse) +async def get_later_tasks_list(request: Request, db: Session = Depends(get_db)): + later_tasks = get_later_tasks(db) + return templates.TemplateResponse( + "calm/partials/later_tasks_list.html", + {"request": request, "later_tasks": later_tasks}, + ) + + +@router.post("/calm/tasks/reorder", response_class=HTMLResponse) +async def reorder_tasks( + request: Request, + db: Session = Depends(get_db), + # Expecting a comma-separated string of task IDs in new order + later_task_ids: str = Form(""), + next_task_id: Optional[int] = Form(None), +): + # Reorder LATER tasks + if later_task_ids: + ids_in_order = [int(x.strip()) for x in later_task_ids.split(',') if x.strip()] + for index, task_id in enumerate(ids_in_order): + task = db.query(Task).filter(Task.id == task_id).first() + if task and task.state == TaskState.LATER: + task.sort_order = index + db.add(task) + + # Handle NEXT task if it's part of the reorder (e.g., moved from LATER to NEXT explicitly) + if next_task_id: + task = db.query(Task).filter(Task.id == next_task_id).first() + if task and task.state == TaskState.LATER: # Only if it was a LATER task being promoted manually + # Demote current NEXT to LATER + current_next = get_next_task(db) + if current_next: + current_next.state = TaskState.LATER + current_next.sort_order = len(get_later_tasks(db)) # Add to end of later + db.add(current_next) + + task.state = TaskState.NEXT + task.sort_order = 0 # NEXT tasks don't really need sort_order, but for consistency + db.add(task) + + db.commit() + + # Re-render the relevant parts of the UI + return templates.TemplateResponse( + "calm/partials/now_next_later.html", + { + "request": request, + "now_task": get_now_task(db), + "next_task": get_next_task(db), + "later_tasks_count": len(get_later_tasks(db)), + }, + ) + + +# Include this router in the main FastAPI app +# In src/dashboard/app.py, add: +# from dashboard.routes.calm import router as calm_router +# app.include_router(calm_router) diff --git a/src/dashboard/templates/base.html b/src/dashboard/templates/base.html index 3522508..e90594d 100644 --- a/src/dashboard/templates/base.html +++ b/src/dashboard/templates/base.html @@ -27,6 +27,7 @@
+ CALM TASKS BRIEFING THINKING @@ -73,6 +74,7 @@
HOME
CORE
+ CALM TASKS BRIEFING THINKING diff --git a/src/dashboard/templates/calm/calm_view.html b/src/dashboard/templates/calm/calm_view.html new file mode 100644 index 0000000..5e5ee08 --- /dev/null +++ b/src/dashboard/templates/calm/calm_view.html @@ -0,0 +1,127 @@ + +{% extends "base.html" %} + +{% block title %}Timmy Calm{% endblock %} + +{% block extra_styles %} + +{% endblock %} + +{% block content %} +
+
+

Timmy Calm

+

Your focused attention stack

+
+ +
+ {% include "calm/partials/now_next_later.html" %} +
+ + {% if not now_task and not next_task and later_tasks_count == 0 %} +
+

It looks like you have no tasks. Start your day with a morning ritual!

+ Start Morning Ritual +
+ {% endif %} + +
+ +
+ + +
+
+

Add New Task

+
+ + + + + +
+ + +
+
+
+
+ +
+ + +{% endblock %} diff --git a/src/dashboard/templates/calm/evening_ritual_complete.html b/src/dashboard/templates/calm/evening_ritual_complete.html new file mode 100644 index 0000000..181a457 --- /dev/null +++ b/src/dashboard/templates/calm/evening_ritual_complete.html @@ -0,0 +1,24 @@ + +{% extends "base.html" %} + +{% block title %}Evening Ritual Complete - Timmy Calm{% endblock %} + +{% block extra_styles %} + +{% endblock %} + +{% block content %} +
+

✓ Evening Ritual Complete

+

+ You've reflected on your day and archived your tasks. Rest well — tomorrow is a fresh start. +

+ Return to Calm +
+{% endblock %} diff --git a/src/dashboard/templates/calm/evening_ritual_form.html b/src/dashboard/templates/calm/evening_ritual_form.html new file mode 100644 index 0000000..d797aea --- /dev/null +++ b/src/dashboard/templates/calm/evening_ritual_form.html @@ -0,0 +1,54 @@ + +{% extends "base.html" %} + +{% block title %}Evening Ritual - Timmy Calm{% endblock %} + +{% block extra_styles %} + +{% endblock %} + +{% block content %} +
+
+

Good Evening, Timmy.

+

Reflect on your day and prepare for tomorrow.

+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
+{% endblock %} diff --git a/src/dashboard/templates/calm/morning_ritual_form.html b/src/dashboard/templates/calm/morning_ritual_form.html new file mode 100644 index 0000000..4042897 --- /dev/null +++ b/src/dashboard/templates/calm/morning_ritual_form.html @@ -0,0 +1,66 @@ + +{% extends "base.html" %} + +{% block title %}Morning Ritual - Timmy Calm{% endblock %} + +{% block extra_styles %} + +{% endblock %} + +{% block content %} +
+
+

Good Morning, Timmy.

+

Let's set your intentions for a calm and focused day.

+
+ +
+
+

Your 1-3 Most Important Tasks (MITs) for today:

+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ +
+
+
+{% endblock %} diff --git a/src/dashboard/templates/calm/partials/later_count.html b/src/dashboard/templates/calm/partials/later_count.html new file mode 100644 index 0000000..85299b5 --- /dev/null +++ b/src/dashboard/templates/calm/partials/later_count.html @@ -0,0 +1 @@ +{{ later_tasks_count }} diff --git a/src/dashboard/templates/calm/partials/later_tasks_list.html b/src/dashboard/templates/calm/partials/later_tasks_list.html new file mode 100644 index 0000000..e7fd84a --- /dev/null +++ b/src/dashboard/templates/calm/partials/later_tasks_list.html @@ -0,0 +1,19 @@ + +{% if later_tasks %} +
+ + + +
+{% else %} +
No tasks in Later.
+{% endif %} diff --git a/src/dashboard/templates/calm/partials/now_next_later.html b/src/dashboard/templates/calm/partials/now_next_later.html new file mode 100644 index 0000000..3159a49 --- /dev/null +++ b/src/dashboard/templates/calm/partials/now_next_later.html @@ -0,0 +1,50 @@ + +
+ {% if now_task %} +
+

{{ now_task.title }}

+ {% if now_task.description %} +

{{ now_task.description }}

+ {% endif %} +
+ + +
+
+ {% else %} +
+

No task is NOW. Time to pick one or start your morning ritual!

+
+ {% endif %} + + {% if next_task %} +
+

Next: {{ next_task.title }}

+ {% if next_task.description %} +

{{ next_task.description }}

+ {% endif %} +
+ {% endif %} + +
+
+ + Later ({{ later_tasks_count }} items) + + +
+ +
+
+
+
diff --git a/tests/dashboard/test_calm.py b/tests/dashboard/test_calm.py new file mode 100644 index 0000000..e8ee73b --- /dev/null +++ b/tests/dashboard/test_calm.py @@ -0,0 +1,229 @@ +import pytest +import sys +from datetime import date +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, Session + +sys.path.insert(0, "/home/ubuntu/Timmy-time-dashboard/src") +from src.dashboard.app import app +from src.dashboard.models.database import Base, get_db +from src.dashboard.models.calm import Task, JournalEntry, TaskState, TaskCertainty + + +@pytest.fixture(name="test_db_engine") +def test_db_engine_fixture(): + # Create a new in-memory SQLite database for each test + engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}) + Base.metadata.create_all(bind=engine) # Create tables + yield engine + Base.metadata.drop_all(bind=engine) # Drop tables after test + + +@pytest.fixture(name="db_session") +def db_session_fixture(test_db_engine): + TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=test_db_engine) + db = TestingSessionLocal() + try: + yield db + finally: + db.close() + + +@pytest.fixture(name="client") +def client_fixture(db_session: Session): + app.dependency_overrides[get_db] = lambda: db_session + with TestClient(app) as client: + yield client + app.dependency_overrides.clear() + + +def test_create_task(client: TestClient, db_session: Session): + response = client.post( + "/calm/tasks", + data={ + "title": "Test Task", + "description": "This is a test description", + "is_mit": False, + "certainty": TaskCertainty.SOFT.value, + }, + ) + assert response.status_code == 200 + assert "later_count-container" in response.text + + task = db_session.query(Task).filter(Task.title == "Test Task").first() + assert task is not None + assert task.state == TaskState.LATER + assert task.description == "This is a test description" + + +def test_morning_ritual_creates_tasks_and_journal_entry(client: TestClient, db_session: Session): + response = client.post( + "/calm/ritual/morning", + data={ + "mit1_title": "MIT Task 1", + "mit2_title": "MIT Task 2", + "other_tasks": "Other Task 1\nOther Task 2", + }, + ) + assert response.status_code == 200 + assert "Timmy Calm" in response.text + + journal_entry = db_session.query(JournalEntry).first() + assert journal_entry is not None + assert len(journal_entry.mit_task_ids) == 2 + + tasks = db_session.query(Task).all() + assert len(tasks) == 4 + + mit_tasks = db_session.query(Task).filter(Task.is_mit == True).all() + assert len(mit_tasks) == 2 + + now_task = db_session.query(Task).filter(Task.state == TaskState.NOW).first() + next_task = db_session.query(Task).filter(Task.state == TaskState.NEXT).first() + later_tasks = db_session.query(Task).filter(Task.state == TaskState.LATER).all() + + assert now_task is not None + assert next_task is not None + assert len(later_tasks) == 2 + + +def test_complete_now_task_promotes_next_and_later(client: TestClient, db_session: Session): + task_now = Task(title="Task NOW", state=TaskState.NOW, is_mit=True, sort_order=0) + task_next = Task(title="Task NEXT", state=TaskState.NEXT, is_mit=False, sort_order=0) + task_later1 = Task(title="Task LATER 1", state=TaskState.LATER, is_mit=True, sort_order=0) + task_later2 = Task(title="Task LATER 2", state=TaskState.LATER, is_mit=False, sort_order=1) + db_session.add_all([task_now, task_next, task_later1, task_later2]) + db_session.commit() + db_session.refresh(task_now) + db_session.refresh(task_next) + db_session.refresh(task_later1) + db_session.refresh(task_later2) + + response = client.post(f"/calm/tasks/{task_now.id}/complete") + assert response.status_code == 200 + + assert db_session.query(Task).filter(Task.id == task_now.id).first().state == TaskState.DONE + assert db_session.query(Task).filter(Task.id == task_next.id).first().state == TaskState.NOW + assert db_session.query(Task).filter(Task.id == task_later1.id).first().state == TaskState.NEXT + assert db_session.query(Task).filter(Task.id == task_later2.id).first().state == TaskState.LATER + + +def test_defer_now_task_promotes_next_and_later(client: TestClient, db_session: Session): + task_now = Task(title="Task NOW", state=TaskState.NOW, is_mit=True, sort_order=0) + task_next = Task(title="Task NEXT", state=TaskState.NEXT, is_mit=False, sort_order=0) + task_later1 = Task(title="Task LATER 1", state=TaskState.LATER, is_mit=True, sort_order=0) + task_later2 = Task(title="Task LATER 2", state=TaskState.LATER, is_mit=False, sort_order=1) + db_session.add_all([task_now, task_next, task_later1, task_later2]) + db_session.commit() + db_session.refresh(task_now) + db_session.refresh(task_next) + db_session.refresh(task_later1) + db_session.refresh(task_later2) + + response = client.post(f"/calm/tasks/{task_now.id}/defer") + assert response.status_code == 200 + + assert db_session.query(Task).filter(Task.id == task_now.id).first().state == TaskState.DEFERRED + assert db_session.query(Task).filter(Task.id == task_next.id).first().state == TaskState.NOW + assert db_session.query(Task).filter(Task.id == task_later1.id).first().state == TaskState.NEXT + assert db_session.query(Task).filter(Task.id == task_later2.id).first().state == TaskState.LATER + + +def test_start_task_demotes_current_now_and_promotes_to_now(client: TestClient, db_session: Session): + task_now = Task(title="Task NOW", state=TaskState.NOW, is_mit=True, sort_order=0) + task_next = Task(title="Task NEXT", state=TaskState.NEXT, is_mit=False, sort_order=0) + task_later1 = Task(title="Task LATER 1", state=TaskState.LATER, is_mit=True, sort_order=0) + db_session.add_all([task_now, task_next, task_later1]) + db_session.commit() + db_session.refresh(task_now) + db_session.refresh(task_next) + db_session.refresh(task_later1) + + response = client.post(f"/calm/tasks/{task_later1.id}/start") + assert response.status_code == 200 + + assert db_session.query(Task).filter(Task.id == task_later1.id).first().state == TaskState.NOW + assert db_session.query(Task).filter(Task.id == task_now.id).first().state == TaskState.NEXT + assert db_session.query(Task).filter(Task.id == task_next.id).first().state == TaskState.LATER + + +def test_evening_ritual_archives_active_tasks(client: TestClient, db_session: Session): + journal_entry = JournalEntry(entry_date=date.today()) + db_session.add(journal_entry) + db_session.commit() + db_session.refresh(journal_entry) + + task_now = Task(title="Task NOW", state=TaskState.NOW) + task_next = Task(title="Task NEXT", state=TaskState.NEXT) + task_later = Task(title="Task LATER", state=TaskState.LATER) + task_done = Task(title="Task DONE", state=TaskState.DONE) + db_session.add_all([task_now, task_next, task_later, task_done]) + db_session.commit() + + response = client.post( + "/calm/ritual/evening", + data={ + "evening_reflection": "Reflected well", + "gratitude": "Grateful for everything", + "energy_level": 8, + }, + ) + assert response.status_code == 200 + assert "Evening Ritual Complete" in response.text + + assert db_session.query(Task).filter(Task.id == task_now.id).first().state == TaskState.DEFERRED + assert db_session.query(Task).filter(Task.id == task_next.id).first().state == TaskState.DEFERRED + assert db_session.query(Task).filter(Task.id == task_later.id).first().state == TaskState.DEFERRED + assert db_session.query(Task).filter(Task.id == task_done.id).first().state == TaskState.DONE + + updated_journal = db_session.query(JournalEntry).filter(JournalEntry.id == journal_entry.id).first() + assert updated_journal.evening_reflection == "Reflected well" + assert updated_journal.gratitude == "Grateful for everything" + assert updated_journal.energy_level == 8 + + +def test_reorder_later_tasks(client: TestClient, db_session: Session): + task_later1 = Task(title="Task LATER 1", state=TaskState.LATER, sort_order=0) + task_later2 = Task(title="Task LATER 2", state=TaskState.LATER, sort_order=1) + task_later3 = Task(title="Task LATER 3", state=TaskState.LATER, sort_order=2) + db_session.add_all([task_later1, task_later2, task_later3]) + db_session.commit() + db_session.refresh(task_later1) + db_session.refresh(task_later2) + db_session.refresh(task_later3) + + response = client.post( + "/calm/tasks/reorder", + data={ + "later_task_ids": f"{task_later3.id},{task_later1.id},{task_later2.id}" + }, + ) + assert response.status_code == 200 + + assert db_session.query(Task).filter(Task.id == task_later3.id).first().sort_order == 0 + assert db_session.query(Task).filter(Task.id == task_later1.id).first().sort_order == 1 + assert db_session.query(Task).filter(Task.id == task_later2.id).first().sort_order == 2 + + +def test_reorder_promote_later_to_next(client: TestClient, db_session: Session): + task_now = Task(title="Task NOW", state=TaskState.NOW, is_mit=True, sort_order=0) + task_later1 = Task(title="Task LATER 1", state=TaskState.LATER, is_mit=False, sort_order=0) + task_later2 = Task(title="Task LATER 2", state=TaskState.LATER, is_mit=False, sort_order=1) + db_session.add_all([task_now, task_later1, task_later2]) + db_session.commit() + db_session.refresh(task_now) + db_session.refresh(task_later1) + db_session.refresh(task_later2) + + response = client.post( + "/calm/tasks/reorder", + data={ + "next_task_id": task_later1.id + }, + ) + assert response.status_code == 200 + + assert db_session.query(Task).filter(Task.id == task_now.id).first().state == TaskState.NOW + assert db_session.query(Task).filter(Task.id == task_later1.id).first().state == TaskState.NEXT + assert db_session.query(Task).filter(Task.id == task_later2.id).first().state == TaskState.LATER