From 473b094234dae5b1c0326d41e258ee72aa0d3820 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=94=90=E5=83=A7=E6=B4=97=E5=8F=91=E7=94=A8=E9=A3=98?= =?UTF-8?q?=E6=9F=94?= <2906266135@qq.com> Date: Fri, 7 Aug 2026 18:38:29 +0800 Subject: [PATCH 1/9] feat(schedule): define agent schedule service skeleton --- .../timeflow/business/calendar/__init__.py | 41 ++++ .../timeflow/business/calendar/contracts.py | 204 ++++++++++++++++++ .../src/timeflow/business/calendar/service.py | 81 +++++++ 3 files changed, 326 insertions(+) create mode 100644 backend/src/timeflow/business/calendar/__init__.py create mode 100644 backend/src/timeflow/business/calendar/contracts.py create mode 100644 backend/src/timeflow/business/calendar/service.py diff --git a/backend/src/timeflow/business/calendar/__init__.py b/backend/src/timeflow/business/calendar/__init__.py new file mode 100644 index 0000000..e218594 --- /dev/null +++ b/backend/src/timeflow/business/calendar/__init__.py @@ -0,0 +1,41 @@ +"""Schedule domain contracts and application service boundary.""" + +from timeflow.business.calendar.contracts import ( + CreateScheduleCommand, + DeleteOnceScheduleCommand, + DeleteRecurringScheduleCommand, + FindSchedulesQuery, + OccurrenceOverrideAction, + RecurringDeleteScope, + ReminderStrength, + ReminderType, + ScheduleKind, + ScheduleMutationResult, + ScheduleOccurrenceOverrideSnapshot, + ScheduleSearchResult, + ScheduleSnapshot, + ScheduleStatus, + ScheduleType, + UpdateScheduleCommand, +) +from timeflow.business.calendar.service import ScheduleAgentService + +__all__ = [ + "CreateScheduleCommand", + "DeleteOnceScheduleCommand", + "DeleteRecurringScheduleCommand", + "FindSchedulesQuery", + "OccurrenceOverrideAction", + "RecurringDeleteScope", + "ReminderStrength", + "ReminderType", + "ScheduleAgentService", + "ScheduleKind", + "ScheduleMutationResult", + "ScheduleOccurrenceOverrideSnapshot", + "ScheduleSearchResult", + "ScheduleSnapshot", + "ScheduleStatus", + "ScheduleType", + "UpdateScheduleCommand", +] diff --git a/backend/src/timeflow/business/calendar/contracts.py b/backend/src/timeflow/business/calendar/contracts.py new file mode 100644 index 0000000..dc356c9 --- /dev/null +++ b/backend/src/timeflow/business/calendar/contracts.py @@ -0,0 +1,204 @@ +"""Framework-independent contracts for the schedule business boundary.""" + +from collections.abc import Mapping +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum +from typing import TypeAlias + + +class ScheduleType(str, Enum): + """Supported schedule categories.""" + + TIME = "time" + LOCATION = "location" + + +class ScheduleKind(str, Enum): + """Whether a schedule occurs once or follows an RRULE.""" + + ONCE = "once" + RECURRING = "recurring" + + +class ScheduleStatus(str, Enum): + """Cloud lifecycle status for a schedule.""" + + ACTIVE = "active" + DELETED = "deleted" + + +class ReminderType(str, Enum): + """The single reminder configuration attached to a schedule.""" + + AT_TIME = "at_time" + BEFORE_START = "before_start" + ARRIVE_LOCATION = "arrive_location" + RETURN_TO_RECORDED_LOCATION = "return_to_recorded_location" + + +class ReminderStrength(str, Enum): + """Reminder delivery strength selected for a schedule.""" + + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + + +class RecurringDeleteScope(str, Enum): + """Deletion scopes based on the schedule-local current date. + + The implementation must derive the current date from the schedule's IANA + timezone. It then finds the first occurrence whose local date is today or + later; the caller cannot provide an arbitrary occurrence date. + """ + + NEXT_OCCURRENCE = "next_occurrence" + NEXT_AND_FUTURE = "next_and_future" + + +class OccurrenceOverrideAction(str, Enum): + """Supported changes to one expanded recurring occurrence.""" + + CANCEL = "cancel" + REPLACE = "replace" + + +SchedulePatchValue: TypeAlias = str | bool | int | float | datetime | None + + +@dataclass(frozen=True, slots=True) +class ScheduleSnapshot: + """A final schedule snapshot committed by the cloud service.""" + + id: str + account_id: str + schedule_type: ScheduleType + schedule_kind: ScheduleKind + title: str + is_all_day: bool + timezone: str + status: ScheduleStatus + revision: int + created_at: datetime + updated_at: datetime + start_time: datetime | None = None + end_time: datetime | None = None + recurrence_rule: str | None = None + location_name: str | None = None + latitude: float | None = None + longitude: float | None = None + reminder_type: ReminderType | None = None + reminder_trigger_at: datetime | None = None + reminder_offset_minutes: int | None = None + reminder_strength: ReminderStrength | None = None + reminder_disposition_state: str | None = None + deleted_at: datetime | None = None + + +@dataclass(frozen=True, slots=True) +class ScheduleOccurrenceOverrideSnapshot: + """A committed exception for one recurring occurrence.""" + + id: str + schedule_id: str + occurrence_start: datetime + action: OccurrenceOverrideAction + created_at: datetime + updated_at: datetime + replacement_schedule_id: str | None = None + + +@dataclass(frozen=True, slots=True) +class CreateScheduleCommand: + """Structured, user-confirmed input used to create a schedule.""" + + schedule_type: ScheduleType + schedule_kind: ScheduleKind + title: str + timezone: str + is_all_day: bool = False + start_time: datetime | None = None + end_time: datetime | None = None + recurrence_rule: str | None = None + location_name: str | None = None + latitude: float | None = None + longitude: float | None = None + reminder_type: ReminderType | None = None + reminder_trigger_at: datetime | None = None + reminder_offset_minutes: int | None = None + reminder_strength: ReminderStrength | None = None + + +@dataclass(frozen=True, slots=True) +class FindSchedulesQuery: + """Search criteria supplied by the Agent for matching schedules.""" + + schedule_id: str | None = None + title: str | None = None + starts_at_or_after: datetime | None = None + starts_before: datetime | None = None + location_name: str | None = None + include_deleted: bool = False + + +@dataclass(frozen=True, slots=True) +class UpdateScheduleCommand: + """A confirmed patch applied to one schedule or an entire recurring series.""" + + schedule_id: str + expected_revision: int + changes: Mapping[str, SchedulePatchValue] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class DeleteOnceScheduleCommand: + """A confirmed request to soft-delete one non-recurring schedule.""" + + schedule_id: str + expected_revision: int + + +@dataclass(frozen=True, slots=True) +class DeleteRecurringScheduleCommand: + """A confirmed request to delete the next recurring occurrence or its future.""" + + schedule_id: str + expected_revision: int + scope: RecurringDeleteScope + + +@dataclass(frozen=True, slots=True) +class ScheduleMutationResult: + """Final cloud snapshots produced by a successful mutation.""" + + schedules: tuple[ScheduleSnapshot, ...] + occurrence_overrides: tuple[ScheduleOccurrenceOverrideSnapshot, ...] = () + + +@dataclass(frozen=True, slots=True) +class ScheduleSearchResult: + """Schedules matched for Agent query or disambiguation.""" + + schedules: tuple[ScheduleSnapshot, ...] + + +__all__ = [ + "CreateScheduleCommand", + "DeleteOnceScheduleCommand", + "DeleteRecurringScheduleCommand", + "FindSchedulesQuery", + "OccurrenceOverrideAction", + "RecurringDeleteScope", + "ReminderStrength", + "ReminderType", + "ScheduleKind", + "ScheduleMutationResult", + "ScheduleOccurrenceOverrideSnapshot", + "SchedulePatchValue", + "ScheduleSearchResult", + "ScheduleSnapshot", + "ScheduleStatus", + "ScheduleType", + "UpdateScheduleCommand", +] diff --git a/backend/src/timeflow/business/calendar/service.py b/backend/src/timeflow/business/calendar/service.py new file mode 100644 index 0000000..0aa13d3 --- /dev/null +++ b/backend/src/timeflow/business/calendar/service.py @@ -0,0 +1,81 @@ +"""Agent-facing schedule application service skeleton.""" + +from abc import ABC, abstractmethod + +from timeflow.business.calendar.contracts import ( + CreateScheduleCommand, + DeleteOnceScheduleCommand, + DeleteRecurringScheduleCommand, + FindSchedulesQuery, + ScheduleMutationResult, + ScheduleSearchResult, + UpdateScheduleCommand, +) + + +class ScheduleAgentService(ABC): + """Five stable schedule operations exposed to the Agent. + + This abstract class intentionally contains no persistence, validation, + recurrence expansion, or mutation logic yet. + """ + + @abstractmethod + def create_schedule( + self, + *, + account_id: str, + command: CreateScheduleCommand, + ) -> ScheduleMutationResult: + """Create an ordinary or recurring schedule from a confirmed command.""" + # TODO(person-2): validate the aggregate and persist it transactionally. + raise NotImplementedError + + @abstractmethod + def find_schedules( + self, + *, + account_id: str, + query: FindSchedulesQuery, + ) -> ScheduleSearchResult: + """Find schedules for Agent queries, matching, and disambiguation.""" + # TODO(person-2): implement account-scoped schedule matching. + raise NotImplementedError + + @abstractmethod + def update_schedule( + self, + *, + account_id: str, + command: UpdateScheduleCommand, + ) -> ScheduleMutationResult: + """Update one schedule; recurring changes apply to the complete series.""" + # TODO(person-2): validate the patch, revision, and final aggregate. + raise NotImplementedError + + @abstractmethod + def delete_once_schedule( + self, + *, + account_id: str, + command: DeleteOnceScheduleCommand, + ) -> ScheduleMutationResult: + """Soft-delete one non-recurring schedule.""" + # TODO(person-2): validate the target and create a deleted cloud snapshot. + raise NotImplementedError + + @abstractmethod + def delete_recurring_schedule( + self, + *, + account_id: str, + command: DeleteRecurringScheduleCommand, + ) -> ScheduleMutationResult: + """Delete the next occurrence or that occurrence and all future ones.""" + # TODO(person-2): use the schedule timezone and a system clock to find + # the first occurrence on or after the current local date, then apply + # command.scope without accepting an arbitrary date from the caller. + raise NotImplementedError + + +__all__ = ["ScheduleAgentService"] From e5ea72677e2789a1c62ee496d5f957cbe7e76acd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=94=90=E5=83=A7=E6=B4=97=E5=8F=91=E7=94=A8=E9=A3=98?= =?UTF-8?q?=E6=9F=94?= <2906266135@qq.com> Date: Fri, 7 Aug 2026 18:38:45 +0800 Subject: [PATCH 2/9] test(schedule): lock agent service interface contract --- .../tests/test_schedule_service_skeleton.py | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 backend/tests/test_schedule_service_skeleton.py diff --git a/backend/tests/test_schedule_service_skeleton.py b/backend/tests/test_schedule_service_skeleton.py new file mode 100644 index 0000000..3c171bf --- /dev/null +++ b/backend/tests/test_schedule_service_skeleton.py @@ -0,0 +1,30 @@ +"""Contract tests for the person-two schedule service skeleton.""" + +from timeflow.business.calendar import RecurringDeleteScope, ScheduleAgentService + + +def test_agent_schedule_service_exposes_exactly_five_business_operations() -> None: + """The first collaboration skeleton keeps the agreed Agent boundary stable.""" + + operations = { + name + for name, value in ScheduleAgentService.__dict__.items() + if callable(value) and getattr(value, "__isabstractmethod__", False) + } + + assert operations == { + "create_schedule", + "find_schedules", + "update_schedule", + "delete_once_schedule", + "delete_recurring_schedule", + } + + +def test_recurring_delete_scope_has_only_the_two_agreed_choices() -> None: + """Callers cannot select an arbitrary recurring occurrence or date range.""" + + assert list(RecurringDeleteScope) == [ + RecurringDeleteScope.NEXT_OCCURRENCE, + RecurringDeleteScope.NEXT_AND_FUTURE, + ] From ef6897e1f042cc15f6fc5b6ba600ada0508b127c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=94=90=E5=83=A7=E6=B4=97=E5=8F=91=E7=94=A8=E9=A3=98?= =?UTF-8?q?=E6=9F=94?= <2906266135@qq.com> Date: Fri, 7 Aug 2026 18:39:05 +0800 Subject: [PATCH 3/9] feat(schedule): define local calendar sync contracts --- frontend/src/contracts/schedule.ts | 56 +++++++++++++++++ .../features/schedule/application/index.ts | 8 +++ .../application/scheduleClientService.ts | 61 +++++++++++++++++++ 3 files changed, 125 insertions(+) create mode 100644 frontend/src/contracts/schedule.ts create mode 100644 frontend/src/features/schedule/application/index.ts create mode 100644 frontend/src/features/schedule/application/scheduleClientService.ts diff --git a/frontend/src/contracts/schedule.ts b/frontend/src/contracts/schedule.ts new file mode 100644 index 0000000..a85b1f2 --- /dev/null +++ b/frontend/src/contracts/schedule.ts @@ -0,0 +1,56 @@ +export type ScheduleType = 'time' | 'location'; + +export type ScheduleKind = 'once' | 'recurring'; + +export type ScheduleStatus = 'active' | 'deleted'; + +export type ReminderType = + 'at_time' | 'before_start' | 'arrive_location' | 'return_to_recorded_location'; + +export type ReminderStrength = 'low' | 'medium' | 'high'; + +export type OccurrenceOverrideAction = 'cancel' | 'replace'; + +/** Final schedule fields already committed by the cloud service. */ +export interface ScheduleSnapshot { + id: string; + account_id: string; + schedule_type: ScheduleType; + schedule_kind: ScheduleKind; + title: string; + is_all_day: boolean; + start_time: string | null; + end_time: string | null; + timezone: string; + recurrence_rule: string | null; + location_name: string | null; + latitude: number | null; + longitude: number | null; + reminder_type: ReminderType | null; + reminder_trigger_at: string | null; + reminder_offset_minutes: number | null; + reminder_strength: ReminderStrength | null; + reminder_disposition_state: string | null; + status: ScheduleStatus; + revision: number; + created_at: string; + updated_at: string; + deleted_at: string | null; +} + +/** Final recurring exception fields already committed by the cloud service. */ +export interface ScheduleOccurrenceOverrideSnapshot { + id: string; + schedule_id: string; + occurrence_start: string; + action: OccurrenceOverrideAction; + replacement_schedule_id: string | null; + created_at: string; + updated_at: string; +} + +/** One WebSocket command result, including every local row it can affect. */ +export interface CloudScheduleSnapshot { + schedules: readonly ScheduleSnapshot[]; + occurrence_overrides: readonly ScheduleOccurrenceOverrideSnapshot[]; +} diff --git a/frontend/src/features/schedule/application/index.ts b/frontend/src/features/schedule/application/index.ts new file mode 100644 index 0000000..43dae8e --- /dev/null +++ b/frontend/src/features/schedule/application/index.ts @@ -0,0 +1,8 @@ +export type { + ApplyScheduleSnapshotCommand, + GetSchedulesByDayQuery, + ScheduleClientService, + ScheduleOccurrenceView, + SnapshotApplyResult, + SnapshotApplyStatus, +} from './scheduleClientService'; diff --git a/frontend/src/features/schedule/application/scheduleClientService.ts b/frontend/src/features/schedule/application/scheduleClientService.ts new file mode 100644 index 0000000..21b11df --- /dev/null +++ b/frontend/src/features/schedule/application/scheduleClientService.ts @@ -0,0 +1,61 @@ +import type { CloudScheduleSnapshot, ScheduleSnapshot } from '../../../contracts/schedule'; + +/** Input from the calendar UI when a user selects one local calendar date. */ +export interface GetSchedulesByDayQuery { + accountId: string; + /** Calendar date formatted as YYYY-MM-DD. */ + selectedDate: string; + /** IANA timezone used to interpret the selected calendar date. */ + timezone: string; +} + +/** One displayable occurrence returned to the calendar UI. */ +export interface ScheduleOccurrenceView { + schedule: ScheduleSnapshot; + occurrenceStart: string | null; + occurrenceEnd: string | null; +} + +/** WebSocket result passed to the local synchronization boundary. */ +export interface ApplyScheduleSnapshotCommand { + messageId: string; + accountId: string; + snapshot: CloudScheduleSnapshot; +} + +export type SnapshotApplyStatus = 'applied' | 'ignored_stale' | 'failed'; + +/** Result used by the WebSocket owner to decide whether an ACK can be sent. */ +export interface SnapshotApplyResult { + messageId: string; + status: SnapshotApplyStatus; + changedScheduleIds: readonly string[]; + errorCode?: string; +} + +/** + * Two client-side schedule operations owned by person two. + * + * Implementations will live above the SQLite adapter. This interface contains + * no database access, RRULE expansion, revision comparison, or transaction + * logic yet. + */ +export interface ScheduleClientService { + /** + * Return once, all-day, and expanded recurring occurrences for one day. + * + * TODO(person-2): read local SQLite, expand RRULE values, then apply local + * occurrence overrides before returning display rows. + */ + getSchedulesByDay(query: GetSchedulesByDayQuery): Promise; + + /** + * Apply every server-confirmed create, update, delete, and recurrence change. + * + * TODO(person-2): apply schedules and occurrence overrides in one SQLite + * transaction, ignore stale revisions, then report changed schedule IDs. + */ + applyScheduleSnapshotToSqlite( + command: ApplyScheduleSnapshotCommand, + ): Promise; +} From 7252b3fbf247f62286af5ed04c012790bae8c25f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=94=90=E5=83=A7=E6=B4=97=E5=8F=91=E7=94=A8=E9=A3=98?= =?UTF-8?q?=E6=9F=94?= <2906266135@qq.com> Date: Mon, 10 Aug 2026 11:10:31 +0800 Subject: [PATCH 4/9] fix(schedule): use StrEnum for schedule enums --- .../src/timeflow/business/calendar/contracts.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/backend/src/timeflow/business/calendar/contracts.py b/backend/src/timeflow/business/calendar/contracts.py index dc356c9..aa0b10c 100644 --- a/backend/src/timeflow/business/calendar/contracts.py +++ b/backend/src/timeflow/business/calendar/contracts.py @@ -3,32 +3,32 @@ from collections.abc import Mapping from dataclasses import dataclass, field from datetime import datetime -from enum import Enum +from enum import StrEnum from typing import TypeAlias -class ScheduleType(str, Enum): +class ScheduleType(StrEnum): """Supported schedule categories.""" TIME = "time" LOCATION = "location" -class ScheduleKind(str, Enum): +class ScheduleKind(StrEnum): """Whether a schedule occurs once or follows an RRULE.""" ONCE = "once" RECURRING = "recurring" -class ScheduleStatus(str, Enum): +class ScheduleStatus(StrEnum): """Cloud lifecycle status for a schedule.""" ACTIVE = "active" DELETED = "deleted" -class ReminderType(str, Enum): +class ReminderType(StrEnum): """The single reminder configuration attached to a schedule.""" AT_TIME = "at_time" @@ -37,7 +37,7 @@ class ReminderType(str, Enum): RETURN_TO_RECORDED_LOCATION = "return_to_recorded_location" -class ReminderStrength(str, Enum): +class ReminderStrength(StrEnum): """Reminder delivery strength selected for a schedule.""" LOW = "low" @@ -45,7 +45,7 @@ class ReminderStrength(str, Enum): HIGH = "high" -class RecurringDeleteScope(str, Enum): +class RecurringDeleteScope(StrEnum): """Deletion scopes based on the schedule-local current date. The implementation must derive the current date from the schedule's IANA @@ -57,7 +57,7 @@ class RecurringDeleteScope(str, Enum): NEXT_AND_FUTURE = "next_and_future" -class OccurrenceOverrideAction(str, Enum): +class OccurrenceOverrideAction(StrEnum): """Supported changes to one expanded recurring occurrence.""" CANCEL = "cancel" From 885308f9fa27eac60122fd900cfa0cda7d043d17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=94=90=E5=83=A7=E6=B4=97=E5=8F=91=E7=94=A8=E9=A3=98?= =?UTF-8?q?=E6=9F=94?= <2906266135@qq.com> Date: Mon, 10 Aug 2026 12:30:16 +0800 Subject: [PATCH 5/9] fix(schedule): tighten update and error contracts --- .../timeflow/business/calendar/__init__.py | 6 ++ .../timeflow/business/calendar/contracts.py | 65 ++++++++++++++++-- .../src/timeflow/business/calendar/service.py | 30 ++++++-- .../tests/test_schedule_service_skeleton.py | 68 ++++++++++++++++++- 4 files changed, 157 insertions(+), 12 deletions(-) diff --git a/backend/src/timeflow/business/calendar/__init__.py b/backend/src/timeflow/business/calendar/__init__.py index e218594..16478e8 100644 --- a/backend/src/timeflow/business/calendar/__init__.py +++ b/backend/src/timeflow/business/calendar/__init__.py @@ -9,6 +9,8 @@ RecurringDeleteScope, ReminderStrength, ReminderType, + ScheduleBusinessError, + ScheduleErrorCode, ScheduleKind, ScheduleMutationResult, ScheduleOccurrenceOverrideSnapshot, @@ -16,6 +18,7 @@ ScheduleSnapshot, ScheduleStatus, ScheduleType, + ScheduleUpdatePatch, UpdateScheduleCommand, ) from timeflow.business.calendar.service import ScheduleAgentService @@ -30,6 +33,8 @@ "ReminderStrength", "ReminderType", "ScheduleAgentService", + "ScheduleBusinessError", + "ScheduleErrorCode", "ScheduleKind", "ScheduleMutationResult", "ScheduleOccurrenceOverrideSnapshot", @@ -37,5 +42,6 @@ "ScheduleSnapshot", "ScheduleStatus", "ScheduleType", + "ScheduleUpdatePatch", "UpdateScheduleCommand", ] diff --git a/backend/src/timeflow/business/calendar/contracts.py b/backend/src/timeflow/business/calendar/contracts.py index aa0b10c..b09d4da 100644 --- a/backend/src/timeflow/business/calendar/contracts.py +++ b/backend/src/timeflow/business/calendar/contracts.py @@ -1,10 +1,9 @@ """Framework-independent contracts for the schedule business boundary.""" -from collections.abc import Mapping -from dataclasses import dataclass, field +from dataclasses import dataclass from datetime import datetime from enum import StrEnum -from typing import TypeAlias +from typing import TypedDict class ScheduleType(StrEnum): @@ -64,7 +63,59 @@ class OccurrenceOverrideAction(StrEnum): REPLACE = "replace" -SchedulePatchValue: TypeAlias = str | bool | int | float | datetime | None +class ScheduleErrorCode(StrEnum): + """Stable business failures raised by the Agent schedule boundary.""" + + SCHEDULE_NOT_FOUND = "schedule_not_found" + REVISION_CONFLICT = "revision_conflict" + OCCURRENCE_NOT_FOUND = "occurrence_not_found" + INVALID_TIMEZONE = "invalid_timezone" + INVALID_UPDATE_PATCH = "invalid_update_patch" + INVALID_SCHEDULE_KIND = "invalid_schedule_kind" + VALIDATION_FAILED = "validation_failed" + + +class ScheduleBusinessError(Exception): + """Expected schedule failure that callers can translate without parsing text.""" + + __slots__ = ("code", "field", "message", "schedule_id") + + def __init__( + self, + *, + code: ScheduleErrorCode, + message: str, + schedule_id: str | None = None, + field: str | None = None, + ) -> None: + super().__init__(message) + self.code = code + self.message = message + self.schedule_id = schedule_id + self.field = field + + +class ScheduleUpdatePatch(TypedDict, total=False): + """Explicit user-editable fields for an update command. + + Omitting a key leaves the persisted value unchanged. Supplying ``None`` + explicitly clears a nullable field. Identity, ownership, lifecycle, + revision, and audit fields are intentionally not patchable. + """ + + title: str + is_all_day: bool + start_time: datetime | None + end_time: datetime | None + timezone: str + recurrence_rule: str | None + location_name: str | None + latitude: float | None + longitude: float | None + reminder_type: ReminderType | None + reminder_trigger_at: datetime | None + reminder_offset_minutes: int | None + reminder_strength: ReminderStrength | None @dataclass(frozen=True, slots=True) @@ -148,7 +199,7 @@ class UpdateScheduleCommand: schedule_id: str expected_revision: int - changes: Mapping[str, SchedulePatchValue] = field(default_factory=dict) + changes: ScheduleUpdatePatch @dataclass(frozen=True, slots=True) @@ -192,13 +243,15 @@ class ScheduleSearchResult: "RecurringDeleteScope", "ReminderStrength", "ReminderType", + "ScheduleBusinessError", + "ScheduleErrorCode", "ScheduleKind", "ScheduleMutationResult", "ScheduleOccurrenceOverrideSnapshot", - "SchedulePatchValue", "ScheduleSearchResult", "ScheduleSnapshot", "ScheduleStatus", "ScheduleType", + "ScheduleUpdatePatch", "UpdateScheduleCommand", ] diff --git a/backend/src/timeflow/business/calendar/service.py b/backend/src/timeflow/business/calendar/service.py index 0aa13d3..4fa15ac 100644 --- a/backend/src/timeflow/business/calendar/service.py +++ b/backend/src/timeflow/business/calendar/service.py @@ -27,7 +27,11 @@ def create_schedule( account_id: str, command: CreateScheduleCommand, ) -> ScheduleMutationResult: - """Create an ordinary or recurring schedule from a confirmed command.""" + """Create an ordinary or recurring schedule from a confirmed command. + + Raises: + ScheduleBusinessError: If the confirmed command is invalid. + """ # TODO(person-2): validate the aggregate and persist it transactionally. raise NotImplementedError @@ -38,7 +42,11 @@ def find_schedules( account_id: str, query: FindSchedulesQuery, ) -> ScheduleSearchResult: - """Find schedules for Agent queries, matching, and disambiguation.""" + """Find schedules for Agent queries, matching, and disambiguation. + + Raises: + ScheduleBusinessError: If the query contains invalid criteria. + """ # TODO(person-2): implement account-scoped schedule matching. raise NotImplementedError @@ -49,7 +57,11 @@ def update_schedule( account_id: str, command: UpdateScheduleCommand, ) -> ScheduleMutationResult: - """Update one schedule; recurring changes apply to the complete series.""" + """Update one schedule; recurring changes apply to the complete series. + + Raises: + ScheduleBusinessError: If the target, revision, or patch is invalid. + """ # TODO(person-2): validate the patch, revision, and final aggregate. raise NotImplementedError @@ -60,7 +72,11 @@ def delete_once_schedule( account_id: str, command: DeleteOnceScheduleCommand, ) -> ScheduleMutationResult: - """Soft-delete one non-recurring schedule.""" + """Soft-delete one non-recurring schedule. + + Raises: + ScheduleBusinessError: If the target or revision is invalid. + """ # TODO(person-2): validate the target and create a deleted cloud snapshot. raise NotImplementedError @@ -71,7 +87,11 @@ def delete_recurring_schedule( account_id: str, command: DeleteRecurringScheduleCommand, ) -> ScheduleMutationResult: - """Delete the next occurrence or that occurrence and all future ones.""" + """Delete the next occurrence or that occurrence and all future ones. + + Raises: + ScheduleBusinessError: If the target, revision, or occurrence is invalid. + """ # TODO(person-2): use the schedule timezone and a system clock to find # the first occurrence on or after the current local date, then apply # command.scope without accepting an arbitrary date from the caller. diff --git a/backend/tests/test_schedule_service_skeleton.py b/backend/tests/test_schedule_service_skeleton.py index 3c171bf..0aba13a 100644 --- a/backend/tests/test_schedule_service_skeleton.py +++ b/backend/tests/test_schedule_service_skeleton.py @@ -1,6 +1,13 @@ """Contract tests for the person-two schedule service skeleton.""" -from timeflow.business.calendar import RecurringDeleteScope, ScheduleAgentService +from timeflow.business.calendar import ( + RecurringDeleteScope, + ScheduleAgentService, + ScheduleBusinessError, + ScheduleErrorCode, + ScheduleUpdatePatch, + UpdateScheduleCommand, +) def test_agent_schedule_service_exposes_exactly_five_business_operations() -> None: @@ -28,3 +35,62 @@ def test_recurring_delete_scope_has_only_the_two_agreed_choices() -> None: RecurringDeleteScope.NEXT_OCCURRENCE, RecurringDeleteScope.NEXT_AND_FUTURE, ] + + +def test_update_patch_exposes_only_explicitly_mutable_fields() -> None: + """Identity, ownership, lifecycle, revision, and audit fields stay protected.""" + + assert ScheduleUpdatePatch.__required_keys__ == frozenset() + assert ScheduleUpdatePatch.__optional_keys__ == { + "title", + "is_all_day", + "start_time", + "end_time", + "timezone", + "recurrence_rule", + "location_name", + "latitude", + "longitude", + "reminder_type", + "reminder_trigger_at", + "reminder_offset_minutes", + "reminder_strength", + } + + command = UpdateScheduleCommand( + schedule_id="schedule-1", + expected_revision=3, + changes={"title": "Updated title", "location_name": None}, + ) + + assert command.changes == {"title": "Updated title", "location_name": None} + + +def test_business_error_has_stable_machine_readable_context() -> None: + """Agent adapters can translate expected failures without parsing messages.""" + + error = ScheduleBusinessError( + code=ScheduleErrorCode.REVISION_CONFLICT, + message="The schedule revision is stale.", + schedule_id="schedule-1", + field="expected_revision", + ) + + assert str(error) == "The schedule revision is stale." + assert error.code is ScheduleErrorCode.REVISION_CONFLICT + assert error.schedule_id == "schedule-1" + assert error.field == "expected_revision" + + +def test_business_error_codes_are_stable() -> None: + """All agreed failure categories remain explicit at the service boundary.""" + + assert {code.value for code in ScheduleErrorCode} == { + "schedule_not_found", + "revision_conflict", + "occurrence_not_found", + "invalid_timezone", + "invalid_update_patch", + "invalid_schedule_kind", + "validation_failed", + } From 0c72d90ede94bd78a0f995e553ca421b995b709a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=94=90=E5=83=A7=E6=B4=97=E5=8F=91=E7=94=A8=E9=A3=98?= =?UTF-8?q?=E6=9F=94?= <2906266135@qq.com> Date: Mon, 10 Aug 2026 12:30:39 +0800 Subject: [PATCH 6/9] refactor(schedule): separate local read and sync contracts --- .../features/schedule/application/index.ts | 3 - .../application/scheduleClientService.ts | 46 +++++--------- .../src/features/sync/application/index.ts | 9 +++ .../sync/application/scheduleSyncService.ts | 44 +++++++++++++ frontend/tests/scheduleContracts.test-d.ts | 62 +++++++++++++++++++ 5 files changed, 131 insertions(+), 33 deletions(-) create mode 100644 frontend/src/features/sync/application/index.ts create mode 100644 frontend/src/features/sync/application/scheduleSyncService.ts create mode 100644 frontend/tests/scheduleContracts.test-d.ts diff --git a/frontend/src/features/schedule/application/index.ts b/frontend/src/features/schedule/application/index.ts index 43dae8e..5d7e518 100644 --- a/frontend/src/features/schedule/application/index.ts +++ b/frontend/src/features/schedule/application/index.ts @@ -1,8 +1,5 @@ export type { - ApplyScheduleSnapshotCommand, GetSchedulesByDayQuery, ScheduleClientService, ScheduleOccurrenceView, - SnapshotApplyResult, - SnapshotApplyStatus, } from './scheduleClientService'; diff --git a/frontend/src/features/schedule/application/scheduleClientService.ts b/frontend/src/features/schedule/application/scheduleClientService.ts index 21b11df..35b236d 100644 --- a/frontend/src/features/schedule/application/scheduleClientService.ts +++ b/frontend/src/features/schedule/application/scheduleClientService.ts @@ -1,4 +1,9 @@ -import type { CloudScheduleSnapshot, ScheduleSnapshot } from '../../../contracts/schedule'; +import type { + ReminderStrength, + ReminderType, + ScheduleKind, + ScheduleType, +} from '../../../contracts/schedule'; /** Input from the calendar UI when a user selects one local calendar date. */ export interface GetSchedulesByDayQuery { @@ -11,30 +16,21 @@ export interface GetSchedulesByDayQuery { /** One displayable occurrence returned to the calendar UI. */ export interface ScheduleOccurrenceView { - schedule: ScheduleSnapshot; + scheduleId: string; + scheduleType: ScheduleType; + scheduleKind: ScheduleKind; + title: string; + isAllDay: boolean; + timezone: string; + locationName: string | null; + reminderType: ReminderType | null; + reminderStrength: ReminderStrength | null; occurrenceStart: string | null; occurrenceEnd: string | null; } -/** WebSocket result passed to the local synchronization boundary. */ -export interface ApplyScheduleSnapshotCommand { - messageId: string; - accountId: string; - snapshot: CloudScheduleSnapshot; -} - -export type SnapshotApplyStatus = 'applied' | 'ignored_stale' | 'failed'; - -/** Result used by the WebSocket owner to decide whether an ACK can be sent. */ -export interface SnapshotApplyResult { - messageId: string; - status: SnapshotApplyStatus; - changedScheduleIds: readonly string[]; - errorCode?: string; -} - /** - * Two client-side schedule operations owned by person two. + * Local calendar read operation owned by person two. * * Implementations will live above the SQLite adapter. This interface contains * no database access, RRULE expansion, revision comparison, or transaction @@ -48,14 +44,4 @@ export interface ScheduleClientService { * occurrence overrides before returning display rows. */ getSchedulesByDay(query: GetSchedulesByDayQuery): Promise; - - /** - * Apply every server-confirmed create, update, delete, and recurrence change. - * - * TODO(person-2): apply schedules and occurrence overrides in one SQLite - * transaction, ignore stale revisions, then report changed schedule IDs. - */ - applyScheduleSnapshotToSqlite( - command: ApplyScheduleSnapshotCommand, - ): Promise; } diff --git a/frontend/src/features/sync/application/index.ts b/frontend/src/features/sync/application/index.ts new file mode 100644 index 0000000..fb173f3 --- /dev/null +++ b/frontend/src/features/sync/application/index.ts @@ -0,0 +1,9 @@ +export type { + ApplyScheduleSnapshotCommand, + ScheduleSyncService, + SnapshotApplyErrorCode, + SnapshotApplyFailureResult, + SnapshotApplyResult, + SnapshotApplyStatus, + SnapshotApplySuccessResult, +} from './scheduleSyncService'; diff --git a/frontend/src/features/sync/application/scheduleSyncService.ts b/frontend/src/features/sync/application/scheduleSyncService.ts new file mode 100644 index 0000000..9e67c9c --- /dev/null +++ b/frontend/src/features/sync/application/scheduleSyncService.ts @@ -0,0 +1,44 @@ +import type { CloudScheduleSnapshot } from '../../../contracts/schedule'; + +/** WebSocket result passed to the local synchronization boundary. */ +export interface ApplyScheduleSnapshotCommand { + messageId: string; + accountId: string; + snapshot: CloudScheduleSnapshot; +} + +export type SnapshotApplyStatus = 'applied' | 'ignored_stale' | 'failed'; + +export type SnapshotApplyErrorCode = + 'invalid_snapshot' | 'account_mismatch' | 'sqlite_transaction_failed'; + +/** Successful or stale result for which the WebSocket owner can send an ACK. */ +export interface SnapshotApplySuccessResult { + messageId: string; + status: Exclude; + changedScheduleIds: readonly string[]; +} + +/** Failed local transaction result for which the WebSocket owner must not ACK. */ +export interface SnapshotApplyFailureResult { + messageId: string; + status: 'failed'; + changedScheduleIds: readonly string[]; + errorCode: SnapshotApplyErrorCode; +} + +/** Result used by the WebSocket owner to decide whether an ACK can be sent. */ +export type SnapshotApplyResult = SnapshotApplySuccessResult | SnapshotApplyFailureResult; + +/** Apply server-confirmed schedule snapshots to the local SQLite projection. */ +export interface ScheduleSyncService { + /** + * Apply every confirmed create, update, delete, and recurrence change. + * + * TODO(person-2): apply schedules and occurrence overrides in one SQLite + * transaction, ignore stale revisions, then report changed schedule IDs. + */ + applyScheduleSnapshotToSqlite( + command: ApplyScheduleSnapshotCommand, + ): Promise; +} diff --git a/frontend/tests/scheduleContracts.test-d.ts b/frontend/tests/scheduleContracts.test-d.ts new file mode 100644 index 0000000..77ff838 --- /dev/null +++ b/frontend/tests/scheduleContracts.test-d.ts @@ -0,0 +1,62 @@ +import type { + ScheduleClientService, + ScheduleOccurrenceView, +} from '../src/features/schedule/application'; +import type { + ScheduleSyncService, + SnapshotApplyErrorCode, + SnapshotApplyFailureResult, + SnapshotApplyStatus, + SnapshotApplySuccessResult, +} from '../src/features/sync/application'; + +type Equal = + (() => Value extends Left ? 1 : 2) extends () => Value extends Right ? 1 : 2 + ? true + : false; + +type Assert = Condition; + +export type ScheduleClientOperationsContract = Assert< + Equal +>; + +export type ScheduleSyncOperationsContract = Assert< + Equal +>; + +export type ScheduleOccurrenceViewContract = Assert< + Equal< + keyof ScheduleOccurrenceView, + | 'scheduleId' + | 'scheduleType' + | 'scheduleKind' + | 'title' + | 'isAllDay' + | 'timezone' + | 'locationName' + | 'reminderType' + | 'reminderStrength' + | 'occurrenceStart' + | 'occurrenceEnd' + > +>; + +export type SnapshotApplyStatusContract = Assert< + Equal +>; + +export type SnapshotApplyErrorCodeContract = Assert< + Equal< + SnapshotApplyErrorCode, + 'invalid_snapshot' | 'account_mismatch' | 'sqlite_transaction_failed' + > +>; + +export type SnapshotApplySuccessContract = Assert< + Equal +>; + +export type SnapshotApplyFailureContract = Assert< + Equal +>; From 286412e887fa7b6d84d0d596dbc4e46de020ab7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=94=90=E5=83=A7=E6=B4=97=E5=8F=91=E7=94=A8=E9=A3=98?= =?UTF-8?q?=E6=9F=94?= <2906266135@qq.com> Date: Mon, 10 Aug 2026 17:30:36 +0800 Subject: [PATCH 7/9] fix(schedule): align recurring and reminder contracts --- .../timeflow/business/calendar/__init__.py | 2 + .../timeflow/business/calendar/contracts.py | 23 +++--- .../src/timeflow/business/calendar/service.py | 7 +- .../tests/test_schedule_service_skeleton.py | 70 +++++++++++++++++-- frontend/src/contracts/schedule.ts | 6 +- frontend/tests/scheduleContracts.test-d.ts | 19 +++++ 6 files changed, 110 insertions(+), 17 deletions(-) diff --git a/backend/src/timeflow/business/calendar/__init__.py b/backend/src/timeflow/business/calendar/__init__.py index 16478e8..6112fa4 100644 --- a/backend/src/timeflow/business/calendar/__init__.py +++ b/backend/src/timeflow/business/calendar/__init__.py @@ -7,6 +7,7 @@ FindSchedulesQuery, OccurrenceOverrideAction, RecurringDeleteScope, + ReminderDispositionState, ReminderStrength, ReminderType, ScheduleBusinessError, @@ -30,6 +31,7 @@ "FindSchedulesQuery", "OccurrenceOverrideAction", "RecurringDeleteScope", + "ReminderDispositionState", "ReminderStrength", "ReminderType", "ScheduleAgentService", diff --git a/backend/src/timeflow/business/calendar/contracts.py b/backend/src/timeflow/business/calendar/contracts.py index b09d4da..7bb0a65 100644 --- a/backend/src/timeflow/business/calendar/contracts.py +++ b/backend/src/timeflow/business/calendar/contracts.py @@ -44,16 +44,22 @@ class ReminderStrength(StrEnum): HIGH = "high" +class ReminderDispositionState(StrEnum): + """Cloud-persisted final disposition for the current reminder occurrence.""" + + CONFIRMED = "confirmed" + + class RecurringDeleteScope(StrEnum): - """Deletion scopes based on the schedule-local current date. + """Wiki-defined deletion scopes for a recurring schedule. - The implementation must derive the current date from the schedule's IANA - timezone. It then finds the first occurrence whose local date is today or - later; the caller cannot provide an arbitrary occurrence date. + Occurrence-scoped deletion targets the confirmed current occurrence; + deleting an entire series does not require occurrence resolution. """ - NEXT_OCCURRENCE = "next_occurrence" - NEXT_AND_FUTURE = "next_and_future" + THIS_OCCURRENCE = "this_occurrence" + THIS_AND_FUTURE = "this_and_future" + ENTIRE_SERIES = "entire_series" class OccurrenceOverrideAction(StrEnum): @@ -143,7 +149,7 @@ class ScheduleSnapshot: reminder_trigger_at: datetime | None = None reminder_offset_minutes: int | None = None reminder_strength: ReminderStrength | None = None - reminder_disposition_state: str | None = None + reminder_disposition_state: ReminderDispositionState | None = None deleted_at: datetime | None = None @@ -212,7 +218,7 @@ class DeleteOnceScheduleCommand: @dataclass(frozen=True, slots=True) class DeleteRecurringScheduleCommand: - """A confirmed request to delete the next recurring occurrence or its future.""" + """A confirmed request carrying the Wiki-defined recurring deletion scope.""" schedule_id: str expected_revision: int @@ -241,6 +247,7 @@ class ScheduleSearchResult: "FindSchedulesQuery", "OccurrenceOverrideAction", "RecurringDeleteScope", + "ReminderDispositionState", "ReminderStrength", "ReminderType", "ScheduleBusinessError", diff --git a/backend/src/timeflow/business/calendar/service.py b/backend/src/timeflow/business/calendar/service.py index 4fa15ac..e111ca1 100644 --- a/backend/src/timeflow/business/calendar/service.py +++ b/backend/src/timeflow/business/calendar/service.py @@ -87,14 +87,13 @@ def delete_recurring_schedule( account_id: str, command: DeleteRecurringScheduleCommand, ) -> ScheduleMutationResult: - """Delete the next occurrence or that occurrence and all future ones. + """Apply the confirmed occurrence, future, or entire-series deletion scope. Raises: ScheduleBusinessError: If the target, revision, or occurrence is invalid. """ - # TODO(person-2): use the schedule timezone and a system clock to find - # the first occurrence on or after the current local date, then apply - # command.scope without accepting an arbitrary date from the caller. + # TODO(person-2): resolve the current occurrence when the confirmed + # scope requires one, then apply command.scope transactionally. raise NotImplementedError diff --git a/backend/tests/test_schedule_service_skeleton.py b/backend/tests/test_schedule_service_skeleton.py index 0aba13a..8ed67f4 100644 --- a/backend/tests/test_schedule_service_skeleton.py +++ b/backend/tests/test_schedule_service_skeleton.py @@ -1,10 +1,20 @@ """Contract tests for the person-two schedule service skeleton.""" +import json +from datetime import UTC, datetime + +import pytest + from timeflow.business.calendar import ( RecurringDeleteScope, + ReminderDispositionState, ScheduleAgentService, ScheduleBusinessError, ScheduleErrorCode, + ScheduleKind, + ScheduleSnapshot, + ScheduleStatus, + ScheduleType, ScheduleUpdatePatch, UpdateScheduleCommand, ) @@ -28,15 +38,67 @@ def test_agent_schedule_service_exposes_exactly_five_business_operations() -> No } -def test_recurring_delete_scope_has_only_the_two_agreed_choices() -> None: - """Callers cannot select an arbitrary recurring occurrence or date range.""" +def test_recurring_delete_scope_matches_the_three_wiki_wire_values() -> None: + """Recurring deletion scopes serialize exactly as the v3.10 Wiki defines.""" assert list(RecurringDeleteScope) == [ - RecurringDeleteScope.NEXT_OCCURRENCE, - RecurringDeleteScope.NEXT_AND_FUTURE, + RecurringDeleteScope.THIS_OCCURRENCE, + RecurringDeleteScope.THIS_AND_FUTURE, + RecurringDeleteScope.ENTIRE_SERIES, + ] + assert [scope.value for scope in RecurringDeleteScope] == [ + "this_occurrence", + "this_and_future", + "entire_series", ] +def test_reminder_disposition_state_matches_the_cloud_snapshot_contract() -> None: + """Cloud snapshots accept only confirmed or no final disposition state.""" + + now = datetime.now(UTC) + confirmed_snapshot = ScheduleSnapshot( + id="schedule-confirmed", + account_id="account-1", + schedule_type=ScheduleType.TIME, + schedule_kind=ScheduleKind.ONCE, + title="Confirmed reminder", + is_all_day=False, + timezone="Asia/Shanghai", + status=ScheduleStatus.ACTIVE, + revision=1, + created_at=now, + updated_at=now, + reminder_disposition_state=ReminderDispositionState.CONFIRMED, + ) + empty_snapshot = ScheduleSnapshot( + id="schedule-empty", + account_id="account-1", + schedule_type=ScheduleType.TIME, + schedule_kind=ScheduleKind.ONCE, + title="No disposition", + is_all_day=False, + timezone="Asia/Shanghai", + status=ScheduleStatus.ACTIVE, + revision=1, + created_at=now, + updated_at=now, + reminder_disposition_state=None, + ) + + assert ReminderDispositionState.CONFIRMED.value == "confirmed" + assert json.dumps(ReminderDispositionState.CONFIRMED) == '"confirmed"' + assert confirmed_snapshot.reminder_disposition_state is ReminderDispositionState.CONFIRMED + assert empty_snapshot.reminder_disposition_state is None + + +def test_reminder_disposition_state_rejects_local_only_values() -> None: + """Local snooze state cannot be represented as a cloud disposition enum.""" + + with pytest.raises(ValueError): + ReminderDispositionState("snoozed") + + def test_update_patch_exposes_only_explicitly_mutable_fields() -> None: """Identity, ownership, lifecycle, revision, and audit fields stay protected.""" diff --git a/frontend/src/contracts/schedule.ts b/frontend/src/contracts/schedule.ts index a85b1f2..49c78df 100644 --- a/frontend/src/contracts/schedule.ts +++ b/frontend/src/contracts/schedule.ts @@ -2,6 +2,8 @@ export type ScheduleType = 'time' | 'location'; export type ScheduleKind = 'once' | 'recurring'; +export type RecurringDeleteScope = 'this_occurrence' | 'this_and_future' | 'entire_series'; + export type ScheduleStatus = 'active' | 'deleted'; export type ReminderType = @@ -9,6 +11,8 @@ export type ReminderType = export type ReminderStrength = 'low' | 'medium' | 'high'; +export type ReminderDispositionState = 'confirmed'; + export type OccurrenceOverrideAction = 'cancel' | 'replace'; /** Final schedule fields already committed by the cloud service. */ @@ -30,7 +34,7 @@ export interface ScheduleSnapshot { reminder_trigger_at: string | null; reminder_offset_minutes: number | null; reminder_strength: ReminderStrength | null; - reminder_disposition_state: string | null; + reminder_disposition_state: ReminderDispositionState | null; status: ScheduleStatus; revision: number; created_at: string; diff --git a/frontend/tests/scheduleContracts.test-d.ts b/frontend/tests/scheduleContracts.test-d.ts index 77ff838..d3c989a 100644 --- a/frontend/tests/scheduleContracts.test-d.ts +++ b/frontend/tests/scheduleContracts.test-d.ts @@ -1,3 +1,8 @@ +import type { + RecurringDeleteScope, + ReminderDispositionState, + ScheduleSnapshot, +} from '../src/contracts/schedule'; import type { ScheduleClientService, ScheduleOccurrenceView, @@ -17,6 +22,20 @@ type Equal = type Assert = Condition; +export type RecurringDeleteScopeContract = Assert< + Equal +>; + +export type ReminderDispositionStateContract = Assert>; + +export type SnapshotReminderDispositionContract = Assert< + Equal +>; + +export type LocalReminderStateIsNotCloudDispositionContract = Assert< + Equal, never> +>; + export type ScheduleClientOperationsContract = Assert< Equal >; From 5350b3f81d611e82fb573cb6ff901b5072e9bbf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=94=90=E5=83=A7=E6=B4=97=E5=8F=91=E7=94=A8=E9=A3=98?= =?UTF-8?q?=E6=9F=94?= <2906266135@qq.com> Date: Mon, 10 Aug 2026 19:13:01 +0800 Subject: [PATCH 8/9] feat(schedule): add cloud schedule persistence --- backend/alembic/env.py | 2 +- .../20260810_0003_schedule_storage_v3.py | 382 ++++++++++++++++++ backend/src/timeflow/data/__init__.py | 4 +- backend/src/timeflow/data/models.py | 182 +++++++-- .../timeflow/data/repositories/__init__.py | 5 + .../timeflow/data/repositories/schedule.py | 252 ++++++++++++ backend/tests/test_schedule_repository.py | 123 ++++++ backend/tests/test_schedule_schema.py | 68 +++- 8 files changed, 956 insertions(+), 62 deletions(-) create mode 100644 backend/alembic/versions/20260810_0003_schedule_storage_v3.py create mode 100644 backend/src/timeflow/data/repositories/__init__.py create mode 100644 backend/src/timeflow/data/repositories/schedule.py create mode 100644 backend/tests/test_schedule_repository.py diff --git a/backend/alembic/env.py b/backend/alembic/env.py index d1cb440..b53a74d 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -6,7 +6,7 @@ from alembic import context from timeflow.data.database import Base -from timeflow.data.models import Schedule # noqa: F401 +from timeflow.data.models import Schedule, ScheduleOccurrenceOverride # noqa: F401 from timeflow.infrastructure.settings import get_settings config = context.config diff --git a/backend/alembic/versions/20260810_0003_schedule_storage_v3.py b/backend/alembic/versions/20260810_0003_schedule_storage_v3.py new file mode 100644 index 0000000..4759773 --- /dev/null +++ b/backend/alembic/versions/20260810_0003_schedule_storage_v3.py @@ -0,0 +1,382 @@ +"""Align schedule storage with the v3.10 architecture. + +Revision ID: 20260810_0003 +Revises: 20260729_0002 +Create Date: 2026-08-10 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "20260810_0003" +down_revision: str | Sequence[str] | None = "20260729_0002" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def _create_v3_schedules_table() -> None: + op.create_table( + "schedules_v3", + sa.Column("id", sa.String(length=64), primary_key=True, nullable=False), + sa.Column("account_id", sa.String(length=64), nullable=False), + sa.Column("schedule_type", sa.String(length=16), nullable=False), + sa.Column( + "schedule_kind", + sa.String(length=16), + nullable=False, + server_default="once", + ), + sa.Column("title", sa.String(length=255), nullable=False), + sa.Column( + "is_all_day", + sa.Boolean(), + nullable=False, + server_default=sa.text("false"), + ), + sa.Column("start_time", sa.DateTime(timezone=True), nullable=True), + sa.Column("end_time", sa.DateTime(timezone=True), nullable=True), + sa.Column("timezone", sa.String(length=64), nullable=False), + sa.Column("recurrence_rule", sa.String(length=512), nullable=True), + sa.Column("location_name", sa.String(length=255), nullable=True), + sa.Column("latitude", sa.Numeric(precision=9, scale=6), nullable=True), + sa.Column("longitude", sa.Numeric(precision=9, scale=6), nullable=True), + sa.Column("reminder_type", sa.String(length=32), nullable=True), + sa.Column("reminder_trigger_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("reminder_offset_minutes", sa.Integer(), nullable=True), + sa.Column("reminder_strength", sa.String(length=16), nullable=True), + sa.Column("reminder_disposition_state", sa.String(length=16), nullable=True), + sa.Column("status", sa.String(length=16), nullable=False), + sa.Column("revision", sa.BigInteger(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.CheckConstraint( + "schedule_type IN ('time', 'location')", + name="ck_schedules_schedule_type", + ), + sa.CheckConstraint( + "schedule_kind IN ('once', 'recurring')", + name="ck_schedules_schedule_kind", + ), + sa.CheckConstraint( + "status IN ('active', 'deleted')", + name="ck_schedules_status", + ), + sa.CheckConstraint( + "reminder_type IS NULL OR reminder_type IN " + "('at_time', 'before_start', 'arrive_location', " + "'return_to_recorded_location')", + name="ck_schedules_reminder_type", + ), + sa.CheckConstraint( + "reminder_strength IS NULL OR reminder_strength IN ('low', 'medium', 'high')", + name="ck_schedules_reminder_strength", + ), + sa.CheckConstraint( + "reminder_disposition_state IS NULL OR reminder_disposition_state = 'confirmed'", + name="ck_schedules_reminder_disposition_state", + ), + sa.CheckConstraint("revision > 0", name="ck_schedules_revision_positive"), + sa.CheckConstraint( + "latitude IS NULL OR latitude BETWEEN -90 AND 90", + name="ck_schedules_latitude_range", + ), + sa.CheckConstraint( + "longitude IS NULL OR longitude BETWEEN -180 AND 180", + name="ck_schedules_longitude_range", + ), + sa.CheckConstraint( + "(schedule_type = 'time' AND start_time IS NOT NULL) " + "OR (schedule_type = 'location' AND start_time IS NULL " + "AND latitude IS NOT NULL AND longitude IS NOT NULL AND is_all_day = false)", + name="ck_schedules_schedule_type_requirements", + ), + sa.CheckConstraint( + "(schedule_kind = 'once' AND recurrence_rule IS NULL) " + "OR (schedule_kind = 'recurring' AND schedule_type = 'time' " + "AND recurrence_rule IS NOT NULL)", + name="ck_schedules_recurrence_requirements", + ), + sa.CheckConstraint( + "is_all_day = false " + "OR (schedule_type = 'time' AND start_time IS NOT NULL AND end_time IS NOT NULL)", + name="ck_schedules_all_day_requirements", + ), + sa.CheckConstraint( + "end_time IS NULL OR start_time IS NOT NULL", + name="ck_schedules_end_requires_start", + ), + sa.CheckConstraint( + "reminder_offset_minutes IS NULL OR reminder_offset_minutes >= 0", + name="ck_schedules_reminder_offset_nonnegative", + ), + sa.CheckConstraint( + "(reminder_type IS NULL AND reminder_trigger_at IS NULL " + "AND reminder_offset_minutes IS NULL AND reminder_strength IS NULL " + "AND reminder_disposition_state IS NULL) " + "OR (reminder_type IS NOT NULL AND reminder_strength IS NOT NULL)", + name="ck_schedules_reminder_presence", + ), + sa.CheckConstraint( + "reminder_type IS NULL " + "OR (reminder_type = 'at_time' AND reminder_trigger_at IS NOT NULL " + "AND reminder_offset_minutes IS NULL) " + "OR (reminder_type = 'before_start' AND reminder_trigger_at IS NULL " + "AND reminder_offset_minutes IS NOT NULL) " + "OR (reminder_type IN ('arrive_location', 'return_to_recorded_location') " + "AND reminder_trigger_at IS NULL AND reminder_offset_minutes IS NULL " + "AND latitude IS NOT NULL AND longitude IS NOT NULL)", + name="ck_schedules_reminder_configuration", + ), + sa.CheckConstraint( + "(status = 'active' AND deleted_at IS NULL) " + "OR (status = 'deleted' AND deleted_at IS NOT NULL)", + name="ck_schedules_deleted_at", + ), + ) + + +def _create_occurrence_overrides_table() -> None: + op.create_table( + "schedule_occurrence_overrides", + sa.Column("id", sa.String(length=64), primary_key=True, nullable=False), + sa.Column( + "schedule_id", + sa.String(length=64), + sa.ForeignKey("schedules.id", ondelete="RESTRICT"), + nullable=False, + ), + sa.Column("occurrence_start", sa.DateTime(timezone=True), nullable=False), + sa.Column("action", sa.String(length=16), nullable=False), + sa.Column( + "replacement_schedule_id", + sa.String(length=64), + sa.ForeignKey("schedules.id", ondelete="RESTRICT"), + nullable=True, + ), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.CheckConstraint( + "action IN ('cancel', 'replace')", + name="ck_schedule_occurrence_overrides_action", + ), + sa.CheckConstraint( + "(action = 'cancel' AND replacement_schedule_id IS NULL) " + "OR (action = 'replace' AND replacement_schedule_id IS NOT NULL)", + name="ck_schedule_occurrence_overrides_replacement", + ), + sa.UniqueConstraint( + "schedule_id", + "occurrence_start", + name="uq_schedule_occurrence_overrides_schedule_occurrence", + ), + ) + op.create_index( + "ix_schedule_occurrence_overrides_replacement_schedule_id", + "schedule_occurrence_overrides", + ["replacement_schedule_id"], + ) + + +def upgrade() -> None: + """Migrate the legacy schedule rows and create occurrence overrides.""" + op.drop_index("ix_schedules_system_alarm_ref_id", table_name="schedules") + op.drop_index("ix_schedules_system_schedule_ref_id", table_name="schedules") + op.drop_index("ix_schedules_user_status_start_time", table_name="schedules") + + _create_v3_schedules_table() + op.create_index( + "ix_schedules_account_status_start_time", + "schedules_v3", + ["account_id", "status", "start_time"], + ) + op.create_index( + "ix_schedules_account_revision", + "schedules_v3", + ["account_id", "revision"], + ) + + op.execute( + sa.text( + """ + INSERT INTO schedules_v3 ( + id, account_id, schedule_type, schedule_kind, title, is_all_day, + start_time, end_time, timezone, recurrence_rule, location_name, + latitude, longitude, reminder_type, reminder_trigger_at, + reminder_offset_minutes, reminder_strength, + reminder_disposition_state, status, revision, created_at, + updated_at, deleted_at + ) + SELECT + left(id, 64), + left(user_id, 64), + schedule_type, + 'once', + left(title, 255), + false, + NULLIF(start_time, '')::timestamptz, + NULLIF(end_time, '')::timestamptz, + left(COALESCE(NULLIF(timezone, ''), 'UTC'), 64), + NULL, + left(location_name, 255), + latitude::numeric(9, 6), + longitude::numeric(9, 6), + NULL, + NULL, + NULL, + NULL, + NULL, + CASE WHEN status = 'deleted' THEN 'deleted' ELSE 'active' END, + 1, + COALESCE(NULLIF(created_at, '')::timestamptz, now()), + COALESCE(NULLIF(updated_at, '')::timestamptz, now()), + CASE + WHEN status = 'deleted' + THEN COALESCE(NULLIF(updated_at, '')::timestamptz, now()) + ELSE NULL + END + FROM schedules + """ + ) + ) + + op.drop_table("schedules") + op.rename_table("schedules_v3", "schedules") + _create_occurrence_overrides_table() + + +def downgrade() -> None: + """Restore the legacy MVP schedule shape while retaining core row data.""" + op.create_table( + "schedules_v2", + sa.Column("id", sa.Text(), primary_key=True, nullable=False), + sa.Column("user_id", sa.Text(), nullable=False), + sa.Column("source_mode", sa.Text(), nullable=False), + sa.Column("schedule_type", sa.Text(), nullable=False), + sa.Column("status", sa.Text(), nullable=False), + sa.Column("title", sa.Text(), nullable=False), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("start_time", sa.Text(), nullable=True), + sa.Column("end_time", sa.Text(), nullable=True), + sa.Column("timezone", sa.Text(), nullable=True), + sa.Column("location_name", sa.Text(), nullable=True), + sa.Column("location_address", sa.Text(), nullable=True), + sa.Column("latitude", sa.Float(), nullable=True), + sa.Column("longitude", sa.Float(), nullable=True), + sa.Column("geofence_radius_meters", sa.Integer(), nullable=False), + sa.Column("geofence_armed", sa.Integer(), nullable=False), + sa.Column("time_remind_offset_minutes", sa.Integer(), nullable=False), + sa.Column("time_triggered_at", sa.Text(), nullable=True), + sa.Column("geo_triggered_at", sa.Text(), nullable=True), + sa.Column("system_schedule_ref_id", sa.Text(), nullable=True), + sa.Column("system_alarm_ref_id", sa.Text(), nullable=True), + sa.Column("created_at", sa.Text(), nullable=False), + sa.Column("updated_at", sa.Text(), nullable=False), + sa.CheckConstraint( + "source_mode IN ('manual', 'voice')", + name="ck_schedules_source_mode", + ), + sa.CheckConstraint( + "schedule_type IN ('time', 'location')", + name="ck_schedules_schedule_type", + ), + sa.CheckConstraint( + "status IN ('scheduled', 'done', 'deleted')", + name="ck_schedules_status", + ), + sa.CheckConstraint( + "geofence_armed IN (0, 1)", + name="ck_schedules_geofence_armed", + ), + sa.CheckConstraint( + "geofence_radius_meters > 0", + name="ck_schedules_geofence_radius_positive", + ), + sa.CheckConstraint( + "time_remind_offset_minutes >= 0", + name="ck_schedules_time_remind_offset_nonnegative", + ), + sa.CheckConstraint( + "latitude IS NULL OR latitude BETWEEN -90 AND 90", + name="ck_schedules_latitude_range", + ), + sa.CheckConstraint( + "longitude IS NULL OR longitude BETWEEN -180 AND 180", + name="ck_schedules_longitude_range", + ), + sa.CheckConstraint( + "(schedule_type = 'time' AND start_time IS NOT NULL) " + "OR (schedule_type = 'location' AND start_time IS NULL " + "AND latitude IS NOT NULL AND longitude IS NOT NULL)", + name="ck_schedules_schedule_type_requirements", + ), + sa.CheckConstraint( + "end_time IS NULL OR start_time IS NOT NULL", + name="ck_schedules_end_requires_start", + ), + ) + + op.execute( + sa.text( + """ + INSERT INTO schedules_v2 ( + id, user_id, source_mode, schedule_type, status, title, notes, + start_time, end_time, timezone, location_name, location_address, + latitude, longitude, geofence_radius_meters, geofence_armed, + time_remind_offset_minutes, time_triggered_at, geo_triggered_at, + system_schedule_ref_id, system_alarm_ref_id, created_at, updated_at + ) + SELECT + id, + account_id, + 'voice', + schedule_type, + CASE WHEN status = 'deleted' THEN 'deleted' ELSE 'scheduled' END, + title, + NULL, + start_time::text, + end_time::text, + timezone, + location_name, + NULL, + latitude::double precision, + longitude::double precision, + 100, + 0, + 0, + NULL, + NULL, + NULL, + NULL, + created_at::text, + updated_at::text + FROM schedules + """ + ) + ) + + op.drop_index( + "ix_schedule_occurrence_overrides_replacement_schedule_id", + table_name="schedule_occurrence_overrides", + ) + op.drop_table("schedule_occurrence_overrides") + op.drop_table("schedules") + op.rename_table("schedules_v2", "schedules") + op.create_index( + "ix_schedules_user_status_start_time", + "schedules", + ["user_id", "status", "start_time"], + ) + op.create_index( + "ix_schedules_system_schedule_ref_id", + "schedules", + ["system_schedule_ref_id"], + ) + op.create_index( + "ix_schedules_system_alarm_ref_id", + "schedules", + ["system_alarm_ref_id"], + ) diff --git a/backend/src/timeflow/data/__init__.py b/backend/src/timeflow/data/__init__.py index cbdf38a..8cccc51 100644 --- a/backend/src/timeflow/data/__init__.py +++ b/backend/src/timeflow/data/__init__.py @@ -1,6 +1,6 @@ """Database models and primitives for TimeFlow.""" from timeflow.data.database import Base -from timeflow.data.models import Schedule +from timeflow.data.models import Schedule, ScheduleOccurrenceOverride -__all__ = ["Base", "Schedule"] +__all__ = ["Base", "Schedule", "ScheduleOccurrenceOverride"] diff --git a/backend/src/timeflow/data/models.py b/backend/src/timeflow/data/models.py index 6a41d98..13d9c7f 100644 --- a/backend/src/timeflow/data/models.py +++ b/backend/src/timeflow/data/models.py @@ -1,40 +1,57 @@ """SQLAlchemy models for TimeFlow business data.""" -from sqlalchemy import CheckConstraint, Float, Index, Integer, Text +from datetime import datetime +from decimal import Decimal + +from sqlalchemy import ( + BigInteger, + Boolean, + CheckConstraint, + DateTime, + ForeignKey, + Index, + Integer, + Numeric, + String, + UniqueConstraint, +) from sqlalchemy.orm import Mapped, mapped_column from timeflow.data.database import Base class Schedule(Base): - """Business schedule persisted by the backend.""" + """Cloud-authoritative schedule persisted by the backend.""" __tablename__ = "schedules" __table_args__ = ( - CheckConstraint( - "source_mode IN ('manual', 'voice')", - name="ck_schedules_source_mode", - ), CheckConstraint( "schedule_type IN ('time', 'location')", name="ck_schedules_schedule_type", ), CheckConstraint( - "status IN ('scheduled', 'done', 'deleted')", + "schedule_kind IN ('once', 'recurring')", + name="ck_schedules_schedule_kind", + ), + CheckConstraint( + "status IN ('active', 'deleted')", name="ck_schedules_status", ), CheckConstraint( - "geofence_armed IN (0, 1)", - name="ck_schedules_geofence_armed", + "reminder_type IS NULL OR reminder_type IN " + "('at_time', 'before_start', 'arrive_location', " + "'return_to_recorded_location')", + name="ck_schedules_reminder_type", ), CheckConstraint( - "geofence_radius_meters > 0", - name="ck_schedules_geofence_radius_positive", + "reminder_strength IS NULL OR reminder_strength IN ('low', 'medium', 'high')", + name="ck_schedules_reminder_strength", ), CheckConstraint( - "time_remind_offset_minutes >= 0", - name="ck_schedules_time_remind_offset_nonnegative", + "reminder_disposition_state IS NULL OR reminder_disposition_state = 'confirmed'", + name="ck_schedules_reminder_disposition_state", ), + CheckConstraint("revision > 0", name="ck_schedules_revision_positive"), CheckConstraint( "latitude IS NULL OR latitude BETWEEN -90 AND 90", name="ck_schedules_latitude_range", @@ -46,46 +63,127 @@ class Schedule(Base): CheckConstraint( "(schedule_type = 'time' AND start_time IS NOT NULL) " "OR (schedule_type = 'location' AND start_time IS NULL " - "AND latitude IS NOT NULL AND longitude IS NOT NULL)", + "AND latitude IS NOT NULL AND longitude IS NOT NULL AND is_all_day = false)", name="ck_schedules_schedule_type_requirements", ), + CheckConstraint( + "(schedule_kind = 'once' AND recurrence_rule IS NULL) " + "OR (schedule_kind = 'recurring' AND schedule_type = 'time' " + "AND recurrence_rule IS NOT NULL)", + name="ck_schedules_recurrence_requirements", + ), + CheckConstraint( + "is_all_day = false " + "OR (schedule_type = 'time' AND start_time IS NOT NULL AND end_time IS NOT NULL)", + name="ck_schedules_all_day_requirements", + ), CheckConstraint( "end_time IS NULL OR start_time IS NOT NULL", name="ck_schedules_end_requires_start", ), + CheckConstraint( + "reminder_offset_minutes IS NULL OR reminder_offset_minutes >= 0", + name="ck_schedules_reminder_offset_nonnegative", + ), + CheckConstraint( + "(reminder_type IS NULL AND reminder_trigger_at IS NULL " + "AND reminder_offset_minutes IS NULL AND reminder_strength IS NULL " + "AND reminder_disposition_state IS NULL) " + "OR (reminder_type IS NOT NULL AND reminder_strength IS NOT NULL)", + name="ck_schedules_reminder_presence", + ), + CheckConstraint( + "reminder_type IS NULL " + "OR (reminder_type = 'at_time' AND reminder_trigger_at IS NOT NULL " + "AND reminder_offset_minutes IS NULL) " + "OR (reminder_type = 'before_start' AND reminder_trigger_at IS NULL " + "AND reminder_offset_minutes IS NOT NULL) " + "OR (reminder_type IN ('arrive_location', 'return_to_recorded_location') " + "AND reminder_trigger_at IS NULL AND reminder_offset_minutes IS NULL " + "AND latitude IS NOT NULL AND longitude IS NOT NULL)", + name="ck_schedules_reminder_configuration", + ), + CheckConstraint( + "(status = 'active' AND deleted_at IS NULL) " + "OR (status = 'deleted' AND deleted_at IS NOT NULL)", + name="ck_schedules_deleted_at", + ), Index( - "ix_schedules_user_status_start_time", - "user_id", + "ix_schedules_account_status_start_time", + "account_id", "status", "start_time", ), - Index("ix_schedules_system_schedule_ref_id", "system_schedule_ref_id"), - Index("ix_schedules_system_alarm_ref_id", "system_alarm_ref_id"), + Index("ix_schedules_account_revision", "account_id", "revision"), ) - id: Mapped[str] = mapped_column(Text, primary_key=True) - user_id: Mapped[str] = mapped_column(Text, nullable=False) - source_mode: Mapped[str] = mapped_column(Text, nullable=False) - schedule_type: Mapped[str] = mapped_column(Text, nullable=False) - status: Mapped[str] = mapped_column(Text, nullable=False) - title: Mapped[str] = mapped_column(Text, nullable=False) - notes: Mapped[str | None] = mapped_column(Text, nullable=True) - start_time: Mapped[str | None] = mapped_column(Text, nullable=True) - end_time: Mapped[str | None] = mapped_column(Text, nullable=True) - timezone: Mapped[str | None] = mapped_column(Text, nullable=True) - location_name: Mapped[str | None] = mapped_column(Text, nullable=True) - location_address: Mapped[str | None] = mapped_column(Text, nullable=True) - latitude: Mapped[float | None] = mapped_column(Float, nullable=True) - longitude: Mapped[float | None] = mapped_column(Float, nullable=True) - geofence_radius_meters: Mapped[int] = mapped_column(Integer, nullable=False) - geofence_armed: Mapped[int] = mapped_column(Integer, nullable=False) - time_remind_offset_minutes: Mapped[int] = mapped_column(Integer, nullable=False) - time_triggered_at: Mapped[str | None] = mapped_column(Text, nullable=True) - geo_triggered_at: Mapped[str | None] = mapped_column(Text, nullable=True) - system_schedule_ref_id: Mapped[str | None] = mapped_column(Text, nullable=True) - system_alarm_ref_id: Mapped[str | None] = mapped_column(Text, nullable=True) - created_at: Mapped[str] = mapped_column(Text, nullable=False) - updated_at: Mapped[str] = mapped_column(Text, nullable=False) + id: Mapped[str] = mapped_column(String(64), primary_key=True) + account_id: Mapped[str] = mapped_column(String(64), nullable=False) + schedule_type: Mapped[str] = mapped_column(String(16), nullable=False) + schedule_kind: Mapped[str] = mapped_column( + String(16), nullable=False, default="once", server_default="once" + ) + title: Mapped[str] = mapped_column(String(255), nullable=False) + is_all_day: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False, server_default="false" + ) + start_time: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + end_time: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + timezone: Mapped[str] = mapped_column(String(64), nullable=False) + recurrence_rule: Mapped[str | None] = mapped_column(String(512), nullable=True) + location_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + latitude: Mapped[Decimal | None] = mapped_column(Numeric(9, 6), nullable=True) + longitude: Mapped[Decimal | None] = mapped_column(Numeric(9, 6), nullable=True) + reminder_type: Mapped[str | None] = mapped_column(String(32), nullable=True) + reminder_trigger_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + reminder_offset_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True) + reminder_strength: Mapped[str | None] = mapped_column(String(16), nullable=True) + reminder_disposition_state: Mapped[str | None] = mapped_column(String(16), nullable=True) + status: Mapped[str] = mapped_column(String(16), nullable=False) + revision: Mapped[int] = mapped_column(BigInteger, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + +class ScheduleOccurrenceOverride(Base): + """Persisted exception for one occurrence of a recurring schedule.""" + + __tablename__ = "schedule_occurrence_overrides" + __table_args__ = ( + CheckConstraint( + "action IN ('cancel', 'replace')", + name="ck_schedule_occurrence_overrides_action", + ), + CheckConstraint( + "(action = 'cancel' AND replacement_schedule_id IS NULL) " + "OR (action = 'replace' AND replacement_schedule_id IS NOT NULL)", + name="ck_schedule_occurrence_overrides_replacement", + ), + UniqueConstraint( + "schedule_id", + "occurrence_start", + name="uq_schedule_occurrence_overrides_schedule_occurrence", + ), + Index( + "ix_schedule_occurrence_overrides_replacement_schedule_id", + "replacement_schedule_id", + ), + ) + + id: Mapped[str] = mapped_column(String(64), primary_key=True) + schedule_id: Mapped[str] = mapped_column( + String(64), ForeignKey("schedules.id", ondelete="RESTRICT"), nullable=False + ) + occurrence_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + action: Mapped[str] = mapped_column(String(16), nullable=False) + replacement_schedule_id: Mapped[str | None] = mapped_column( + String(64), ForeignKey("schedules.id", ondelete="RESTRICT"), nullable=True + ) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) -__all__ = ["Schedule"] +__all__ = ["Schedule", "ScheduleOccurrenceOverride"] diff --git a/backend/src/timeflow/data/repositories/__init__.py b/backend/src/timeflow/data/repositories/__init__.py new file mode 100644 index 0000000..3ee8d0a --- /dev/null +++ b/backend/src/timeflow/data/repositories/__init__.py @@ -0,0 +1,5 @@ +"""Concrete database repositories.""" + +from timeflow.data.repositories.schedule import ScheduleRepository + +__all__ = ["ScheduleRepository"] diff --git a/backend/src/timeflow/data/repositories/schedule.py b/backend/src/timeflow/data/repositories/schedule.py new file mode 100644 index 0000000..0447aa3 --- /dev/null +++ b/backend/src/timeflow/data/repositories/schedule.py @@ -0,0 +1,252 @@ +"""SQLAlchemy persistence adapter for schedules and occurrence overrides.""" + +from decimal import Decimal + +from sqlalchemy import select, update +from sqlalchemy.orm import Session + +from timeflow.business.calendar.contracts import ( + OccurrenceOverrideAction, + ReminderDispositionState, + ReminderStrength, + ReminderType, + ScheduleKind, + ScheduleOccurrenceOverrideSnapshot, + ScheduleSnapshot, + ScheduleStatus, + ScheduleType, +) +from timeflow.data.models import Schedule, ScheduleOccurrenceOverride + + +class ScheduleRepository: + """Account-scoped persistence primitives used by the schedule service. + + The repository flushes changes but never commits or rolls back. Transaction + ownership stays with the later application service implementation. + """ + + def __init__(self, session: Session) -> None: + self._session = session + + def add_schedule(self, snapshot: ScheduleSnapshot) -> ScheduleSnapshot: + """Insert one cloud schedule without committing the surrounding transaction.""" + model = Schedule(**_schedule_values(snapshot)) + self._session.add(model) + self._session.flush() + return _to_schedule_snapshot(model) + + def get_schedule( + self, + *, + account_id: str, + schedule_id: str, + include_deleted: bool = False, + ) -> ScheduleSnapshot | None: + """Return one schedule only when it belongs to the requested account.""" + statement = select(Schedule).where( + Schedule.account_id == account_id, + Schedule.id == schedule_id, + ) + if not include_deleted: + statement = statement.where(Schedule.status == ScheduleStatus.ACTIVE.value) + + model = self._session.scalar(statement) + return None if model is None else _to_schedule_snapshot(model) + + def list_schedules( + self, + *, + account_id: str, + include_deleted: bool = False, + ) -> tuple[ScheduleSnapshot, ...]: + """List schedules for exactly one account in deterministic order.""" + statement = select(Schedule).where(Schedule.account_id == account_id) + if not include_deleted: + statement = statement.where(Schedule.status == ScheduleStatus.ACTIVE.value) + statement = statement.order_by(Schedule.start_time, Schedule.created_at, Schedule.id) + + return tuple(_to_schedule_snapshot(model) for model in self._session.scalars(statement)) + + def update_schedule( + self, + *, + snapshot: ScheduleSnapshot, + expected_revision: int, + ) -> ScheduleSnapshot | None: + """Conditionally replace mutable persisted fields using optimistic revision.""" + values = _schedule_values(snapshot) + values.pop("id") + values.pop("account_id") + statement = ( + update(Schedule) + .where( + Schedule.id == snapshot.id, + Schedule.account_id == snapshot.account_id, + Schedule.revision == expected_revision, + ) + .values(**values) + .returning(Schedule) + ) + model = self._session.scalars(statement).one_or_none() + return None if model is None else _to_schedule_snapshot(model) + + def add_occurrence_override( + self, + *, + account_id: str, + snapshot: ScheduleOccurrenceOverrideSnapshot, + ) -> ScheduleOccurrenceOverrideSnapshot | None: + """Insert an override only for schedules owned by the requested account.""" + if not self._schedule_belongs_to_account(account_id, snapshot.schedule_id): + return None + if snapshot.replacement_schedule_id is not None and not self._schedule_belongs_to_account( + account_id, snapshot.replacement_schedule_id + ): + return None + + model = ScheduleOccurrenceOverride( + id=snapshot.id, + schedule_id=snapshot.schedule_id, + occurrence_start=snapshot.occurrence_start, + action=snapshot.action.value, + replacement_schedule_id=snapshot.replacement_schedule_id, + created_at=snapshot.created_at, + updated_at=snapshot.updated_at, + ) + self._session.add(model) + self._session.flush() + return _to_override_snapshot(model) + + def get_occurrence_override( + self, + *, + account_id: str, + override_id: str, + ) -> ScheduleOccurrenceOverrideSnapshot | None: + """Return one override through an account-scoped schedule join.""" + statement = ( + select(ScheduleOccurrenceOverride) + .join(Schedule, Schedule.id == ScheduleOccurrenceOverride.schedule_id) + .where( + Schedule.account_id == account_id, + ScheduleOccurrenceOverride.id == override_id, + ) + ) + model = self._session.scalar(statement) + return None if model is None else _to_override_snapshot(model) + + def list_occurrence_overrides( + self, + *, + account_id: str, + schedule_id: str | None = None, + ) -> tuple[ScheduleOccurrenceOverrideSnapshot, ...]: + """List overrides whose recurring schedules belong to one account.""" + statement = ( + select(ScheduleOccurrenceOverride) + .join(Schedule, Schedule.id == ScheduleOccurrenceOverride.schedule_id) + .where(Schedule.account_id == account_id) + ) + if schedule_id is not None: + statement = statement.where(ScheduleOccurrenceOverride.schedule_id == schedule_id) + statement = statement.order_by( + ScheduleOccurrenceOverride.occurrence_start, + ScheduleOccurrenceOverride.id, + ) + return tuple(_to_override_snapshot(model) for model in self._session.scalars(statement)) + + def _schedule_belongs_to_account(self, account_id: str, schedule_id: str) -> bool: + statement = select(Schedule.id).where( + Schedule.account_id == account_id, + Schedule.id == schedule_id, + ) + return self._session.scalar(statement) is not None + + +def _schedule_values(snapshot: ScheduleSnapshot) -> dict[str, object]: + """Map the framework-independent snapshot to ORM column values.""" + return { + "id": snapshot.id, + "account_id": snapshot.account_id, + "schedule_type": snapshot.schedule_type.value, + "schedule_kind": snapshot.schedule_kind.value, + "title": snapshot.title, + "is_all_day": snapshot.is_all_day, + "start_time": snapshot.start_time, + "end_time": snapshot.end_time, + "timezone": snapshot.timezone, + "recurrence_rule": snapshot.recurrence_rule, + "location_name": snapshot.location_name, + "latitude": None if snapshot.latitude is None else Decimal(str(snapshot.latitude)), + "longitude": None if snapshot.longitude is None else Decimal(str(snapshot.longitude)), + "reminder_type": None if snapshot.reminder_type is None else snapshot.reminder_type.value, + "reminder_trigger_at": snapshot.reminder_trigger_at, + "reminder_offset_minutes": snapshot.reminder_offset_minutes, + "reminder_strength": ( + None if snapshot.reminder_strength is None else snapshot.reminder_strength.value + ), + "reminder_disposition_state": ( + None + if snapshot.reminder_disposition_state is None + else snapshot.reminder_disposition_state.value + ), + "status": snapshot.status.value, + "revision": snapshot.revision, + "created_at": snapshot.created_at, + "updated_at": snapshot.updated_at, + "deleted_at": snapshot.deleted_at, + } + + +def _to_schedule_snapshot(model: Schedule) -> ScheduleSnapshot: + """Map one ORM row to the shared final cloud snapshot contract.""" + return ScheduleSnapshot( + id=model.id, + account_id=model.account_id, + schedule_type=ScheduleType(model.schedule_type), + schedule_kind=ScheduleKind(model.schedule_kind), + title=model.title, + is_all_day=model.is_all_day, + timezone=model.timezone, + status=ScheduleStatus(model.status), + revision=model.revision, + created_at=model.created_at, + updated_at=model.updated_at, + start_time=model.start_time, + end_time=model.end_time, + recurrence_rule=model.recurrence_rule, + location_name=model.location_name, + latitude=None if model.latitude is None else float(model.latitude), + longitude=None if model.longitude is None else float(model.longitude), + reminder_type=None if model.reminder_type is None else ReminderType(model.reminder_type), + reminder_trigger_at=model.reminder_trigger_at, + reminder_offset_minutes=model.reminder_offset_minutes, + reminder_strength=( + None if model.reminder_strength is None else ReminderStrength(model.reminder_strength) + ), + reminder_disposition_state=( + None + if model.reminder_disposition_state is None + else ReminderDispositionState(model.reminder_disposition_state) + ), + deleted_at=model.deleted_at, + ) + + +def _to_override_snapshot( + model: ScheduleOccurrenceOverride, +) -> ScheduleOccurrenceOverrideSnapshot: + """Map one occurrence override row to the shared snapshot contract.""" + return ScheduleOccurrenceOverrideSnapshot( + id=model.id, + schedule_id=model.schedule_id, + occurrence_start=model.occurrence_start, + action=OccurrenceOverrideAction(model.action), + replacement_schedule_id=model.replacement_schedule_id, + created_at=model.created_at, + updated_at=model.updated_at, + ) + + +__all__ = ["ScheduleRepository"] diff --git a/backend/tests/test_schedule_repository.py b/backend/tests/test_schedule_repository.py new file mode 100644 index 0000000..e7f1f54 --- /dev/null +++ b/backend/tests/test_schedule_repository.py @@ -0,0 +1,123 @@ +"""Repository tests for account isolation and optimistic persistence primitives.""" + +from dataclasses import replace +from datetime import UTC, datetime + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from timeflow.business.calendar import ( + OccurrenceOverrideAction, + ScheduleKind, + ScheduleOccurrenceOverrideSnapshot, + ScheduleSnapshot, + ScheduleStatus, + ScheduleType, +) +from timeflow.data.database import Base +from timeflow.data.repositories import ScheduleRepository + + +@pytest.fixture +def session() -> Session: + """Return an isolated SQLAlchemy session for repository behavior tests.""" + engine = create_engine("sqlite+pysqlite:///:memory:") + Base.metadata.create_all(engine) + with Session(engine) as database_session: + yield database_session + + +def _schedule( + schedule_id: str, + account_id: str, + *, + revision: int = 1, +) -> ScheduleSnapshot: + now = datetime.now(UTC) + return ScheduleSnapshot( + id=schedule_id, + account_id=account_id, + schedule_type=ScheduleType.TIME, + schedule_kind=ScheduleKind.ONCE, + title=f"Schedule {schedule_id}", + is_all_day=False, + timezone="Asia/Shanghai", + status=ScheduleStatus.ACTIVE, + revision=revision, + created_at=now, + updated_at=now, + start_time=now, + ) + + +def test_schedule_reads_are_account_scoped(session: Session) -> None: + """An account can never load rows owned by another account.""" + repository = ScheduleRepository(session) + repository.add_schedule(_schedule("schedule-a", "account-a")) + repository.add_schedule(_schedule("schedule-b", "account-b")) + + assert repository.get_schedule(account_id="account-a", schedule_id="schedule-b") is None + account_schedules = repository.list_schedules(account_id="account-a") + assert [snapshot.id for snapshot in account_schedules] == ["schedule-a"] + + +def test_schedule_update_requires_matching_account_and_revision(session: Session) -> None: + """The persistence update is a single optimistic conditional statement.""" + repository = ScheduleRepository(session) + original = repository.add_schedule(_schedule("schedule-a", "account-a")) + updated = replace(original, title="Updated", revision=2, updated_at=datetime.now(UTC)) + + assert repository.update_schedule(snapshot=updated, expected_revision=0) is None + persisted = repository.update_schedule(snapshot=updated, expected_revision=1) + + assert persisted is not None + assert persisted.title == "Updated" + assert persisted.revision == 2 + + +def test_deleted_schedules_are_hidden_by_default(session: Session) -> None: + """Soft-deleted rows remain available only to explicit snapshot queries.""" + repository = ScheduleRepository(session) + original = repository.add_schedule(_schedule("schedule-a", "account-a")) + deleted = replace( + original, + status=ScheduleStatus.DELETED, + revision=2, + updated_at=datetime.now(UTC), + deleted_at=datetime.now(UTC), + ) + assert repository.update_schedule(snapshot=deleted, expected_revision=1) is not None + + assert repository.get_schedule(account_id="account-a", schedule_id="schedule-a") is None + assert ( + repository.get_schedule( + account_id="account-a", + schedule_id="schedule-a", + include_deleted=True, + ) + is not None + ) + + +def test_occurrence_overrides_are_account_scoped(session: Session) -> None: + """Override writes and reads follow ownership through their parent schedule.""" + repository = ScheduleRepository(session) + parent = repository.add_schedule(_schedule("series-a", "account-a")) + repository.add_schedule(_schedule("series-b", "account-b")) + now = datetime.now(UTC) + override = ScheduleOccurrenceOverrideSnapshot( + id="override-a", + schedule_id=parent.id, + occurrence_start=now, + action=OccurrenceOverrideAction.CANCEL, + created_at=now, + updated_at=now, + ) + + assert repository.add_occurrence_override(account_id="account-b", snapshot=override) is None + assert repository.add_occurrence_override(account_id="account-a", snapshot=override) == override + assert repository.list_occurrence_overrides(account_id="account-b") == () + account_overrides = repository.list_occurrence_overrides(account_id="account-a") + assert [snapshot.id for snapshot in account_overrides] == ["override-a"] + assert account_overrides[0].action is OccurrenceOverrideAction.CANCEL diff --git a/backend/tests/test_schedule_schema.py b/backend/tests/test_schedule_schema.py index dddec23..48b7122 100644 --- a/backend/tests/test_schedule_schema.py +++ b/backend/tests/test_schedule_schema.py @@ -1,33 +1,67 @@ -"""Schema tests for the MVP schedules table.""" +"""Schema tests for the v3.10 schedule persistence model.""" -from timeflow.data.models import Schedule +from timeflow.data.models import Schedule, ScheduleOccurrenceOverride -def test_schedule_table_has_mvp_columns() -> None: - """The schedules table exposes the fields required by the MVP design.""" - +def test_schedule_table_matches_cloud_snapshot_storage_fields() -> None: + """The cloud table stores only authoritative schedule and reminder fields.""" assert list(Schedule.__table__.columns.keys()) == [ "id", - "user_id", - "source_mode", + "account_id", "schedule_type", - "status", + "schedule_kind", "title", - "notes", + "is_all_day", "start_time", "end_time", "timezone", + "recurrence_rule", "location_name", - "location_address", "latitude", "longitude", - "geofence_radius_meters", - "geofence_armed", - "time_remind_offset_minutes", - "time_triggered_at", - "geo_triggered_at", - "system_schedule_ref_id", - "system_alarm_ref_id", + "reminder_type", + "reminder_trigger_at", + "reminder_offset_minutes", + "reminder_strength", + "reminder_disposition_state", + "status", + "revision", "created_at", "updated_at", + "deleted_at", ] + + +def test_schedule_table_keeps_device_runtime_state_out_of_cloud_storage() -> None: + """Geofence, snooze, and next-trigger state belong only to client SQLite.""" + columns = set(Schedule.__table__.columns.keys()) + + assert columns.isdisjoint( + { + "geofence_armed", + "next_trigger_at", + "snoozed_until", + "sync_status", + "time_triggered_at", + "geo_triggered_at", + } + ) + + +def test_occurrence_override_table_matches_v3_contract() -> None: + """Only exceptional recurring occurrences are persisted.""" + assert list(ScheduleOccurrenceOverride.__table__.columns.keys()) == [ + "id", + "schedule_id", + "occurrence_start", + "action", + "replacement_schedule_id", + "created_at", + "updated_at", + ] + + constraint_names = { + constraint.name for constraint in ScheduleOccurrenceOverride.__table__.constraints + } + assert "uq_schedule_occurrence_overrides_schedule_occurrence" in constraint_names + assert "ck_schedule_occurrence_overrides_replacement" in constraint_names From aa96588340784b1f0c0ea666f857dd595dff58eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=94=90=E5=83=A7=E6=B4=97=E5=8F=91=E7=94=A8=E9=A3=98?= =?UTF-8?q?=E6=9F=94?= <2906266135@qq.com> Date: Mon, 10 Aug 2026 19:13:26 +0800 Subject: [PATCH 9/9] feat(schedule): add local SQLite storage --- frontend/app.json | 3 +- frontend/package-lock.json | 21 ++ frontend/package.json | 1 + frontend/src/database/index.ts | 6 + frontend/src/database/migrations.ts | 134 +++++++++++ frontend/src/database/sqlite.ts | 12 + frontend/src/features/schedule/data/index.ts | 7 + .../src/features/schedule/data/local/index.ts | 7 + .../data/local/scheduleLocalRepository.ts | 218 ++++++++++++++++++ .../tests/scheduleStorageContracts.test-d.ts | 73 ++++++ 10 files changed, 481 insertions(+), 1 deletion(-) create mode 100644 frontend/src/database/index.ts create mode 100644 frontend/src/database/migrations.ts create mode 100644 frontend/src/database/sqlite.ts create mode 100644 frontend/src/features/schedule/data/index.ts create mode 100644 frontend/src/features/schedule/data/local/index.ts create mode 100644 frontend/src/features/schedule/data/local/scheduleLocalRepository.ts create mode 100644 frontend/tests/scheduleStorageContracts.test-d.ts diff --git a/frontend/app.json b/frontend/app.json index 5c52d0b..22004ff 100644 --- a/frontend/app.json +++ b/frontend/app.json @@ -17,6 +17,7 @@ "monochromeImage": "./assets/android-icon-monochrome.png" }, "predictiveBackGestureEnabled": false - } + }, + "plugins": ["expo-sqlite"] } } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2628621..f769ea6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "expo": "~57.0.7", + "expo-sqlite": "~57.0.1", "expo-status-bar": "~57.0.1", "react": "19.2.3", "react-native": "0.86.0" @@ -3034,6 +3035,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/await-lock": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/await-lock/-/await-lock-2.2.2.tgz", + "integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==", + "license": "MIT" + }, "node_modules/babel-plugin-polyfill-corejs2": { "version": "0.4.17", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", @@ -4818,6 +4825,20 @@ "node": ">=20.16.0" } }, + "node_modules/expo-sqlite": { + "version": "57.0.1", + "resolved": "https://registry.npmjs.org/expo-sqlite/-/expo-sqlite-57.0.1.tgz", + "integrity": "sha512-I6KoUvfGIiROTKxr5D3H+jRIGA/iEvWEtqHK4XMukAA7tVTinz/YdS8zOz6/DdG6vgrNmvF7gyOcbhLinlfxzQ==", + "license": "MIT", + "dependencies": { + "await-lock": "^2.2.2" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-status-bar": { "version": "57.0.1", "resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-57.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1b2211c..bd63c3c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,7 @@ "packageManager": "npm@10.8.2", "dependencies": { "expo": "~57.0.7", + "expo-sqlite": "~57.0.1", "expo-status-bar": "~57.0.1", "react": "19.2.3", "react-native": "0.86.0" diff --git a/frontend/src/database/index.ts b/frontend/src/database/index.ts new file mode 100644 index 0000000..18151e7 --- /dev/null +++ b/frontend/src/database/index.ts @@ -0,0 +1,6 @@ +export { + CREATE_SCHEDULE_STORAGE_SQL, + CURRENT_DATABASE_VERSION, + migrateScheduleDatabase, +} from './migrations'; +export { openTimeflowDatabase, TIMEFLOW_DATABASE_NAME } from './sqlite'; diff --git a/frontend/src/database/migrations.ts b/frontend/src/database/migrations.ts new file mode 100644 index 0000000..d1258d0 --- /dev/null +++ b/frontend/src/database/migrations.ts @@ -0,0 +1,134 @@ +import type { SQLiteDatabase } from 'expo-sqlite'; + +export const CURRENT_DATABASE_VERSION = 1; + +export const CREATE_SCHEDULE_STORAGE_SQL = ` +CREATE TABLE IF NOT EXISTS local_schedules ( + id TEXT PRIMARY KEY NOT NULL, + account_id TEXT NOT NULL, + schedule_type TEXT NOT NULL CHECK (schedule_type IN ('time', 'location')), + schedule_kind TEXT NOT NULL DEFAULT 'once' CHECK (schedule_kind IN ('once', 'recurring')), + title TEXT NOT NULL, + is_all_day INTEGER NOT NULL DEFAULT 0 CHECK (is_all_day IN (0, 1)), + start_time TEXT NULL, + end_time TEXT NULL, + timezone TEXT NOT NULL, + recurrence_rule TEXT NULL, + location_name TEXT NULL, + latitude REAL NULL CHECK (latitude IS NULL OR latitude BETWEEN -90 AND 90), + longitude REAL NULL CHECK (longitude IS NULL OR longitude BETWEEN -180 AND 180), + reminder_type TEXT NULL CHECK ( + reminder_type IS NULL OR reminder_type IN ( + 'at_time', + 'before_start', + 'arrive_location', + 'return_to_recorded_location' + ) + ), + reminder_trigger_at TEXT NULL, + reminder_offset_minutes INTEGER NULL CHECK ( + reminder_offset_minutes IS NULL OR reminder_offset_minutes >= 0 + ), + reminder_strength TEXT NULL CHECK ( + reminder_strength IS NULL OR reminder_strength IN ('low', 'medium', 'high') + ), + reminder_disposition_state TEXT NULL CHECK ( + reminder_disposition_state IS NULL + OR reminder_disposition_state IN ('confirmed', 'snoozed') + ), + next_trigger_at TEXT NULL, + snoozed_until TEXT NULL, + geofence_armed INTEGER NOT NULL DEFAULT 0 CHECK (geofence_armed IN (0, 1)), + disposition_updated_at TEXT NULL, + sync_status TEXT NOT NULL DEFAULT 'synced' CHECK (sync_status IN ('pending', 'synced')), + status TEXT NOT NULL CHECK (status IN ('active', 'deleted')), + cloud_revision INTEGER NOT NULL CHECK (cloud_revision > 0), + updated_at TEXT NOT NULL, + CHECK ( + (schedule_type = 'time' AND start_time IS NOT NULL) + OR ( + schedule_type = 'location' + AND start_time IS NULL + AND latitude IS NOT NULL + AND longitude IS NOT NULL + AND is_all_day = 0 + ) + ), + CHECK ( + (schedule_kind = 'once' AND recurrence_rule IS NULL) + OR ( + schedule_kind = 'recurring' + AND schedule_type = 'time' + AND recurrence_rule IS NOT NULL + ) + ), + CHECK ( + is_all_day = 0 + OR (schedule_type = 'time' AND start_time IS NOT NULL AND end_time IS NOT NULL) + ), + CHECK ( + (reminder_type IS NULL + AND reminder_trigger_at IS NULL + AND reminder_offset_minutes IS NULL + AND reminder_strength IS NULL) + OR (reminder_type IS NOT NULL AND reminder_strength IS NOT NULL) + ), + CHECK ( + reminder_type IS NULL + OR (reminder_type = 'at_time' + AND reminder_trigger_at IS NOT NULL + AND reminder_offset_minutes IS NULL) + OR (reminder_type = 'before_start' + AND reminder_trigger_at IS NULL + AND reminder_offset_minutes IS NOT NULL) + OR (reminder_type IN ('arrive_location', 'return_to_recorded_location') + AND reminder_trigger_at IS NULL + AND reminder_offset_minutes IS NULL + AND latitude IS NOT NULL + AND longitude IS NOT NULL) + ) +); + +CREATE INDEX IF NOT EXISTS ix_local_schedules_account_status_start_time + ON local_schedules (account_id, status, start_time); +CREATE INDEX IF NOT EXISTS ix_local_schedules_account_cloud_revision + ON local_schedules (account_id, cloud_revision); + +CREATE TABLE IF NOT EXISTS local_schedule_occurrence_overrides ( + id TEXT PRIMARY KEY NOT NULL, + schedule_id TEXT NOT NULL REFERENCES local_schedules(id) ON DELETE CASCADE, + occurrence_start TEXT NOT NULL, + action TEXT NOT NULL CHECK (action IN ('cancel', 'replace')), + replacement_schedule_id TEXT NULL REFERENCES local_schedules(id) ON DELETE RESTRICT, + UNIQUE (schedule_id, occurrence_start), + CHECK ( + (action = 'cancel' AND replacement_schedule_id IS NULL) + OR (action = 'replace' AND replacement_schedule_id IS NOT NULL) + ) +); + +CREATE INDEX IF NOT EXISTS ix_local_schedule_overrides_replacement_schedule_id + ON local_schedule_occurrence_overrides (replacement_schedule_id); +`; + +export async function migrateScheduleDatabase(database: SQLiteDatabase): Promise { + await database.execAsync('PRAGMA foreign_keys = ON'); + const versionRow = await database.getFirstAsync<{ user_version: number }>('PRAGMA user_version'); + const currentVersion = versionRow?.user_version ?? 0; + + if (currentVersion > CURRENT_DATABASE_VERSION) { + throw new Error( + `Unsupported Timeflow database version ${currentVersion}; expected at most ${CURRENT_DATABASE_VERSION}`, + ); + } + if (currentVersion === CURRENT_DATABASE_VERSION) { + return; + } + + await database.withExclusiveTransactionAsync(async (transaction) => { + if (currentVersion < 1) { + await transaction.execAsync(CREATE_SCHEDULE_STORAGE_SQL); + await transaction.execAsync('PRAGMA user_version = 1'); + } + }); +} diff --git a/frontend/src/database/sqlite.ts b/frontend/src/database/sqlite.ts new file mode 100644 index 0000000..1ed4263 --- /dev/null +++ b/frontend/src/database/sqlite.ts @@ -0,0 +1,12 @@ +import { openDatabaseAsync, type SQLiteDatabase } from 'expo-sqlite'; + +import { migrateScheduleDatabase } from './migrations'; + +export const TIMEFLOW_DATABASE_NAME = 'timeflow.db'; + +export async function openTimeflowDatabase(): Promise { + const database = await openDatabaseAsync(TIMEFLOW_DATABASE_NAME); + await database.execAsync('PRAGMA journal_mode = WAL'); + await migrateScheduleDatabase(database); + return database; +} diff --git a/frontend/src/features/schedule/data/index.ts b/frontend/src/features/schedule/data/index.ts new file mode 100644 index 0000000..b9e4e19 --- /dev/null +++ b/frontend/src/features/schedule/data/index.ts @@ -0,0 +1,7 @@ +export { + ScheduleLocalRepository, + type LocalReminderDispositionState, + type LocalReminderSyncStatus, + type LocalScheduleOccurrenceOverrideRow, + type LocalScheduleRow, +} from './local'; diff --git a/frontend/src/features/schedule/data/local/index.ts b/frontend/src/features/schedule/data/local/index.ts new file mode 100644 index 0000000..ca240ff --- /dev/null +++ b/frontend/src/features/schedule/data/local/index.ts @@ -0,0 +1,7 @@ +export { + ScheduleLocalRepository, + type LocalReminderDispositionState, + type LocalReminderSyncStatus, + type LocalScheduleOccurrenceOverrideRow, + type LocalScheduleRow, +} from './scheduleLocalRepository'; diff --git a/frontend/src/features/schedule/data/local/scheduleLocalRepository.ts b/frontend/src/features/schedule/data/local/scheduleLocalRepository.ts new file mode 100644 index 0000000..e69a9c1 --- /dev/null +++ b/frontend/src/features/schedule/data/local/scheduleLocalRepository.ts @@ -0,0 +1,218 @@ +import type { SQLiteDatabase } from 'expo-sqlite'; + +import type { + OccurrenceOverrideAction, + ReminderStrength, + ReminderType, + ScheduleKind, + ScheduleStatus, + ScheduleType, +} from '../../../../contracts/schedule'; + +export type LocalReminderDispositionState = 'confirmed' | 'snoozed'; +export type LocalReminderSyncStatus = 'pending' | 'synced'; + +export interface LocalScheduleRow { + id: string; + account_id: string; + schedule_type: ScheduleType; + schedule_kind: ScheduleKind; + title: string; + is_all_day: 0 | 1; + start_time: string | null; + end_time: string | null; + timezone: string; + recurrence_rule: string | null; + location_name: string | null; + latitude: number | null; + longitude: number | null; + reminder_type: ReminderType | null; + reminder_trigger_at: string | null; + reminder_offset_minutes: number | null; + reminder_strength: ReminderStrength | null; + reminder_disposition_state: LocalReminderDispositionState | null; + next_trigger_at: string | null; + snoozed_until: string | null; + geofence_armed: 0 | 1; + disposition_updated_at: string | null; + sync_status: LocalReminderSyncStatus; + status: ScheduleStatus; + cloud_revision: number; + updated_at: string; +} + +export interface LocalScheduleOccurrenceOverrideRow { + id: string; + schedule_id: string; + occurrence_start: string; + action: OccurrenceOverrideAction; + replacement_schedule_id: string | null; +} + +export class ScheduleLocalRepository { + public constructor(private readonly database: SQLiteDatabase) {} + + public getSchedule(accountId: string, scheduleId: string): Promise { + return this.database.getFirstAsync( + `SELECT * FROM local_schedules WHERE account_id = ? AND id = ?`, + accountId, + scheduleId, + ); + } + + public listSchedules(accountId: string): Promise { + return this.database.getAllAsync( + `SELECT * + FROM local_schedules + WHERE account_id = ? + ORDER BY start_time, updated_at, id`, + accountId, + ); + } + + public async upsertSchedule(row: LocalScheduleRow): Promise { + const result = await this.database.runAsync( + `INSERT INTO local_schedules ( + id, account_id, schedule_type, schedule_kind, title, is_all_day, + start_time, end_time, timezone, recurrence_rule, location_name, + latitude, longitude, reminder_type, reminder_trigger_at, + reminder_offset_minutes, reminder_strength, reminder_disposition_state, + next_trigger_at, snoozed_until, geofence_armed, disposition_updated_at, + sync_status, status, cloud_revision, updated_at + ) VALUES ( + $id, $account_id, $schedule_type, $schedule_kind, $title, $is_all_day, + $start_time, $end_time, $timezone, $recurrence_rule, $location_name, + $latitude, $longitude, $reminder_type, $reminder_trigger_at, + $reminder_offset_minutes, $reminder_strength, $reminder_disposition_state, + $next_trigger_at, $snoozed_until, $geofence_armed, $disposition_updated_at, + $sync_status, $status, $cloud_revision, $updated_at + ) + ON CONFLICT(id) DO UPDATE SET + schedule_type = excluded.schedule_type, + schedule_kind = excluded.schedule_kind, + title = excluded.title, + is_all_day = excluded.is_all_day, + start_time = excluded.start_time, + end_time = excluded.end_time, + timezone = excluded.timezone, + recurrence_rule = excluded.recurrence_rule, + location_name = excluded.location_name, + latitude = excluded.latitude, + longitude = excluded.longitude, + reminder_type = excluded.reminder_type, + reminder_trigger_at = excluded.reminder_trigger_at, + reminder_offset_minutes = excluded.reminder_offset_minutes, + reminder_strength = excluded.reminder_strength, + reminder_disposition_state = excluded.reminder_disposition_state, + next_trigger_at = excluded.next_trigger_at, + snoozed_until = excluded.snoozed_until, + geofence_armed = excluded.geofence_armed, + disposition_updated_at = excluded.disposition_updated_at, + sync_status = excluded.sync_status, + status = excluded.status, + cloud_revision = excluded.cloud_revision, + updated_at = excluded.updated_at + WHERE local_schedules.account_id = excluded.account_id`, + { + $id: row.id, + $account_id: row.account_id, + $schedule_type: row.schedule_type, + $schedule_kind: row.schedule_kind, + $title: row.title, + $is_all_day: row.is_all_day, + $start_time: row.start_time, + $end_time: row.end_time, + $timezone: row.timezone, + $recurrence_rule: row.recurrence_rule, + $location_name: row.location_name, + $latitude: row.latitude, + $longitude: row.longitude, + $reminder_type: row.reminder_type, + $reminder_trigger_at: row.reminder_trigger_at, + $reminder_offset_minutes: row.reminder_offset_minutes, + $reminder_strength: row.reminder_strength, + $reminder_disposition_state: row.reminder_disposition_state, + $next_trigger_at: row.next_trigger_at, + $snoozed_until: row.snoozed_until, + $geofence_armed: row.geofence_armed, + $disposition_updated_at: row.disposition_updated_at, + $sync_status: row.sync_status, + $status: row.status, + $cloud_revision: row.cloud_revision, + $updated_at: row.updated_at, + }, + ); + return result.changes === 1; + } + + public async deleteSchedule(accountId: string, scheduleId: string): Promise { + const result = await this.database.runAsync( + `DELETE FROM local_schedules WHERE account_id = ? AND id = ?`, + accountId, + scheduleId, + ); + return result.changes === 1; + } + + public async upsertOccurrenceOverride( + accountId: string, + row: LocalScheduleOccurrenceOverrideRow, + ): Promise { + const owner = await this.database.getFirstAsync<{ id: string }>( + `SELECT id FROM local_schedules WHERE account_id = ? AND id = ?`, + accountId, + row.schedule_id, + ); + if (owner === null) { + return false; + } + if (row.replacement_schedule_id !== null) { + const replacementOwner = await this.database.getFirstAsync<{ id: string }>( + `SELECT id FROM local_schedules WHERE account_id = ? AND id = ?`, + accountId, + row.replacement_schedule_id, + ); + if (replacementOwner === null) { + return false; + } + } + + const result = await this.database.runAsync( + `INSERT INTO local_schedule_occurrence_overrides ( + id, schedule_id, occurrence_start, action, replacement_schedule_id + ) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + occurrence_start = excluded.occurrence_start, + action = excluded.action, + replacement_schedule_id = excluded.replacement_schedule_id + WHERE schedule_id = excluded.schedule_id`, + row.id, + row.schedule_id, + row.occurrence_start, + row.action, + row.replacement_schedule_id, + ); + return result.changes === 1; + } + + public listOccurrenceOverrides( + accountId: string, + scheduleId?: string, + ): Promise { + const scheduleFilter = scheduleId === undefined ? '' : 'AND overrides.schedule_id = ?'; + const parameters = scheduleId === undefined ? [accountId] : [accountId, scheduleId]; + return this.database.getAllAsync( + `SELECT + overrides.id, + overrides.schedule_id, + overrides.occurrence_start, + overrides.action, + overrides.replacement_schedule_id + FROM local_schedule_occurrence_overrides AS overrides + INNER JOIN local_schedules AS schedules ON schedules.id = overrides.schedule_id + WHERE schedules.account_id = ? ${scheduleFilter} + ORDER BY overrides.occurrence_start, overrides.id`, + parameters, + ); + } +} diff --git a/frontend/tests/scheduleStorageContracts.test-d.ts b/frontend/tests/scheduleStorageContracts.test-d.ts new file mode 100644 index 0000000..b28460b --- /dev/null +++ b/frontend/tests/scheduleStorageContracts.test-d.ts @@ -0,0 +1,73 @@ +import type { ReminderDispositionState } from '../src/contracts/schedule'; +import type { + LocalReminderDispositionState, + LocalScheduleOccurrenceOverrideRow, + LocalScheduleRow, + ScheduleLocalRepository, +} from '../src/features/schedule/data'; + +type Equal = + (() => Value extends Left ? 1 : 2) extends () => Value extends Right ? 1 : 2 + ? true + : false; + +type Assert = Condition; + +export type LocalReminderStateContract = Assert< + Equal +>; + +export type LocalSnoozeDoesNotEnterCloudContract = Assert< + Equal, never> +>; + +export type LocalScheduleStorageColumnsContract = Assert< + Equal< + keyof LocalScheduleRow, + | 'id' + | 'account_id' + | 'schedule_type' + | 'schedule_kind' + | 'title' + | 'is_all_day' + | 'start_time' + | 'end_time' + | 'timezone' + | 'recurrence_rule' + | 'location_name' + | 'latitude' + | 'longitude' + | 'reminder_type' + | 'reminder_trigger_at' + | 'reminder_offset_minutes' + | 'reminder_strength' + | 'reminder_disposition_state' + | 'next_trigger_at' + | 'snoozed_until' + | 'geofence_armed' + | 'disposition_updated_at' + | 'sync_status' + | 'status' + | 'cloud_revision' + | 'updated_at' + > +>; + +export type LocalOccurrenceOverrideStorageColumnsContract = Assert< + Equal< + keyof LocalScheduleOccurrenceOverrideRow, + 'id' | 'schedule_id' | 'occurrence_start' | 'action' | 'replacement_schedule_id' + > +>; + +export type LocalRepositoryOperationsContract = Assert< + Equal< + keyof ScheduleLocalRepository, + | 'getSchedule' + | 'listSchedules' + | 'upsertSchedule' + | 'deleteSchedule' + | 'upsertOccurrenceOverride' + | 'listOccurrenceOverrides' + > +>;