diff --git a/backend/migrations/033_unified_schedules.sql b/backend/migrations/033_unified_schedules.sql new file mode 100644 index 0000000..bdc2d2a --- /dev/null +++ b/backend/migrations/033_unified_schedules.sql @@ -0,0 +1,51 @@ +-- Unified Schedule & Cadence Layer (ADP v0.1) + +CREATE TABLE schedules ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + schedule_kind VARCHAR(16) NOT NULL DEFAULT 'interval' + CHECK (schedule_kind IN ('interval', 'weekdays')), + interval_days INT NULL CHECK (interval_days IS NULL OR interval_days > 0), + weekday_mask INT NOT NULL DEFAULT 0, + pause_until DATE NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_schedules_tenant ON schedules(tenant_id); + +ALTER TABLE recurring_elements + ADD COLUMN IF NOT EXISTS schedule_id UUID NULL + REFERENCES schedules(id) ON DELETE SET NULL; + +ALTER TABLE recurring_elements + ADD COLUMN IF NOT EXISTS action_id UUID NULL + REFERENCES actions(id) ON DELETE CASCADE; + +CREATE UNIQUE INDEX idx_recurring_elements_action + ON recurring_elements(action_id) + WHERE action_id IS NOT NULL; + +ALTER TABLE actions + ADD COLUMN IF NOT EXISTS schedule_id UUID NULL + REFERENCES schedules(id) ON DELETE SET NULL; + +-- Backfill: one schedule per recurring without schedule_id +DO $$ +DECLARE + r RECORD; + sid UUID; + idays INT; +BEGIN + FOR r IN + SELECT id, tenant_id, interval_days + FROM recurring_elements + WHERE schedule_id IS NULL + LOOP + idays := GREATEST(COALESCE(r.interval_days, 1), 1); + INSERT INTO schedules (tenant_id, schedule_kind, interval_days, weekday_mask) + VALUES (r.tenant_id, 'interval', idays, 0) + RETURNING id INTO sid; + UPDATE recurring_elements SET schedule_id = sid WHERE id = r.id; + END LOOP; +END $$; diff --git a/backend/routers/actions.py b/backend/routers/actions.py index f5cc923..4eeb5cf 100644 --- a/backend/routers/actions.py +++ b/backend/routers/actions.py @@ -9,7 +9,9 @@ from capabilities import require_capability from data_layer import actions as dl_actions from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field +from schemas.schedule_payload import SchedulePayload from services import actions as action_service +from services import scheduled_work as scheduled_work_service from services import tasks as task_service from tenant_context import TenantContext @@ -155,6 +157,47 @@ def update_action( return item +@router.put("/{action_id}/schedule") +def upsert_action_schedule( + action_id: str, + body: SchedulePayload, + ctx: TenantContext = Depends(require_capability("kairo.action.manage")), +): + try: + return scheduled_work_service.upsert_action_schedule( + tenant_id=ctx.tenant_id, + action_id=action_id, + user_id=ctx.user_id, + schedule_kind=body.schedule_kind, + interval_days=body.interval_days, + weekday_mask=body.weekday_mask, + pause_until=body.pause_until_date(), + ) + except ValueError as exc: + detail = str(exc) + if detail == "Arbeitspaket nicht gefunden": + raise HTTPException(status_code=404, detail=detail) from exc + raise HTTPException(status_code=400, detail=detail) from exc + + +@router.delete("/{action_id}/schedule", status_code=200) +def clear_action_schedule( + action_id: str, + ctx: TenantContext = Depends(require_capability("kairo.action.manage")), +): + try: + return scheduled_work_service.clear_action_schedule( + tenant_id=ctx.tenant_id, + action_id=action_id, + user_id=ctx.user_id, + ) + except ValueError as exc: + detail = str(exc) + if detail == "Arbeitspaket nicht gefunden": + raise HTTPException(status_code=404, detail=detail) from exc + raise HTTPException(status_code=400, detail=detail) from exc + + @router.put("/{action_id}/assignments") def set_action_assignments( action_id: str, diff --git a/backend/routers/recurring.py b/backend/routers/recurring.py index a45e361..54cd8e4 100644 --- a/backend/routers/recurring.py +++ b/backend/routers/recurring.py @@ -8,6 +8,7 @@ from typing import Literal, Optional from capabilities import require_capability from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field +from schemas.schedule_payload import SchedulePayload from services import recurring as recurring_service from tenant_context import TenantContext @@ -24,6 +25,7 @@ class RecurringUpdateRequest(BaseModel): clear_next_due_at: bool = False roadmap_item_id: Optional[str] = None clear_roadmap_item_id: bool = False + schedule: Optional[SchedulePayload] = None class RecurringCompleteRequest(BaseModel): @@ -59,6 +61,7 @@ def update_recurring_element( ctx: TenantContext = Depends(require_capability("kairo.recurring.manage")), ): next_due_at = _parse_datetime(body.next_due_at) if body.next_due_at is not None else None + sched = body.schedule try: item = recurring_service.update_recurring_element( tenant_id=ctx.tenant_id, @@ -67,12 +70,16 @@ def update_recurring_element( title=body.title, description=body.description, status=body.status, - interval_days=body.interval_days, + interval_days=body.interval_days if sched is None else sched.interval_days, clear_interval_days=body.clear_interval_days, next_due_at=next_due_at, clear_next_due_at=body.clear_next_due_at, roadmap_item_id=body.roadmap_item_id, clear_roadmap_item_id=body.clear_roadmap_item_id, + schedule_kind=sched.schedule_kind if sched else None, + weekday_mask=sched.weekday_mask if sched else None, + pause_until=sched.pause_until_date() if sched else None, + clear_pause_until=sched is not None and sched.pause_until is None, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc diff --git a/backend/routers/roadmap.py b/backend/routers/roadmap.py index 1b7905e..4ff654e 100644 --- a/backend/routers/roadmap.py +++ b/backend/routers/roadmap.py @@ -8,6 +8,7 @@ from typing import Any, Literal, Optional from capabilities import require_capability from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field +from schemas.schedule_payload import SchedulePayload from services import roadmap as roadmap_service from services import roadmap_criteria as criteria_service from services import roadmap_plan_snapshot as snapshot_service @@ -92,6 +93,7 @@ class GatePracticeCreateRequest(BaseModel): description: str = "" status: Literal["active", "paused", "ended"] = "active" interval_days: int = Field(default=1, gt=0) + schedule: Optional[SchedulePayload] = None class PlanSnapshotCreateRequest(BaseModel): @@ -352,12 +354,25 @@ def create_gate_practice( body: GatePracticeCreateRequest, ctx: TenantContext = Depends(require_capability("kairo.recurring.manage")), ): - from datetime import datetime, timezone - from services import recurring as recurring_service item = _work_gate_or_400(tenant_id=ctx.tenant_id, item_id=item_id) + sched = body.schedule try: + if sched: + return recurring_service.create_recurring_element( + tenant_id=ctx.tenant_id, + initiative_id=str(item["initiative_id"]), + title=body.title, + description=body.description, + status=body.status, + schedule_kind=sched.schedule_kind, + interval_days=sched.interval_days, + weekday_mask=sched.weekday_mask, + pause_until=sched.pause_until_date(), + roadmap_item_id=item_id, + user_id=ctx.user_id, + ) return recurring_service.create_recurring_element( tenant_id=ctx.tenant_id, initiative_id=str(item["initiative_id"]), @@ -365,7 +380,6 @@ def create_gate_practice( description=body.description, status=body.status, interval_days=body.interval_days, - next_due_at=datetime.now(timezone.utc), roadmap_item_id=item_id, user_id=ctx.user_id, ) diff --git a/backend/schemas/schedule_payload.py b/backend/schemas/schedule_payload.py new file mode 100644 index 0000000..19bb1ea --- /dev/null +++ b/backend/schemas/schedule_payload.py @@ -0,0 +1,25 @@ +"""Shared Pydantic models for Schedule API payloads.""" + +from __future__ import annotations + +from typing import Literal, Optional + +from pydantic import BaseModel, Field, model_validator + + +class SchedulePayload(BaseModel): + schedule_kind: Literal["interval", "weekdays"] = "interval" + interval_days: int = Field(default=1, gt=0) + weekday_mask: int = Field(default=0, ge=0, le=127) + pause_until: Optional[str] = Field(default=None, description="ISO date YYYY-MM-DD") + + @model_validator(mode="after") + def validate_kind(self) -> "SchedulePayload": + if self.schedule_kind == "weekdays" and self.weekday_mask <= 0: + raise ValueError("Mindestens ein Wochentag auswählen") + return self + + def pause_until_date(self): + from services.schedule import _parse_pause_until + + return _parse_pause_until(self.pause_until) diff --git a/backend/services/actions.py b/backend/services/actions.py index fcf2c6f..5449713 100644 --- a/backend/services/actions.py +++ b/backend/services/actions.py @@ -25,8 +25,9 @@ OPEN_ACTION_STATUSES = frozenset( ) _ACTION_COLUMNS = """ - id, tenant_id, initiative_id, project_id, roadmap_item_id, work_cycle_id, title, description, - status, priority, due_at, sort_order, action_kind, parent_action_id, created_at, updated_at + id, tenant_id, initiative_id, project_id, roadmap_item_id, work_cycle_id, schedule_id, + title, description, status, priority, due_at, sort_order, action_kind, parent_action_id, + created_at, updated_at """ ACTION_KINDS = frozenset({"delivery", "planning", "review", "bug", "issue", "tech_debt"}) @@ -47,6 +48,7 @@ def _serialize_row(row: dict[str, Any]) -> dict[str, Any]: "work_cycle_id", "parent_action_id", "owner_actor_id", + "schedule_id", ): if result.get(key): result[key] = str(result[key]) @@ -337,7 +339,10 @@ def get_action(*, tenant_id: str, action_id: str) -> Optional[dict[str, Any]]: action = _serialize_row(dict(row)) finally: conn.close() - return _attach_assignments([action], tenant_id=tenant_id)[0] + enriched = _attach_assignments([action], tenant_id=tenant_id)[0] + from services.scheduled_work import attach_action_schedule_fields + + return attach_action_schedule_fields(enriched, tenant_id=tenant_id) def update_action( diff --git a/backend/services/cadence_instance.py b/backend/services/cadence_instance.py index 96da315..fcc95cf 100644 --- a/backend/services/cadence_instance.py +++ b/backend/services/cadence_instance.py @@ -86,7 +86,16 @@ def ensure_open_instance( return row -def _next_due_at(*, now: datetime, interval_days: int | None) -> datetime: +def _next_due_at( + *, + now: datetime, + interval_days: int | None = None, + schedule: dict[str, Any] | None = None, +) -> datetime: + if schedule: + from services.schedule import next_due_after_completion + + return next_due_after_completion(schedule=schedule, completed_at=now) days = max(int(interval_days or 1), 1) next_date = now.date() + timedelta(days=days) return datetime.combine(next_date, time.min, tzinfo=timezone.utc) @@ -161,7 +170,10 @@ def complete_open_instance( conn.close() interval = recurring.get("interval_days") or 1 - next_due = _next_due_at(now=now, interval_days=interval) + from services.schedule import resolve_schedule_for_recurring + + schedule = resolve_schedule_for_recurring(recurring, tenant_id=tenant_id) + next_due = _next_due_at(now=now, interval_days=interval, schedule=schedule) update_recurring_element( tenant_id=tenant_id, diff --git a/backend/services/recurring.py b/backend/services/recurring.py index 2ffc531..39d087d 100644 --- a/backend/services/recurring.py +++ b/backend/services/recurring.py @@ -16,14 +16,14 @@ RecurringStatus = Literal["active", "paused", "ended"] RECURRING_STATUSES = frozenset({"active", "paused", "ended"}) _RECURRING_COLUMNS = """ - id, tenant_id, initiative_id, roadmap_item_id, project_id, title, description, status, - interval_days, next_due_at, created_at, updated_at + id, tenant_id, initiative_id, roadmap_item_id, project_id, action_id, schedule_id, + title, description, status, interval_days, next_due_at, created_at, updated_at """ def _serialize_row(row: dict[str, Any]) -> dict[str, Any]: result = dict(row) - for key in ("id", "tenant_id", "initiative_id", "roadmap_item_id", "project_id"): + for key in ("id", "tenant_id", "initiative_id", "roadmap_item_id", "project_id", "action_id", "schedule_id"): if result.get(key): result[key] = str(result[key]) for ts_key in ("created_at", "updated_at", "next_due_at"): @@ -48,6 +48,10 @@ def create_recurring_element( next_due_at: Optional[datetime] = None, roadmap_item_id: Optional[str] = None, project_id: Optional[str] = None, + schedule_id: Optional[str] = None, + schedule_kind: str = "interval", + weekday_mask: int = 0, + pause_until=None, user_id: Optional[str] = None, ) -> dict[str, Any]: title = title.strip() @@ -59,16 +63,40 @@ def create_recurring_element( if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id): raise ValueError("Initiative nicht gefunden") + from services.schedule import create_schedule, initial_due_at + + schedule = None + if schedule_id: + from services.schedule import get_schedule + + schedule = get_schedule(tenant_id=tenant_id, schedule_id=schedule_id) + if not schedule: + raise ValueError("Schedule nicht gefunden") + else: + days = interval_days if interval_days is not None else 1 + schedule = create_schedule( + tenant_id=tenant_id, + schedule_kind=schedule_kind, # type: ignore[arg-type] + interval_days=days, + weekday_mask=weekday_mask, + pause_until=pause_until, + ) + schedule_id = schedule["id"] + interval_days = schedule.get("interval_days") or days + + if next_due_at is None: + next_due_at = initial_due_at(schedule=schedule) + conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( f""" INSERT INTO recurring_elements ( - tenant_id, initiative_id, roadmap_item_id, project_id, title, description, status, - interval_days, next_due_at + tenant_id, initiative_id, roadmap_item_id, project_id, schedule_id, + title, description, status, interval_days, next_due_at ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING {_RECURRING_COLUMNS} """, ( @@ -76,6 +104,7 @@ def create_recurring_element( initiative_id, roadmap_item_id, project_id, + schedule_id, title, description, status, @@ -97,13 +126,13 @@ def create_recurring_element( from services.cadence_instance import ensure_open_instance - due = next_due_at or datetime.now(timezone.utc) open_inst = ensure_open_instance( tenant_id=tenant_id, recurring_element_id=row["id"], - due_at=due, + due_at=next_due_at, ) row["open_cadence_instance"] = open_inst + row["schedule"] = schedule return row @@ -130,7 +159,9 @@ def list_recurring_for_initiative( conn.close() from services.cadence_instance import attach_open_instances_to_recurring_list + from services.schedule import attach_schedules_to_items + rows = attach_schedules_to_items(rows, tenant_id=tenant_id) return attach_open_instances_to_recurring_list(rows, tenant_id=tenant_id) @@ -158,10 +189,17 @@ def get_recurring_element( (recurring_id, tenant_id), ) row = cur.fetchone() - return _serialize_row(dict(row)) if row else None + if not row: + return None + result = _serialize_row(dict(row)) finally: conn.close() + from services.schedule import attach_schedules_to_items + + enriched = attach_schedules_to_items([result], tenant_id=tenant_id) + return enriched[0] if enriched else result + def update_recurring_element( *, @@ -179,6 +217,10 @@ def update_recurring_element( clear_roadmap_item_id: bool = False, project_id: Optional[str] = None, clear_project_id: bool = False, + schedule_kind: Optional[str] = None, + weekday_mask: Optional[int] = None, + pause_until=None, + clear_pause_until: bool = False, ) -> Optional[dict[str, Any]]: existing = get_recurring_element(tenant_id=tenant_id, recurring_id=recurring_id) if not existing: @@ -224,7 +266,39 @@ def update_recurring_element( updates.append("project_id = %s") params.append(project_id) - if not updates: + if not updates and schedule_kind is None and weekday_mask is None and not clear_pause_until and pause_until is None: + return existing + + schedule_updated = False + if existing.get("schedule_id") and ( + schedule_kind is not None + or weekday_mask is not None + or pause_until is not None + or clear_pause_until + or interval_days is not None + ): + from services.schedule import initial_due_at, update_schedule + + sched = update_schedule( + tenant_id=tenant_id, + schedule_id=str(existing["schedule_id"]), + schedule_kind=schedule_kind, # type: ignore[arg-type] + interval_days=interval_days, + weekday_mask=weekday_mask, + pause_until=pause_until, + clear_pause_until=clear_pause_until, + ) + if sched: + schedule_updated = True + if interval_days is not None and sched.get("interval_days"): + updates.append("interval_days = %s") + params.append(sched["interval_days"]) + if next_due_at is None: + next_due_at = initial_due_at(schedule=sched) + updates.append("next_due_at = %s") + params.append(next_due_at) + + if not updates and not schedule_updated: return existing updates.append("updated_at = NOW()") @@ -267,7 +341,7 @@ def update_recurring_element( "to_status": status, }, ) - return result + return get_recurring_element(tenant_id=tenant_id, recurring_id=recurring_id) or result def delete_recurring_element( diff --git a/backend/services/schedule.py b/backend/services/schedule.py new file mode 100644 index 0000000..499ef4e --- /dev/null +++ b/backend/services/schedule.py @@ -0,0 +1,342 @@ +"""Unified Schedule service — ADP v0.1.""" + +from __future__ import annotations + +from datetime import date, datetime, time, timedelta, timezone +from typing import Any, Literal, Optional + +from psycopg2.extras import RealDictCursor + +from db import get_connection + +ScheduleKind = Literal["interval", "weekdays"] + +_SCHEDULE_COLUMNS = """ + id, tenant_id, schedule_kind, interval_days, weekday_mask, pause_until, + created_at, updated_at +""" + +WEEKDAY_LABELS = ("Mo", "Di", "Mi", "Do", "Fr", "Sa", "So") + + +def weekday_bit(weekday: int) -> int: + """ISO weekday: Monday=0 … Sunday=6.""" + return 1 << weekday + + +def _serialize_row(row: dict[str, Any]) -> dict[str, Any]: + result = dict(row) + for key in ("id", "tenant_id"): + if result.get(key): + result[key] = str(result[key]) + for ts_key in ("created_at", "updated_at"): + if result.get(ts_key): + result[ts_key] = result[ts_key].isoformat() + if result.get("pause_until"): + result["pause_until"] = result["pause_until"].isoformat() + return result + + +def validate_schedule_payload( + *, + schedule_kind: str, + interval_days: Optional[int], + weekday_mask: int, +) -> None: + if schedule_kind not in ("interval", "weekdays"): + raise ValueError("Ungültiger schedule_kind") + if schedule_kind == "interval": + days = int(interval_days or 1) + if days <= 0: + raise ValueError("interval_days muss positiv sein") + elif weekday_mask <= 0 or weekday_mask > 127: + raise ValueError("Mindestens ein Wochentag auswählen") + + +def format_schedule_label(schedule: dict[str, Any]) -> str: + kind = schedule.get("schedule_kind") or "interval" + if kind == "weekdays": + mask = int(schedule.get("weekday_mask") or 0) + days = [WEEKDAY_LABELS[i] for i in range(7) if mask & weekday_bit(i)] + label = ", ".join(days) if days else "Wochentage" + else: + days = max(int(schedule.get("interval_days") or 1), 1) + if days == 1: + label = "Täglich" + elif days == 7: + label = "Wöchentlich" + else: + label = f"Alle {days} Tage" + pause = schedule.get("pause_until") + if pause: + label = f"{label} (Pause bis {pause})" + return label + + +def _parse_pause_until(value: Any) -> Optional[date]: + if value is None: + return None + if isinstance(value, date) and not isinstance(value, datetime): + return value + if isinstance(value, datetime): + return value.date() + text = str(value).strip() + if not text: + return None + return date.fromisoformat(text[:10]) + + +def is_paused_on(schedule: dict[str, Any], on_date: date) -> bool: + pause = _parse_pause_until(schedule.get("pause_until")) + return pause is not None and on_date <= pause + + +def _weekday_matches(schedule: dict[str, Any], on_date: date) -> bool: + mask = int(schedule.get("weekday_mask") or 0) + return bool(mask & weekday_bit(on_date.weekday())) + + +def _as_utc_midnight(d: date) -> datetime: + return datetime.combine(d, time.min, tzinfo=timezone.utc) + + +def _next_weekday_on_or_after( + start: date, *, weekday_mask: int, pause_until: Optional[date] +) -> date: + for offset in range(366): + candidate = start + timedelta(days=offset) + if pause_until is not None and candidate <= pause_until: + continue + if weekday_mask & weekday_bit(candidate.weekday()): + return candidate + return start + timedelta(days=7) + + +def initial_due_at(*, schedule: dict[str, Any], now: Optional[datetime] = None) -> datetime: + now = now or datetime.now(timezone.utc) + today = now.date() + if is_paused_on(schedule, today): + pause = _parse_pause_until(schedule.get("pause_until")) + assert pause is not None + start = pause + timedelta(days=1) + else: + start = today + + kind = schedule.get("schedule_kind") or "interval" + if kind == "weekdays": + mask = int(schedule.get("weekday_mask") or 0) + pause = _parse_pause_until(schedule.get("pause_until")) + due_date = _next_weekday_on_or_after(start, weekday_mask=mask, pause_until=pause) + return _as_utc_midnight(due_date) + + return _as_utc_midnight(start) + + +def next_due_after_completion( + *, schedule: dict[str, Any], completed_at: Optional[datetime] = None +) -> datetime: + now = completed_at or datetime.now(timezone.utc) + today = now.date() + kind = schedule.get("schedule_kind") or "interval" + + if kind == "weekdays": + mask = int(schedule.get("weekday_mask") or 0) + pause = _parse_pause_until(schedule.get("pause_until")) + start = today + timedelta(days=1) + due_date = _next_weekday_on_or_after(start, weekday_mask=mask, pause_until=pause) + return _as_utc_midnight(due_date) + + days = max(int(schedule.get("interval_days") or 1), 1) + next_date = today + timedelta(days=days) + pause = _parse_pause_until(schedule.get("pause_until")) + if pause is not None and next_date <= pause: + next_date = pause + timedelta(days=1) + return _as_utc_midnight(next_date) + + +def create_schedule( + *, + tenant_id: str, + schedule_kind: ScheduleKind = "interval", + interval_days: Optional[int] = 1, + weekday_mask: int = 0, + pause_until: Optional[date] = None, +) -> dict[str, Any]: + validate_schedule_payload( + schedule_kind=schedule_kind, + interval_days=interval_days, + weekday_mask=weekday_mask, + ) + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + f""" + INSERT INTO schedules ( + tenant_id, schedule_kind, interval_days, weekday_mask, pause_until + ) + VALUES (%s, %s, %s, %s, %s) + RETURNING {_SCHEDULE_COLUMNS} + """, + ( + tenant_id, + schedule_kind, + interval_days if schedule_kind == "interval" else None, + weekday_mask, + pause_until, + ), + ) + row = _serialize_row(dict(cur.fetchone())) + conn.commit() + finally: + conn.close() + row["label"] = format_schedule_label(row) + return row + + +def get_schedule(*, tenant_id: str, schedule_id: str) -> Optional[dict[str, Any]]: + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + f""" + SELECT {_SCHEDULE_COLUMNS} + FROM schedules + WHERE id = %s AND tenant_id = %s + """, + (schedule_id, tenant_id), + ) + row = cur.fetchone() + if not row: + return None + result = _serialize_row(dict(row)) + result["label"] = format_schedule_label(result) + return result + finally: + conn.close() + + +def update_schedule( + *, + tenant_id: str, + schedule_id: str, + schedule_kind: Optional[ScheduleKind] = None, + interval_days: Optional[int] = None, + weekday_mask: Optional[int] = None, + pause_until: Optional[date] = None, + clear_pause_until: bool = False, +) -> Optional[dict[str, Any]]: + existing = get_schedule(tenant_id=tenant_id, schedule_id=schedule_id) + if not existing: + return None + + kind = schedule_kind or existing["schedule_kind"] + interval = interval_days if interval_days is not None else existing.get("interval_days") + mask = weekday_mask if weekday_mask is not None else int(existing.get("weekday_mask") or 0) + validate_schedule_payload( + schedule_kind=kind, interval_days=interval, weekday_mask=mask + ) + + updates: list[str] = [] + params: list[Any] = [] + if schedule_kind is not None: + updates.append("schedule_kind = %s") + params.append(schedule_kind) + if interval_days is not None or schedule_kind == "weekdays": + updates.append("interval_days = %s") + params.append(None if kind == "weekdays" else interval) + if weekday_mask is not None: + updates.append("weekday_mask = %s") + params.append(mask) + if clear_pause_until: + updates.append("pause_until = NULL") + elif pause_until is not None: + updates.append("pause_until = %s") + params.append(pause_until) + + if not updates: + return existing + + updates.append("updated_at = NOW()") + params.extend([schedule_id, tenant_id]) + + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + f""" + UPDATE schedules + SET {", ".join(updates)} + WHERE id = %s AND tenant_id = %s + RETURNING {_SCHEDULE_COLUMNS} + """, + params, + ) + row = cur.fetchone() + if not row: + return None + result = _serialize_row(dict(row)) + conn.commit() + finally: + conn.close() + result["label"] = format_schedule_label(result) + return result + + +def attach_schedules_to_items( + items: list[dict[str, Any]], *, tenant_id: str, schedule_id_key: str = "schedule_id" +) -> list[dict[str, Any]]: + ids = [ + str(item[schedule_id_key]) + for item in items + if item.get(schedule_id_key) + ] + if not ids: + return [dict(item, schedule=None) for item in items] + + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + f""" + SELECT {_SCHEDULE_COLUMNS} + FROM schedules + WHERE tenant_id = %s AND id = ANY(%s::uuid[]) + """, + (tenant_id, ids), + ) + by_id = {} + for row in cur.fetchall(): + sched = _serialize_row(dict(row)) + sched["label"] = format_schedule_label(sched) + by_id[str(row["id"])] = sched + finally: + conn.close() + + enriched = [] + for item in items: + copy = dict(item) + sid = copy.get(schedule_id_key) + copy["schedule"] = by_id.get(str(sid)) if sid else None + enriched.append(copy) + return enriched + + +def resolve_schedule_for_recurring( + recurring: dict[str, Any], *, tenant_id: str +) -> Optional[dict[str, Any]]: + sid = recurring.get("schedule_id") + if sid: + return get_schedule(tenant_id=tenant_id, schedule_id=str(sid)) + interval = recurring.get("interval_days") + if interval is None: + return None + return { + "schedule_kind": "interval", + "interval_days": max(int(interval), 1), + "weekday_mask": 0, + "pause_until": None, + "label": format_schedule_label( + {"schedule_kind": "interval", "interval_days": interval, "weekday_mask": 0} + ), + } diff --git a/backend/services/scheduled_work.py b/backend/services/scheduled_work.py new file mode 100644 index 0000000..a08826d --- /dev/null +++ b/backend/services/scheduled_work.py @@ -0,0 +1,262 @@ +"""Action ↔ Recurring bridge for unified schedules (ADP v0.1 Slice 2).""" + +from __future__ import annotations + +from typing import Any, Optional + +from psycopg2.extras import RealDictCursor + +from db import get_connection +from services.audit import log_audit +from services.cadence_instance import ensure_open_instance +from services.recurring import ( + create_recurring_element, + get_recurring_element, + update_recurring_element, +) +from services.schedule import ( + create_schedule, + get_schedule, + initial_due_at, + update_schedule, +) + + +def _get_action_row(*, tenant_id: str, action_id: str) -> dict[str, Any]: + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + """ + SELECT id, tenant_id, initiative_id, project_id, roadmap_item_id, schedule_id, + title, description + FROM actions + WHERE id = %s AND tenant_id = %s + """, + (action_id, tenant_id), + ) + row = cur.fetchone() + if not row: + raise ValueError("Arbeitspaket nicht gefunden") + result = dict(row) + for key in ( + "id", + "tenant_id", + "initiative_id", + "project_id", + "roadmap_item_id", + "schedule_id", + ): + if result.get(key): + result[key] = str(result[key]) + return result + finally: + conn.close() + + +def _find_recurring_for_action(*, tenant_id: str, action_id: str) -> Optional[dict[str, Any]]: + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + """ + SELECT id + FROM recurring_elements + WHERE tenant_id = %s AND action_id = %s + LIMIT 1 + """, + (tenant_id, action_id), + ) + row = cur.fetchone() + if not row: + return None + return get_recurring_element(tenant_id=tenant_id, recurring_id=str(row["id"])) + finally: + conn.close() + + +def attach_action_schedule_fields( + action: dict[str, Any], *, tenant_id: str +) -> dict[str, Any]: + schedule = None + if action.get("schedule_id"): + schedule = get_schedule(tenant_id=tenant_id, schedule_id=str(action["schedule_id"])) + bridge = _find_recurring_for_action(tenant_id=tenant_id, action_id=str(action["id"])) + copy = dict(action) + copy["schedule"] = schedule + copy["recurring_id"] = bridge["id"] if bridge else None + return copy + + +def get_action_schedule_bundle(*, tenant_id: str, action_id: str) -> dict[str, Any]: + action = _get_action_row(tenant_id=tenant_id, action_id=action_id) + enriched = attach_action_schedule_fields(action, tenant_id=tenant_id) + bridge = _find_recurring_for_action(tenant_id=tenant_id, action_id=action_id) + return { + "action_id": action_id, + "schedule": enriched.get("schedule"), + "recurring_id": enriched.get("recurring_id"), + "recurring": bridge, + } + + +def upsert_action_schedule( + *, + tenant_id: str, + action_id: str, + user_id: Optional[str] = None, + schedule_kind: str = "interval", + interval_days: Optional[int] = 1, + weekday_mask: int = 0, + pause_until=None, +) -> dict[str, Any]: + action = _get_action_row(tenant_id=tenant_id, action_id=action_id) + existing_schedule_id = action.get("schedule_id") + if existing_schedule_id: + schedule = update_schedule( + tenant_id=tenant_id, + schedule_id=str(existing_schedule_id), + schedule_kind=schedule_kind, # type: ignore[arg-type] + interval_days=interval_days, + weekday_mask=weekday_mask, + pause_until=pause_until, + clear_pause_until=pause_until is None, + ) + else: + schedule = create_schedule( + tenant_id=tenant_id, + schedule_kind=schedule_kind, # type: ignore[arg-type] + interval_days=interval_days, + weekday_mask=weekday_mask, + pause_until=pause_until, + ) + + if not schedule: + raise ValueError("Schedule konnte nicht gespeichert werden") + + next_due = initial_due_at(schedule=schedule) + interval = schedule.get("interval_days") or 1 + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE actions + SET schedule_id = %s, updated_at = NOW() + WHERE id = %s AND tenant_id = %s + """, + (schedule["id"], action_id, tenant_id), + ) + conn.commit() + finally: + conn.close() + + bridge = _find_recurring_for_action(tenant_id=tenant_id, action_id=action_id) + if bridge: + update_recurring_element( + tenant_id=tenant_id, + recurring_id=bridge["id"], + user_id=user_id, + status="active", + interval_days=int(interval), + next_due_at=next_due, + ) + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE recurring_elements + SET schedule_id = %s, updated_at = NOW() + WHERE id = %s AND tenant_id = %s + """, + (schedule["id"], bridge["id"], tenant_id), + ) + conn.commit() + finally: + conn.close() + recurring = get_recurring_element(tenant_id=tenant_id, recurring_id=bridge["id"]) + else: + recurring = create_recurring_element( + tenant_id=tenant_id, + initiative_id=str(action["initiative_id"]), + title=action["title"], + description=action.get("description") or "", + status="active", + interval_days=int(interval), + next_due_at=next_due, + roadmap_item_id=action.get("roadmap_item_id"), + project_id=action.get("project_id"), + schedule_id=str(schedule["id"]), + user_id=user_id, + ) + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE recurring_elements + SET action_id = %s + WHERE id = %s AND tenant_id = %s + """, + (action_id, recurring["id"], tenant_id), + ) + conn.commit() + finally: + conn.close() + + ensure_open_instance( + tenant_id=tenant_id, + recurring_element_id=recurring["id"], + due_at=next_due, + ) + + log_audit( + "action.schedule_set", + user_id=user_id, + tenant_id=tenant_id, + details={"action_id": action_id, "schedule_id": schedule["id"]}, + ) + + return get_action_schedule_bundle(tenant_id=tenant_id, action_id=action_id) + + +def clear_action_schedule( + *, + tenant_id: str, + action_id: str, + user_id: Optional[str] = None, +) -> dict[str, Any]: + bundle = get_action_schedule_bundle(tenant_id=tenant_id, action_id=action_id) + bridge = bundle.get("recurring") + if bridge: + update_recurring_element( + tenant_id=tenant_id, + recurring_id=bridge["id"], + user_id=user_id, + status="ended", + ) + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE actions + SET schedule_id = NULL, updated_at = NOW() + WHERE id = %s AND tenant_id = %s + """, + (action_id, tenant_id), + ) + conn.commit() + finally: + conn.close() + + log_audit( + "action.schedule_cleared", + user_id=user_id, + tenant_id=tenant_id, + details={"action_id": action_id}, + ) + return {"action_id": action_id, "schedule": None, "recurring_id": None} diff --git a/backend/tests/test_schedule_service.py b/backend/tests/test_schedule_service.py new file mode 100644 index 0000000..d5c6da3 --- /dev/null +++ b/backend/tests/test_schedule_service.py @@ -0,0 +1,40 @@ +"""Unit tests for schedule due-date logic (no DB).""" + +from __future__ import annotations + +from datetime import date, datetime, timezone + +from services.schedule import ( + format_schedule_label, + initial_due_at, + next_due_after_completion, + weekday_bit, +) + + +def test_interval_daily_label(): + label = format_schedule_label({"schedule_kind": "interval", "interval_days": 1, "weekday_mask": 0}) + assert label == "Täglich" + + +def test_weekday_mask_next_due_after_friday(): + schedule = { + "schedule_kind": "weekdays", + "weekday_mask": weekday_bit(0) | weekday_bit(2) | weekday_bit(4), + "pause_until": None, + } + friday = datetime(2026, 8, 7, 12, 0, tzinfo=timezone.utc) + nxt = next_due_after_completion(schedule=schedule, completed_at=friday) + assert nxt.date() == date(2026, 8, 10) + + +def test_pause_until_pushes_initial_due(): + schedule = { + "schedule_kind": "interval", + "interval_days": 1, + "weekday_mask": 0, + "pause_until": "2026-08-10", + } + now = datetime(2026, 8, 4, 9, 0, tzinfo=timezone.utc) + due = initial_due_at(schedule=schedule, now=now) + assert due.date() == date(2026, 8, 11) diff --git a/docs/architecture/ADP_Unified_Schedule_and_Cadence_Layer_v0.1.md b/docs/architecture/ADP_Unified_Schedule_and_Cadence_Layer_v0.1.md new file mode 100644 index 0000000..644bf43 --- /dev/null +++ b/docs/architecture/ADP_Unified_Schedule_and_Cadence_Layer_v0.1.md @@ -0,0 +1,108 @@ +# ADP — Unified Schedule & Cadence Layer v0.1 + +**Status:** PO-Freigabe ausstehend (2026-08-04, Implementierung Slice 1 gestartet) +**Stand:** 2026-08-04 +**Auslöser:** A1-Validation — RecurringElement als UI-Insel; Nutzer erwarten Rhythmus an AP/Task wie in Todo-Tools +**Bezug:** `Kairo_Steering_Method_Kernel_v0.1` Q2, `ADP_A1_Progression_Graph_Derived_Lanes_v0.1.md`, Spec-D `recurring_control`, Spec-D `maturity_progression` + +--- + +## 1. Leitentscheidung + +**Rhythmus/Wiederholung ist Kernfähigkeit**, nicht Archetyp-Insel. + +| Kern | Host (Bindung) | Methode (Regeln) | +|------|----------------|------------------| +| **`Schedule`** — interval, Wochentage, Pause | `Action`, `RecurringElement` (Gate-Übung), später `Task` | `maturity_progression`, `sequential_dependency`, `recurring_control`, … | +| **`CadenceInstance`** — eine offene Fälligkeit, „heute erledigt“ | immer an `RecurringElement` (Bridge von `Action` optional) | Ready/Ranking in Method-Strategie | + +**Nicht:** separates Planungs-UI pro Archetyp. **Eine** Schedule-Semantik, **ein** Editor-Widget, Host-spezifische Bindung. + +--- + +## 2. Schedule-Modell + +```text +schedules + schedule_kind 'interval' | 'weekdays' + interval_days int (nur interval; Default 1 = täglich nach Erledigen) + weekday_mask int (Mo=1, Di=2, Mi=4, Do=8, Fr=16, Sa=32, So=64) + pause_until date nullable — bis einschl. pausiert +``` + +| schedule_kind | Nutzer-Label | Semantik | +|---------------|--------------|----------| +| `interval` | „Alle N Tage“ | Nach Erledigen: nächste Fälligkeit = heute + N Kalendertage | +| `weekdays` | „Bestimmte Wochentage“ | Fällig an gewählten Tagen; nach Erledigen: nächster passender Tag | + +**Anti-Duplikat (unverändert):** pro Recurring genau **eine** offene `CadenceInstance`. + +--- + +## 3. Hosts + +| Host | Spalte | Use Case | +|------|--------|----------| +| `recurring_elements` | `schedule_id`, `roadmap_item_id` | A1 Gate-Übung, A3 Ritual | +| `recurring_elements` | `action_id` (Bridge) | AP mit Wiederholung — ein Recurring spiegelt AP | +| `actions` | `schedule_id` | Steuerung / PMO-Review am AP | + +**Action + Schedule:** beim Setzen wird `RecurringElement` mit `action_id` synchronisiert (Cadence-Pfad). AP-Status `done` schließt **nicht** die Serie — Cadence steuert Serie. + +**Gate-Übung:** direkt `RecurringElement` + `schedule_id` + `roadmap_item_id`. + +--- + +## 4. UI (Slice 1–2) + +| Fläche | Widget | +|--------|--------| +| Gate-Detail | `ScheduleEditor` im Activity Set | +| AP-Detail | `ActionScheduleSection` — Toggle Wiederholung + gleicher Editor | +| Initiative (Slice 3) | „Rhythmen“ — Liste aller Schedules | + +**Labels (Pflicht):** „Täglich“, „Alle 2 Tage“, „Wöchentlich“, Mo–So Checkboxen — **kein** nacktes „Rhythmus (Tage)“. + +--- + +## 5. Methoden — unverändert getrennt + +| Methode | Leading | Schedule-Host typisch | +|---------|---------|---------------------| +| `maturity_progression` | Cadence an **aktiven Gates** | Recurring + Gate | +| `recurring_control` | Cadence initiative-weit | Recurring | +| `sequential_dependency` | Action; optional Schedule am AP | Action + Bridge | +| `agile_iteration` | Cadence vor Sprint-Actions | komponiert | + +Archetyp ändert **Auswertung**, nicht das Schedule-Schema. + +--- + +## 6. Migration & Kompatibilität + +- Migration `033`: Tabelle `schedules`; `schedule_id` auf `recurring_elements`, `actions`; `action_id` auf `recurring_elements` +- Bestehende `interval_days` auf Recurring → Backfill `schedules` (kind=interval) +- `interval_days` am Recurring **deprecated** (Lesefallback bis Entfernung) + +--- + +## 7. Implementierungsfolge + +```text +Slice 1 Schedule + Cadence-Logik (weekdays, pause) + Gate-UI ← dieser Commit +Slice 2 Action-Schedule + Bridge-Recurring +Slice 3 Initiative „Rhythmen“ / Wochenblick (read-only) +Slice 4 Task-Host optional +``` + +--- + +## 8. Nicht in Scope + +- Cron/Uhrzeit (nur Datum) +- Alternanz A/B (Spec §4.3 — eigener Slice) +- RecurringElement-Tabelle entfernen ( erst nach Action-Host stabil) + +--- + +*Bei Konflikt mit PO-Lock „Rhythmus am Recurring“: **Schedule ersetzt nicht Cadence**, vereinheitlicht nur Konfiguration.* diff --git a/frontend/src/api/actions.js b/frontend/src/api/actions.js index 24babb1..25402cc 100644 --- a/frontend/src/api/actions.js +++ b/frontend/src/api/actions.js @@ -27,6 +27,17 @@ export function unplanActionToBacklog(id, { carryover = false } = {}) { return apiFetch(`/api/actions/${id}/unplan-to-backlog${query}`, { method: 'POST' }) } +export function upsertActionSchedule(id, schedule) { + return apiFetch(`/api/actions/${id}/schedule`, { + method: 'PUT', + body: JSON.stringify(schedule), + }) +} + +export function clearActionSchedule(id) { + return apiFetch(`/api/actions/${id}/schedule`, { method: 'DELETE' }) +} + export function listMyOpenActions() { return apiFetch('/api/actions/me/open') } diff --git a/frontend/src/components/ActionScheduleSection.jsx b/frontend/src/components/ActionScheduleSection.jsx new file mode 100644 index 0000000..21658dd --- /dev/null +++ b/frontend/src/components/ActionScheduleSection.jsx @@ -0,0 +1,148 @@ +import { useEffect, useState } from 'react' +import { clearActionSchedule, getAction, upsertActionSchedule } from '../api/actions.js' +import { ScheduleEditor } from './ScheduleEditor.jsx' +import { + buildSchedulePayload, + defaultScheduleValue, + formatScheduleLabel, + scheduleFromApi, +} from '../utils/scheduleUtils.js' + +export function ActionScheduleSection({ + action, + canManage = false, + onUpdated, +}) { + const [enabled, setEnabled] = useState(Boolean(action?.schedule)) + const [schedule, setSchedule] = useState(defaultScheduleValue()) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + const [editing, setEditing] = useState(false) + const [loadedSchedule, setLoadedSchedule] = useState(null) + + useEffect(() => { + let cancelled = false + getAction(action.id) + .then((full) => { + if (cancelled) return + setLoadedSchedule(full.schedule || null) + setEnabled(Boolean(full.schedule)) + setSchedule(scheduleFromApi(full.schedule)) + }) + .catch(() => {}) + return () => { + cancelled = true + } + }, [action.id, action.updated_at]) + + const displaySchedule = loadedSchedule ?? action?.schedule + + async function run(fn) { + setBusy(true) + setError(null) + try { + await fn() + if (onUpdated) await onUpdated() + } catch (err) { + setError(err.message) + } finally { + setBusy(false) + } + } + + async function handleEnable(checked) { + if (!checked) { + if (!displaySchedule) { + setEnabled(false) + return + } + if (!window.confirm('Wiederholung am Arbeitspaket entfernen?')) return + await run(() => clearActionSchedule(action.id)) + setEnabled(false) + return + } + setEnabled(true) + setEditing(true) + } + + async function handleSave() { + let payload + try { + payload = buildSchedulePayload(schedule) + } catch (err) { + setError(err.message) + return + } + await run(() => upsertActionSchedule(action.id, payload)) + setEditing(false) + } + + return ( +
+
+
+

Wiederholung

+

+ Regelmäßige Ausführung dieses Arbeitspakets — gleicher Rhythmus wie bei Gate-Übungen. +

+
+ {canManage && ( + + )} +
+ + {error &&

{error}

} + + {!enabled && !displaySchedule && ( +

Keine Wiederholung — eignet sich z. B. für monatliches Reporting.

+ )} + + {(enabled || displaySchedule) && !editing && ( +

+ {formatScheduleLabel(displaySchedule || schedule)} + {canManage && ( + + )} +

+ )} + + {editing && canManage && ( +
+ +
+ + +
+
+ )} +
+ ) +} diff --git a/frontend/src/components/GateActivitySetSection.jsx b/frontend/src/components/GateActivitySetSection.jsx index e95f33f..d1b5504 100644 --- a/frontend/src/components/GateActivitySetSection.jsx +++ b/frontend/src/components/GateActivitySetSection.jsx @@ -1,14 +1,17 @@ import { useCallback, useEffect, useState } from 'react' import { Link } from 'react-router-dom' -import { - createGatePractice, - listGatePractices, -} from '../api/roadmap.js' +import { createGatePractice, listGatePractices } from '../api/roadmap.js' import { deleteRecurring, updateRecurring } from '../api/recurring.js' import { RECURRING_STATUSES, RECURRING_STATUS_LABELS } from '../constants/status.js' import { EmptyState } from './EmptyState.jsx' +import { ScheduleEditor, scheduleEditorFromPractice } from './ScheduleEditor.jsx' import { StatusBadge } from './StatusBadge.jsx' import { scopedPath } from '../utils/routes.js' +import { + buildSchedulePayload, + defaultScheduleValue, + formatScheduleLabel, +} from '../utils/scheduleUtils.js' function formatDueAt(iso) { if (!iso) return null @@ -23,6 +26,7 @@ export function GateActivitySetSection({ gateId, initiativeId, gateStatus, + gateTitle, canManage = false, }) { const [practices, setPractices] = useState([]) @@ -32,7 +36,9 @@ export function GateActivitySetSection({ const [showForm, setShowForm] = useState(false) const [title, setTitle] = useState('') const [description, setDescription] = useState('') - const [intervalDays, setIntervalDays] = useState('1') + const [schedule, setSchedule] = useState(defaultScheduleValue()) + const [editingRhythmId, setEditingRhythmId] = useState(null) + const [editSchedule, setEditSchedule] = useState(defaultScheduleValue()) const load = useCallback(async () => { setLoading(true) @@ -67,34 +73,51 @@ export function GateActivitySetSection({ async function handleCreate(e) { e.preventDefault() if (!title.trim()) return - const interval = Number.parseInt(intervalDays, 10) - if (!Number.isFinite(interval) || interval < 1) { - setError('Intervall muss mindestens 1 Tag sein.') + let payload + try { + payload = buildSchedulePayload(schedule) + } catch (err) { + setError(err.message) return } await runAction(async () => { await createGatePractice(gateId, { title: title.trim(), description: description.trim(), - interval_days: interval, status: 'active', + schedule: payload, }) setTitle('') setDescription('') - setIntervalDays('1') + setSchedule(defaultScheduleValue()) setShowForm(false) }) } + async function saveRhythm(practice) { + let payload + try { + payload = buildSchedulePayload(editSchedule) + } catch (err) { + setError(err.message) + return + } + await runAction(async () => { + await updateRecurring(practice.id, { schedule: payload }) + setEditingRhythmId(null) + }) + } + const isActiveGate = gateStatus === 'active' || gateStatus === 'at_risk' + const routineLabel = gateTitle ? `Routine: ${gateTitle}` : 'Routine an diesem Gate' return (
-

Übungen (Activity Set)

+

{routineLabel}

- Wiederkehrende Routinen an diesem Zielzustand — erscheinen in Today, wenn das Gate{' '} + Übungen gehören zu diesem Zielzustand — sie erscheinen in Today, wenn das Gate{' '} aktiv ist.

@@ -115,7 +138,7 @@ export function GateActivitySetSection({ {!isActiveGate && (

Gate-Status ist {gateStatus} — Übungen werden in Today sichtbar, sobald - das Gate auf aktiv steht (Stammdaten bearbeiten). + das Gate auf aktiv steht.

)} @@ -124,7 +147,7 @@ export function GateActivitySetSection({ Ausführen → Today {' '} - — fällige Übungen dieses Gates abhaken. + — fällige Übungen dieser Routine abhaken.

)} @@ -149,16 +172,7 @@ export function GateActivitySetSection({ placeholder="Hinweise zur Ausführung" /> - + @@ -168,7 +182,7 @@ export function GateActivitySetSection({ {loading ? (

Übungen werden geladen…

) : practices.length === 0 ? ( - + ) : (
    {practices.map((practice) => ( @@ -179,14 +193,40 @@ export function GateActivitySetSection({

    {practice.description}

    )}

    - {practice.interval_days - ? `Alle ${practice.interval_days} Tag(e)` - : 'Kein Intervall'} + {formatScheduleLabel(practice.schedule || practice)} {practice.open_cadence_instance?.due_at ? ` · Fällig: ${formatDueAt(practice.open_cadence_instance.due_at)}` : ''} {practice.today_completed ? ' · heute erledigt' : ''}

    + {editingRhythmId === practice.id && ( +
    + +
    + + +
    +
    + )}
{canManage && (
@@ -213,17 +253,8 @@ export function GateActivitySetSection({ className="btn btn-secondary btn-sm" disabled={busy} onClick={() => { - const next = window.prompt( - 'Neues Intervall in Tagen:', - String(practice.interval_days || 1), - ) - if (next == null) return - const days = Number.parseInt(next, 10) - if (!Number.isFinite(days) || days < 1) { - setError('Intervall muss mindestens 1 Tag sein.') - return - } - runAction(() => updateRecurring(practice.id, { interval_days: days })) + setEditingRhythmId(practice.id) + setEditSchedule(scheduleEditorFromPractice(practice)) }} > Rhythmus diff --git a/frontend/src/components/ScheduleEditor.jsx b/frontend/src/components/ScheduleEditor.jsx new file mode 100644 index 0000000..ebdaa90 --- /dev/null +++ b/frontend/src/components/ScheduleEditor.jsx @@ -0,0 +1,142 @@ +import { useMemo, useState } from 'react' +import { + INTERVAL_PRESETS, + WEEKDAY_OPTIONS, + defaultScheduleValue, + detectIntervalPreset, + scheduleFromApi, + toggleWeekdayMask, +} from '../utils/scheduleUtils.js' + +export function ScheduleEditor({ + value, + onChange, + disabled = false, + compact = false, +}) { + const current = value || defaultScheduleValue() + const [preset, setPreset] = useState(() => + current.schedule_kind === 'weekdays' + ? 'weekdays' + : detectIntervalPreset(current.interval_days), + ) + + const showCustomInterval = preset === 'custom' + + function setKind(kind) { + if (kind === 'weekdays') { + onChange({ + ...current, + schedule_kind: 'weekdays', + weekday_mask: current.weekday_mask || WEEKDAY_OPTIONS[0].bit, + }) + setPreset('weekdays') + return + } + const presetDef = INTERVAL_PRESETS.find((p) => p.id === kind) || INTERVAL_PRESETS[0] + onChange({ + ...current, + schedule_kind: 'interval', + interval_days: presetDef.intervalDays || current.interval_days || 1, + }) + setPreset(kind) + } + + const weekdaySummary = useMemo(() => { + const selected = WEEKDAY_OPTIONS.filter((d) => (current.weekday_mask || 0) & d.bit) + return selected.map((d) => d.label).join(', ') + }, [current.weekday_mask]) + + return ( +
+ Rhythmus + +
+ {INTERVAL_PRESETS.filter((p) => p.id !== 'custom').map((p) => ( + + ))} + + +
+ + {current.schedule_kind === 'interval' && showCustomInterval && ( + + )} + + {current.schedule_kind === 'weekdays' && ( +
+

+ An welchen Tagen? {weekdaySummary ? `(${weekdaySummary})` : ''} +

+
+ {WEEKDAY_OPTIONS.map((day) => { + const active = Boolean((current.weekday_mask || 0) & day.bit) + return ( + + ) + })} +
+
+ )} + + +
+ ) +} + +export function scheduleEditorFromPractice(practice) { + return scheduleFromApi(practice?.schedule || { + schedule_kind: 'interval', + interval_days: practice?.interval_days || 1, + weekday_mask: 0, + pause_until: null, + }) +} diff --git a/frontend/src/pages/ActionDetailPage.jsx b/frontend/src/pages/ActionDetailPage.jsx index 037938f..1f875ee 100644 --- a/frontend/src/pages/ActionDetailPage.jsx +++ b/frontend/src/pages/ActionDetailPage.jsx @@ -12,6 +12,7 @@ import { updateAction, setActionAssignments } from '../api/actions.js' import { createInitiativeBlocker } from '../api/blockers.js' import { ActionHubCard } from '../components/ActionHubCard.jsx' import { ActionDependenciesSection } from '../components/ActionDependenciesSection.jsx' +import { ActionScheduleSection } from '../components/ActionScheduleSection.jsx' import { ActionForm } from '../components/ActionForm.jsx' import { TasksSection } from '../components/TasksSection.jsx' import { ErrorState } from '../components/ErrorState.jsx' @@ -39,6 +40,7 @@ function ActionDetailBody({ roadmapItems = [], allActions = [], executionGraph = null, + onReload, }) { if (editing) { return ( @@ -80,6 +82,11 @@ function ActionDetailBody({ onReloadActors={actorsState.reload} detailMode /> +
@@ -317,6 +325,7 @@ function ActionDetailNested() { roadmapItems={ops.roadmapItems} allActions={ops.actions} executionGraph={executionGraph} + onReload={ops.reload} /> ) diff --git a/frontend/src/pages/initiative/RoadmapItemDetailPage.jsx b/frontend/src/pages/initiative/RoadmapItemDetailPage.jsx index 511af0e..30b7fde 100644 --- a/frontend/src/pages/initiative/RoadmapItemDetailPage.jsx +++ b/frontend/src/pages/initiative/RoadmapItemDetailPage.jsx @@ -241,6 +241,7 @@ export function RoadmapItemDetailPage({ overrideItemId }) { gateId={itemId} initiativeId={initiativeId || item.initiative_id} gateStatus={item.status} + gateTitle={item.title} canManage={capabilities.has('kairo.recurring.manage')} /> )} diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css index 1e4c099..599d5c8 100644 --- a/frontend/src/styles/components.css +++ b/frontend/src/styles/components.css @@ -2477,3 +2477,70 @@ margin-bottom: 0.75rem; } +.schedule-editor { + border: 1px solid var(--jk-border, #dde3ea); + border-radius: 8px; + padding: 0.75rem 1rem; + margin: 0.75rem 0; +} + +.schedule-editor__legend { + font-size: 0.875rem; + font-weight: 600; + padding: 0 0.25rem; +} + +.schedule-editor__modes { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin-bottom: 0.75rem; +} + +.schedule-editor__field { + display: block; + margin-top: 0.5rem; + font-size: 0.875rem; +} + +.schedule-editor__weekday-row { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.schedule-weekday { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + border-radius: 6px; + border: 1px solid var(--jk-border, #dde3ea); + font-size: 0.8125rem; + cursor: pointer; +} + +.schedule-weekday.is-active { + background: var(--jk-accent-soft, #e8f0fe); + border-color: var(--jk-accent, #2563eb); +} + +.gate-activity-set__rhythm-edit, +.action-schedule-section__edit { + margin-top: 0.75rem; +} + +.action-schedule-section__summary { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} + +.toggle-inline { + display: inline-flex; + align-items: center; + gap: 0.35rem; + font-size: 0.875rem; +} + diff --git a/frontend/src/utils/scheduleUtils.js b/frontend/src/utils/scheduleUtils.js new file mode 100644 index 0000000..2182f71 --- /dev/null +++ b/frontend/src/utils/scheduleUtils.js @@ -0,0 +1,81 @@ +export const WEEKDAY_OPTIONS = [ + { bit: 1, label: 'Mo', index: 0 }, + { bit: 2, label: 'Di', index: 1 }, + { bit: 4, label: 'Mi', index: 2 }, + { bit: 8, label: 'Do', index: 3 }, + { bit: 16, label: 'Fr', index: 4 }, + { bit: 32, label: 'Sa', index: 5 }, + { bit: 64, label: 'So', index: 6 }, +] + +export const INTERVAL_PRESETS = [ + { id: 'daily', label: 'Täglich', intervalDays: 1 }, + { id: 'every2', label: 'Alle 2 Tage', intervalDays: 2 }, + { id: 'weekly', label: 'Wöchentlich', intervalDays: 7 }, + { id: 'custom', label: 'Individuell…', intervalDays: null }, +] + +export function defaultScheduleValue() { + return { + schedule_kind: 'interval', + interval_days: 1, + weekday_mask: 0, + pause_until: '', + } +} + +export function scheduleFromApi(schedule) { + if (!schedule) return defaultScheduleValue() + return { + schedule_kind: schedule.schedule_kind || 'interval', + interval_days: schedule.interval_days || 1, + weekday_mask: schedule.weekday_mask || 0, + pause_until: schedule.pause_until ? schedule.pause_until.slice(0, 10) : '', + } +} + +export function formatScheduleLabel(schedule) { + if (!schedule) return 'Kein Rhythmus' + if (schedule.label) return schedule.label + if (schedule.schedule_kind === 'weekdays') { + const days = WEEKDAY_OPTIONS.filter((d) => (schedule.weekday_mask || 0) & d.bit).map( + (d) => d.label, + ) + const base = days.length ? days.join(', ') : 'Wochentage' + return schedule.pause_until ? `${base} (Pause bis ${schedule.pause_until.slice(0, 10)})` : base + } + const days = schedule.interval_days || 1 + let base = days === 1 ? 'Täglich' : days === 7 ? 'Wöchentlich' : `Alle ${days} Tage` + if (schedule.pause_until) { + base = `${base} (Pause bis ${schedule.pause_until.slice(0, 10)})` + } + return base +} + +export function buildSchedulePayload(value) { + const kind = value.schedule_kind + const payload = { + schedule_kind: kind, + interval_days: kind === 'interval' ? Number(value.interval_days) || 1 : 1, + weekday_mask: kind === 'weekdays' ? Number(value.weekday_mask) || 0 : 0, + pause_until: value.pause_until ? value.pause_until : null, + } + if (kind === 'weekdays' && payload.weekday_mask <= 0) { + throw new Error('Mindestens ein Wochentag auswählen.') + } + if (kind === 'interval' && payload.interval_days < 1) { + throw new Error('Intervall muss mindestens 1 Tag sein.') + } + return payload +} + +export function toggleWeekdayMask(mask, bit) { + return (mask & bit) ? mask & ~bit : mask | bit +} + +export function detectIntervalPreset(intervalDays) { + const match = INTERVAL_PRESETS.find( + (p) => p.intervalDays === intervalDays && p.id !== 'custom', + ) + return match ? match.id : 'custom' +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..cd4f982 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,76 @@ +{ + "name": "kairo-jinkendo", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "kairo-jinkendo", + "devDependencies": { + "@playwright/test": "^1.49.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +}