feat: Implement Minimum Viable Calm (MVC) feature and initial tests

This commit is contained in:
AlexanderWhitestone
2026-03-02 11:46:40 -05:00
parent 62ef1120a4
commit d080e67faf
20 changed files with 1389 additions and 30 deletions

149
alembic.ini Normal file
View File

@@ -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 <script_location>/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

1
migrations/README Normal file
View File

@@ -0,0 +1 @@
Generic single-database configuration with an async dbapi.

80
migrations/env.py Normal file
View File

@@ -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()

28
migrations/script.py.mako Normal file
View File

@@ -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"}

View File

@@ -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 ###

0
src/__init__.py Normal file
View File

View File

@@ -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)

View File

@@ -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)

View File

@@ -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()

View File

@@ -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__)

View File

@@ -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)

View File

@@ -27,6 +27,7 @@
<!-- Desktop nav -->
<div class="mc-header-right mc-desktop-nav">
<a href="/calm" class="mc-test-link">CALM</a>
<a href="/tasks" class="mc-test-link">TASKS</a>
<a href="/briefing" class="mc-test-link">BRIEFING</a>
<a href="/thinking" class="mc-test-link mc-link-thinking">THINKING</a>
@@ -73,6 +74,7 @@
</div>
<a href="/" class="mc-mobile-link">HOME</a>
<div class="mc-mobile-section-label">CORE</div>
<a href="/calm" class="mc-mobile-link">CALM</a>
<a href="/tasks" class="mc-mobile-link">TASKS</a>
<a href="/briefing" class="mc-mobile-link">BRIEFING</a>
<a href="/thinking" class="mc-mobile-link">THINKING</a>

View File

@@ -0,0 +1,127 @@
{% extends "base.html" %}
{% block title %}Timmy Calm{% endblock %}
{% block extra_styles %}
<style>
.calm-container { max-width: 600px; margin: 0 auto; padding: 20px; }
.calm-header { text-align: center; margin-bottom: 30px; }
.calm-title { font-size: 2.5rem; font-weight: 700; color: var(--text-bright); letter-spacing: 0.05em; }
.calm-subtitle { font-size: 1.1rem; color: var(--text-dim); margin-top: 5px; }
.task-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 25px;
margin-bottom: 20px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
transition: all 0.3s ease;
}
.task-card:hover { transform: translateY(-3px); box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3); }
.now-card {
background: linear-gradient(135deg, var(--bg-secondary) 0%, rgba(124, 58, 237, 0.1) 100%);
border-color: rgba(124, 58, 237, 0.4);
}
.now-card .task-title { font-size: 2.2rem; font-weight: 800; color: var(--green); margin-bottom: 15px; }
.now-card .task-description { font-size: 1.1rem; color: var(--text); line-height: 1.6; margin-bottom: 20px; }
.now-card .task-actions { display: flex; gap: 15px; justify-content: center; }
.now-card .task-btn { padding: 12px 25px; font-size: 1rem; font-weight: 700; border-radius: var(--radius-md); cursor: pointer; transition: all 0.2s ease; }
.now-card .task-btn-complete { background: var(--green); color: var(--bg-secondary); border: none; }
.now-card .task-btn-complete:hover { background: var(--green-dark); }
.now-card .task-btn-defer { background: var(--bg-tertiary); color: var(--text); border: 1px solid var(--border); }
.now-card .task-btn-defer:hover { background: var(--bg-tertiary-hover); }
.next-card {
background: var(--bg-tertiary);
border-color: var(--border);
padding: 15px 20px;
}
.next-card .task-title { font-size: 1.3rem; font-weight: 600; color: var(--info); margin-bottom: 5px; }
.next-card .task-description { font-size: 0.9rem; color: var(--text-dim); max-height: 40px; overflow: hidden; }
.later-section {
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
margin-top: 20px;
}
.later-summary { padding: 15px 20px; cursor: pointer; display: flex; justify-content: space-between; align-items: center; font-size: 1.1rem; font-weight: 600; color: var(--text-bright); }
.later-summary:hover { background: var(--bg-tertiary-hover); border-radius: var(--radius-lg); }
.later-content { padding: 10px 20px 20px; border-top: 1px solid var(--border); }
.later-task-item { padding: 8px 0; border-bottom: 1px dashed var(--border); display: flex; justify-content: space-between; align-items: center; }
.later-task-item:last-child { border-bottom: none; }
.later-task-title { font-size: 0.95rem; color: var(--text); }
.later-task-actions .task-btn { font-size: 0.75rem; padding: 5px 10px; }
.empty-state { text-align: center; color: var(--text-dim); padding: 40px 20px; font-size: 1rem; }
.ritual-btn { display: block; width: fit-content; margin: 20px auto; padding: 10px 20px; background: var(--purple); color: white; border-radius: var(--radius-md); text-decoration: none; font-weight: 600; }
.ritual-btn:hover { opacity: 0.9; }
/* Inter font - assuming it's available or linked in base.html */
body { font-family: 'Inter', sans-serif; }
</style>
{% endblock %}
{% block content %}
<div class="calm-container py-3">
<div class="calm-header">
<h1 class="calm-title">Timmy Calm</h1>
<p class="calm-subtitle">Your focused attention stack</p>
</div>
<div id="now-next-later-stack" hx-trigger="load, taskPromoted from:body" hx-get="/calm/partials/now_next_later" hx-swap="outerHTML">
{% include "calm/partials/now_next_later.html" %}
</div>
{% if not now_task and not next_task and later_tasks_count == 0 %}
<div class="empty-state">
<p>It looks like you have no tasks. Start your day with a morning ritual!</p>
<a href="/calm/ritual/morning" class="ritual-btn">Start Morning Ritual</a>
</div>
{% endif %}
<div class="add-task-section" style="text-align: center; margin-top: 30px;">
<button class="task-btn task-btn-defer" onclick="openAddTaskModal()">+ Add New Task</button>
</div>
<!-- Add Task Modal (simple example, can be expanded) -->
<div id="add-task-modal" class="task-modal-overlay">
<div class="task-modal">
<h3>Add New Task</h3>
<form hx-post="/calm/tasks" hx-target="#later-count-container" hx-swap="outerHTML" hx-on::after-request="closeAddTaskModal()">
<label>Title</label>
<input type="text" name="title" required>
<label>Description (optional)</label>
<textarea name="description"></textarea>
<label>
<input type="checkbox" name="is_mit" value="true"> Is this a Most Important Task (MIT)?
</label>
<div class="task-modal-actions">
<button type="button" class="task-btn task-btn-cancel" onclick="closeAddTaskModal()">Cancel</button>
<button type="submit" class="task-btn task-btn-complete">Add Task</button>
</div>
</form>
</div>
</div>
</div>
<script>
function openAddTaskModal() {
document.getElementById('add-task-modal').classList.add('open');
}
function closeAddTaskModal() {
document.getElementById('add-task-modal').classList.remove('open');
}
document.getElementById('add-task-modal').addEventListener('click', function(e) {
if (e.target === this) closeAddTaskModal();
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeAddTaskModal();
});
</script>
{% endblock %}

View File

@@ -0,0 +1,24 @@
{% extends "base.html" %}
{% block title %}Evening Ritual Complete - Timmy Calm{% endblock %}
{% block extra_styles %}
<style>
.ritual-container { max-width: 700px; margin: 0 auto; padding: 30px; background: var(--bg-secondary); border-radius: var(--radius-lg); box-shadow: 0 5px 20px rgba(0,0,0,0.2); text-align: center; }
.ritual-title { font-size: 2rem; font-weight: 700; color: var(--green); margin-bottom: 20px; }
.ritual-message { font-size: 1.1rem; color: var(--text); line-height: 1.6; margin-bottom: 30px; }
.ritual-btn { display: inline-block; padding: 12px 25px; font-size: 1rem; font-weight: 700; border-radius: var(--radius-md); cursor: pointer; transition: all 0.2s ease; border: none; background: var(--purple); color: white; text-decoration: none; }
.ritual-btn:hover { opacity: 0.9; }
</style>
{% endblock %}
{% block content %}
<div class="ritual-container">
<h1 class="ritual-title">✓ Evening Ritual Complete</h1>
<p class="ritual-message">
You've reflected on your day and archived your tasks. Rest well — tomorrow is a fresh start.
</p>
<a href="/calm" class="ritual-btn">Return to Calm</a>
</div>
{% endblock %}

View File

@@ -0,0 +1,54 @@
{% extends "base.html" %}
{% block title %}Evening Ritual - Timmy Calm{% endblock %}
{% block extra_styles %}
<style>
.ritual-container { max-width: 700px; margin: 0 auto; padding: 30px; background: var(--bg-secondary); border-radius: var(--radius-lg); box-shadow: 0 5px 20px rgba(0,0,0,0.2); }
.ritual-header { text-align: center; margin-bottom: 30px; }
.ritual-title { font-size: 2rem; font-weight: 700; color: var(--text-bright); margin-bottom: 10px; }
.ritual-subtitle { font-size: 1rem; color: var(--text-dim); line-height: 1.5; }
.form-group { margin-bottom: 20px; }
.form-group label { display: block; font-size: 0.9rem; color: var(--text-dim); margin-bottom: 8px; font-weight: 600; }
.form-group input[type="text"], .form-group textarea, .form-group input[type="number"] { width: 100%; padding: 12px; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--bg-tertiary); color: var(--text); font-size: 1rem; }
.form-group textarea { min-height: 100px; resize: vertical; }
.form-group input[type="text"]:focus, .form-group textarea:focus, .form-group input[type="number"]:focus { border-color: var(--purple); outline: none; box-shadow: 0 0 0 2px rgba(124, 58, 237, 0.2); }
.form-actions { display: flex; justify-content: flex-end; margin-top: 30px; }
.form-actions button { padding: 12px 25px; font-size: 1rem; font-weight: 700; border-radius: var(--radius-md); cursor: pointer; transition: all 0.2s ease; border: none; }
.form-actions .btn-submit { background: var(--green); color: var(--bg-secondary); }
.form-actions .btn-submit:hover { background: var(--green-dark); }
</style>
{% endblock %}
{% block content %}
<div class="ritual-container">
<div class="ritual-header">
<h1 class="ritual-title">Good Evening, Timmy.</h1>
<p class="ritual-subtitle">Reflect on your day and prepare for tomorrow.</p>
</div>
<form hx-post="/calm/ritual/evening" hx-swap="outerHTML" hx-target="body">
<div class="form-group">
<label for="evening_reflection">Evening Reflection</label>
<textarea id="evening_reflection" name="evening_reflection" placeholder="What went well today? What could be improved?" rows="5">{{ journal_entry.evening_reflection if journal_entry else '' }}</textarea>
</div>
<div class="form-group">
<label for="gratitude">Gratitude</label>
<input type="text" id="gratitude" name="gratitude" placeholder="What are you grateful for today?" value="{{ journal_entry.gratitude if journal_entry else '' }}">
</div>
<div class="form-group">
<label for="energy_level">Energy Level (1-10)</label>
<input type="number" id="energy_level" name="energy_level" min="1" max="10" placeholder="e.g., 7" value="{{ journal_entry.energy_level if journal_entry else '' }}">
</div>
<div class="form-actions">
<button type="submit" class="btn-submit">Complete Evening Ritual</button>
</div>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,66 @@
{% extends "base.html" %}
{% block title %}Morning Ritual - Timmy Calm{% endblock %}
{% block extra_styles %}
<style>
.ritual-container { max-width: 700px; margin: 0 auto; padding: 30px; background: var(--bg-secondary); border-radius: var(--radius-lg); box-shadow: 0 5px 20px rgba(0,0,0,0.2); }
.ritual-header { text-align: center; margin-bottom: 30px; }
.ritual-title { font-size: 2rem; font-weight: 700; color: var(--text-bright); margin-bottom: 10px; }
.ritual-subtitle { font-size: 1rem; color: var(--text-dim); line-height: 1.5; }
.form-group { margin-bottom: 20px; }
.form-group label { display: block; font-size: 0.9rem; color: var(--text-dim); margin-bottom: 8px; font-weight: 600; }
.form-group input[type="text"], .form-group textarea { width: 100%; padding: 12px; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--bg-tertiary); color: var(--text); font-size: 1rem; }
.form-group textarea { min-height: 100px; resize: vertical; }
.form-group input[type="text"]:focus, .form-group textarea:focus { border-color: var(--purple); outline: none; box-shadow: 0 0 0 2px rgba(124, 58, 237, 0.2); }
.mit-section { border: 1px dashed var(--border); padding: 20px; border-radius: var(--radius-md); margin-top: 25px; background: rgba(124, 58, 237, 0.05); }
.mit-section h4 { color: var(--purple); margin-top: 0; margin-bottom: 15px; font-size: 1.1rem; }
.form-actions { display: flex; justify-content: flex-end; margin-top: 30px; }
.form-actions button { padding: 12px 25px; font-size: 1rem; font-weight: 700; border-radius: var(--radius-md); cursor: pointer; transition: all 0.2s ease; border: none; }
.form-actions .btn-submit { background: var(--green); color: var(--bg-secondary); }
.form-actions .btn-submit:hover { background: var(--green-dark); }
</style>
{% endblock %}
{% block content %}
<div class="ritual-container">
<div class="ritual-header">
<h1 class="ritual-title">Good Morning, Timmy.</h1>
<p class="ritual-subtitle">Let's set your intentions for a calm and focused day.</p>
</div>
<form hx-post="/calm/ritual/morning" hx-swap="outerHTML" hx-target="body">
<div class="mit-section">
<h4>Your 1-3 Most Important Tasks (MITs) for today:</h4>
<div class="form-group">
<label for="mit1_title">MIT 1</label>
<input type="text" id="mit1_title" name="mit1_title" placeholder="e.g., Finish report draft">
</div>
<div class="form-group">
<label for="mit2_title">MIT 2 (optional)</label>
<input type="text" id="mit2_title" name="mit2_title" placeholder="e.g., Prepare for client meeting">
</div>
<div class="form-group">
<label for="mit3_title">MIT 3 (optional)</label>
<input type="text" id="mit3_title" name="mit3_title" placeholder="e.g., Review team's code">
</div>
</div>
<div class="form-group" style="margin-top: 30px;">
<label for="other_tasks">Other tasks or intentions (one per line)</label>
<textarea id="other_tasks" name="other_tasks" placeholder="e.g.,
Reply to emails
Schedule dentist appointment
Research new framework"></textarea>
</div>
<div class="form-actions">
<button type="submit" class="btn-submit">Start My Day</button>
</div>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1 @@
<span id="later-count-container">{{ later_tasks_count }}</span>

View File

@@ -0,0 +1,19 @@
{% if later_tasks %}
<form hx-post="/calm/tasks/reorder" hx-target="#now-next-later-stack" hx-swap="outerHTML">
<ul class="later-task-list">
{% for task in later_tasks %}
<li class="later-task-item">
<span class="later-task-title">{{ task.title }}</span>
<div class="later-task-actions">
<button type="submit" name="next_task_id" value="{{ task.id }}" class="task-btn task-btn-approve">Make Next</button>
</div>
</li>
{% endfor %}
</ul>
<!-- Hidden input to send all later task IDs for reordering, if drag-and-drop were implemented -->
<input type="hidden" name="later_task_ids" value="{% for task in later_tasks %}{{ task.id }}{% if not loop.last %},{% endif %}{% endfor %}">
</form>
{% else %}
<div class="empty-state">No tasks in Later.</div>
{% endif %}

View File

@@ -0,0 +1,50 @@
<div id="now-next-later-stack">
{% if now_task %}
<div class="task-card now-card">
<h2 class="task-title">{{ now_task.title }}</h2>
{% if now_task.description %}
<p class="task-description">{{ now_task.description }}</p>
{% endif %}
<div class="task-actions">
<button class="task-btn task-btn-complete"
hx-post="/calm/tasks/{{ now_task.id }}/complete"
hx-target="#now-next-later-stack"
hx-swap="outerHTML">
Complete
</button>
<button class="task-btn task-btn-defer"
hx-post="/calm/tasks/{{ now_task.id }}/defer"
hx-target="#now-next-later-stack"
hx-swap="outerHTML">
Defer
</button>
</div>
</div>
{% else %}
<div class="empty-state">
<p>No task is NOW. Time to pick one or start your morning ritual!</p>
</div>
{% endif %}
{% if next_task %}
<div class="task-card next-card">
<h3 class="task-title">Next: {{ next_task.title }}</h3>
{% if next_task.description %}
<p class="task-description">{{ next_task.description }}</p>
{% endif %}
</div>
{% endif %}
<div class="later-section">
<details hx-get="/calm/partials/later_tasks_list" hx-trigger="toggle" hx-target="#later-content" hx-swap="innerHTML">
<summary class="later-summary">
Later ({{ later_tasks_count }} items)
<span id="later-count-container"></span>
</summary>
<div id="later-content" class="later-content">
<!-- Later tasks will be loaded here via HTMX -->
</div>
</details>
</div>
</div>

View File

@@ -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