feat(schedule): einheitliche Rhythmus-Schicht an Gate und Arbeitspaket
All checks were successful
Deploy Development / deploy (push) Successful in 49s
Test Suite / pytest-backend (push) Successful in 4m57s
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 12s
All checks were successful
Deploy Development / deploy (push) Successful in 49s
Test Suite / pytest-backend (push) Successful in 4m57s
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 12s
Bestehende Recurring-Pfade bleiben kompatibel (interval_days-Fallback, Migration backfillt schedules), damit der aktuelle Betrieb nicht bricht und der Stand auf einem zweiten Rechner weitergebaut werden kann. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
87ecee5301
commit
ea0aa18b8d
51
backend/migrations/033_unified_schedules.sql
Normal file
51
backend/migrations/033_unified_schedules.sql
Normal file
|
|
@ -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 $$;
|
||||||
|
|
@ -9,7 +9,9 @@ from capabilities import require_capability
|
||||||
from data_layer import actions as dl_actions
|
from data_layer import actions as dl_actions
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
from schemas.schedule_payload import SchedulePayload
|
||||||
from services import actions as action_service
|
from services import actions as action_service
|
||||||
|
from services import scheduled_work as scheduled_work_service
|
||||||
from services import tasks as task_service
|
from services import tasks as task_service
|
||||||
from tenant_context import TenantContext
|
from tenant_context import TenantContext
|
||||||
|
|
||||||
|
|
@ -155,6 +157,47 @@ def update_action(
|
||||||
return item
|
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")
|
@router.put("/{action_id}/assignments")
|
||||||
def set_action_assignments(
|
def set_action_assignments(
|
||||||
action_id: str,
|
action_id: str,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ from typing import Literal, Optional
|
||||||
from capabilities import require_capability
|
from capabilities import require_capability
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
from schemas.schedule_payload import SchedulePayload
|
||||||
from services import recurring as recurring_service
|
from services import recurring as recurring_service
|
||||||
from tenant_context import TenantContext
|
from tenant_context import TenantContext
|
||||||
|
|
||||||
|
|
@ -24,6 +25,7 @@ class RecurringUpdateRequest(BaseModel):
|
||||||
clear_next_due_at: bool = False
|
clear_next_due_at: bool = False
|
||||||
roadmap_item_id: Optional[str] = None
|
roadmap_item_id: Optional[str] = None
|
||||||
clear_roadmap_item_id: bool = False
|
clear_roadmap_item_id: bool = False
|
||||||
|
schedule: Optional[SchedulePayload] = None
|
||||||
|
|
||||||
|
|
||||||
class RecurringCompleteRequest(BaseModel):
|
class RecurringCompleteRequest(BaseModel):
|
||||||
|
|
@ -59,6 +61,7 @@ def update_recurring_element(
|
||||||
ctx: TenantContext = Depends(require_capability("kairo.recurring.manage")),
|
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
|
next_due_at = _parse_datetime(body.next_due_at) if body.next_due_at is not None else None
|
||||||
|
sched = body.schedule
|
||||||
try:
|
try:
|
||||||
item = recurring_service.update_recurring_element(
|
item = recurring_service.update_recurring_element(
|
||||||
tenant_id=ctx.tenant_id,
|
tenant_id=ctx.tenant_id,
|
||||||
|
|
@ -67,12 +70,16 @@ def update_recurring_element(
|
||||||
title=body.title,
|
title=body.title,
|
||||||
description=body.description,
|
description=body.description,
|
||||||
status=body.status,
|
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,
|
clear_interval_days=body.clear_interval_days,
|
||||||
next_due_at=next_due_at,
|
next_due_at=next_due_at,
|
||||||
clear_next_due_at=body.clear_next_due_at,
|
clear_next_due_at=body.clear_next_due_at,
|
||||||
roadmap_item_id=body.roadmap_item_id,
|
roadmap_item_id=body.roadmap_item_id,
|
||||||
clear_roadmap_item_id=body.clear_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:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ from typing import Any, Literal, Optional
|
||||||
from capabilities import require_capability
|
from capabilities import require_capability
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
from schemas.schedule_payload import SchedulePayload
|
||||||
from services import roadmap as roadmap_service
|
from services import roadmap as roadmap_service
|
||||||
from services import roadmap_criteria as criteria_service
|
from services import roadmap_criteria as criteria_service
|
||||||
from services import roadmap_plan_snapshot as snapshot_service
|
from services import roadmap_plan_snapshot as snapshot_service
|
||||||
|
|
@ -92,6 +93,7 @@ class GatePracticeCreateRequest(BaseModel):
|
||||||
description: str = ""
|
description: str = ""
|
||||||
status: Literal["active", "paused", "ended"] = "active"
|
status: Literal["active", "paused", "ended"] = "active"
|
||||||
interval_days: int = Field(default=1, gt=0)
|
interval_days: int = Field(default=1, gt=0)
|
||||||
|
schedule: Optional[SchedulePayload] = None
|
||||||
|
|
||||||
|
|
||||||
class PlanSnapshotCreateRequest(BaseModel):
|
class PlanSnapshotCreateRequest(BaseModel):
|
||||||
|
|
@ -352,12 +354,25 @@ def create_gate_practice(
|
||||||
body: GatePracticeCreateRequest,
|
body: GatePracticeCreateRequest,
|
||||||
ctx: TenantContext = Depends(require_capability("kairo.recurring.manage")),
|
ctx: TenantContext = Depends(require_capability("kairo.recurring.manage")),
|
||||||
):
|
):
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from services import recurring as recurring_service
|
from services import recurring as recurring_service
|
||||||
|
|
||||||
item = _work_gate_or_400(tenant_id=ctx.tenant_id, item_id=item_id)
|
item = _work_gate_or_400(tenant_id=ctx.tenant_id, item_id=item_id)
|
||||||
|
sched = body.schedule
|
||||||
try:
|
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(
|
return recurring_service.create_recurring_element(
|
||||||
tenant_id=ctx.tenant_id,
|
tenant_id=ctx.tenant_id,
|
||||||
initiative_id=str(item["initiative_id"]),
|
initiative_id=str(item["initiative_id"]),
|
||||||
|
|
@ -365,7 +380,6 @@ def create_gate_practice(
|
||||||
description=body.description,
|
description=body.description,
|
||||||
status=body.status,
|
status=body.status,
|
||||||
interval_days=body.interval_days,
|
interval_days=body.interval_days,
|
||||||
next_due_at=datetime.now(timezone.utc),
|
|
||||||
roadmap_item_id=item_id,
|
roadmap_item_id=item_id,
|
||||||
user_id=ctx.user_id,
|
user_id=ctx.user_id,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
25
backend/schemas/schedule_payload.py
Normal file
25
backend/schemas/schedule_payload.py
Normal file
|
|
@ -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)
|
||||||
|
|
@ -25,8 +25,9 @@ OPEN_ACTION_STATUSES = frozenset(
|
||||||
)
|
)
|
||||||
|
|
||||||
_ACTION_COLUMNS = """
|
_ACTION_COLUMNS = """
|
||||||
id, tenant_id, initiative_id, project_id, roadmap_item_id, work_cycle_id, title, description,
|
id, tenant_id, initiative_id, project_id, roadmap_item_id, work_cycle_id, schedule_id,
|
||||||
status, priority, due_at, sort_order, action_kind, parent_action_id, created_at, updated_at
|
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"})
|
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",
|
"work_cycle_id",
|
||||||
"parent_action_id",
|
"parent_action_id",
|
||||||
"owner_actor_id",
|
"owner_actor_id",
|
||||||
|
"schedule_id",
|
||||||
):
|
):
|
||||||
if result.get(key):
|
if result.get(key):
|
||||||
result[key] = str(result[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))
|
action = _serialize_row(dict(row))
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
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(
|
def update_action(
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,16 @@ def ensure_open_instance(
|
||||||
return row
|
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)
|
days = max(int(interval_days or 1), 1)
|
||||||
next_date = now.date() + timedelta(days=days)
|
next_date = now.date() + timedelta(days=days)
|
||||||
return datetime.combine(next_date, time.min, tzinfo=timezone.utc)
|
return datetime.combine(next_date, time.min, tzinfo=timezone.utc)
|
||||||
|
|
@ -161,7 +170,10 @@ def complete_open_instance(
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
interval = recurring.get("interval_days") or 1
|
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(
|
update_recurring_element(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
|
|
|
||||||
|
|
@ -16,14 +16,14 @@ RecurringStatus = Literal["active", "paused", "ended"]
|
||||||
RECURRING_STATUSES = frozenset({"active", "paused", "ended"})
|
RECURRING_STATUSES = frozenset({"active", "paused", "ended"})
|
||||||
|
|
||||||
_RECURRING_COLUMNS = """
|
_RECURRING_COLUMNS = """
|
||||||
id, tenant_id, initiative_id, roadmap_item_id, project_id, title, description, status,
|
id, tenant_id, initiative_id, roadmap_item_id, project_id, action_id, schedule_id,
|
||||||
interval_days, next_due_at, created_at, updated_at
|
title, description, status, interval_days, next_due_at, created_at, updated_at
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
|
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||||
result = dict(row)
|
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):
|
if result.get(key):
|
||||||
result[key] = str(result[key])
|
result[key] = str(result[key])
|
||||||
for ts_key in ("created_at", "updated_at", "next_due_at"):
|
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,
|
next_due_at: Optional[datetime] = None,
|
||||||
roadmap_item_id: Optional[str] = None,
|
roadmap_item_id: Optional[str] = None,
|
||||||
project_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,
|
user_id: Optional[str] = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
title = title.strip()
|
title = title.strip()
|
||||||
|
|
@ -59,16 +63,40 @@ def create_recurring_element(
|
||||||
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
|
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
|
||||||
raise ValueError("Initiative nicht gefunden")
|
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()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
f"""
|
f"""
|
||||||
INSERT INTO recurring_elements (
|
INSERT INTO recurring_elements (
|
||||||
tenant_id, initiative_id, roadmap_item_id, project_id, title, description, status,
|
tenant_id, initiative_id, roadmap_item_id, project_id, schedule_id,
|
||||||
interval_days, next_due_at
|
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}
|
RETURNING {_RECURRING_COLUMNS}
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
|
|
@ -76,6 +104,7 @@ def create_recurring_element(
|
||||||
initiative_id,
|
initiative_id,
|
||||||
roadmap_item_id,
|
roadmap_item_id,
|
||||||
project_id,
|
project_id,
|
||||||
|
schedule_id,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
status,
|
status,
|
||||||
|
|
@ -97,13 +126,13 @@ def create_recurring_element(
|
||||||
|
|
||||||
from services.cadence_instance import ensure_open_instance
|
from services.cadence_instance import ensure_open_instance
|
||||||
|
|
||||||
due = next_due_at or datetime.now(timezone.utc)
|
|
||||||
open_inst = ensure_open_instance(
|
open_inst = ensure_open_instance(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
recurring_element_id=row["id"],
|
recurring_element_id=row["id"],
|
||||||
due_at=due,
|
due_at=next_due_at,
|
||||||
)
|
)
|
||||||
row["open_cadence_instance"] = open_inst
|
row["open_cadence_instance"] = open_inst
|
||||||
|
row["schedule"] = schedule
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -130,7 +159,9 @@ def list_recurring_for_initiative(
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
from services.cadence_instance import attach_open_instances_to_recurring_list
|
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)
|
return attach_open_instances_to_recurring_list(rows, tenant_id=tenant_id)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -158,10 +189,17 @@ def get_recurring_element(
|
||||||
(recurring_id, tenant_id),
|
(recurring_id, tenant_id),
|
||||||
)
|
)
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
return _serialize_row(dict(row)) if row else None
|
if not row:
|
||||||
|
return None
|
||||||
|
result = _serialize_row(dict(row))
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
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(
|
def update_recurring_element(
|
||||||
*,
|
*,
|
||||||
|
|
@ -179,6 +217,10 @@ def update_recurring_element(
|
||||||
clear_roadmap_item_id: bool = False,
|
clear_roadmap_item_id: bool = False,
|
||||||
project_id: Optional[str] = None,
|
project_id: Optional[str] = None,
|
||||||
clear_project_id: bool = False,
|
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]]:
|
) -> Optional[dict[str, Any]]:
|
||||||
existing = get_recurring_element(tenant_id=tenant_id, recurring_id=recurring_id)
|
existing = get_recurring_element(tenant_id=tenant_id, recurring_id=recurring_id)
|
||||||
if not existing:
|
if not existing:
|
||||||
|
|
@ -224,7 +266,39 @@ def update_recurring_element(
|
||||||
updates.append("project_id = %s")
|
updates.append("project_id = %s")
|
||||||
params.append(project_id)
|
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
|
return existing
|
||||||
|
|
||||||
updates.append("updated_at = NOW()")
|
updates.append("updated_at = NOW()")
|
||||||
|
|
@ -267,7 +341,7 @@ def update_recurring_element(
|
||||||
"to_status": status,
|
"to_status": status,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return result
|
return get_recurring_element(tenant_id=tenant_id, recurring_id=recurring_id) or result
|
||||||
|
|
||||||
|
|
||||||
def delete_recurring_element(
|
def delete_recurring_element(
|
||||||
|
|
|
||||||
342
backend/services/schedule.py
Normal file
342
backend/services/schedule.py
Normal file
|
|
@ -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}
|
||||||
|
),
|
||||||
|
}
|
||||||
262
backend/services/scheduled_work.py
Normal file
262
backend/services/scheduled_work.py
Normal file
|
|
@ -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}
|
||||||
40
backend/tests/test_schedule_service.py
Normal file
40
backend/tests/test_schedule_service.py
Normal file
|
|
@ -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)
|
||||||
108
docs/architecture/ADP_Unified_Schedule_and_Cadence_Layer_v0.1.md
Normal file
108
docs/architecture/ADP_Unified_Schedule_and_Cadence_Layer_v0.1.md
Normal file
|
|
@ -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.*
|
||||||
|
|
@ -27,6 +27,17 @@ export function unplanActionToBacklog(id, { carryover = false } = {}) {
|
||||||
return apiFetch(`/api/actions/${id}/unplan-to-backlog${query}`, { method: 'POST' })
|
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() {
|
export function listMyOpenActions() {
|
||||||
return apiFetch('/api/actions/me/open')
|
return apiFetch('/api/actions/me/open')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
148
frontend/src/components/ActionScheduleSection.jsx
Normal file
148
frontend/src/components/ActionScheduleSection.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<section className="card action-schedule-section">
|
||||||
|
<div className="section-header">
|
||||||
|
<div>
|
||||||
|
<h3>Wiederholung</h3>
|
||||||
|
<p className="section-lead muted">
|
||||||
|
Regelmäßige Ausführung dieses Arbeitspakets — gleicher Rhythmus wie bei Gate-Übungen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{canManage && (
|
||||||
|
<label className="toggle-inline">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={enabled}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(e) => handleEnable(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Rhythmus aktiv
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
|
||||||
|
{!enabled && !displaySchedule && (
|
||||||
|
<p className="muted">Keine Wiederholung — eignet sich z. B. für monatliches Reporting.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(enabled || displaySchedule) && !editing && (
|
||||||
|
<p className="action-schedule-section__summary">
|
||||||
|
<strong>{formatScheduleLabel(displaySchedule || schedule)}</strong>
|
||||||
|
{canManage && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => setEditing(true)}
|
||||||
|
>
|
||||||
|
Bearbeiten
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editing && canManage && (
|
||||||
|
<div className="action-schedule-section__edit">
|
||||||
|
<ScheduleEditor value={schedule} onChange={setSchedule} disabled={busy} />
|
||||||
|
<div className="action-controls">
|
||||||
|
<button type="button" className="btn btn-primary btn-sm" disabled={busy} onClick={handleSave}>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(false)
|
||||||
|
setSchedule(scheduleFromApi(displaySchedule))
|
||||||
|
setEnabled(Boolean(displaySchedule))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,14 +1,17 @@
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import {
|
import { createGatePractice, listGatePractices } from '../api/roadmap.js'
|
||||||
createGatePractice,
|
|
||||||
listGatePractices,
|
|
||||||
} from '../api/roadmap.js'
|
|
||||||
import { deleteRecurring, updateRecurring } from '../api/recurring.js'
|
import { deleteRecurring, updateRecurring } from '../api/recurring.js'
|
||||||
import { RECURRING_STATUSES, RECURRING_STATUS_LABELS } from '../constants/status.js'
|
import { RECURRING_STATUSES, RECURRING_STATUS_LABELS } from '../constants/status.js'
|
||||||
import { EmptyState } from './EmptyState.jsx'
|
import { EmptyState } from './EmptyState.jsx'
|
||||||
|
import { ScheduleEditor, scheduleEditorFromPractice } from './ScheduleEditor.jsx'
|
||||||
import { StatusBadge } from './StatusBadge.jsx'
|
import { StatusBadge } from './StatusBadge.jsx'
|
||||||
import { scopedPath } from '../utils/routes.js'
|
import { scopedPath } from '../utils/routes.js'
|
||||||
|
import {
|
||||||
|
buildSchedulePayload,
|
||||||
|
defaultScheduleValue,
|
||||||
|
formatScheduleLabel,
|
||||||
|
} from '../utils/scheduleUtils.js'
|
||||||
|
|
||||||
function formatDueAt(iso) {
|
function formatDueAt(iso) {
|
||||||
if (!iso) return null
|
if (!iso) return null
|
||||||
|
|
@ -23,6 +26,7 @@ export function GateActivitySetSection({
|
||||||
gateId,
|
gateId,
|
||||||
initiativeId,
|
initiativeId,
|
||||||
gateStatus,
|
gateStatus,
|
||||||
|
gateTitle,
|
||||||
canManage = false,
|
canManage = false,
|
||||||
}) {
|
}) {
|
||||||
const [practices, setPractices] = useState([])
|
const [practices, setPractices] = useState([])
|
||||||
|
|
@ -32,7 +36,9 @@ export function GateActivitySetSection({
|
||||||
const [showForm, setShowForm] = useState(false)
|
const [showForm, setShowForm] = useState(false)
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
const [description, setDescription] = 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 () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
@ -67,34 +73,51 @@ export function GateActivitySetSection({
|
||||||
async function handleCreate(e) {
|
async function handleCreate(e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!title.trim()) return
|
if (!title.trim()) return
|
||||||
const interval = Number.parseInt(intervalDays, 10)
|
let payload
|
||||||
if (!Number.isFinite(interval) || interval < 1) {
|
try {
|
||||||
setError('Intervall muss mindestens 1 Tag sein.')
|
payload = buildSchedulePayload(schedule)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await runAction(async () => {
|
await runAction(async () => {
|
||||||
await createGatePractice(gateId, {
|
await createGatePractice(gateId, {
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
description: description.trim(),
|
description: description.trim(),
|
||||||
interval_days: interval,
|
|
||||||
status: 'active',
|
status: 'active',
|
||||||
|
schedule: payload,
|
||||||
})
|
})
|
||||||
setTitle('')
|
setTitle('')
|
||||||
setDescription('')
|
setDescription('')
|
||||||
setIntervalDays('1')
|
setSchedule(defaultScheduleValue())
|
||||||
setShowForm(false)
|
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 isActiveGate = gateStatus === 'active' || gateStatus === 'at_risk'
|
||||||
|
const routineLabel = gateTitle ? `Routine: ${gateTitle}` : 'Routine an diesem Gate'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="card gate-activity-set">
|
<section className="card gate-activity-set">
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
<div>
|
<div>
|
||||||
<h3>Übungen (Activity Set)</h3>
|
<h3>{routineLabel}</h3>
|
||||||
<p className="section-lead muted">
|
<p className="section-lead muted">
|
||||||
Wiederkehrende Routinen an diesem Zielzustand — erscheinen in Today, wenn das Gate{' '}
|
Übungen gehören zu diesem Zielzustand — sie erscheinen in Today, wenn das Gate{' '}
|
||||||
<strong>aktiv</strong> ist.
|
<strong>aktiv</strong> ist.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -115,7 +138,7 @@ export function GateActivitySetSection({
|
||||||
{!isActiveGate && (
|
{!isActiveGate && (
|
||||||
<p className="muted gate-activity-set__hint">
|
<p className="muted gate-activity-set__hint">
|
||||||
Gate-Status ist <strong>{gateStatus}</strong> — Übungen werden in Today sichtbar, sobald
|
Gate-Status ist <strong>{gateStatus}</strong> — Übungen werden in Today sichtbar, sobald
|
||||||
das Gate auf <strong>aktiv</strong> steht (Stammdaten bearbeiten).
|
das Gate auf <strong>aktiv</strong> steht.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -124,7 +147,7 @@ export function GateActivitySetSection({
|
||||||
<Link to={scopedPath('/work/today', { initiativeId })} className="link-inline">
|
<Link to={scopedPath('/work/today', { initiativeId })} className="link-inline">
|
||||||
Ausführen → Today
|
Ausführen → Today
|
||||||
</Link>{' '}
|
</Link>{' '}
|
||||||
— fällige Übungen dieses Gates abhaken.
|
— fällige Übungen dieser Routine abhaken.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -149,16 +172,7 @@ export function GateActivitySetSection({
|
||||||
placeholder="Hinweise zur Ausführung"
|
placeholder="Hinweise zur Ausführung"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<ScheduleEditor value={schedule} onChange={setSchedule} disabled={busy} />
|
||||||
Rhythmus (Tage)
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
value={intervalDays}
|
|
||||||
onChange={(e) => setIntervalDays(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<button type="submit" className="btn btn-primary" disabled={busy}>
|
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||||
Übung speichern
|
Übung speichern
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -168,7 +182,7 @@ export function GateActivitySetSection({
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="muted">Übungen werden geladen…</p>
|
<p className="muted">Übungen werden geladen…</p>
|
||||||
) : practices.length === 0 ? (
|
) : practices.length === 0 ? (
|
||||||
<EmptyState message="Noch keine Übungen an diesem Gate — mindestens eine für aktive Gates anlegen." />
|
<EmptyState message="Noch keine Übungen — mindestens eine Übung für diese Routine anlegen." />
|
||||||
) : (
|
) : (
|
||||||
<ul className="item-list gate-activity-set__list">
|
<ul className="item-list gate-activity-set__list">
|
||||||
{practices.map((practice) => (
|
{practices.map((practice) => (
|
||||||
|
|
@ -179,14 +193,40 @@ export function GateActivitySetSection({
|
||||||
<p className="muted list-item-sub">{practice.description}</p>
|
<p className="muted list-item-sub">{practice.description}</p>
|
||||||
)}
|
)}
|
||||||
<p className="muted list-item-sub">
|
<p className="muted list-item-sub">
|
||||||
{practice.interval_days
|
{formatScheduleLabel(practice.schedule || practice)}
|
||||||
? `Alle ${practice.interval_days} Tag(e)`
|
|
||||||
: 'Kein Intervall'}
|
|
||||||
{practice.open_cadence_instance?.due_at
|
{practice.open_cadence_instance?.due_at
|
||||||
? ` · Fällig: ${formatDueAt(practice.open_cadence_instance.due_at)}`
|
? ` · Fällig: ${formatDueAt(practice.open_cadence_instance.due_at)}`
|
||||||
: ''}
|
: ''}
|
||||||
{practice.today_completed ? ' · heute erledigt' : ''}
|
{practice.today_completed ? ' · heute erledigt' : ''}
|
||||||
</p>
|
</p>
|
||||||
|
{editingRhythmId === practice.id && (
|
||||||
|
<div className="gate-activity-set__rhythm-edit">
|
||||||
|
<ScheduleEditor
|
||||||
|
value={editSchedule}
|
||||||
|
onChange={setEditSchedule}
|
||||||
|
disabled={busy}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
<div className="action-controls">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => saveRhythm(practice)}
|
||||||
|
>
|
||||||
|
Rhythmus speichern
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => setEditingRhythmId(null)}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{canManage && (
|
{canManage && (
|
||||||
<div className="list-item-meta action-controls">
|
<div className="list-item-meta action-controls">
|
||||||
|
|
@ -213,17 +253,8 @@ export function GateActivitySetSection({
|
||||||
className="btn btn-secondary btn-sm"
|
className="btn btn-secondary btn-sm"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const next = window.prompt(
|
setEditingRhythmId(practice.id)
|
||||||
'Neues Intervall in Tagen:',
|
setEditSchedule(scheduleEditorFromPractice(practice))
|
||||||
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 }))
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Rhythmus
|
Rhythmus
|
||||||
|
|
|
||||||
142
frontend/src/components/ScheduleEditor.jsx
Normal file
142
frontend/src/components/ScheduleEditor.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<fieldset className={`schedule-editor${compact ? ' schedule-editor--compact' : ''}`} disabled={disabled}>
|
||||||
|
<legend className="schedule-editor__legend">Rhythmus</legend>
|
||||||
|
|
||||||
|
<div className="schedule-editor__modes" role="group" aria-label="Rhythmus-Art">
|
||||||
|
{INTERVAL_PRESETS.filter((p) => p.id !== 'custom').map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-sm ${preset === p.id ? 'btn-primary' : 'btn-secondary'}`}
|
||||||
|
onClick={() => setKind(p.id)}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-sm ${preset === 'weekdays' ? 'btn-primary' : 'btn-secondary'}`}
|
||||||
|
onClick={() => setKind('weekdays')}
|
||||||
|
>
|
||||||
|
Wochentage
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-sm ${preset === 'custom' ? 'btn-primary' : 'btn-secondary'}`}
|
||||||
|
onClick={() => setKind('custom')}
|
||||||
|
>
|
||||||
|
Individuell
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{current.schedule_kind === 'interval' && showCustomInterval && (
|
||||||
|
<label className="schedule-editor__field">
|
||||||
|
Alle wie viele Tage?
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={current.interval_days}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({ ...current, interval_days: Number.parseInt(e.target.value, 10) || 1 })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{current.schedule_kind === 'weekdays' && (
|
||||||
|
<div className="schedule-editor__weekdays">
|
||||||
|
<p className="muted schedule-editor__hint">
|
||||||
|
An welchen Tagen? {weekdaySummary ? `(${weekdaySummary})` : ''}
|
||||||
|
</p>
|
||||||
|
<div className="schedule-editor__weekday-row">
|
||||||
|
{WEEKDAY_OPTIONS.map((day) => {
|
||||||
|
const active = Boolean((current.weekday_mask || 0) & day.bit)
|
||||||
|
return (
|
||||||
|
<label key={day.bit} className={`schedule-weekday${active ? ' is-active' : ''}`}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={active}
|
||||||
|
onChange={() =>
|
||||||
|
onChange({
|
||||||
|
...current,
|
||||||
|
weekday_mask: toggleWeekdayMask(current.weekday_mask || 0, day.bit),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{day.label}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label className="schedule-editor__field">
|
||||||
|
Pause bis (optional)
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={current.pause_until || ''}
|
||||||
|
onChange={(e) => onChange({ ...current, pause_until: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scheduleEditorFromPractice(practice) {
|
||||||
|
return scheduleFromApi(practice?.schedule || {
|
||||||
|
schedule_kind: 'interval',
|
||||||
|
interval_days: practice?.interval_days || 1,
|
||||||
|
weekday_mask: 0,
|
||||||
|
pause_until: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ import { updateAction, setActionAssignments } from '../api/actions.js'
|
||||||
import { createInitiativeBlocker } from '../api/blockers.js'
|
import { createInitiativeBlocker } from '../api/blockers.js'
|
||||||
import { ActionHubCard } from '../components/ActionHubCard.jsx'
|
import { ActionHubCard } from '../components/ActionHubCard.jsx'
|
||||||
import { ActionDependenciesSection } from '../components/ActionDependenciesSection.jsx'
|
import { ActionDependenciesSection } from '../components/ActionDependenciesSection.jsx'
|
||||||
|
import { ActionScheduleSection } from '../components/ActionScheduleSection.jsx'
|
||||||
import { ActionForm } from '../components/ActionForm.jsx'
|
import { ActionForm } from '../components/ActionForm.jsx'
|
||||||
import { TasksSection } from '../components/TasksSection.jsx'
|
import { TasksSection } from '../components/TasksSection.jsx'
|
||||||
import { ErrorState } from '../components/ErrorState.jsx'
|
import { ErrorState } from '../components/ErrorState.jsx'
|
||||||
|
|
@ -39,6 +40,7 @@ function ActionDetailBody({
|
||||||
roadmapItems = [],
|
roadmapItems = [],
|
||||||
allActions = [],
|
allActions = [],
|
||||||
executionGraph = null,
|
executionGraph = null,
|
||||||
|
onReload,
|
||||||
}) {
|
}) {
|
||||||
if (editing) {
|
if (editing) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -80,6 +82,11 @@ function ActionDetailBody({
|
||||||
onReloadActors={actorsState.reload}
|
onReloadActors={actorsState.reload}
|
||||||
detailMode
|
detailMode
|
||||||
/>
|
/>
|
||||||
|
<ActionScheduleSection
|
||||||
|
action={action}
|
||||||
|
canManage={capabilities.has('kairo.action.manage')}
|
||||||
|
onUpdated={onReload}
|
||||||
|
/>
|
||||||
<TasksSection
|
<TasksSection
|
||||||
actionId={action.id}
|
actionId={action.id}
|
||||||
canManage={capabilities.has('kairo.action.manage')}
|
canManage={capabilities.has('kairo.action.manage')}
|
||||||
|
|
@ -254,6 +261,7 @@ function ActionDetailStandalone() {
|
||||||
roadmapItems={roadmapItems}
|
roadmapItems={roadmapItems}
|
||||||
allActions={allActions}
|
allActions={allActions}
|
||||||
executionGraph={executionGraph}
|
executionGraph={executionGraph}
|
||||||
|
onReload={load}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -317,6 +325,7 @@ function ActionDetailNested() {
|
||||||
roadmapItems={ops.roadmapItems}
|
roadmapItems={ops.roadmapItems}
|
||||||
allActions={ops.actions}
|
allActions={ops.actions}
|
||||||
executionGraph={executionGraph}
|
executionGraph={executionGraph}
|
||||||
|
onReload={ops.reload}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -241,6 +241,7 @@ export function RoadmapItemDetailPage({ overrideItemId }) {
|
||||||
gateId={itemId}
|
gateId={itemId}
|
||||||
initiativeId={initiativeId || item.initiative_id}
|
initiativeId={initiativeId || item.initiative_id}
|
||||||
gateStatus={item.status}
|
gateStatus={item.status}
|
||||||
|
gateTitle={item.title}
|
||||||
canManage={capabilities.has('kairo.recurring.manage')}
|
canManage={capabilities.has('kairo.recurring.manage')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -2477,3 +2477,70 @@
|
||||||
margin-bottom: 0.75rem;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
81
frontend/src/utils/scheduleUtils.js
Normal file
81
frontend/src/utils/scheduleUtils.js
Normal file
|
|
@ -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'
|
||||||
|
}
|
||||||
76
package-lock.json
generated
Normal file
76
package-lock.json
generated
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user