feat: Implement Minimum Viable Calm (MVC) feature and initial tests
This commit is contained in:
149
alembic.ini
Normal file
149
alembic.ini
Normal 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
1
migrations/README
Normal file
@@ -0,0 +1 @@
|
||||
Generic single-database configuration with an async dbapi.
|
||||
80
migrations/env.py
Normal file
80
migrations/env.py
Normal 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
28
migrations/script.py.mako
Normal 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"}
|
||||
@@ -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
0
src/__init__.py
Normal 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)
|
||||
|
||||
|
||||
|
||||
60
src/dashboard/models/calm.py
Normal file
60
src/dashboard/models/calm.py
Normal 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)
|
||||
20
src/dashboard/models/database.py
Normal file
20
src/dashboard/models/database.py
Normal 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()
|
||||
@@ -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__)
|
||||
|
||||
380
src/dashboard/routes/calm.py
Normal file
380
src/dashboard/routes/calm.py
Normal 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)
|
||||
@@ -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>
|
||||
|
||||
127
src/dashboard/templates/calm/calm_view.html
Normal file
127
src/dashboard/templates/calm/calm_view.html
Normal 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 %}
|
||||
24
src/dashboard/templates/calm/evening_ritual_complete.html
Normal file
24
src/dashboard/templates/calm/evening_ritual_complete.html
Normal 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 %}
|
||||
54
src/dashboard/templates/calm/evening_ritual_form.html
Normal file
54
src/dashboard/templates/calm/evening_ritual_form.html
Normal 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 %}
|
||||
66
src/dashboard/templates/calm/morning_ritual_form.html
Normal file
66
src/dashboard/templates/calm/morning_ritual_form.html
Normal 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 %}
|
||||
1
src/dashboard/templates/calm/partials/later_count.html
Normal file
1
src/dashboard/templates/calm/partials/later_count.html
Normal file
@@ -0,0 +1 @@
|
||||
<span id="later-count-container">{{ later_tasks_count }}</span>
|
||||
19
src/dashboard/templates/calm/partials/later_tasks_list.html
Normal file
19
src/dashboard/templates/calm/partials/later_tasks_list.html
Normal 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 %}
|
||||
50
src/dashboard/templates/calm/partials/now_next_later.html
Normal file
50
src/dashboard/templates/calm/partials/now_next_later.html
Normal 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>
|
||||
229
tests/dashboard/test_calm.py
Normal file
229
tests/dashboard/test_calm.py
Normal 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
|
||||
Reference in New Issue
Block a user