From 891bbec1fdbf6033e211a5ebb085464b437fff55 Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Wed, 1 Jul 2026 12:46:45 -0700 Subject: [PATCH] refactor: split package source files --- .../lib/src/encrypted_backup.dart | 244 +- .../backup_decryption_exception.dart | 15 + .../backup_encryption_codec.dart | 79 + .../encrypted_backup/backup_exception.dart | 13 + .../encrypted_backup/backup_file_names.dart | 30 + .../src/encrypted_backup/backup_paths.dart | 43 + .../encrypted_backup_operations.dart | 64 + .../lib/src/application_commands.dart | 1489 +----------- .../application_child_task_draft.dart | 31 + .../application_command_code.dart | 22 + .../application_command_read_hint.dart | 30 + .../application_command_result.dart | 69 + .../application_commands/planning_state.dart | 252 ++ .../v1_application_command_use_cases.dart | 1085 +++++++++ .../lib/src/application_layer.dart | 1997 +--------------- .../application_failure.dart | 19 + .../application_failure_code.dart | 13 + .../application_failure_exception.dart | 9 + .../application_operation_context.dart | 44 + .../application_operation_record.dart | 25 + .../application_operation_repository.dart | 17 + .../application_persistence_exception.dart | 6 + .../application_layer/application_result.dart | 40 + .../application_scheduling_loader.dart | 30 + .../application_unit_of_work.dart | 18 + ...application_unit_of_work_repositories.dart | 22 + .../in_memory_application_repositories.dart | 72 + .../in_memory_application_state.dart | 106 + .../in_memory_application_unit_of_work.dart | 286 +++ .../notice_acknowledgement_record.dart | 20 + .../notice_acknowledgement_repository.dart | 22 + .../src/application_layer/owner_settings.dart | 62 + .../owner_settings_repository.dart | 15 + .../owner_time_zone_context.dart | 16 + .../project_statistics_repository.dart | 20 + .../task_activity_repository.dart | 28 + .../unit_of_work_locked_block_repository.dart | 254 ++ ...ork_notice_acknowledgement_repository.dart | 74 + .../unit_of_work_operation_repository.dart | 154 ++ ...nit_of_work_owner_settings_repository.dart | 70 + .../unit_of_work_project_repository.dart | 140 ++ ...of_work_project_statistics_repository.dart | 83 + ...f_work_scheduling_snapshot_repository.dart | 29 + ...unit_of_work_task_activity_repository.dart | 91 + .../unit_of_work_task_repository.dart | 212 ++ .../lib/src/application_management.dart | 778 +----- .../application_management_command_code.dart | 16 + .../backlog_item_read_model.dart | 15 + .../backlog_query_result.dart | 19 + .../get_backlog_request.dart | 19 + .../get_project_defaults_request.dart | 15 + .../locked_block_draft.dart | 24 + .../locked_block_update.dart | 22 + .../notice_acknowledgement_result.dart | 15 + .../owner_settings_update.dart | 18 + .../owner_settings_update_result.dart | 16 + .../owner_settings_warning_code.dart | 7 + .../parsed_notice_id.dart | 11 + .../project_defaults_query_result.dart | 15 + .../project_profile_draft.dart | 24 + .../project_profile_update.dart | 24 + .../v1_application_management_use_cases.dart | 518 ++++ .../lib/src/application_recovery.dart | 399 +--- .../app_open_recovery_outcome.dart | 8 + .../app_open_recovery_result.dart | 42 + .../run_app_open_recovery_request.dart | 22 + .../v1_app_open_recovery_use_cases.dart | 327 +++ packages/scheduler_core/lib/src/backlog.dart | 294 +-- .../lib/src/backlog/backlog_filter.dart | 27 + .../lib/src/backlog/backlog_sort_key.dart | 24 + .../src/backlog/backlog_staleness_marker.dart | 17 + .../backlog/backlog_staleness_settings.dart | 42 + .../lib/src/backlog/backlog_view.dart | 172 ++ .../lib/src/backlog/indexed_task.dart | 12 + .../scheduler_core/lib/src/child_tasks.dart | 752 +----- .../child_task_break_up_request.dart | 21 + .../child_task_break_up_result.dart | 37 + .../child_task_break_up_service.dart | 74 + .../child_task_completion_result.dart | 50 + .../child_task_completion_service.dart | 267 +++ .../lib/src/child_tasks/child_task_entry.dart | 72 + .../src/child_tasks/child_task_summary.dart | 86 + .../lib/src/child_tasks/child_task_view.dart | 87 + .../lib/src/child_tasks/indexed_child.dart | 58 + .../lib/src/document_mapping.dart | 2097 +---------------- ...pplication_operation_document_mapping.dart | 49 + .../backlog_staleness_document_mapping.dart | 36 + .../document_mapping_exception.dart | 25 + .../document_mapping_failure_code.dart | 11 + .../document_mapping/document_metadata.dart | 18 + .../locked_block_document_mapping.dart | 81 + ...ocked_block_override_document_mapping.dart | 81 + ...ked_block_recurrence_document_mapping.dart | 42 + ...tice_acknowledgement_document_mapping.dart | 44 + .../owner_settings_document_mapping.dart | 60 + .../persistence_enum_mapping.dart | 288 +++ .../project_document_mapping.dart | 72 + ...project_statistics_document_extension.dart | 577 +++++ .../project_statistics_document_mapping.dart | 101 + .../scheduling_change_document_mapping.dart | 42 + .../scheduling_notice_document_mapping.dart | 67 + .../scheduling_overlap_document_mapping.dart | 32 + .../scheduling_snapshot_document_mapping.dart | 110 + .../scheduling_window_document_mapping.dart | 23 + .../task_activity_document_mapping.dart | 56 + .../task_document_extension.dart | 21 + .../task_document_mapping.dart | 136 ++ .../task_statistics_document_extension.dart | 8 + .../task_statistics_document_mapping.dart | 90 + .../time_interval_document_mapping.dart | 27 + .../lib/src/document_migration.dart | 1142 +-------- .../document_migration_entity.dart | 9 + .../document_migration_failure_code.dart | 9 + .../document_migration_note.dart | 14 + .../document_migration_note_code.dart | 7 + .../document_migration_provenance.dart | 6 + .../document_migration_report.dart | 30 + .../document_migration_result.dart | 19 + .../document_migration_status.dart | 8 + .../legacy_document_exception.dart | 11 + .../legacy_metadata_instants.dart | 602 +++++ .../schema_version_read.dart | 9 + .../src/document_migration/v0_migrator.dart | 6 + .../v1_document_migration_service.dart | 412 ++++ .../scheduler_core/lib/src/free_slots.dart | 322 +-- .../lib/src/free_slots/free_slot_service.dart | 256 ++ .../protected_free_slot_conflict.dart | 23 + .../required_commitment_schedule_result.dart | 27 + .../required_commitment_schedule_status.dart | 16 + .../scheduler_core/lib/src/locked_time.dart | 958 +------- .../lib/src/locked_time/clock_time.dart | 4 + .../lib/src/locked_time/locked_block.dart | 121 + .../locked_time/locked_block_occurrence.dart | 48 + .../locked_time/locked_block_override.dart | 189 ++ .../locked_block_override_type.dart | 16 + .../locked_time/locked_block_recurrence.dart | 30 + .../locked_schedule_expansion.dart | 529 +++++ .../lib/src/locked_time/locked_weekday.dart | 21 + packages/scheduler_core/lib/src/models.dart | 786 +----- .../lib/src/models/backlog_tag.dart | 11 + .../lib/src/models/difficulty_level.dart | 27 + .../src/models/domain_validation_code.dart | 31 + .../models/domain_validation_exception.dart | 14 + .../lib/src/models/priority_level.dart | 25 + .../lib/src/models/project_profile.dart | 156 ++ .../lib/src/models/reminder_profile.dart | 20 + .../lib/src/models/reward_level.dart | 26 + .../scheduler_core/lib/src/models/task.dart | 288 +++ .../lib/src/models/task_status.dart | 31 + .../lib/src/models/task_type.dart | 31 + .../lib/src/models/time_interval.dart | 126 + .../lib/src/occupancy_policy.dart | 339 +-- .../occupancy_policy/occupancy_category.dart | 28 + .../src/occupancy_policy/occupancy_entry.dart | 51 + .../occupancy_policy/occupancy_policy.dart | 247 ++ .../occupancy_policy/occupancy_source.dart | 13 + .../lib/src/persistence_contract.dart | 1079 +-------- ...application_operation_document_fields.dart | 21 + .../backlog_staleness_document_fields.dart | 12 + .../clock_time_document_fields.dart | 10 + .../persistence_contract/document_fields.dart | 31 + .../locked_block_document_fields.dart | 31 + ...locked_block_override_document_fields.dart | 31 + ...cked_block_recurrence_document_fields.dart | 12 + ...otice_acknowledgement_document_fields.dart | 19 + .../owner_settings_document_fields.dart | 25 + .../persistence_civil_date_convention.dart | 18 + .../persistence_collections.dart | 31 + .../persistence_date_time_convention.dart | 23 + .../persistence_document_field_sets.dart | 27 + .../persistence_enum_name.dart | 10 + .../persistence_guard_result.dart | 33 + .../persistence_index_catalog.dart | 300 +++ .../persistence_index_direction.dart | 7 + .../persistence_index_field.dart | 12 + .../persistence_index_spec.dart | 20 + .../persistence_payload_guard.dart | 35 + .../persistence_payload_limits.dart | 13 + .../persistence_wall_time_convention.dart | 18 + .../project_document_fields.dart | 31 + .../project_statistics_document_fields.dart | 35 + .../repository_query_names.dart | 37 + .../scheduling_change_document_fields.dart | 18 + .../scheduling_notice_document_fields.dart | 22 + .../scheduling_overlap_document_fields.dart | 14 + .../scheduling_snapshot_document_fields.dart | 41 + .../scheduling_window_document_fields.dart | 12 + .../task_activity_document_fields.dart | 27 + .../task_document_fields.dart | 53 + .../task_statistics_document_fields.dart | 36 + .../time_interval_document_fields.dart | 14 + .../lib/src/project_statistics.dart | 700 +----- .../project_completion_time_bucket.dart | 10 + .../project_default_resolution.dart | 21 + .../project_statistics.dart | 207 ++ ...roject_statistics_aggregation_service.dart | 59 + ...project_statistics_application_result.dart | 15 + .../project_suggestion.dart | 31 + .../project_suggestion_confidence.dart | 8 + .../project_suggestion_policy.dart | 18 + .../project_suggestion_service.dart | 301 +++ .../project_suggestion_set.dart | 20 + .../project_suggestion_type.dart | 10 + .../scheduler_core/lib/src/quick_capture.dart | 283 +-- .../quick_capture/quick_capture_request.dart | 57 + .../quick_capture/quick_capture_result.dart | 31 + .../quick_capture/quick_capture_service.dart | 177 ++ .../quick_capture/quick_capture_status.dart | 18 + .../lib/src/reminder_policy.dart | 332 +-- .../effective_reminder_profile.dart | 15 + .../effective_reminder_profile_source.dart | 8 + .../reminder_policy/reminder_directive.dart | 47 + .../reminder_directive_action.dart | 9 + .../reminder_directive_reason.dart | 16 + .../reminder_policy_service.dart | 237 ++ .../scheduler_core/lib/src/repositories.dart | 1215 +--------- .../repositories/backlog_candidate_query.dart | 16 + .../in_memory_locked_block_repository.dart | 302 +++ .../in_memory_project_repository.dart | 166 ++ ...memory_scheduling_snapshot_repository.dart | 108 + .../in_memory_task_repository.dart | 244 ++ .../repositories/locked_block_repository.dart | 73 + .../src/repositories/project_repository.dart | 44 + .../src/repositories/repository_failure.dart | 16 + .../repositories/repository_failure_code.dart | 11 + .../repository_mutation_result.dart | 22 + .../lib/src/repositories/repository_page.dart | 14 + .../repositories/repository_page_request.dart | 15 + .../src/repositories/repository_record.dart | 18 + .../scheduling_snapshot_repository.dart | 13 + .../scheduling_state_snapshot.dart | 74 + .../lib/src/repositories/task_repository.dart | 79 + .../lib/src/repository_values.dart | 89 +- .../lib/src/repository_values/owner_id.dart | 20 + .../lib/src/repository_values/page.dart | 26 + .../src/repository_values/page_request.dart | 15 + .../lib/src/repository_values/revision.dart | 29 + .../lib/src/scheduling_engine.dart | 1752 +------------- .../backlog_insertion_plan.dart | 13 + .../src/scheduling_engine/placement_item.dart | 23 + .../scheduling_engine/scheduling_change.dart | 31 + .../scheduling_conflict_code.dart | 8 + .../scheduling_engine/scheduling_engine.dart | 1309 ++++++++++ .../scheduling_engine/scheduling_input.dart | 97 + .../scheduling_issue_code.dart | 14 + .../scheduling_movement_code.dart | 12 + .../scheduling_engine/scheduling_notice.dart | 40 + .../scheduling_notice_type.dart | 23 + .../scheduling_operation_code.dart | 31 + .../scheduling_outcome_code.dart | 25 + .../scheduling_engine/scheduling_overlap.dart | 23 + .../scheduling_engine/scheduling_result.dart | 57 + .../scheduling_engine/scheduling_window.dart | 46 + .../scheduler_core/lib/src/task_actions.dart | 1097 +-------- .../flexible_task_action_result.dart | 38 + .../flexible_task_action_service.dart | 264 +++ .../flexible_task_quick_action.dart | 20 + .../src/task_actions/push_destination.dart | 17 + .../task_actions/push_destination_result.dart | 29 + .../task_actions/required_task_action.dart | 20 + .../required_task_action_result.dart | 31 + .../required_task_action_service.dart | 81 + .../surprise_task_log_request.dart | 49 + .../surprise_task_log_result.dart | 31 + .../surprise_task_log_service.dart | 517 ++++ .../lib/src/task_lifecycle.dart | 959 +------- .../lib/src/task_lifecycle/task_activity.dart | 43 + .../task_activity_accounting_service.dart | 43 + .../task_activity_application_result.dart | 15 + .../task_lifecycle/task_activity_code.dart | 14 + .../task_lifecycle/task_transition_code.dart | 14 + .../task_transition_outcome_code.dart | 9 + .../task_transition_result.dart | 30 + .../task_transition_service.dart | 791 +++++++ .../lib/src/time_contracts.dart | 306 +-- .../lib/src/time_contracts/civil_date.dart | 83 + .../lib/src/time_contracts/clock.dart | 6 + .../lib/src/time_contracts/fixed_clock.dart | 11 + .../fixed_offset_time_zone_resolver.dart | 44 + .../lib/src/time_contracts/id_generator.dart | 6 + .../time_contracts/local_time_resolution.dart | 8 + .../nonexistent_local_time_policy.dart | 9 + .../repeated_local_time_policy.dart | 12 + .../resolved_local_date_time.dart | 19 + .../sequential_id_generator.dart | 19 + .../lib/src/time_contracts/system_clock.dart | 10 + .../time_zone_resolution_options.dart | 11 + .../time_contracts/time_zone_resolver.dart | 11 + .../lib/src/time_contracts/wall_time.dart | 57 + .../lib/src/timeline_state.dart | 461 +--- .../compact_timeline_state.dart | 48 + .../timeline_background_token.dart | 11 + .../timeline_difficulty_icon_token.dart | 11 + .../lib/src/timeline_state/timeline_item.dart | 63 + .../timeline_item_category.dart | 10 + .../timeline_state/timeline_item_mapper.dart | 295 +++ .../timeline_state/timeline_quick_action.dart | 12 + .../timeline_reward_icon_token.dart | 11 + .../scheduler_core/lib/src/today_state.dart | 648 +---- .../today_state/get_today_state_query.dart | 372 +++ .../today_state/get_today_state_request.dart | 28 + .../src/today_state/today_compact_state.dart | 49 + .../src/today_state/today_pending_notice.dart | 47 + .../lib/src/today_state/today_state.dart | 60 + .../src/today_state/today_timeline_item.dart | 85 + .../today_timeline_item_source.dart | 7 + .../lib/src/export_controller.dart | 311 +-- .../export_controller/csv_cell_encoder.dart | 8 + .../export_controller/csv_export_writer.dart | 62 + .../src/export_controller/export_context.dart | 20 + .../export_controller/export_controller.dart | 58 + .../export_controller/export_exception.dart | 13 + .../export_format_normalizer.dart | 13 + .../export_json_context_encoder.dart | 9 + .../export_repository_exception.dart | 11 + .../src/export_controller/export_writer.dart | 13 + .../export_writer_factory.dart | 4 + .../export_writer_registry.dart | 44 + .../export_controller/json_export_writer.dart | 56 + .../lib/src/json_csv_writers.dart | 136 +- .../json_csv_writers/csv_cell_encoder.dart | 8 + .../json_csv_writers/csv_export_writer.dart | 62 + .../export_json_context_encoder.dart | 9 + .../json_csv_writers/json_export_writer.dart | 57 + .../lib/src/notification_adapter.dart | 155 +- .../fake_notification_adapter.dart | 50 + .../notification_adapter.dart | 13 + .../notification_feedback.dart | 24 + .../notification_feedback_type.dart | 13 + .../notification_request.dart | 44 + .../notification_request_impl.dart | 11 + .../src/desktop_notification_adapter_io.dart | 234 +- .../apple_script_string_encoder.dart | 5 + .../desktop_notification_adapter.dart | 38 + .../desktop_notification_backend.dart | 13 + .../desktop_notification_backend_factory.dart | 28 + .../desktop_notification_log_sink.dart | 4 + .../desktop_notification_platform.dart | 16 + ...esktop_notification_platform_detector.dart | 8 + .../desktop_process_runner.dart | 7 + .../linux_notify_send_backend.dart | 48 + .../mac_os_notification_backend.dart | 36 + .../stdout_notification_backend.dart | 31 + .../desktop_notification_adapter_stub.dart | 121 +- .../default_log_sink.dart | 6 + .../desktop_notification_adapter.dart | 38 + .../desktop_notification_backend.dart | 13 + .../desktop_notification_backend_factory.dart | 13 + .../desktop_notification_log_sink.dart | 4 + .../desktop_notification_platform.dart | 16 + .../stdout_notification_backend.dart | 31 + .../lib/persistence.dart | 332 +-- .../lib/persistence/either.dart | 24 + .../lib/persistence/left.dart | 10 + .../persistence/locked_block_repository.dart | 64 + .../lib/persistence/project_repository.dart | 38 + .../lib/persistence/repo_result.dart | 4 + .../persistence/repository_duplicate_id.dart | 7 + .../lib/persistence/repository_failure.dart | 23 + .../repository_invalid_revision.dart | 11 + .../lib/persistence/repository_not_found.dart | 7 + .../repository_owner_mismatch.dart | 7 + .../repository_stale_revision.dart | 12 + .../repository_storage_failure.dart | 7 + .../lib/persistence/right.dart | 10 + .../schedule_snapshot_repository.dart | 29 + .../lib/persistence/settings_repository.dart | 22 + .../lib/persistence/task_repository.dart | 57 + .../lib/persistence_memory.dart | 845 +------ .../persistence_memory/identified_record.dart | 5 + .../in_memory_locked_block_repository.dart | 191 ++ .../in_memory_project_repository.dart | 125 + ...n_memory_schedule_snapshot_repository.dart | 251 ++ .../in_memory_settings_repository.dart | 78 + .../in_memory_task_repository.dart | 141 ++ .../lib/persistence_memory/record.dart | 36 + .../persistence_memory/snapshot_record.dart | 18 + .../lib/src/scheduler_db.dart | 234 +- .../src/scheduler_db/locked_block_dao.dart | 8 + .../lib/src/scheduler_db/locked_blocks.dart | 23 + .../src/scheduler_db/locked_overrides.dart | 22 + .../lib/src/scheduler_db/project_dao.dart | 7 + .../lib/src/scheduler_db/projects.dart | 25 + .../lib/src/scheduler_db/scheduler_db.dart | 33 + .../lib/src/scheduler_db/settings_dao.dart | 8 + .../lib/src/scheduler_db/settings_table.dart | 22 + .../lib/src/scheduler_db/snapshot_dao.dart | 8 + .../lib/src/scheduler_db/snapshots.dart | 29 + .../lib/src/scheduler_db/task_dao.dart | 7 + .../lib/src/scheduler_db/tasks.dart | 42 + .../lib/src/sqlite_repositories.dart | 1357 +---------- .../sqlite_locked_block_repository.dart | 304 +++ .../sqlite_project_repository.dart | 184 ++ .../sqlite_schedule_snapshot_repository.dart | 541 +++++ .../sqlite_settings_repository.dart | 113 + .../sqlite_task_repository.dart | 215 ++ 396 files changed, 24606 insertions(+), 23881 deletions(-) create mode 100644 packages/scheduler_backup/lib/src/encrypted_backup/backup_decryption_exception.dart create mode 100644 packages/scheduler_backup/lib/src/encrypted_backup/backup_encryption_codec.dart create mode 100644 packages/scheduler_backup/lib/src/encrypted_backup/backup_exception.dart create mode 100644 packages/scheduler_backup/lib/src/encrypted_backup/backup_file_names.dart create mode 100644 packages/scheduler_backup/lib/src/encrypted_backup/backup_paths.dart create mode 100644 packages/scheduler_backup/lib/src/encrypted_backup/encrypted_backup_operations.dart create mode 100644 packages/scheduler_core/lib/src/application_commands/application_child_task_draft.dart create mode 100644 packages/scheduler_core/lib/src/application_commands/application_command_code.dart create mode 100644 packages/scheduler_core/lib/src/application_commands/application_command_read_hint.dart create mode 100644 packages/scheduler_core/lib/src/application_commands/application_command_result.dart create mode 100644 packages/scheduler_core/lib/src/application_commands/planning_state.dart create mode 100644 packages/scheduler_core/lib/src/application_commands/v1_application_command_use_cases.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/application_failure.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/application_failure_code.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/application_failure_exception.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/application_operation_context.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/application_operation_record.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/application_operation_repository.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/application_persistence_exception.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/application_result.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/application_scheduling_loader.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/application_unit_of_work.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/application_unit_of_work_repositories.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/in_memory_application_repositories.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/in_memory_application_state.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/in_memory_application_unit_of_work.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/notice_acknowledgement_record.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/notice_acknowledgement_repository.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/owner_settings.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/owner_settings_repository.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/owner_time_zone_context.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/project_statistics_repository.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/task_activity_repository.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/unit_of_work_locked_block_repository.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/unit_of_work_notice_acknowledgement_repository.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/unit_of_work_operation_repository.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/unit_of_work_owner_settings_repository.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/unit_of_work_project_repository.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/unit_of_work_project_statistics_repository.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/unit_of_work_scheduling_snapshot_repository.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/unit_of_work_task_activity_repository.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/unit_of_work_task_repository.dart create mode 100644 packages/scheduler_core/lib/src/application_management/application_management_command_code.dart create mode 100644 packages/scheduler_core/lib/src/application_management/backlog_item_read_model.dart create mode 100644 packages/scheduler_core/lib/src/application_management/backlog_query_result.dart create mode 100644 packages/scheduler_core/lib/src/application_management/get_backlog_request.dart create mode 100644 packages/scheduler_core/lib/src/application_management/get_project_defaults_request.dart create mode 100644 packages/scheduler_core/lib/src/application_management/locked_block_draft.dart create mode 100644 packages/scheduler_core/lib/src/application_management/locked_block_update.dart create mode 100644 packages/scheduler_core/lib/src/application_management/notice_acknowledgement_result.dart create mode 100644 packages/scheduler_core/lib/src/application_management/owner_settings_update.dart create mode 100644 packages/scheduler_core/lib/src/application_management/owner_settings_update_result.dart create mode 100644 packages/scheduler_core/lib/src/application_management/owner_settings_warning_code.dart create mode 100644 packages/scheduler_core/lib/src/application_management/parsed_notice_id.dart create mode 100644 packages/scheduler_core/lib/src/application_management/project_defaults_query_result.dart create mode 100644 packages/scheduler_core/lib/src/application_management/project_profile_draft.dart create mode 100644 packages/scheduler_core/lib/src/application_management/project_profile_update.dart create mode 100644 packages/scheduler_core/lib/src/application_management/v1_application_management_use_cases.dart create mode 100644 packages/scheduler_core/lib/src/application_recovery/app_open_recovery_outcome.dart create mode 100644 packages/scheduler_core/lib/src/application_recovery/app_open_recovery_result.dart create mode 100644 packages/scheduler_core/lib/src/application_recovery/run_app_open_recovery_request.dart create mode 100644 packages/scheduler_core/lib/src/application_recovery/v1_app_open_recovery_use_cases.dart create mode 100644 packages/scheduler_core/lib/src/backlog/backlog_filter.dart create mode 100644 packages/scheduler_core/lib/src/backlog/backlog_sort_key.dart create mode 100644 packages/scheduler_core/lib/src/backlog/backlog_staleness_marker.dart create mode 100644 packages/scheduler_core/lib/src/backlog/backlog_staleness_settings.dart create mode 100644 packages/scheduler_core/lib/src/backlog/backlog_view.dart create mode 100644 packages/scheduler_core/lib/src/backlog/indexed_task.dart create mode 100644 packages/scheduler_core/lib/src/child_tasks/child_task_break_up_request.dart create mode 100644 packages/scheduler_core/lib/src/child_tasks/child_task_break_up_result.dart create mode 100644 packages/scheduler_core/lib/src/child_tasks/child_task_break_up_service.dart create mode 100644 packages/scheduler_core/lib/src/child_tasks/child_task_completion_result.dart create mode 100644 packages/scheduler_core/lib/src/child_tasks/child_task_completion_service.dart create mode 100644 packages/scheduler_core/lib/src/child_tasks/child_task_entry.dart create mode 100644 packages/scheduler_core/lib/src/child_tasks/child_task_summary.dart create mode 100644 packages/scheduler_core/lib/src/child_tasks/child_task_view.dart create mode 100644 packages/scheduler_core/lib/src/child_tasks/indexed_child.dart create mode 100644 packages/scheduler_core/lib/src/document_mapping/application_operation_document_mapping.dart create mode 100644 packages/scheduler_core/lib/src/document_mapping/backlog_staleness_document_mapping.dart create mode 100644 packages/scheduler_core/lib/src/document_mapping/document_mapping_exception.dart create mode 100644 packages/scheduler_core/lib/src/document_mapping/document_mapping_failure_code.dart create mode 100644 packages/scheduler_core/lib/src/document_mapping/document_metadata.dart create mode 100644 packages/scheduler_core/lib/src/document_mapping/locked_block_document_mapping.dart create mode 100644 packages/scheduler_core/lib/src/document_mapping/locked_block_override_document_mapping.dart create mode 100644 packages/scheduler_core/lib/src/document_mapping/locked_block_recurrence_document_mapping.dart create mode 100644 packages/scheduler_core/lib/src/document_mapping/notice_acknowledgement_document_mapping.dart create mode 100644 packages/scheduler_core/lib/src/document_mapping/owner_settings_document_mapping.dart create mode 100644 packages/scheduler_core/lib/src/document_mapping/persistence_enum_mapping.dart create mode 100644 packages/scheduler_core/lib/src/document_mapping/project_document_mapping.dart create mode 100644 packages/scheduler_core/lib/src/document_mapping/project_statistics_document_extension.dart create mode 100644 packages/scheduler_core/lib/src/document_mapping/project_statistics_document_mapping.dart create mode 100644 packages/scheduler_core/lib/src/document_mapping/scheduling_change_document_mapping.dart create mode 100644 packages/scheduler_core/lib/src/document_mapping/scheduling_notice_document_mapping.dart create mode 100644 packages/scheduler_core/lib/src/document_mapping/scheduling_overlap_document_mapping.dart create mode 100644 packages/scheduler_core/lib/src/document_mapping/scheduling_snapshot_document_mapping.dart create mode 100644 packages/scheduler_core/lib/src/document_mapping/scheduling_window_document_mapping.dart create mode 100644 packages/scheduler_core/lib/src/document_mapping/task_activity_document_mapping.dart create mode 100644 packages/scheduler_core/lib/src/document_mapping/task_document_extension.dart create mode 100644 packages/scheduler_core/lib/src/document_mapping/task_document_mapping.dart create mode 100644 packages/scheduler_core/lib/src/document_mapping/task_statistics_document_extension.dart create mode 100644 packages/scheduler_core/lib/src/document_mapping/task_statistics_document_mapping.dart create mode 100644 packages/scheduler_core/lib/src/document_mapping/time_interval_document_mapping.dart create mode 100644 packages/scheduler_core/lib/src/document_migration/document_migration_entity.dart create mode 100644 packages/scheduler_core/lib/src/document_migration/document_migration_failure_code.dart create mode 100644 packages/scheduler_core/lib/src/document_migration/document_migration_note.dart create mode 100644 packages/scheduler_core/lib/src/document_migration/document_migration_note_code.dart create mode 100644 packages/scheduler_core/lib/src/document_migration/document_migration_provenance.dart create mode 100644 packages/scheduler_core/lib/src/document_migration/document_migration_report.dart create mode 100644 packages/scheduler_core/lib/src/document_migration/document_migration_result.dart create mode 100644 packages/scheduler_core/lib/src/document_migration/document_migration_status.dart create mode 100644 packages/scheduler_core/lib/src/document_migration/legacy_document_exception.dart create mode 100644 packages/scheduler_core/lib/src/document_migration/legacy_metadata_instants.dart create mode 100644 packages/scheduler_core/lib/src/document_migration/schema_version_read.dart create mode 100644 packages/scheduler_core/lib/src/document_migration/v0_migrator.dart create mode 100644 packages/scheduler_core/lib/src/document_migration/v1_document_migration_service.dart create mode 100644 packages/scheduler_core/lib/src/free_slots/free_slot_service.dart create mode 100644 packages/scheduler_core/lib/src/free_slots/protected_free_slot_conflict.dart create mode 100644 packages/scheduler_core/lib/src/free_slots/required_commitment_schedule_result.dart create mode 100644 packages/scheduler_core/lib/src/free_slots/required_commitment_schedule_status.dart create mode 100644 packages/scheduler_core/lib/src/locked_time/clock_time.dart create mode 100644 packages/scheduler_core/lib/src/locked_time/locked_block.dart create mode 100644 packages/scheduler_core/lib/src/locked_time/locked_block_occurrence.dart create mode 100644 packages/scheduler_core/lib/src/locked_time/locked_block_override.dart create mode 100644 packages/scheduler_core/lib/src/locked_time/locked_block_override_type.dart create mode 100644 packages/scheduler_core/lib/src/locked_time/locked_block_recurrence.dart create mode 100644 packages/scheduler_core/lib/src/locked_time/locked_schedule_expansion.dart create mode 100644 packages/scheduler_core/lib/src/locked_time/locked_weekday.dart create mode 100644 packages/scheduler_core/lib/src/models/backlog_tag.dart create mode 100644 packages/scheduler_core/lib/src/models/difficulty_level.dart create mode 100644 packages/scheduler_core/lib/src/models/domain_validation_code.dart create mode 100644 packages/scheduler_core/lib/src/models/domain_validation_exception.dart create mode 100644 packages/scheduler_core/lib/src/models/priority_level.dart create mode 100644 packages/scheduler_core/lib/src/models/project_profile.dart create mode 100644 packages/scheduler_core/lib/src/models/reminder_profile.dart create mode 100644 packages/scheduler_core/lib/src/models/reward_level.dart create mode 100644 packages/scheduler_core/lib/src/models/task.dart create mode 100644 packages/scheduler_core/lib/src/models/task_status.dart create mode 100644 packages/scheduler_core/lib/src/models/task_type.dart create mode 100644 packages/scheduler_core/lib/src/models/time_interval.dart create mode 100644 packages/scheduler_core/lib/src/occupancy_policy/occupancy_category.dart create mode 100644 packages/scheduler_core/lib/src/occupancy_policy/occupancy_entry.dart create mode 100644 packages/scheduler_core/lib/src/occupancy_policy/occupancy_policy.dart create mode 100644 packages/scheduler_core/lib/src/occupancy_policy/occupancy_source.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/application_operation_document_fields.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/backlog_staleness_document_fields.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/clock_time_document_fields.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/document_fields.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/locked_block_document_fields.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/locked_block_override_document_fields.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/locked_block_recurrence_document_fields.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/notice_acknowledgement_document_fields.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/owner_settings_document_fields.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/persistence_civil_date_convention.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/persistence_collections.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/persistence_date_time_convention.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/persistence_document_field_sets.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/persistence_enum_name.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/persistence_guard_result.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/persistence_index_catalog.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/persistence_index_direction.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/persistence_index_field.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/persistence_index_spec.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/persistence_payload_guard.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/persistence_payload_limits.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/persistence_wall_time_convention.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/project_document_fields.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/project_statistics_document_fields.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/repository_query_names.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/scheduling_change_document_fields.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/scheduling_notice_document_fields.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/scheduling_overlap_document_fields.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/scheduling_snapshot_document_fields.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/scheduling_window_document_fields.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/task_activity_document_fields.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/task_document_fields.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/task_statistics_document_fields.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/time_interval_document_fields.dart create mode 100644 packages/scheduler_core/lib/src/project_statistics/project_completion_time_bucket.dart create mode 100644 packages/scheduler_core/lib/src/project_statistics/project_default_resolution.dart create mode 100644 packages/scheduler_core/lib/src/project_statistics/project_statistics.dart create mode 100644 packages/scheduler_core/lib/src/project_statistics/project_statistics_aggregation_service.dart create mode 100644 packages/scheduler_core/lib/src/project_statistics/project_statistics_application_result.dart create mode 100644 packages/scheduler_core/lib/src/project_statistics/project_suggestion.dart create mode 100644 packages/scheduler_core/lib/src/project_statistics/project_suggestion_confidence.dart create mode 100644 packages/scheduler_core/lib/src/project_statistics/project_suggestion_policy.dart create mode 100644 packages/scheduler_core/lib/src/project_statistics/project_suggestion_service.dart create mode 100644 packages/scheduler_core/lib/src/project_statistics/project_suggestion_set.dart create mode 100644 packages/scheduler_core/lib/src/project_statistics/project_suggestion_type.dart create mode 100644 packages/scheduler_core/lib/src/quick_capture/quick_capture_request.dart create mode 100644 packages/scheduler_core/lib/src/quick_capture/quick_capture_result.dart create mode 100644 packages/scheduler_core/lib/src/quick_capture/quick_capture_service.dart create mode 100644 packages/scheduler_core/lib/src/quick_capture/quick_capture_status.dart create mode 100644 packages/scheduler_core/lib/src/reminder_policy/effective_reminder_profile.dart create mode 100644 packages/scheduler_core/lib/src/reminder_policy/effective_reminder_profile_source.dart create mode 100644 packages/scheduler_core/lib/src/reminder_policy/reminder_directive.dart create mode 100644 packages/scheduler_core/lib/src/reminder_policy/reminder_directive_action.dart create mode 100644 packages/scheduler_core/lib/src/reminder_policy/reminder_directive_reason.dart create mode 100644 packages/scheduler_core/lib/src/reminder_policy/reminder_policy_service.dart create mode 100644 packages/scheduler_core/lib/src/repositories/backlog_candidate_query.dart create mode 100644 packages/scheduler_core/lib/src/repositories/in_memory_locked_block_repository.dart create mode 100644 packages/scheduler_core/lib/src/repositories/in_memory_project_repository.dart create mode 100644 packages/scheduler_core/lib/src/repositories/in_memory_scheduling_snapshot_repository.dart create mode 100644 packages/scheduler_core/lib/src/repositories/in_memory_task_repository.dart create mode 100644 packages/scheduler_core/lib/src/repositories/locked_block_repository.dart create mode 100644 packages/scheduler_core/lib/src/repositories/project_repository.dart create mode 100644 packages/scheduler_core/lib/src/repositories/repository_failure.dart create mode 100644 packages/scheduler_core/lib/src/repositories/repository_failure_code.dart create mode 100644 packages/scheduler_core/lib/src/repositories/repository_mutation_result.dart create mode 100644 packages/scheduler_core/lib/src/repositories/repository_page.dart create mode 100644 packages/scheduler_core/lib/src/repositories/repository_page_request.dart create mode 100644 packages/scheduler_core/lib/src/repositories/repository_record.dart create mode 100644 packages/scheduler_core/lib/src/repositories/scheduling_snapshot_repository.dart create mode 100644 packages/scheduler_core/lib/src/repositories/scheduling_state_snapshot.dart create mode 100644 packages/scheduler_core/lib/src/repositories/task_repository.dart create mode 100644 packages/scheduler_core/lib/src/repository_values/owner_id.dart create mode 100644 packages/scheduler_core/lib/src/repository_values/page.dart create mode 100644 packages/scheduler_core/lib/src/repository_values/page_request.dart create mode 100644 packages/scheduler_core/lib/src/repository_values/revision.dart create mode 100644 packages/scheduler_core/lib/src/scheduling_engine/backlog_insertion_plan.dart create mode 100644 packages/scheduler_core/lib/src/scheduling_engine/placement_item.dart create mode 100644 packages/scheduler_core/lib/src/scheduling_engine/scheduling_change.dart create mode 100644 packages/scheduler_core/lib/src/scheduling_engine/scheduling_conflict_code.dart create mode 100644 packages/scheduler_core/lib/src/scheduling_engine/scheduling_engine.dart create mode 100644 packages/scheduler_core/lib/src/scheduling_engine/scheduling_input.dart create mode 100644 packages/scheduler_core/lib/src/scheduling_engine/scheduling_issue_code.dart create mode 100644 packages/scheduler_core/lib/src/scheduling_engine/scheduling_movement_code.dart create mode 100644 packages/scheduler_core/lib/src/scheduling_engine/scheduling_notice.dart create mode 100644 packages/scheduler_core/lib/src/scheduling_engine/scheduling_notice_type.dart create mode 100644 packages/scheduler_core/lib/src/scheduling_engine/scheduling_operation_code.dart create mode 100644 packages/scheduler_core/lib/src/scheduling_engine/scheduling_outcome_code.dart create mode 100644 packages/scheduler_core/lib/src/scheduling_engine/scheduling_overlap.dart create mode 100644 packages/scheduler_core/lib/src/scheduling_engine/scheduling_result.dart create mode 100644 packages/scheduler_core/lib/src/scheduling_engine/scheduling_window.dart create mode 100644 packages/scheduler_core/lib/src/task_actions/flexible_task_action_result.dart create mode 100644 packages/scheduler_core/lib/src/task_actions/flexible_task_action_service.dart create mode 100644 packages/scheduler_core/lib/src/task_actions/flexible_task_quick_action.dart create mode 100644 packages/scheduler_core/lib/src/task_actions/push_destination.dart create mode 100644 packages/scheduler_core/lib/src/task_actions/push_destination_result.dart create mode 100644 packages/scheduler_core/lib/src/task_actions/required_task_action.dart create mode 100644 packages/scheduler_core/lib/src/task_actions/required_task_action_result.dart create mode 100644 packages/scheduler_core/lib/src/task_actions/required_task_action_service.dart create mode 100644 packages/scheduler_core/lib/src/task_actions/surprise_task_log_request.dart create mode 100644 packages/scheduler_core/lib/src/task_actions/surprise_task_log_result.dart create mode 100644 packages/scheduler_core/lib/src/task_actions/surprise_task_log_service.dart create mode 100644 packages/scheduler_core/lib/src/task_lifecycle/task_activity.dart create mode 100644 packages/scheduler_core/lib/src/task_lifecycle/task_activity_accounting_service.dart create mode 100644 packages/scheduler_core/lib/src/task_lifecycle/task_activity_application_result.dart create mode 100644 packages/scheduler_core/lib/src/task_lifecycle/task_activity_code.dart create mode 100644 packages/scheduler_core/lib/src/task_lifecycle/task_transition_code.dart create mode 100644 packages/scheduler_core/lib/src/task_lifecycle/task_transition_outcome_code.dart create mode 100644 packages/scheduler_core/lib/src/task_lifecycle/task_transition_result.dart create mode 100644 packages/scheduler_core/lib/src/task_lifecycle/task_transition_service.dart create mode 100644 packages/scheduler_core/lib/src/time_contracts/civil_date.dart create mode 100644 packages/scheduler_core/lib/src/time_contracts/clock.dart create mode 100644 packages/scheduler_core/lib/src/time_contracts/fixed_clock.dart create mode 100644 packages/scheduler_core/lib/src/time_contracts/fixed_offset_time_zone_resolver.dart create mode 100644 packages/scheduler_core/lib/src/time_contracts/id_generator.dart create mode 100644 packages/scheduler_core/lib/src/time_contracts/local_time_resolution.dart create mode 100644 packages/scheduler_core/lib/src/time_contracts/nonexistent_local_time_policy.dart create mode 100644 packages/scheduler_core/lib/src/time_contracts/repeated_local_time_policy.dart create mode 100644 packages/scheduler_core/lib/src/time_contracts/resolved_local_date_time.dart create mode 100644 packages/scheduler_core/lib/src/time_contracts/sequential_id_generator.dart create mode 100644 packages/scheduler_core/lib/src/time_contracts/system_clock.dart create mode 100644 packages/scheduler_core/lib/src/time_contracts/time_zone_resolution_options.dart create mode 100644 packages/scheduler_core/lib/src/time_contracts/time_zone_resolver.dart create mode 100644 packages/scheduler_core/lib/src/time_contracts/wall_time.dart create mode 100644 packages/scheduler_core/lib/src/timeline_state/compact_timeline_state.dart create mode 100644 packages/scheduler_core/lib/src/timeline_state/timeline_background_token.dart create mode 100644 packages/scheduler_core/lib/src/timeline_state/timeline_difficulty_icon_token.dart create mode 100644 packages/scheduler_core/lib/src/timeline_state/timeline_item.dart create mode 100644 packages/scheduler_core/lib/src/timeline_state/timeline_item_category.dart create mode 100644 packages/scheduler_core/lib/src/timeline_state/timeline_item_mapper.dart create mode 100644 packages/scheduler_core/lib/src/timeline_state/timeline_quick_action.dart create mode 100644 packages/scheduler_core/lib/src/timeline_state/timeline_reward_icon_token.dart create mode 100644 packages/scheduler_core/lib/src/today_state/get_today_state_query.dart create mode 100644 packages/scheduler_core/lib/src/today_state/get_today_state_request.dart create mode 100644 packages/scheduler_core/lib/src/today_state/today_compact_state.dart create mode 100644 packages/scheduler_core/lib/src/today_state/today_pending_notice.dart create mode 100644 packages/scheduler_core/lib/src/today_state/today_state.dart create mode 100644 packages/scheduler_core/lib/src/today_state/today_timeline_item.dart create mode 100644 packages/scheduler_core/lib/src/today_state/today_timeline_item_source.dart create mode 100644 packages/scheduler_export/lib/src/export_controller/csv_cell_encoder.dart create mode 100644 packages/scheduler_export/lib/src/export_controller/csv_export_writer.dart create mode 100644 packages/scheduler_export/lib/src/export_controller/export_context.dart create mode 100644 packages/scheduler_export/lib/src/export_controller/export_controller.dart create mode 100644 packages/scheduler_export/lib/src/export_controller/export_exception.dart create mode 100644 packages/scheduler_export/lib/src/export_controller/export_format_normalizer.dart create mode 100644 packages/scheduler_export/lib/src/export_controller/export_json_context_encoder.dart create mode 100644 packages/scheduler_export/lib/src/export_controller/export_repository_exception.dart create mode 100644 packages/scheduler_export/lib/src/export_controller/export_writer.dart create mode 100644 packages/scheduler_export/lib/src/export_controller/export_writer_factory.dart create mode 100644 packages/scheduler_export/lib/src/export_controller/export_writer_registry.dart create mode 100644 packages/scheduler_export/lib/src/export_controller/json_export_writer.dart create mode 100644 packages/scheduler_export_json/lib/src/json_csv_writers/csv_cell_encoder.dart create mode 100644 packages/scheduler_export_json/lib/src/json_csv_writers/csv_export_writer.dart create mode 100644 packages/scheduler_export_json/lib/src/json_csv_writers/export_json_context_encoder.dart create mode 100644 packages/scheduler_export_json/lib/src/json_csv_writers/json_export_writer.dart create mode 100644 packages/scheduler_notifications/lib/src/notification_adapter/fake_notification_adapter.dart create mode 100644 packages/scheduler_notifications/lib/src/notification_adapter/notification_adapter.dart create mode 100644 packages/scheduler_notifications/lib/src/notification_adapter/notification_feedback.dart create mode 100644 packages/scheduler_notifications/lib/src/notification_adapter/notification_feedback_type.dart create mode 100644 packages/scheduler_notifications/lib/src/notification_adapter/notification_request.dart create mode 100644 packages/scheduler_notifications/lib/src/notification_adapter/notification_request_impl.dart create mode 100644 packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/apple_script_string_encoder.dart create mode 100644 packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_adapter.dart create mode 100644 packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_backend.dart create mode 100644 packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_backend_factory.dart create mode 100644 packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_log_sink.dart create mode 100644 packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_platform.dart create mode 100644 packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_platform_detector.dart create mode 100644 packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_process_runner.dart create mode 100644 packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/linux_notify_send_backend.dart create mode 100644 packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/mac_os_notification_backend.dart create mode 100644 packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/stdout_notification_backend.dart create mode 100644 packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/default_log_sink.dart create mode 100644 packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/desktop_notification_adapter.dart create mode 100644 packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/desktop_notification_backend.dart create mode 100644 packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/desktop_notification_backend_factory.dart create mode 100644 packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/desktop_notification_log_sink.dart create mode 100644 packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/desktop_notification_platform.dart create mode 100644 packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/stdout_notification_backend.dart create mode 100644 packages/scheduler_persistence/lib/persistence/either.dart create mode 100644 packages/scheduler_persistence/lib/persistence/left.dart create mode 100644 packages/scheduler_persistence/lib/persistence/locked_block_repository.dart create mode 100644 packages/scheduler_persistence/lib/persistence/project_repository.dart create mode 100644 packages/scheduler_persistence/lib/persistence/repo_result.dart create mode 100644 packages/scheduler_persistence/lib/persistence/repository_duplicate_id.dart create mode 100644 packages/scheduler_persistence/lib/persistence/repository_failure.dart create mode 100644 packages/scheduler_persistence/lib/persistence/repository_invalid_revision.dart create mode 100644 packages/scheduler_persistence/lib/persistence/repository_not_found.dart create mode 100644 packages/scheduler_persistence/lib/persistence/repository_owner_mismatch.dart create mode 100644 packages/scheduler_persistence/lib/persistence/repository_stale_revision.dart create mode 100644 packages/scheduler_persistence/lib/persistence/repository_storage_failure.dart create mode 100644 packages/scheduler_persistence/lib/persistence/right.dart create mode 100644 packages/scheduler_persistence/lib/persistence/schedule_snapshot_repository.dart create mode 100644 packages/scheduler_persistence/lib/persistence/settings_repository.dart create mode 100644 packages/scheduler_persistence/lib/persistence/task_repository.dart create mode 100644 packages/scheduler_persistence_memory/lib/persistence_memory/identified_record.dart create mode 100644 packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_locked_block_repository.dart create mode 100644 packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_project_repository.dart create mode 100644 packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_schedule_snapshot_repository.dart create mode 100644 packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_settings_repository.dart create mode 100644 packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_task_repository.dart create mode 100644 packages/scheduler_persistence_memory/lib/persistence_memory/record.dart create mode 100644 packages/scheduler_persistence_memory/lib/persistence_memory/snapshot_record.dart create mode 100644 packages/scheduler_persistence_sqlite/lib/src/scheduler_db/locked_block_dao.dart create mode 100644 packages/scheduler_persistence_sqlite/lib/src/scheduler_db/locked_blocks.dart create mode 100644 packages/scheduler_persistence_sqlite/lib/src/scheduler_db/locked_overrides.dart create mode 100644 packages/scheduler_persistence_sqlite/lib/src/scheduler_db/project_dao.dart create mode 100644 packages/scheduler_persistence_sqlite/lib/src/scheduler_db/projects.dart create mode 100644 packages/scheduler_persistence_sqlite/lib/src/scheduler_db/scheduler_db.dart create mode 100644 packages/scheduler_persistence_sqlite/lib/src/scheduler_db/settings_dao.dart create mode 100644 packages/scheduler_persistence_sqlite/lib/src/scheduler_db/settings_table.dart create mode 100644 packages/scheduler_persistence_sqlite/lib/src/scheduler_db/snapshot_dao.dart create mode 100644 packages/scheduler_persistence_sqlite/lib/src/scheduler_db/snapshots.dart create mode 100644 packages/scheduler_persistence_sqlite/lib/src/scheduler_db/task_dao.dart create mode 100644 packages/scheduler_persistence_sqlite/lib/src/scheduler_db/tasks.dart create mode 100644 packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories/sqlite_locked_block_repository.dart create mode 100644 packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories/sqlite_project_repository.dart create mode 100644 packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories/sqlite_schedule_snapshot_repository.dart create mode 100644 packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories/sqlite_settings_repository.dart create mode 100644 packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories/sqlite_task_repository.dart diff --git a/packages/scheduler_backup/lib/src/encrypted_backup.dart b/packages/scheduler_backup/lib/src/encrypted_backup.dart index 1bee79f..706f997 100644 --- a/packages/scheduler_backup/lib/src/encrypted_backup.dart +++ b/packages/scheduler_backup/lib/src/encrypted_backup.dart @@ -7,6 +7,12 @@ import 'dart:math'; import 'dart:typed_data'; import 'package:cryptography/cryptography.dart'; +part 'encrypted_backup/backup_decryption_exception.dart'; +part 'encrypted_backup/backup_encryption_codec.dart'; +part 'encrypted_backup/backup_exception.dart'; +part 'encrypted_backup/backup_file_names.dart'; +part 'encrypted_backup/backup_paths.dart'; +part 'encrypted_backup/encrypted_backup_operations.dart'; /// Default scheduler data directory name under the user's home directory. const defaultSchedulerDirectoryName = 'ADHD_Scheduler'; @@ -25,241 +31,3 @@ const _nonceLength = 12; const _keyLength = 32; final _utf8 = utf8.encoder; - -/// Thrown when an encrypted backup cannot be decrypted with the passphrase. -final class BackupDecryptionException implements Exception { - /// Creates a decryption failure with optional diagnostic [message]. - const BackupDecryptionException([ - this.message = 'Backup could not be decrypted.', - ]); - - /// Diagnostic text for logs and tests. - final String message; - - @override - String toString() => 'BackupDecryptionException: $message'; -} - -/// Thrown when backup input paths or file contents are invalid. -final class BackupException implements Exception { - /// Creates a backup exception with diagnostic [message]. - const BackupException(this.message); - - /// Diagnostic text for logs and tests. - final String message; - - @override - String toString() => 'BackupException: $message'; -} - -/// Create an encrypted backup of the scheduler SQLite file. -/// -/// Defaults: -/// - Source DB: `~/ADHD_Scheduler/scheduler.sqlite` -/// - Backup dir: `~/ADHD_Scheduler/backups` -/// - File name: `yyyymmdd_hhmm.db.enc` -Future createEncryptedBackup({ - required String passphrase, - File? sqliteFile, - Directory? backupDirectory, - DateTime? now, -}) async { - final source = sqliteFile ?? defaultSchedulerSqliteFile(); - if (!await source.exists()) { - throw BackupException('SQLite file does not exist: ${source.path}'); - } - - final destinationDirectory = - backupDirectory ?? defaultSchedulerBackupDirectory(); - await destinationDirectory.create(recursive: true); - - final timestamp = _backupTimestamp(now ?? DateTime.now()); - final destination = await _nextAvailableBackupFile( - destinationDirectory, - '$timestamp$_fileExtension', - ); - - final plaintext = await source.readAsBytes(); - final encoded = await _encryptBytes( - passphrase: passphrase, - plaintext: plaintext, - ); - return destination.writeAsBytes(encoded, flush: true); -} - -/// Restore an encrypted backup over the scheduler SQLite file. -/// -/// Defaults to restoring into `~/ADHD_Scheduler/scheduler.sqlite`. Tests and -/// tools can pass [targetFile] to restore into a temp or alternate database. -Future restoreEncryptedBackup( - File backup, - String passphrase, { - File? targetFile, -}) async { - if (!await backup.exists()) { - throw BackupException('Backup file does not exist: ${backup.path}'); - } - - final plaintext = await _decryptBytes( - passphrase: passphrase, - encoded: await backup.readAsBytes(), - ); - final target = targetFile ?? defaultSchedulerSqliteFile(); - await target.parent.create(recursive: true); - - final temporary = File('${target.path}.restore.tmp'); - await temporary.writeAsBytes(plaintext, flush: true); - if (await target.exists()) { - await target.delete(); - } - await temporary.rename(target.path); -} - -/// Default scheduler SQLite database path. -File defaultSchedulerSqliteFile() { - return File(_joinPath( - _homeDirectory().path, - defaultSchedulerDirectoryName, - defaultSchedulerDatabaseFileName, - )); -} - -/// Default encrypted backup output directory. -Directory defaultSchedulerBackupDirectory() { - return Directory(_joinPath( - _homeDirectory().path, - defaultSchedulerDirectoryName, - defaultSchedulerBackupDirectoryName, - )); -} - -Future> _encryptBytes({ - required String passphrase, - required List plaintext, -}) async { - final salt = _randomBytes(_saltLength); - final nonce = _randomBytes(_nonceLength); - final key = await _deriveKey(passphrase: passphrase, salt: salt); - final algorithm = AesGcm.with256bits(); - final secretBox = await algorithm.encrypt( - plaintext, - secretKey: key, - nonce: nonce, - ); - final envelope = { - 'magic': _formatMagic, - 'kdf': 'pbkdf2-hmac-sha256', - 'iterations': _kdfIterations, - 'cipher': 'aes-256-gcm', - 'salt': base64Encode(salt), - 'nonce': base64Encode(secretBox.nonce), - 'ciphertext': base64Encode(secretBox.cipherText), - 'mac': base64Encode(secretBox.mac.bytes), - }; - return _utf8.convert(jsonEncode(envelope)); -} - -Future> _decryptBytes({ - required String passphrase, - required List encoded, -}) async { - try { - final envelope = jsonDecode(utf8.decode(encoded)) as Map; - if (envelope['magic'] != _formatMagic || - envelope['kdf'] != 'pbkdf2-hmac-sha256' || - envelope['iterations'] != _kdfIterations || - envelope['cipher'] != 'aes-256-gcm') { - throw const BackupDecryptionException('Unsupported backup format.'); - } - - final salt = base64Decode(envelope['salt'] as String); - final nonce = base64Decode(envelope['nonce'] as String); - final cipherText = base64Decode(envelope['ciphertext'] as String); - final mac = Mac(base64Decode(envelope['mac'] as String)); - final key = await _deriveKey(passphrase: passphrase, salt: salt); - final algorithm = AesGcm.with256bits(); - return await algorithm.decrypt( - SecretBox(cipherText, nonce: nonce, mac: mac), - secretKey: key, - ); - } on BackupDecryptionException { - rethrow; - } on Object { - throw const BackupDecryptionException(); - } -} - -Future _deriveKey({ - required String passphrase, - required List salt, -}) { - final algorithm = Pbkdf2( - macAlgorithm: Hmac.sha256(), - iterations: _kdfIterations, - bits: _keyLength * 8, - ); - return algorithm.deriveKey( - secretKey: SecretKey(utf8.encode(passphrase)), - nonce: salt, - ); -} - -List _randomBytes(int length) { - final random = Random.secure(); - return Uint8List.fromList( - List.generate(length, (_) => random.nextInt(256)), - ); -} - -Future _nextAvailableBackupFile(Directory directory, String name) async { - final preferred = File(_joinPath(directory.path, name)); - if (!await preferred.exists()) { - return preferred; - } - final dot = name.lastIndexOf('.'); - final stem = dot == -1 ? name : name.substring(0, dot); - final extension = dot == -1 ? '' : name.substring(dot); - var counter = 2; - while (true) { - final candidate = - File(_joinPath(directory.path, '$stem-$counter$extension')); - if (!await candidate.exists()) { - return candidate; - } - counter += 1; - } -} - -String _backupTimestamp(DateTime value) { - final local = value.toLocal(); - return '${_four(local.year)}${_two(local.month)}${_two(local.day)}_' - '${_two(local.hour)}${_two(local.minute)}'; -} - -String _four(int value) => value.toString().padLeft(4, '0'); - -String _two(int value) => value.toString().padLeft(2, '0'); - -Directory _homeDirectory() { - final home = Platform.environment['HOME'] ?? - Platform.environment['USERPROFILE'] ?? - Platform.environment['HOMEDRIVE']; - if (home == null || home.trim().isEmpty) { - throw const BackupException('Home directory could not be resolved.'); - } - return Directory(home); -} - -String _joinPath(String first, String second, [String? third]) { - final separator = Platform.pathSeparator; - final left = first.endsWith(separator) - ? first.substring(0, first.length - separator.length) - : first; - final middle = second.startsWith(separator) ? second.substring(1) : second; - final joined = '$left$separator$middle'; - if (third == null) { - return joined; - } - final right = third.startsWith(separator) ? third.substring(1) : third; - return '$joined$separator$right'; -} diff --git a/packages/scheduler_backup/lib/src/encrypted_backup/backup_decryption_exception.dart b/packages/scheduler_backup/lib/src/encrypted_backup/backup_decryption_exception.dart new file mode 100644 index 0000000..31fd2c8 --- /dev/null +++ b/packages/scheduler_backup/lib/src/encrypted_backup/backup_decryption_exception.dart @@ -0,0 +1,15 @@ +part of '../encrypted_backup.dart'; + +/// Thrown when an encrypted backup cannot be decrypted with the passphrase. +final class BackupDecryptionException implements Exception { + /// Creates a decryption failure with optional diagnostic [message]. + const BackupDecryptionException([ + this.message = 'Backup could not be decrypted.', + ]); + + /// Diagnostic text for logs and tests. + final String message; + + @override + String toString() => 'BackupDecryptionException: $message'; +} diff --git a/packages/scheduler_backup/lib/src/encrypted_backup/backup_encryption_codec.dart b/packages/scheduler_backup/lib/src/encrypted_backup/backup_encryption_codec.dart new file mode 100644 index 0000000..32d3c6f --- /dev/null +++ b/packages/scheduler_backup/lib/src/encrypted_backup/backup_encryption_codec.dart @@ -0,0 +1,79 @@ +part of '../encrypted_backup.dart'; + +Future> _encryptBytes({ + required String passphrase, + required List plaintext, +}) async { + final salt = _randomBytes(_saltLength); + final nonce = _randomBytes(_nonceLength); + final key = await _deriveKey(passphrase: passphrase, salt: salt); + final algorithm = AesGcm.with256bits(); + final secretBox = await algorithm.encrypt( + plaintext, + secretKey: key, + nonce: nonce, + ); + final envelope = { + 'magic': _formatMagic, + 'kdf': 'pbkdf2-hmac-sha256', + 'iterations': _kdfIterations, + 'cipher': 'aes-256-gcm', + 'salt': base64Encode(salt), + 'nonce': base64Encode(secretBox.nonce), + 'ciphertext': base64Encode(secretBox.cipherText), + 'mac': base64Encode(secretBox.mac.bytes), + }; + return _utf8.convert(jsonEncode(envelope)); +} + +Future> _decryptBytes({ + required String passphrase, + required List encoded, +}) async { + try { + final envelope = jsonDecode(utf8.decode(encoded)) as Map; + if (envelope['magic'] != _formatMagic || + envelope['kdf'] != 'pbkdf2-hmac-sha256' || + envelope['iterations'] != _kdfIterations || + envelope['cipher'] != 'aes-256-gcm') { + throw const BackupDecryptionException('Unsupported backup format.'); + } + + final salt = base64Decode(envelope['salt'] as String); + final nonce = base64Decode(envelope['nonce'] as String); + final cipherText = base64Decode(envelope['ciphertext'] as String); + final mac = Mac(base64Decode(envelope['mac'] as String)); + final key = await _deriveKey(passphrase: passphrase, salt: salt); + final algorithm = AesGcm.with256bits(); + return await algorithm.decrypt( + SecretBox(cipherText, nonce: nonce, mac: mac), + secretKey: key, + ); + } on BackupDecryptionException { + rethrow; + } on Object { + throw const BackupDecryptionException(); + } +} + +Future _deriveKey({ + required String passphrase, + required List salt, +}) { + final algorithm = Pbkdf2( + macAlgorithm: Hmac.sha256(), + iterations: _kdfIterations, + bits: _keyLength * 8, + ); + return algorithm.deriveKey( + secretKey: SecretKey(utf8.encode(passphrase)), + nonce: salt, + ); +} + +List _randomBytes(int length) { + final random = Random.secure(); + return Uint8List.fromList( + List.generate(length, (_) => random.nextInt(256)), + ); +} diff --git a/packages/scheduler_backup/lib/src/encrypted_backup/backup_exception.dart b/packages/scheduler_backup/lib/src/encrypted_backup/backup_exception.dart new file mode 100644 index 0000000..1a8b466 --- /dev/null +++ b/packages/scheduler_backup/lib/src/encrypted_backup/backup_exception.dart @@ -0,0 +1,13 @@ +part of '../encrypted_backup.dart'; + +/// Thrown when backup input paths or file contents are invalid. +final class BackupException implements Exception { + /// Creates a backup exception with diagnostic [message]. + const BackupException(this.message); + + /// Diagnostic text for logs and tests. + final String message; + + @override + String toString() => 'BackupException: $message'; +} diff --git a/packages/scheduler_backup/lib/src/encrypted_backup/backup_file_names.dart b/packages/scheduler_backup/lib/src/encrypted_backup/backup_file_names.dart new file mode 100644 index 0000000..4a010ae --- /dev/null +++ b/packages/scheduler_backup/lib/src/encrypted_backup/backup_file_names.dart @@ -0,0 +1,30 @@ +part of '../encrypted_backup.dart'; + +Future _nextAvailableBackupFile(Directory directory, String name) async { + final preferred = File(_joinPath(directory.path, name)); + if (!await preferred.exists()) { + return preferred; + } + final dot = name.lastIndexOf('.'); + final stem = dot == -1 ? name : name.substring(0, dot); + final extension = dot == -1 ? '' : name.substring(dot); + var counter = 2; + while (true) { + final candidate = + File(_joinPath(directory.path, '$stem-$counter$extension')); + if (!await candidate.exists()) { + return candidate; + } + counter += 1; + } +} + +String _backupTimestamp(DateTime value) { + final local = value.toLocal(); + return '${_four(local.year)}${_two(local.month)}${_two(local.day)}_' + '${_two(local.hour)}${_two(local.minute)}'; +} + +String _four(int value) => value.toString().padLeft(4, '0'); + +String _two(int value) => value.toString().padLeft(2, '0'); diff --git a/packages/scheduler_backup/lib/src/encrypted_backup/backup_paths.dart b/packages/scheduler_backup/lib/src/encrypted_backup/backup_paths.dart new file mode 100644 index 0000000..7890550 --- /dev/null +++ b/packages/scheduler_backup/lib/src/encrypted_backup/backup_paths.dart @@ -0,0 +1,43 @@ +part of '../encrypted_backup.dart'; + +/// Default scheduler SQLite database path. +File defaultSchedulerSqliteFile() { + return File(_joinPath( + _homeDirectory().path, + defaultSchedulerDirectoryName, + defaultSchedulerDatabaseFileName, + )); +} + +/// Default encrypted backup output directory. +Directory defaultSchedulerBackupDirectory() { + return Directory(_joinPath( + _homeDirectory().path, + defaultSchedulerDirectoryName, + defaultSchedulerBackupDirectoryName, + )); +} + +Directory _homeDirectory() { + final home = Platform.environment['HOME'] ?? + Platform.environment['USERPROFILE'] ?? + Platform.environment['HOMEDRIVE']; + if (home == null || home.trim().isEmpty) { + throw const BackupException('Home directory could not be resolved.'); + } + return Directory(home); +} + +String _joinPath(String first, String second, [String? third]) { + final separator = Platform.pathSeparator; + final left = first.endsWith(separator) + ? first.substring(0, first.length - separator.length) + : first; + final middle = second.startsWith(separator) ? second.substring(1) : second; + final joined = '$left$separator$middle'; + if (third == null) { + return joined; + } + final right = third.startsWith(separator) ? third.substring(1) : third; + return '$joined$separator$right'; +} diff --git a/packages/scheduler_backup/lib/src/encrypted_backup/encrypted_backup_operations.dart b/packages/scheduler_backup/lib/src/encrypted_backup/encrypted_backup_operations.dart new file mode 100644 index 0000000..b5bb58e --- /dev/null +++ b/packages/scheduler_backup/lib/src/encrypted_backup/encrypted_backup_operations.dart @@ -0,0 +1,64 @@ +part of '../encrypted_backup.dart'; + +/// Create an encrypted backup of the scheduler SQLite file. +/// +/// Defaults: +/// - Source DB: `~/ADHD_Scheduler/scheduler.sqlite` +/// - Backup dir: `~/ADHD_Scheduler/backups` +/// - File name: `yyyymmdd_hhmm.db.enc` +Future createEncryptedBackup({ + required String passphrase, + File? sqliteFile, + Directory? backupDirectory, + DateTime? now, +}) async { + final source = sqliteFile ?? defaultSchedulerSqliteFile(); + if (!await source.exists()) { + throw BackupException('SQLite file does not exist: ${source.path}'); + } + + final destinationDirectory = + backupDirectory ?? defaultSchedulerBackupDirectory(); + await destinationDirectory.create(recursive: true); + + final timestamp = _backupTimestamp(now ?? DateTime.now()); + final destination = await _nextAvailableBackupFile( + destinationDirectory, + '$timestamp$_fileExtension', + ); + + final plaintext = await source.readAsBytes(); + final encoded = await _encryptBytes( + passphrase: passphrase, + plaintext: plaintext, + ); + return destination.writeAsBytes(encoded, flush: true); +} + +/// Restore an encrypted backup over the scheduler SQLite file. +/// +/// Defaults to restoring into `~/ADHD_Scheduler/scheduler.sqlite`. Tests and +/// tools can pass [targetFile] to restore into a temp or alternate database. +Future restoreEncryptedBackup( + File backup, + String passphrase, { + File? targetFile, +}) async { + if (!await backup.exists()) { + throw BackupException('Backup file does not exist: ${backup.path}'); + } + + final plaintext = await _decryptBytes( + passphrase: passphrase, + encoded: await backup.readAsBytes(), + ); + final target = targetFile ?? defaultSchedulerSqliteFile(); + await target.parent.create(recursive: true); + + final temporary = File('${target.path}.restore.tmp'); + await temporary.writeAsBytes(plaintext, flush: true); + if (await target.exists()) { + await target.delete(); + } + await temporary.rename(target.path); +} diff --git a/packages/scheduler_core/lib/src/application_commands.dart b/packages/scheduler_core/lib/src/application_commands.dart index cf39363..f731a88 100644 --- a/packages/scheduler_core/lib/src/application_commands.dart +++ b/packages/scheduler_core/lib/src/application_commands.dart @@ -18,1486 +18,9 @@ import 'scheduling_engine.dart'; import 'task_actions.dart'; import 'task_lifecycle.dart'; import 'time_contracts.dart'; - -/// Stable V1 application command identifiers. -enum ApplicationCommandCode { - quickCaptureToBacklog, - quickCaptureToNextAvailableSlot, - scheduleBacklogItemToNextAvailableSlot, - pushFlexibleToNextAvailableSlot, - pushFlexibleToTomorrowTopOfQueue, - moveFlexibleToBacklog, - completeFlexibleTask, - uncompleteTask, - applyRequiredTaskAction, - logSurpriseTask, - createProtectedFreeSlot, - updateProtectedFreeSlot, - removeProtectedFreeSlot, - breakUpTask, - completeChildTask, - completeParentTask, - completeParentFromChild, -} - -/// Draft row for creating one child task from an application command. -class ApplicationChildTaskDraft { - const ApplicationChildTaskDraft({ - required this.title, - this.priority, - this.reward = RewardLevel.notSet, - this.difficulty = DifficultyLevel.notSet, - this.durationMinutes, - this.projectId, - }); - - /// User-entered child title. - final String title; - - /// Optional child priority. - final PriorityLevel? priority; - - /// Optional reward estimate. - final RewardLevel reward; - - /// Optional difficulty estimate. - final DifficultyLevel difficulty; - - /// Optional duration estimate. - final int? durationMinutes; - - /// Optional project override. Null inherits from the parent. - final String? projectId; -} - -/// Hint for read models that should be refreshed after a command. -class ApplicationCommandReadHint { - ApplicationCommandReadHint({ - CivilDate? localDate, - Iterable affectedDates = const [], - Iterable affectedTaskIds = const [], - this.refreshToday = true, - }) : localDate = localDate, - affectedDates = List.unmodifiable( - { - if (localDate != null) localDate, - ...affectedDates, - }, - ), - affectedTaskIds = List.unmodifiable(affectedTaskIds); - - /// Primary local date for a Today refresh, when known. - final CivilDate? localDate; - - /// All local dates touched by the command. - final List affectedDates; - - /// Task ids changed or created by the command. - final List affectedTaskIds; - - /// Whether Today-style read models should be refreshed. - final bool refreshToday; -} - -/// Structured result returned by V1 application commands. -class ApplicationCommandResult { - ApplicationCommandResult({ - required this.commandCode, - required String operationId, - required List changedTasks, - required this.readHint, - List activities = const [], - List projectStatistics = const [], - List notices = const [], - List changes = const [], - List overlaps = const [], - List protectedFreeSlotConflicts = - const [], - List childTaskIds = const [], - this.schedulingResult, - }) : operationId = _requiredTrimmed(operationId, 'operationId'), - changedTasks = List.unmodifiable(changedTasks), - activities = List.unmodifiable(activities), - projectStatistics = List.unmodifiable( - projectStatistics, - ), - notices = List.unmodifiable(notices), - changes = List.unmodifiable(changes), - overlaps = List.unmodifiable(overlaps), - protectedFreeSlotConflicts = - List.unmodifiable( - protectedFreeSlotConflicts, - ), - childTaskIds = List.unmodifiable(childTaskIds); - - /// Command that produced this result. - final ApplicationCommandCode commandCode; - - /// Exactly-once operation id. - final String operationId; - - /// Tasks inserted or changed by the command. - final List changedTasks; - - /// New internal activities persisted with the command. - final List activities; - - /// Project aggregates updated by completion activities. - final List projectStatistics; - - /// Scheduling notices returned by domain services. - final List notices; - - /// Scheduling movement records returned by domain services. - final List changes; - - /// Scheduling overlaps returned by domain services. - final List overlaps; - - /// Protected rest conflicts caused by an explicit required placement. - final List protectedFreeSlotConflicts; - - /// Child task ids created or affected by child-task commands. - final List childTaskIds; - - /// Full scheduling result when the command invoked the scheduler. - final SchedulingResult? schedulingResult; - - /// Read-model refresh hint for UI/state management. - final ApplicationCommandReadHint readHint; -} - -/// UI-facing V1 command use cases. -class V1ApplicationCommandUseCases { - const V1ApplicationCommandUseCases({ - required this.applicationStore, - required this.timeZoneResolver, - this.resolutionOptions = const TimeZoneResolutionOptions(), - this.quickCaptureService = const QuickCaptureService(), - this.flexibleTaskActionService = const FlexibleTaskActionService(), - this.requiredTaskActionService = const RequiredTaskActionService(), - this.surpriseTaskLogService = const SurpriseTaskLogService(), - this.freeSlotService = const FreeSlotService(), - this.childTaskBreakUpService = const ChildTaskBreakUpService(), - this.childTaskCompletionService = const ChildTaskCompletionService(), - this.projectStatisticsAggregationService = - const ProjectStatisticsAggregationService(), - }); - - /// Atomic repository boundary. - final ApplicationUnitOfWork applicationStore; - - /// Explicit local-time resolver for planning windows and locked expansion. - final TimeZoneResolver timeZoneResolver; - - /// DST/nonexistent/repeated local time policy. - final TimeZoneResolutionOptions resolutionOptions; - - final QuickCaptureService quickCaptureService; - final FlexibleTaskActionService flexibleTaskActionService; - final RequiredTaskActionService requiredTaskActionService; - final SurpriseTaskLogService surpriseTaskLogService; - final FreeSlotService freeSlotService; - final ChildTaskBreakUpService childTaskBreakUpService; - final ChildTaskCompletionService childTaskCompletionService; - final ProjectStatisticsAggregationService projectStatisticsAggregationService; - - /// Quick capture a task into backlog. - Future> quickCaptureToBacklog({ - required ApplicationOperationContext context, - required String title, - String projectId = 'inbox', - PriorityLevel priority = PriorityLevel.medium, - RewardLevel reward = RewardLevel.notSet, - DifficultyLevel difficulty = DifficultyLevel.notSet, - TaskType type = TaskType.flexible, - int? durationMinutes, - Set backlogTags = const {}, - }) { - const commandCode = ApplicationCommandCode.quickCaptureToBacklog; - return applicationStore.run( - context: context, - operationName: commandCode.name, - action: (repositories) async { - final result = quickCaptureService.capture( - QuickCaptureRequest( - id: context.idGenerator.nextId(), - title: title, - createdAt: context.now, - addToNextAvailableSlot: false, - projectId: projectId, - priority: priority, - reward: reward, - difficulty: difficulty, - type: type, - durationMinutes: durationMinutes, - backlogTags: backlogTags, - ), - updatedAt: context.now, - ); - if (!result.isValid) { - return _failure( - ApplicationFailureCode.validation, - detailCode: QuickCaptureStatus.validationError.name, - ); - } - - return _persist( - repositories: repositories, - commandCode: commandCode, - context: context, - beforeTasks: const [], - afterTasks: [result.task], - activities: const [], - readHint: ApplicationCommandReadHint( - affectedTaskIds: [result.task.id], - refreshToday: false, - ), - ); - }, - ); - } - - /// Quick capture a flexible task and schedule it into the next available slot. - Future> - quickCaptureToNextAvailableSlot({ - required ApplicationOperationContext context, - required CivilDate localDate, - required String title, - required int durationMinutes, - String projectId = 'inbox', - PriorityLevel priority = PriorityLevel.medium, - RewardLevel reward = RewardLevel.notSet, - DifficultyLevel difficulty = DifficultyLevel.notSet, - }) { - const commandCode = ApplicationCommandCode.quickCaptureToNextAvailableSlot; - return applicationStore.run( - context: context, - operationName: commandCode.name, - action: (repositories) async { - final state = - await _loadPlanningState(repositories, context, localDate); - final taskId = context.idGenerator.nextId(); - final result = quickCaptureService.capture( - QuickCaptureRequest( - id: taskId, - title: title, - createdAt: context.now, - addToNextAvailableSlot: true, - projectId: projectId, - priority: priority, - reward: reward, - difficulty: difficulty, - durationMinutes: durationMinutes, - ), - schedulingInput: state.input, - updatedAt: context.now, - ); - if (!result.isValid) { - return _failureForQuickCapture(result); - } - - final activities = _activitiesForSchedulingChanges( - result: result.schedulingResult, - beforeTasks: [result.task, ...state.tasks], - operationId: context.operationId, - occurredAt: context.now, - selectedTaskId: result.task.id, - selectedActivityCode: TaskActivityCode.restoredFromBacklog, - movedActivityCode: TaskActivityCode.automaticallyPushed, - ); - return _persist( - repositories: repositories, - commandCode: commandCode, - context: context, - beforeTasks: state.tasks, - afterTasks: result.schedulingResult?.tasks ?? [result.task], - activities: activities, - schedulingResult: result.schedulingResult, - readHint: ApplicationCommandReadHint( - localDate: localDate, - affectedTaskIds: _taskIdsFromTasks([result.task]), - ), - ); - }, - ); - } - - /// Schedule an existing backlog item into the next available flexible slot. - Future> - scheduleBacklogItemToNextAvailableSlot({ - required ApplicationOperationContext context, - required CivilDate localDate, - required String taskId, - int? durationMinutes, - DateTime? expectedUpdatedAt, - }) { - const commandCode = - ApplicationCommandCode.scheduleBacklogItemToNextAvailableSlot; - return applicationStore.run( - context: context, - operationName: commandCode.name, - action: (repositories) async { - var state = await _loadPlanningState(repositories, context, localDate); - var task = _taskById(state.tasks, taskId); - if (task == null) { - task = await repositories.tasks.findById(taskId); - if (task != null) { - state = state.withTask(task); - } - } - if (task == null) { - return _failure(ApplicationFailureCode.notFound, entityId: taskId); - } - final staleFailure = _staleFailure(task, expectedUpdatedAt); - if (staleFailure != null) { - return ApplicationResult.failure(staleFailure); - } - if (durationMinutes != null && durationMinutes <= 0) { - return _failure( - ApplicationFailureCode.validation, - entityId: taskId, - detailCode: DomainValidationCode.nonPositiveDuration.name, - ); - } - if (durationMinutes != null) { - task = task.copyWith( - durationMinutes: durationMinutes, - updatedAt: context.now, - ); - state = state.replaceTask(task); - } - - final schedulingResult = - const SchedulingEngine().insertBacklogTaskIntoNextAvailableSlot( - input: state.input, - taskId: taskId, - updatedAt: context.now, - ); - final schedulingFailure = _failureForSchedulingResult( - schedulingResult, - entityId: taskId, - ); - if (schedulingFailure != null) { - return ApplicationResult.failure(schedulingFailure); - } - - final activities = _activitiesForSchedulingChanges( - result: schedulingResult, - beforeTasks: state.tasks, - operationId: context.operationId, - occurredAt: context.now, - selectedTaskId: taskId, - selectedActivityCode: TaskActivityCode.restoredFromBacklog, - movedActivityCode: TaskActivityCode.automaticallyPushed, - ); - return _persist( - repositories: repositories, - commandCode: commandCode, - context: context, - beforeTasks: state.tasks, - afterTasks: schedulingResult.tasks, - activities: activities, - schedulingResult: schedulingResult, - readHint: ApplicationCommandReadHint( - localDate: localDate, - affectedTaskIds: _taskIdsFromChanges(schedulingResult.changes), - ), - ); - }, - ); - } - - /// Push a planned flexible task later in the same local day. - Future> - pushFlexibleToNextAvailableSlot({ - required ApplicationOperationContext context, - required CivilDate localDate, - required String taskId, - DateTime? expectedUpdatedAt, - }) { - return _pushFlexible( - commandCode: ApplicationCommandCode.pushFlexibleToNextAvailableSlot, - context: context, - localDate: localDate, - taskId: taskId, - destination: PushDestination.nextAvailableSlot, - expectedUpdatedAt: expectedUpdatedAt, - ); - } - - /// Push a planned flexible task to the top of a future/tomorrow queue. - Future> - pushFlexibleToTomorrowTopOfQueue({ - required ApplicationOperationContext context, - required CivilDate targetDate, - required String taskId, - DateTime? expectedUpdatedAt, - }) { - return _pushFlexible( - commandCode: ApplicationCommandCode.pushFlexibleToTomorrowTopOfQueue, - context: context, - localDate: targetDate, - taskId: taskId, - destination: PushDestination.tomorrowTopOfQueue, - expectedUpdatedAt: expectedUpdatedAt, - ); - } - - /// Move a planned flexible task to backlog. - Future> moveFlexibleToBacklog({ - required ApplicationOperationContext context, - required String taskId, - DateTime? expectedUpdatedAt, - }) { - const commandCode = ApplicationCommandCode.moveFlexibleToBacklog; - return applicationStore.run( - context: context, - operationName: commandCode.name, - action: (repositories) async { - final task = await repositories.tasks.findById(taskId); - if (task == null) { - return _failure(ApplicationFailureCode.notFound, entityId: taskId); - } - final staleFailure = _staleFailure(task, expectedUpdatedAt); - if (staleFailure != null) { - return ApplicationResult.failure(staleFailure); - } - final existingActivities = await repositories.taskActivities.findAll(); - final actionResult = flexibleTaskActionService.apply( - task: task, - action: FlexibleTaskQuickAction.backlog, - updatedAt: context.now, - operationId: context.operationId, - existingActivities: existingActivities, - ); - if (actionResult.transitionOutcomeCode == - TaskTransitionOutcomeCode.invalidState) { - return _failure( - ApplicationFailureCode.conflict, - entityId: taskId, - detailCode: TaskTransitionOutcomeCode.invalidState.name, - ); - } - return _persist( - repositories: repositories, - commandCode: commandCode, - context: context, - beforeTasks: [task], - afterTasks: [actionResult.task], - activities: actionResult.activities, - readHint: ApplicationCommandReadHint( - affectedTaskIds: [taskId], - refreshToday: true, - ), - ); - }, - ); - } - - /// Complete a flexible task. - Future> completeFlexibleTask({ - required ApplicationOperationContext context, - required CivilDate localDate, - required String taskId, - DateTime? expectedUpdatedAt, - DateTime? actualStart, - DateTime? actualEnd, - }) { - const commandCode = ApplicationCommandCode.completeFlexibleTask; - return applicationStore.run( - context: context, - operationName: commandCode.name, - action: (repositories) async { - var state = await _loadPlanningState(repositories, context, localDate); - var task = _taskById(state.tasks, taskId); - if (task == null) { - task = await repositories.tasks.findById(taskId); - if (task != null) { - state = state.withTask(task); - } - } - if (task == null) { - return _failure(ApplicationFailureCode.notFound, entityId: taskId); - } - final staleFailure = _staleFailure(task, expectedUpdatedAt); - if (staleFailure != null) { - return ApplicationResult.failure(staleFailure); - } - final existingActivities = await repositories.taskActivities.findAll(); - final actionResult = flexibleTaskActionService.apply( - task: task, - action: FlexibleTaskQuickAction.done, - updatedAt: context.now, - operationId: context.operationId, - actualStart: actualStart, - actualEnd: actualEnd, - lockedIntervals: state.lockedIntervals, - existingActivities: existingActivities, - ); - if (actionResult.transitionOutcomeCode == - TaskTransitionOutcomeCode.invalidState) { - return _failure( - ApplicationFailureCode.conflict, - entityId: taskId, - detailCode: TaskTransitionOutcomeCode.invalidState.name, - ); - } - return _persist( - repositories: repositories, - commandCode: commandCode, - context: context, - beforeTasks: state.tasks, - afterTasks: _replaceTask(state.tasks, actionResult.task), - activities: actionResult.activities, - readHint: ApplicationCommandReadHint( - localDate: localDate, - affectedTaskIds: [taskId], - ), - ); - }, - ); - } - - /// Mark a completed scheduled task as still needing to be done. - Future> uncompleteTask({ - required ApplicationOperationContext context, - required CivilDate localDate, - required String taskId, - DateTime? expectedUpdatedAt, - }) { - const commandCode = ApplicationCommandCode.uncompleteTask; - return applicationStore.run( - context: context, - operationName: commandCode.name, - action: (repositories) async { - var state = await _loadPlanningState(repositories, context, localDate); - var task = _taskById(state.tasks, taskId); - if (task == null) { - task = await repositories.tasks.findById(taskId); - if (task != null) { - state = state.withTask(task); - } - } - if (task == null) { - return _failure(ApplicationFailureCode.notFound, entityId: taskId); - } - final staleFailure = _staleFailure(task, expectedUpdatedAt); - if (staleFailure != null) { - return ApplicationResult.failure(staleFailure); - } - if (task.status != TaskStatus.completed || - (!task.isFlexible && !task.isRequiredVisible)) { - return _failure( - ApplicationFailureCode.conflict, - entityId: taskId, - detailCode: TaskTransitionOutcomeCode.invalidState.name, - ); - } - final uncompleted = task.copyWith( - status: TaskStatus.planned, - updatedAt: context.now, - clearActualInterval: true, - clearCompletion: true, - ); - return _persist( - repositories: repositories, - commandCode: commandCode, - context: context, - beforeTasks: state.tasks, - afterTasks: _replaceTask(state.tasks, uncompleted), - activities: const [], - readHint: ApplicationCommandReadHint( - localDate: localDate, - affectedTaskIds: [taskId], - ), - ); - }, - ); - } - - /// Apply a required visible task lifecycle action. - Future> applyRequiredTaskAction({ - required ApplicationOperationContext context, - required CivilDate localDate, - required String taskId, - required RequiredTaskAction action, - DateTime? expectedUpdatedAt, - DateTime? actualStart, - DateTime? actualEnd, - }) { - const commandCode = ApplicationCommandCode.applyRequiredTaskAction; - return applicationStore.run( - context: context, - operationName: commandCode.name, - action: (repositories) async { - var state = await _loadPlanningState(repositories, context, localDate); - var task = _taskById(state.tasks, taskId); - if (task == null) { - task = await repositories.tasks.findById(taskId); - if (task != null) { - state = state.withTask(task); - } - } - if (task == null) { - return _failure(ApplicationFailureCode.notFound, entityId: taskId); - } - final staleFailure = _staleFailure(task, expectedUpdatedAt); - if (staleFailure != null) { - return ApplicationResult.failure(staleFailure); - } - final existingActivities = await repositories.taskActivities.findAll(); - final actionResult = requiredTaskActionService.apply( - task: task, - action: action, - updatedAt: context.now, - operationId: context.operationId, - actualStart: actualStart, - actualEnd: actualEnd, - lockedIntervals: state.lockedIntervals, - existingActivities: existingActivities, - ); - if (actionResult.transitionOutcomeCode == - TaskTransitionOutcomeCode.invalidState) { - return _failure( - ApplicationFailureCode.conflict, - entityId: taskId, - detailCode: TaskTransitionOutcomeCode.invalidState.name, - ); - } - return _persist( - repositories: repositories, - commandCode: commandCode, - context: context, - beforeTasks: state.tasks, - afterTasks: _replaceTask(state.tasks, actionResult.task), - activities: actionResult.activities, - readHint: ApplicationCommandReadHint( - localDate: localDate, - affectedTaskIds: [taskId], - ), - ); - }, - ); - } - - /// Log completed surprise work and repair overlapping flexible work. - Future> logSurpriseTask({ - required ApplicationOperationContext context, - required CivilDate localDate, - required String title, - DateTime? startedAt, - int? timeUsedMinutes, - String projectId = 'inbox', - PriorityLevel? priority, - RewardLevel reward = RewardLevel.notSet, - DifficultyLevel difficulty = DifficultyLevel.notSet, - bool revealHiddenLockedDetails = false, - }) { - const commandCode = ApplicationCommandCode.logSurpriseTask; - return applicationStore.run( - context: context, - operationName: commandCode.name, - action: (repositories) async { - final state = - await _loadPlanningState(repositories, context, localDate); - final logResult = surpriseTaskLogService.log( - request: SurpriseTaskLogRequest( - id: context.operationId, - title: title, - createdAt: context.now, - startedAt: startedAt, - timeUsedMinutes: timeUsedMinutes, - projectId: projectId, - priority: priority, - reward: reward, - difficulty: difficulty, - ), - input: state.input, - updatedAt: context.now, - revealHiddenLockedDetails: revealHiddenLockedDetails, - ); - return _persist( - repositories: repositories, - commandCode: commandCode, - context: context, - beforeTasks: state.tasks, - afterTasks: logResult.schedulingResult.tasks, - activities: logResult.activities, - schedulingResult: logResult.schedulingResult, - protectedFreeSlotConflicts: const [], - readHint: ApplicationCommandReadHint( - localDate: localDate, - affectedTaskIds: _changedTaskIds( - state.tasks, - logResult.schedulingResult.tasks, - ), - ), - ); - }, - ); - } - - /// Create a protected Free Slot task. - Future> createProtectedFreeSlot({ - required ApplicationOperationContext context, - required CivilDate localDate, - required String title, - required DateTime start, - required DateTime end, - String projectId = 'inbox', - }) { - const commandCode = ApplicationCommandCode.createProtectedFreeSlot; - return applicationStore.run( - context: context, - operationName: commandCode.name, - action: (repositories) async { - final freeSlot = freeSlotService.create( - id: context.idGenerator.nextId(), - title: title, - start: start, - end: end, - createdAt: context.now, - updatedAt: context.now, - projectId: projectId, - ); - return _persist( - repositories: repositories, - commandCode: commandCode, - context: context, - beforeTasks: const [], - afterTasks: [freeSlot], - activities: const [], - readHint: ApplicationCommandReadHint( - localDate: localDate, - affectedTaskIds: [freeSlot.id], - ), - ); - }, - ); - } - - /// Update a protected Free Slot task. - Future> updateProtectedFreeSlot({ - required ApplicationOperationContext context, - required CivilDate localDate, - required String taskId, - required DateTime start, - required DateTime end, - DateTime? expectedUpdatedAt, - String? title, - String? projectId, - }) { - const commandCode = ApplicationCommandCode.updateProtectedFreeSlot; - return applicationStore.run( - context: context, - operationName: commandCode.name, - action: (repositories) async { - final task = await repositories.tasks.findById(taskId); - if (task == null) { - return _failure(ApplicationFailureCode.notFound, entityId: taskId); - } - final staleFailure = _staleFailure(task, expectedUpdatedAt); - if (staleFailure != null) { - return ApplicationResult.failure(staleFailure); - } - final updated = freeSlotService.update( - freeSlot: task, - start: start, - end: end, - updatedAt: context.now, - title: title, - projectId: projectId, - ); - return _persist( - repositories: repositories, - commandCode: commandCode, - context: context, - beforeTasks: [task], - afterTasks: [updated], - activities: const [], - readHint: ApplicationCommandReadHint( - localDate: localDate, - affectedTaskIds: [taskId], - ), - ); - }, - ); - } - - /// Remove a protected Free Slot from the active timeline. - Future> removeProtectedFreeSlot({ - required ApplicationOperationContext context, - required CivilDate localDate, - required String taskId, - DateTime? expectedUpdatedAt, - }) { - const commandCode = ApplicationCommandCode.removeProtectedFreeSlot; - return applicationStore.run( - context: context, - operationName: commandCode.name, - action: (repositories) async { - final task = await repositories.tasks.findById(taskId); - if (task == null) { - return _failure(ApplicationFailureCode.notFound, entityId: taskId); - } - if (task.type != TaskType.freeSlot) { - return _failure( - ApplicationFailureCode.validation, - entityId: taskId, - detailCode: 'invalidFreeSlotType', - ); - } - final staleFailure = _staleFailure(task, expectedUpdatedAt); - if (staleFailure != null) { - return ApplicationResult.failure(staleFailure); - } - final removed = task.copyWith( - status: TaskStatus.cancelled, - updatedAt: context.now, - clearSchedule: true, - ); - return _persist( - repositories: repositories, - commandCode: commandCode, - context: context, - beforeTasks: [task], - afterTasks: [removed], - activities: const [], - readHint: ApplicationCommandReadHint( - localDate: localDate, - affectedTaskIds: [taskId], - ), - ); - }, - ); - } - - /// Break a parent task into direct child tasks. - Future> breakUpTask({ - required ApplicationOperationContext context, - required String parentTaskId, - required List children, - DateTime? expectedUpdatedAt, - }) { - const commandCode = ApplicationCommandCode.breakUpTask; - return applicationStore.run( - context: context, - operationName: commandCode.name, - action: (repositories) async { - final tasks = await repositories.tasks.findAll(); - final parent = _taskById(tasks, parentTaskId); - if (parent == null) { - return _failure( - ApplicationFailureCode.notFound, - entityId: parentTaskId, - ); - } - final staleFailure = _staleFailure(parent, expectedUpdatedAt); - if (staleFailure != null) { - return ApplicationResult.failure(staleFailure); - } - final entries = children.map((draft) { - return ChildTaskEntry( - id: context.idGenerator.nextId(), - title: draft.title, - createdAt: context.now, - priority: draft.priority, - reward: draft.reward, - difficulty: draft.difficulty, - durationMinutes: draft.durationMinutes, - projectId: draft.projectId, - updatedAt: context.now, - ); - }).toList(growable: false); - final breakUpResult = childTaskBreakUpService.breakUp( - tasks: tasks, - request: ChildTaskBreakUpRequest( - parentTaskId: parentTaskId, - entries: entries, - operationId: context.operationId, - ), - ); - return _persist( - repositories: repositories, - commandCode: commandCode, - context: context, - beforeTasks: tasks, - afterTasks: breakUpResult.tasks, - activities: breakUpResult.activities, - childTaskIds: breakUpResult.createdChildTaskIds, - readHint: ApplicationCommandReadHint( - affectedTaskIds: [ - parentTaskId, - ...breakUpResult.createdChildTaskIds - ], - refreshToday: false, - ), - ); - }, - ); - } - - /// Complete one child task and auto-complete its parent if appropriate. - Future> completeChildTask({ - required ApplicationOperationContext context, - required String childTaskId, - DateTime? expectedUpdatedAt, - }) { - const commandCode = ApplicationCommandCode.completeChildTask; - return _completeChildOrParent( - commandCode: commandCode, - context: context, - taskId: childTaskId, - expectedUpdatedAt: expectedUpdatedAt, - apply: (tasks, existingActivities) => - childTaskCompletionService.completeChild( - tasks: tasks, - childTaskId: childTaskId, - updatedAt: context.now, - operationId: context.operationId, - existingActivities: existingActivities, - ), - ); - } - - /// Complete a parent and force-complete unfinished direct children. - Future> completeParentTask({ - required ApplicationOperationContext context, - required String parentTaskId, - DateTime? expectedUpdatedAt, - }) { - const commandCode = ApplicationCommandCode.completeParentTask; - return _completeChildOrParent( - commandCode: commandCode, - context: context, - taskId: parentTaskId, - expectedUpdatedAt: expectedUpdatedAt, - apply: (tasks, existingActivities) => - childTaskCompletionService.completeParent( - tasks: tasks, - parentTaskId: parentTaskId, - updatedAt: context.now, - operationId: context.operationId, - existingActivities: existingActivities, - ), - ); - } - - /// Complete a parent from one of its direct children. - Future> completeParentFromChild({ - required ApplicationOperationContext context, - required String childTaskId, - DateTime? expectedUpdatedAt, - }) { - const commandCode = ApplicationCommandCode.completeParentFromChild; - return _completeChildOrParent( - commandCode: commandCode, - context: context, - taskId: childTaskId, - expectedUpdatedAt: expectedUpdatedAt, - apply: (tasks, existingActivities) => - childTaskCompletionService.completeParentFromChild( - tasks: tasks, - childTaskId: childTaskId, - updatedAt: context.now, - operationId: context.operationId, - existingActivities: existingActivities, - ), - ); - } - - Future> _pushFlexible({ - required ApplicationCommandCode commandCode, - required ApplicationOperationContext context, - required CivilDate localDate, - required String taskId, - required PushDestination destination, - DateTime? expectedUpdatedAt, - }) { - return applicationStore.run( - context: context, - operationName: commandCode.name, - action: (repositories) async { - var state = await _loadPlanningState(repositories, context, localDate); - var task = _taskById(state.tasks, taskId); - if (task == null) { - task = await repositories.tasks.findById(taskId); - if (task != null) { - state = state.withTask(task); - } - } - if (task == null) { - return _failure(ApplicationFailureCode.notFound, entityId: taskId); - } - final staleFailure = _staleFailure(task, expectedUpdatedAt); - if (staleFailure != null) { - return ApplicationResult.failure(staleFailure); - } - final existingActivities = await repositories.taskActivities.findAll(); - final pushResult = flexibleTaskActionService.applyPushDestination( - destination: destination, - input: state.input, - taskId: taskId, - updatedAt: context.now, - operationId: context.operationId, - existingActivities: existingActivities, - ); - final schedulingFailure = _failureForSchedulingResult( - pushResult.schedulingResult, - entityId: taskId, - ); - if (schedulingFailure != null) { - return ApplicationResult.failure(schedulingFailure); - } - return _persist( - repositories: repositories, - commandCode: commandCode, - context: context, - beforeTasks: state.tasks, - afterTasks: pushResult.schedulingResult.tasks, - activities: pushResult.activities, - schedulingResult: pushResult.schedulingResult, - readHint: ApplicationCommandReadHint( - localDate: localDate, - affectedTaskIds: - _taskIdsFromChanges(pushResult.schedulingResult.changes), - ), - ); - }, - ); - } - - Future> _completeChildOrParent({ - required ApplicationCommandCode commandCode, - required ApplicationOperationContext context, - required String taskId, - required ChildTaskCompletionResult Function( - List tasks, - List existingActivities, - ) apply, - DateTime? expectedUpdatedAt, - }) { - return applicationStore.run( - context: context, - operationName: commandCode.name, - action: (repositories) async { - final tasks = await repositories.tasks.findAll(); - final task = _taskById(tasks, taskId); - if (task == null) { - return _failure(ApplicationFailureCode.notFound, entityId: taskId); - } - final staleFailure = _staleFailure(task, expectedUpdatedAt); - if (staleFailure != null) { - return ApplicationResult.failure(staleFailure); - } - final existingActivities = await repositories.taskActivities.findAll(); - final result = apply(tasks, existingActivities); - return _persist( - repositories: repositories, - commandCode: commandCode, - context: context, - beforeTasks: tasks, - afterTasks: result.tasks, - activities: result.activities, - childTaskIds: [ - if (result.completedChildId != null) result.completedChildId!, - ...result.forceCompletedChildIds, - ], - readHint: ApplicationCommandReadHint( - affectedTaskIds: result.changedTaskIds, - refreshToday: true, - ), - ); - }, - ); - } - - Future<_PlanningState> _loadPlanningState( - ApplicationUnitOfWorkRepositories repositories, - ApplicationOperationContext context, - CivilDate localDate, - ) async { - final ownerId = context.ownerTimeZone.ownerId; - final settings = await repositories.ownerSettings.findByOwnerId(ownerId) ?? - OwnerSettings( - ownerId: ownerId, - timeZoneId: context.ownerTimeZone.timeZoneId, - ); - final dayStart = _resolve( - date: localDate, - wallTime: settings.dayStart, - timeZoneId: settings.timeZoneId, - ); - final dayEnd = _resolve( - date: localDate, - wallTime: settings.dayEnd, - timeZoneId: settings.timeZoneId, - ); - final window = SchedulingWindow(start: dayStart, end: dayEnd); - final tasks = (await repositories.tasks.findForLocalDay( - ownerId: ownerId, - localDate: localDate, - window: window, - )) - .items; - final lockedExpansion = expandLockedBlocksForDay( - blocks: (await repositories.lockedBlocks.findBlocksByOwner( - ownerId: ownerId, - )) - .items, - overrides: (await repositories.lockedBlocks.findOverridesForDate( - ownerId: ownerId, - date: localDate, - )) - .items, - date: localDate, - timeZoneId: settings.timeZoneId, - timeZoneResolver: timeZoneResolver, - resolutionOptions: resolutionOptions, - ); - - return _PlanningState( - tasks: tasks, - window: window, - lockedIntervals: lockedExpansion.schedulingIntervals, - ); - } - - DateTime _resolve({ - required CivilDate date, - required WallTime wallTime, - required String timeZoneId, - }) { - return timeZoneResolver - .resolve( - date: date, - wallTime: wallTime, - timeZoneId: timeZoneId, - options: resolutionOptions, - ) - .instant; - } - - Future> _persist({ - required ApplicationUnitOfWorkRepositories repositories, - required ApplicationCommandCode commandCode, - required ApplicationOperationContext context, - required List beforeTasks, - required List afterTasks, - required List activities, - required ApplicationCommandReadHint readHint, - SchedulingResult? schedulingResult, - List protectedFreeSlotConflicts = - const [], - List childTaskIds = const [], - }) async { - final changedTasks = _changedTasks(beforeTasks, afterTasks); - await repositories.tasks.saveAll(changedTasks); - await repositories.taskActivities.saveAll(activities); - final projectStats = await _saveProjectStatistics( - repositories: repositories, - activities: activities, - tasks: afterTasks, - ); - - return ApplicationResult.success( - ApplicationCommandResult( - commandCode: commandCode, - operationId: context.operationId, - changedTasks: changedTasks, - activities: activities, - projectStatistics: projectStats, - notices: schedulingResult?.notices ?? const [], - changes: schedulingResult?.changes ?? const [], - overlaps: schedulingResult?.overlaps ?? const [], - protectedFreeSlotConflicts: protectedFreeSlotConflicts, - childTaskIds: childTaskIds, - schedulingResult: schedulingResult, - readHint: readHint, - ), - ); - } - - Future> _saveProjectStatistics({ - required ApplicationUnitOfWorkRepositories repositories, - required List activities, - required List tasks, - }) async { - final projectIds = activities - .where((activity) => activity.code == TaskActivityCode.completed) - .map((activity) => activity.projectId) - .toSet(); - final updated = []; - - for (final projectId in projectIds) { - final current = await repositories.projectStatistics.findByProjectId( - projectId, - ) ?? - ProjectStatistics(projectId: projectId); - final result = projectStatisticsAggregationService.apply( - statistics: current, - activities: activities, - tasks: tasks, - ); - if (result.appliedActivityIds.isEmpty) { - continue; - } - await repositories.projectStatistics.save(result.statistics); - updated.add(result.statistics); - } - - return List.unmodifiable(updated); - } -} - -class _PlanningState { - _PlanningState({ - required List tasks, - required this.window, - required List lockedIntervals, - }) : tasks = List.unmodifiable(tasks), - lockedIntervals = List.unmodifiable(lockedIntervals); - - final List tasks; - final SchedulingWindow window; - final List lockedIntervals; - - SchedulingInput get input { - return SchedulingInput( - tasks: tasks, - window: window, - lockedIntervals: lockedIntervals, - ); - } - - _PlanningState withTask(Task task) { - if (tasks.any((existing) => existing.id == task.id)) { - return this; - } - return _PlanningState( - tasks: [task, ...tasks], - window: window, - lockedIntervals: lockedIntervals, - ); - } - - _PlanningState replaceTask(Task replacement) { - return _PlanningState( - tasks: _replaceTask(tasks, replacement), - window: window, - lockedIntervals: lockedIntervals, - ); - } -} - -ApplicationResult _failure( - ApplicationFailureCode code, { - String? entityId, - String? detailCode, -}) { - return ApplicationResult.failure( - ApplicationFailure( - code: code, - entityId: entityId, - detailCode: detailCode, - ), - ); -} - -ApplicationResult _failureForQuickCapture( - QuickCaptureResult result, -) { - final schedulingResult = result.schedulingResult; - if (schedulingResult != null) { - final failure = _failureForSchedulingResult( - schedulingResult, - entityId: result.task.id, - ); - if (failure != null) { - return ApplicationResult.failure(failure); - } - } - - return _failure( - ApplicationFailureCode.validation, - entityId: result.task.id, - detailCode: result.status.name, - ); -} - -ApplicationFailure? _failureForSchedulingResult( - SchedulingResult result, { - required String entityId, -}) { - return switch (result.outcomeCode) { - SchedulingOutcomeCode.success || SchedulingOutcomeCode.noOp => null, - SchedulingOutcomeCode.conflict => null, - SchedulingOutcomeCode.notFound => ApplicationFailure( - code: ApplicationFailureCode.notFound, - entityId: entityId, - detailCode: result.outcomeCode.name, - ), - SchedulingOutcomeCode.noSlot || - SchedulingOutcomeCode.overflow => - ApplicationFailure( - code: ApplicationFailureCode.noSlot, - entityId: entityId, - detailCode: result.outcomeCode.name, - ), - SchedulingOutcomeCode.invalidState => ApplicationFailure( - code: ApplicationFailureCode.conflict, - entityId: entityId, - detailCode: result.outcomeCode.name, - ), - }; -} - -ApplicationFailure? _staleFailure(Task task, DateTime? expectedUpdatedAt) { - if (expectedUpdatedAt == null || - _sameNullableInstant(task.updatedAt, expectedUpdatedAt)) { - return null; - } - - return ApplicationFailure( - code: ApplicationFailureCode.staleRevision, - entityId: task.id, - ); -} - -List _activitiesForSchedulingChanges({ - required SchedulingResult? result, - required List beforeTasks, - required String operationId, - required DateTime occurredAt, - required String selectedTaskId, - required TaskActivityCode selectedActivityCode, - required TaskActivityCode movedActivityCode, -}) { - if (result == null || result.outcomeCode != SchedulingOutcomeCode.success) { - return const []; - } - - final activities = []; - for (final change in result.changes) { - final original = _taskById(beforeTasks, change.taskId); - if (original == null) { - continue; - } - final code = change.taskId == selectedTaskId - ? selectedActivityCode - : movedActivityCode; - activities.add( - TaskActivity( - id: '$operationId:${change.taskId}:${code.name}', - operationId: operationId, - code: code, - taskId: change.taskId, - projectId: original.projectId, - occurredAt: occurredAt, - metadata: { - 'previousStatus': original.status.name, - 'nextStatus': _nextStatusForChange(original, code).name, - 'previousStart': change.previousStart, - 'previousEnd': change.previousEnd, - 'nextStart': change.nextStart, - 'nextEnd': change.nextEnd, - }, - ), - ); - } - - return List.unmodifiable(activities); -} - -TaskStatus _nextStatusForChange(Task task, TaskActivityCode code) { - if (code == TaskActivityCode.restoredFromBacklog) { - return TaskStatus.planned; - } - if (code == TaskActivityCode.movedToBacklog) { - return TaskStatus.backlog; - } - - return task.status; -} - -List _changedTasks(List beforeTasks, List afterTasks) { - final beforeById = { - for (final task in beforeTasks) task.id: task, - }; - final changed = []; - - for (final task in afterTasks) { - final before = beforeById[task.id]; - if (before == null || _taskChanged(before, task)) { - changed.add(task); - } - } - - return List.unmodifiable(changed); -} - -List _changedTaskIds(List beforeTasks, List afterTasks) { - return _taskIdsFromTasks(_changedTasks(beforeTasks, afterTasks)); -} - -bool _taskChanged(Task before, Task after) { - return before.title != after.title || - before.projectId != after.projectId || - before.type != after.type || - before.status != after.status || - before.priority != after.priority || - before.reward != after.reward || - before.difficulty != after.difficulty || - before.durationMinutes != after.durationMinutes || - !_sameNullableInstant(before.scheduledStart, after.scheduledStart) || - !_sameNullableInstant(before.scheduledEnd, after.scheduledEnd) || - !_sameNullableInstant(before.actualStart, after.actualStart) || - !_sameNullableInstant(before.actualEnd, after.actualEnd) || - !_sameNullableInstant(before.completedAt, after.completedAt) || - before.parentTaskId != after.parentTaskId || - !_sameNullableInstant(before.updatedAt, after.updatedAt) || - before.stats != after.stats; -} - -bool _sameNullableInstant(DateTime? first, DateTime? second) { - if (first == null || second == null) { - return first == null && second == null; - } - - return first.isAtSameMomentAs(second); -} - -Task? _taskById(List tasks, String taskId) { - for (final task in tasks) { - if (task.id == taskId) { - return task; - } - } - - return null; -} - -List _replaceTask(List tasks, Task replacement) { - return tasks - .map((task) => task.id == replacement.id ? replacement : task) - .toList(growable: false); -} - -List _taskIdsFromChanges(List changes) { - return List.unmodifiable( - changes.map((change) => change.taskId).toSet(), - ); -} - -List _taskIdsFromTasks(List tasks) { - return List.unmodifiable(tasks.map((task) => task.id).toSet()); -} - -String _requiredTrimmed(String value, String name) { - final trimmed = value.trim(); - if (trimmed.isEmpty) { - throw ArgumentError.value(value, name, 'Value is required.'); - } - return trimmed; -} +part 'application_commands/application_command_code.dart'; +part 'application_commands/application_child_task_draft.dart'; +part 'application_commands/application_command_read_hint.dart'; +part 'application_commands/application_command_result.dart'; +part 'application_commands/v1_application_command_use_cases.dart'; +part 'application_commands/planning_state.dart'; diff --git a/packages/scheduler_core/lib/src/application_commands/application_child_task_draft.dart b/packages/scheduler_core/lib/src/application_commands/application_child_task_draft.dart new file mode 100644 index 0000000..d469da0 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_commands/application_child_task_draft.dart @@ -0,0 +1,31 @@ +part of '../application_commands.dart'; + +/// Draft row for creating one child task from an application command. +class ApplicationChildTaskDraft { + const ApplicationChildTaskDraft({ + required this.title, + this.priority, + this.reward = RewardLevel.notSet, + this.difficulty = DifficultyLevel.notSet, + this.durationMinutes, + this.projectId, + }); + + /// User-entered child title. + final String title; + + /// Optional child priority. + final PriorityLevel? priority; + + /// Optional reward estimate. + final RewardLevel reward; + + /// Optional difficulty estimate. + final DifficultyLevel difficulty; + + /// Optional duration estimate. + final int? durationMinutes; + + /// Optional project override. Null inherits from the parent. + final String? projectId; +} diff --git a/packages/scheduler_core/lib/src/application_commands/application_command_code.dart b/packages/scheduler_core/lib/src/application_commands/application_command_code.dart new file mode 100644 index 0000000..94b498a --- /dev/null +++ b/packages/scheduler_core/lib/src/application_commands/application_command_code.dart @@ -0,0 +1,22 @@ +part of '../application_commands.dart'; + +/// Stable V1 application command identifiers. +enum ApplicationCommandCode { + quickCaptureToBacklog, + quickCaptureToNextAvailableSlot, + scheduleBacklogItemToNextAvailableSlot, + pushFlexibleToNextAvailableSlot, + pushFlexibleToTomorrowTopOfQueue, + moveFlexibleToBacklog, + completeFlexibleTask, + uncompleteTask, + applyRequiredTaskAction, + logSurpriseTask, + createProtectedFreeSlot, + updateProtectedFreeSlot, + removeProtectedFreeSlot, + breakUpTask, + completeChildTask, + completeParentTask, + completeParentFromChild, +} diff --git a/packages/scheduler_core/lib/src/application_commands/application_command_read_hint.dart b/packages/scheduler_core/lib/src/application_commands/application_command_read_hint.dart new file mode 100644 index 0000000..06102d7 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_commands/application_command_read_hint.dart @@ -0,0 +1,30 @@ +part of '../application_commands.dart'; + +/// Hint for read models that should be refreshed after a command. +class ApplicationCommandReadHint { + ApplicationCommandReadHint({ + CivilDate? localDate, + Iterable affectedDates = const [], + Iterable affectedTaskIds = const [], + this.refreshToday = true, + }) : localDate = localDate, + affectedDates = List.unmodifiable( + { + if (localDate != null) localDate, + ...affectedDates, + }, + ), + affectedTaskIds = List.unmodifiable(affectedTaskIds); + + /// Primary local date for a Today refresh, when known. + final CivilDate? localDate; + + /// All local dates touched by the command. + final List affectedDates; + + /// Task ids changed or created by the command. + final List affectedTaskIds; + + /// Whether Today-style read models should be refreshed. + final bool refreshToday; +} diff --git a/packages/scheduler_core/lib/src/application_commands/application_command_result.dart b/packages/scheduler_core/lib/src/application_commands/application_command_result.dart new file mode 100644 index 0000000..adb8fca --- /dev/null +++ b/packages/scheduler_core/lib/src/application_commands/application_command_result.dart @@ -0,0 +1,69 @@ +part of '../application_commands.dart'; + +/// Structured result returned by V1 application commands. +class ApplicationCommandResult { + ApplicationCommandResult({ + required this.commandCode, + required String operationId, + required List changedTasks, + required this.readHint, + List activities = const [], + List projectStatistics = const [], + List notices = const [], + List changes = const [], + List overlaps = const [], + List protectedFreeSlotConflicts = + const [], + List childTaskIds = const [], + this.schedulingResult, + }) : operationId = _requiredTrimmed(operationId, 'operationId'), + changedTasks = List.unmodifiable(changedTasks), + activities = List.unmodifiable(activities), + projectStatistics = List.unmodifiable( + projectStatistics, + ), + notices = List.unmodifiable(notices), + changes = List.unmodifiable(changes), + overlaps = List.unmodifiable(overlaps), + protectedFreeSlotConflicts = + List.unmodifiable( + protectedFreeSlotConflicts, + ), + childTaskIds = List.unmodifiable(childTaskIds); + + /// Command that produced this result. + final ApplicationCommandCode commandCode; + + /// Exactly-once operation id. + final String operationId; + + /// Tasks inserted or changed by the command. + final List changedTasks; + + /// New internal activities persisted with the command. + final List activities; + + /// Project aggregates updated by completion activities. + final List projectStatistics; + + /// Scheduling notices returned by domain services. + final List notices; + + /// Scheduling movement records returned by domain services. + final List changes; + + /// Scheduling overlaps returned by domain services. + final List overlaps; + + /// Protected rest conflicts caused by an explicit required placement. + final List protectedFreeSlotConflicts; + + /// Child task ids created or affected by child-task commands. + final List childTaskIds; + + /// Full scheduling result when the command invoked the scheduler. + final SchedulingResult? schedulingResult; + + /// Read-model refresh hint for UI/state management. + final ApplicationCommandReadHint readHint; +} diff --git a/packages/scheduler_core/lib/src/application_commands/planning_state.dart b/packages/scheduler_core/lib/src/application_commands/planning_state.dart new file mode 100644 index 0000000..51288b6 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_commands/planning_state.dart @@ -0,0 +1,252 @@ +part of '../application_commands.dart'; + +class _PlanningState { + _PlanningState({ + required List tasks, + required this.window, + required List lockedIntervals, + }) : tasks = List.unmodifiable(tasks), + lockedIntervals = List.unmodifiable(lockedIntervals); + + final List tasks; + final SchedulingWindow window; + final List lockedIntervals; + + SchedulingInput get input { + return SchedulingInput( + tasks: tasks, + window: window, + lockedIntervals: lockedIntervals, + ); + } + + _PlanningState withTask(Task task) { + if (tasks.any((existing) => existing.id == task.id)) { + return this; + } + return _PlanningState( + tasks: [task, ...tasks], + window: window, + lockedIntervals: lockedIntervals, + ); + } + + _PlanningState replaceTask(Task replacement) { + return _PlanningState( + tasks: _replaceTask(tasks, replacement), + window: window, + lockedIntervals: lockedIntervals, + ); + } +} + +ApplicationResult _failure( + ApplicationFailureCode code, { + String? entityId, + String? detailCode, +}) { + return ApplicationResult.failure( + ApplicationFailure( + code: code, + entityId: entityId, + detailCode: detailCode, + ), + ); +} + +ApplicationResult _failureForQuickCapture( + QuickCaptureResult result, +) { + final schedulingResult = result.schedulingResult; + if (schedulingResult != null) { + final failure = _failureForSchedulingResult( + schedulingResult, + entityId: result.task.id, + ); + if (failure != null) { + return ApplicationResult.failure(failure); + } + } + + return _failure( + ApplicationFailureCode.validation, + entityId: result.task.id, + detailCode: result.status.name, + ); +} + +ApplicationFailure? _failureForSchedulingResult( + SchedulingResult result, { + required String entityId, +}) { + return switch (result.outcomeCode) { + SchedulingOutcomeCode.success || SchedulingOutcomeCode.noOp => null, + SchedulingOutcomeCode.conflict => null, + SchedulingOutcomeCode.notFound => ApplicationFailure( + code: ApplicationFailureCode.notFound, + entityId: entityId, + detailCode: result.outcomeCode.name, + ), + SchedulingOutcomeCode.noSlot || + SchedulingOutcomeCode.overflow => + ApplicationFailure( + code: ApplicationFailureCode.noSlot, + entityId: entityId, + detailCode: result.outcomeCode.name, + ), + SchedulingOutcomeCode.invalidState => ApplicationFailure( + code: ApplicationFailureCode.conflict, + entityId: entityId, + detailCode: result.outcomeCode.name, + ), + }; +} + +ApplicationFailure? _staleFailure(Task task, DateTime? expectedUpdatedAt) { + if (expectedUpdatedAt == null || + _sameNullableInstant(task.updatedAt, expectedUpdatedAt)) { + return null; + } + + return ApplicationFailure( + code: ApplicationFailureCode.staleRevision, + entityId: task.id, + ); +} + +List _activitiesForSchedulingChanges({ + required SchedulingResult? result, + required List beforeTasks, + required String operationId, + required DateTime occurredAt, + required String selectedTaskId, + required TaskActivityCode selectedActivityCode, + required TaskActivityCode movedActivityCode, +}) { + if (result == null || result.outcomeCode != SchedulingOutcomeCode.success) { + return const []; + } + + final activities = []; + for (final change in result.changes) { + final original = _taskById(beforeTasks, change.taskId); + if (original == null) { + continue; + } + final code = change.taskId == selectedTaskId + ? selectedActivityCode + : movedActivityCode; + activities.add( + TaskActivity( + id: '$operationId:${change.taskId}:${code.name}', + operationId: operationId, + code: code, + taskId: change.taskId, + projectId: original.projectId, + occurredAt: occurredAt, + metadata: { + 'previousStatus': original.status.name, + 'nextStatus': _nextStatusForChange(original, code).name, + 'previousStart': change.previousStart, + 'previousEnd': change.previousEnd, + 'nextStart': change.nextStart, + 'nextEnd': change.nextEnd, + }, + ), + ); + } + + return List.unmodifiable(activities); +} + +TaskStatus _nextStatusForChange(Task task, TaskActivityCode code) { + if (code == TaskActivityCode.restoredFromBacklog) { + return TaskStatus.planned; + } + if (code == TaskActivityCode.movedToBacklog) { + return TaskStatus.backlog; + } + + return task.status; +} + +List _changedTasks(List beforeTasks, List afterTasks) { + final beforeById = { + for (final task in beforeTasks) task.id: task, + }; + final changed = []; + + for (final task in afterTasks) { + final before = beforeById[task.id]; + if (before == null || _taskChanged(before, task)) { + changed.add(task); + } + } + + return List.unmodifiable(changed); +} + +List _changedTaskIds(List beforeTasks, List afterTasks) { + return _taskIdsFromTasks(_changedTasks(beforeTasks, afterTasks)); +} + +bool _taskChanged(Task before, Task after) { + return before.title != after.title || + before.projectId != after.projectId || + before.type != after.type || + before.status != after.status || + before.priority != after.priority || + before.reward != after.reward || + before.difficulty != after.difficulty || + before.durationMinutes != after.durationMinutes || + !_sameNullableInstant(before.scheduledStart, after.scheduledStart) || + !_sameNullableInstant(before.scheduledEnd, after.scheduledEnd) || + !_sameNullableInstant(before.actualStart, after.actualStart) || + !_sameNullableInstant(before.actualEnd, after.actualEnd) || + !_sameNullableInstant(before.completedAt, after.completedAt) || + before.parentTaskId != after.parentTaskId || + !_sameNullableInstant(before.updatedAt, after.updatedAt) || + before.stats != after.stats; +} + +bool _sameNullableInstant(DateTime? first, DateTime? second) { + if (first == null || second == null) { + return first == null && second == null; + } + + return first.isAtSameMomentAs(second); +} + +Task? _taskById(List tasks, String taskId) { + for (final task in tasks) { + if (task.id == taskId) { + return task; + } + } + + return null; +} + +List _replaceTask(List tasks, Task replacement) { + return tasks + .map((task) => task.id == replacement.id ? replacement : task) + .toList(growable: false); +} + +List _taskIdsFromChanges(List changes) { + return List.unmodifiable( + changes.map((change) => change.taskId).toSet(), + ); +} + +List _taskIdsFromTasks(List tasks) { + return List.unmodifiable(tasks.map((task) => task.id).toSet()); +} + +String _requiredTrimmed(String value, String name) { + final trimmed = value.trim(); + if (trimmed.isEmpty) { + throw ArgumentError.value(value, name, 'Value is required.'); + } + return trimmed; +} diff --git a/packages/scheduler_core/lib/src/application_commands/v1_application_command_use_cases.dart b/packages/scheduler_core/lib/src/application_commands/v1_application_command_use_cases.dart new file mode 100644 index 0000000..1338fa1 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_commands/v1_application_command_use_cases.dart @@ -0,0 +1,1085 @@ +part of '../application_commands.dart'; + +/// UI-facing V1 command use cases. +class V1ApplicationCommandUseCases { + const V1ApplicationCommandUseCases({ + required this.applicationStore, + required this.timeZoneResolver, + this.resolutionOptions = const TimeZoneResolutionOptions(), + this.quickCaptureService = const QuickCaptureService(), + this.flexibleTaskActionService = const FlexibleTaskActionService(), + this.requiredTaskActionService = const RequiredTaskActionService(), + this.surpriseTaskLogService = const SurpriseTaskLogService(), + this.freeSlotService = const FreeSlotService(), + this.childTaskBreakUpService = const ChildTaskBreakUpService(), + this.childTaskCompletionService = const ChildTaskCompletionService(), + this.projectStatisticsAggregationService = + const ProjectStatisticsAggregationService(), + }); + + /// Atomic repository boundary. + final ApplicationUnitOfWork applicationStore; + + /// Explicit local-time resolver for planning windows and locked expansion. + final TimeZoneResolver timeZoneResolver; + + /// DST/nonexistent/repeated local time policy. + final TimeZoneResolutionOptions resolutionOptions; + + final QuickCaptureService quickCaptureService; + final FlexibleTaskActionService flexibleTaskActionService; + final RequiredTaskActionService requiredTaskActionService; + final SurpriseTaskLogService surpriseTaskLogService; + final FreeSlotService freeSlotService; + final ChildTaskBreakUpService childTaskBreakUpService; + final ChildTaskCompletionService childTaskCompletionService; + final ProjectStatisticsAggregationService projectStatisticsAggregationService; + + /// Quick capture a task into backlog. + Future> quickCaptureToBacklog({ + required ApplicationOperationContext context, + required String title, + String projectId = 'inbox', + PriorityLevel priority = PriorityLevel.medium, + RewardLevel reward = RewardLevel.notSet, + DifficultyLevel difficulty = DifficultyLevel.notSet, + TaskType type = TaskType.flexible, + int? durationMinutes, + Set backlogTags = const {}, + }) { + const commandCode = ApplicationCommandCode.quickCaptureToBacklog; + return applicationStore.run( + context: context, + operationName: commandCode.name, + action: (repositories) async { + final result = quickCaptureService.capture( + QuickCaptureRequest( + id: context.idGenerator.nextId(), + title: title, + createdAt: context.now, + addToNextAvailableSlot: false, + projectId: projectId, + priority: priority, + reward: reward, + difficulty: difficulty, + type: type, + durationMinutes: durationMinutes, + backlogTags: backlogTags, + ), + updatedAt: context.now, + ); + if (!result.isValid) { + return _failure( + ApplicationFailureCode.validation, + detailCode: QuickCaptureStatus.validationError.name, + ); + } + + return _persist( + repositories: repositories, + commandCode: commandCode, + context: context, + beforeTasks: const [], + afterTasks: [result.task], + activities: const [], + readHint: ApplicationCommandReadHint( + affectedTaskIds: [result.task.id], + refreshToday: false, + ), + ); + }, + ); + } + + /// Quick capture a flexible task and schedule it into the next available slot. + Future> + quickCaptureToNextAvailableSlot({ + required ApplicationOperationContext context, + required CivilDate localDate, + required String title, + required int durationMinutes, + String projectId = 'inbox', + PriorityLevel priority = PriorityLevel.medium, + RewardLevel reward = RewardLevel.notSet, + DifficultyLevel difficulty = DifficultyLevel.notSet, + }) { + const commandCode = ApplicationCommandCode.quickCaptureToNextAvailableSlot; + return applicationStore.run( + context: context, + operationName: commandCode.name, + action: (repositories) async { + final state = + await _loadPlanningState(repositories, context, localDate); + final taskId = context.idGenerator.nextId(); + final result = quickCaptureService.capture( + QuickCaptureRequest( + id: taskId, + title: title, + createdAt: context.now, + addToNextAvailableSlot: true, + projectId: projectId, + priority: priority, + reward: reward, + difficulty: difficulty, + durationMinutes: durationMinutes, + ), + schedulingInput: state.input, + updatedAt: context.now, + ); + if (!result.isValid) { + return _failureForQuickCapture(result); + } + + final activities = _activitiesForSchedulingChanges( + result: result.schedulingResult, + beforeTasks: [result.task, ...state.tasks], + operationId: context.operationId, + occurredAt: context.now, + selectedTaskId: result.task.id, + selectedActivityCode: TaskActivityCode.restoredFromBacklog, + movedActivityCode: TaskActivityCode.automaticallyPushed, + ); + return _persist( + repositories: repositories, + commandCode: commandCode, + context: context, + beforeTasks: state.tasks, + afterTasks: result.schedulingResult?.tasks ?? [result.task], + activities: activities, + schedulingResult: result.schedulingResult, + readHint: ApplicationCommandReadHint( + localDate: localDate, + affectedTaskIds: _taskIdsFromTasks([result.task]), + ), + ); + }, + ); + } + + /// Schedule an existing backlog item into the next available flexible slot. + Future> + scheduleBacklogItemToNextAvailableSlot({ + required ApplicationOperationContext context, + required CivilDate localDate, + required String taskId, + int? durationMinutes, + DateTime? expectedUpdatedAt, + }) { + const commandCode = + ApplicationCommandCode.scheduleBacklogItemToNextAvailableSlot; + return applicationStore.run( + context: context, + operationName: commandCode.name, + action: (repositories) async { + var state = await _loadPlanningState(repositories, context, localDate); + var task = _taskById(state.tasks, taskId); + if (task == null) { + task = await repositories.tasks.findById(taskId); + if (task != null) { + state = state.withTask(task); + } + } + if (task == null) { + return _failure(ApplicationFailureCode.notFound, entityId: taskId); + } + final staleFailure = _staleFailure(task, expectedUpdatedAt); + if (staleFailure != null) { + return ApplicationResult.failure(staleFailure); + } + if (durationMinutes != null && durationMinutes <= 0) { + return _failure( + ApplicationFailureCode.validation, + entityId: taskId, + detailCode: DomainValidationCode.nonPositiveDuration.name, + ); + } + if (durationMinutes != null) { + task = task.copyWith( + durationMinutes: durationMinutes, + updatedAt: context.now, + ); + state = state.replaceTask(task); + } + + final schedulingResult = + const SchedulingEngine().insertBacklogTaskIntoNextAvailableSlot( + input: state.input, + taskId: taskId, + updatedAt: context.now, + ); + final schedulingFailure = _failureForSchedulingResult( + schedulingResult, + entityId: taskId, + ); + if (schedulingFailure != null) { + return ApplicationResult.failure(schedulingFailure); + } + + final activities = _activitiesForSchedulingChanges( + result: schedulingResult, + beforeTasks: state.tasks, + operationId: context.operationId, + occurredAt: context.now, + selectedTaskId: taskId, + selectedActivityCode: TaskActivityCode.restoredFromBacklog, + movedActivityCode: TaskActivityCode.automaticallyPushed, + ); + return _persist( + repositories: repositories, + commandCode: commandCode, + context: context, + beforeTasks: state.tasks, + afterTasks: schedulingResult.tasks, + activities: activities, + schedulingResult: schedulingResult, + readHint: ApplicationCommandReadHint( + localDate: localDate, + affectedTaskIds: _taskIdsFromChanges(schedulingResult.changes), + ), + ); + }, + ); + } + + /// Push a planned flexible task later in the same local day. + Future> + pushFlexibleToNextAvailableSlot({ + required ApplicationOperationContext context, + required CivilDate localDate, + required String taskId, + DateTime? expectedUpdatedAt, + }) { + return _pushFlexible( + commandCode: ApplicationCommandCode.pushFlexibleToNextAvailableSlot, + context: context, + localDate: localDate, + taskId: taskId, + destination: PushDestination.nextAvailableSlot, + expectedUpdatedAt: expectedUpdatedAt, + ); + } + + /// Push a planned flexible task to the top of a future/tomorrow queue. + Future> + pushFlexibleToTomorrowTopOfQueue({ + required ApplicationOperationContext context, + required CivilDate targetDate, + required String taskId, + DateTime? expectedUpdatedAt, + }) { + return _pushFlexible( + commandCode: ApplicationCommandCode.pushFlexibleToTomorrowTopOfQueue, + context: context, + localDate: targetDate, + taskId: taskId, + destination: PushDestination.tomorrowTopOfQueue, + expectedUpdatedAt: expectedUpdatedAt, + ); + } + + /// Move a planned flexible task to backlog. + Future> moveFlexibleToBacklog({ + required ApplicationOperationContext context, + required String taskId, + DateTime? expectedUpdatedAt, + }) { + const commandCode = ApplicationCommandCode.moveFlexibleToBacklog; + return applicationStore.run( + context: context, + operationName: commandCode.name, + action: (repositories) async { + final task = await repositories.tasks.findById(taskId); + if (task == null) { + return _failure(ApplicationFailureCode.notFound, entityId: taskId); + } + final staleFailure = _staleFailure(task, expectedUpdatedAt); + if (staleFailure != null) { + return ApplicationResult.failure(staleFailure); + } + final existingActivities = await repositories.taskActivities.findAll(); + final actionResult = flexibleTaskActionService.apply( + task: task, + action: FlexibleTaskQuickAction.backlog, + updatedAt: context.now, + operationId: context.operationId, + existingActivities: existingActivities, + ); + if (actionResult.transitionOutcomeCode == + TaskTransitionOutcomeCode.invalidState) { + return _failure( + ApplicationFailureCode.conflict, + entityId: taskId, + detailCode: TaskTransitionOutcomeCode.invalidState.name, + ); + } + return _persist( + repositories: repositories, + commandCode: commandCode, + context: context, + beforeTasks: [task], + afterTasks: [actionResult.task], + activities: actionResult.activities, + readHint: ApplicationCommandReadHint( + affectedTaskIds: [taskId], + refreshToday: true, + ), + ); + }, + ); + } + + /// Complete a flexible task. + Future> completeFlexibleTask({ + required ApplicationOperationContext context, + required CivilDate localDate, + required String taskId, + DateTime? expectedUpdatedAt, + DateTime? actualStart, + DateTime? actualEnd, + }) { + const commandCode = ApplicationCommandCode.completeFlexibleTask; + return applicationStore.run( + context: context, + operationName: commandCode.name, + action: (repositories) async { + var state = await _loadPlanningState(repositories, context, localDate); + var task = _taskById(state.tasks, taskId); + if (task == null) { + task = await repositories.tasks.findById(taskId); + if (task != null) { + state = state.withTask(task); + } + } + if (task == null) { + return _failure(ApplicationFailureCode.notFound, entityId: taskId); + } + final staleFailure = _staleFailure(task, expectedUpdatedAt); + if (staleFailure != null) { + return ApplicationResult.failure(staleFailure); + } + final existingActivities = await repositories.taskActivities.findAll(); + final actionResult = flexibleTaskActionService.apply( + task: task, + action: FlexibleTaskQuickAction.done, + updatedAt: context.now, + operationId: context.operationId, + actualStart: actualStart, + actualEnd: actualEnd, + lockedIntervals: state.lockedIntervals, + existingActivities: existingActivities, + ); + if (actionResult.transitionOutcomeCode == + TaskTransitionOutcomeCode.invalidState) { + return _failure( + ApplicationFailureCode.conflict, + entityId: taskId, + detailCode: TaskTransitionOutcomeCode.invalidState.name, + ); + } + return _persist( + repositories: repositories, + commandCode: commandCode, + context: context, + beforeTasks: state.tasks, + afterTasks: _replaceTask(state.tasks, actionResult.task), + activities: actionResult.activities, + readHint: ApplicationCommandReadHint( + localDate: localDate, + affectedTaskIds: [taskId], + ), + ); + }, + ); + } + + /// Mark a completed scheduled task as still needing to be done. + Future> uncompleteTask({ + required ApplicationOperationContext context, + required CivilDate localDate, + required String taskId, + DateTime? expectedUpdatedAt, + }) { + const commandCode = ApplicationCommandCode.uncompleteTask; + return applicationStore.run( + context: context, + operationName: commandCode.name, + action: (repositories) async { + var state = await _loadPlanningState(repositories, context, localDate); + var task = _taskById(state.tasks, taskId); + if (task == null) { + task = await repositories.tasks.findById(taskId); + if (task != null) { + state = state.withTask(task); + } + } + if (task == null) { + return _failure(ApplicationFailureCode.notFound, entityId: taskId); + } + final staleFailure = _staleFailure(task, expectedUpdatedAt); + if (staleFailure != null) { + return ApplicationResult.failure(staleFailure); + } + if (task.status != TaskStatus.completed || + (!task.isFlexible && !task.isRequiredVisible)) { + return _failure( + ApplicationFailureCode.conflict, + entityId: taskId, + detailCode: TaskTransitionOutcomeCode.invalidState.name, + ); + } + final uncompleted = task.copyWith( + status: TaskStatus.planned, + updatedAt: context.now, + clearActualInterval: true, + clearCompletion: true, + ); + return _persist( + repositories: repositories, + commandCode: commandCode, + context: context, + beforeTasks: state.tasks, + afterTasks: _replaceTask(state.tasks, uncompleted), + activities: const [], + readHint: ApplicationCommandReadHint( + localDate: localDate, + affectedTaskIds: [taskId], + ), + ); + }, + ); + } + + /// Apply a required visible task lifecycle action. + Future> applyRequiredTaskAction({ + required ApplicationOperationContext context, + required CivilDate localDate, + required String taskId, + required RequiredTaskAction action, + DateTime? expectedUpdatedAt, + DateTime? actualStart, + DateTime? actualEnd, + }) { + const commandCode = ApplicationCommandCode.applyRequiredTaskAction; + return applicationStore.run( + context: context, + operationName: commandCode.name, + action: (repositories) async { + var state = await _loadPlanningState(repositories, context, localDate); + var task = _taskById(state.tasks, taskId); + if (task == null) { + task = await repositories.tasks.findById(taskId); + if (task != null) { + state = state.withTask(task); + } + } + if (task == null) { + return _failure(ApplicationFailureCode.notFound, entityId: taskId); + } + final staleFailure = _staleFailure(task, expectedUpdatedAt); + if (staleFailure != null) { + return ApplicationResult.failure(staleFailure); + } + final existingActivities = await repositories.taskActivities.findAll(); + final actionResult = requiredTaskActionService.apply( + task: task, + action: action, + updatedAt: context.now, + operationId: context.operationId, + actualStart: actualStart, + actualEnd: actualEnd, + lockedIntervals: state.lockedIntervals, + existingActivities: existingActivities, + ); + if (actionResult.transitionOutcomeCode == + TaskTransitionOutcomeCode.invalidState) { + return _failure( + ApplicationFailureCode.conflict, + entityId: taskId, + detailCode: TaskTransitionOutcomeCode.invalidState.name, + ); + } + return _persist( + repositories: repositories, + commandCode: commandCode, + context: context, + beforeTasks: state.tasks, + afterTasks: _replaceTask(state.tasks, actionResult.task), + activities: actionResult.activities, + readHint: ApplicationCommandReadHint( + localDate: localDate, + affectedTaskIds: [taskId], + ), + ); + }, + ); + } + + /// Log completed surprise work and repair overlapping flexible work. + Future> logSurpriseTask({ + required ApplicationOperationContext context, + required CivilDate localDate, + required String title, + DateTime? startedAt, + int? timeUsedMinutes, + String projectId = 'inbox', + PriorityLevel? priority, + RewardLevel reward = RewardLevel.notSet, + DifficultyLevel difficulty = DifficultyLevel.notSet, + bool revealHiddenLockedDetails = false, + }) { + const commandCode = ApplicationCommandCode.logSurpriseTask; + return applicationStore.run( + context: context, + operationName: commandCode.name, + action: (repositories) async { + final state = + await _loadPlanningState(repositories, context, localDate); + final logResult = surpriseTaskLogService.log( + request: SurpriseTaskLogRequest( + id: context.operationId, + title: title, + createdAt: context.now, + startedAt: startedAt, + timeUsedMinutes: timeUsedMinutes, + projectId: projectId, + priority: priority, + reward: reward, + difficulty: difficulty, + ), + input: state.input, + updatedAt: context.now, + revealHiddenLockedDetails: revealHiddenLockedDetails, + ); + return _persist( + repositories: repositories, + commandCode: commandCode, + context: context, + beforeTasks: state.tasks, + afterTasks: logResult.schedulingResult.tasks, + activities: logResult.activities, + schedulingResult: logResult.schedulingResult, + protectedFreeSlotConflicts: const [], + readHint: ApplicationCommandReadHint( + localDate: localDate, + affectedTaskIds: _changedTaskIds( + state.tasks, + logResult.schedulingResult.tasks, + ), + ), + ); + }, + ); + } + + /// Create a protected Free Slot task. + Future> createProtectedFreeSlot({ + required ApplicationOperationContext context, + required CivilDate localDate, + required String title, + required DateTime start, + required DateTime end, + String projectId = 'inbox', + }) { + const commandCode = ApplicationCommandCode.createProtectedFreeSlot; + return applicationStore.run( + context: context, + operationName: commandCode.name, + action: (repositories) async { + final freeSlot = freeSlotService.create( + id: context.idGenerator.nextId(), + title: title, + start: start, + end: end, + createdAt: context.now, + updatedAt: context.now, + projectId: projectId, + ); + return _persist( + repositories: repositories, + commandCode: commandCode, + context: context, + beforeTasks: const [], + afterTasks: [freeSlot], + activities: const [], + readHint: ApplicationCommandReadHint( + localDate: localDate, + affectedTaskIds: [freeSlot.id], + ), + ); + }, + ); + } + + /// Update a protected Free Slot task. + Future> updateProtectedFreeSlot({ + required ApplicationOperationContext context, + required CivilDate localDate, + required String taskId, + required DateTime start, + required DateTime end, + DateTime? expectedUpdatedAt, + String? title, + String? projectId, + }) { + const commandCode = ApplicationCommandCode.updateProtectedFreeSlot; + return applicationStore.run( + context: context, + operationName: commandCode.name, + action: (repositories) async { + final task = await repositories.tasks.findById(taskId); + if (task == null) { + return _failure(ApplicationFailureCode.notFound, entityId: taskId); + } + final staleFailure = _staleFailure(task, expectedUpdatedAt); + if (staleFailure != null) { + return ApplicationResult.failure(staleFailure); + } + final updated = freeSlotService.update( + freeSlot: task, + start: start, + end: end, + updatedAt: context.now, + title: title, + projectId: projectId, + ); + return _persist( + repositories: repositories, + commandCode: commandCode, + context: context, + beforeTasks: [task], + afterTasks: [updated], + activities: const [], + readHint: ApplicationCommandReadHint( + localDate: localDate, + affectedTaskIds: [taskId], + ), + ); + }, + ); + } + + /// Remove a protected Free Slot from the active timeline. + Future> removeProtectedFreeSlot({ + required ApplicationOperationContext context, + required CivilDate localDate, + required String taskId, + DateTime? expectedUpdatedAt, + }) { + const commandCode = ApplicationCommandCode.removeProtectedFreeSlot; + return applicationStore.run( + context: context, + operationName: commandCode.name, + action: (repositories) async { + final task = await repositories.tasks.findById(taskId); + if (task == null) { + return _failure(ApplicationFailureCode.notFound, entityId: taskId); + } + if (task.type != TaskType.freeSlot) { + return _failure( + ApplicationFailureCode.validation, + entityId: taskId, + detailCode: 'invalidFreeSlotType', + ); + } + final staleFailure = _staleFailure(task, expectedUpdatedAt); + if (staleFailure != null) { + return ApplicationResult.failure(staleFailure); + } + final removed = task.copyWith( + status: TaskStatus.cancelled, + updatedAt: context.now, + clearSchedule: true, + ); + return _persist( + repositories: repositories, + commandCode: commandCode, + context: context, + beforeTasks: [task], + afterTasks: [removed], + activities: const [], + readHint: ApplicationCommandReadHint( + localDate: localDate, + affectedTaskIds: [taskId], + ), + ); + }, + ); + } + + /// Break a parent task into direct child tasks. + Future> breakUpTask({ + required ApplicationOperationContext context, + required String parentTaskId, + required List children, + DateTime? expectedUpdatedAt, + }) { + const commandCode = ApplicationCommandCode.breakUpTask; + return applicationStore.run( + context: context, + operationName: commandCode.name, + action: (repositories) async { + final tasks = await repositories.tasks.findAll(); + final parent = _taskById(tasks, parentTaskId); + if (parent == null) { + return _failure( + ApplicationFailureCode.notFound, + entityId: parentTaskId, + ); + } + final staleFailure = _staleFailure(parent, expectedUpdatedAt); + if (staleFailure != null) { + return ApplicationResult.failure(staleFailure); + } + final entries = children.map((draft) { + return ChildTaskEntry( + id: context.idGenerator.nextId(), + title: draft.title, + createdAt: context.now, + priority: draft.priority, + reward: draft.reward, + difficulty: draft.difficulty, + durationMinutes: draft.durationMinutes, + projectId: draft.projectId, + updatedAt: context.now, + ); + }).toList(growable: false); + final breakUpResult = childTaskBreakUpService.breakUp( + tasks: tasks, + request: ChildTaskBreakUpRequest( + parentTaskId: parentTaskId, + entries: entries, + operationId: context.operationId, + ), + ); + return _persist( + repositories: repositories, + commandCode: commandCode, + context: context, + beforeTasks: tasks, + afterTasks: breakUpResult.tasks, + activities: breakUpResult.activities, + childTaskIds: breakUpResult.createdChildTaskIds, + readHint: ApplicationCommandReadHint( + affectedTaskIds: [ + parentTaskId, + ...breakUpResult.createdChildTaskIds + ], + refreshToday: false, + ), + ); + }, + ); + } + + /// Complete one child task and auto-complete its parent if appropriate. + Future> completeChildTask({ + required ApplicationOperationContext context, + required String childTaskId, + DateTime? expectedUpdatedAt, + }) { + const commandCode = ApplicationCommandCode.completeChildTask; + return _completeChildOrParent( + commandCode: commandCode, + context: context, + taskId: childTaskId, + expectedUpdatedAt: expectedUpdatedAt, + apply: (tasks, existingActivities) => + childTaskCompletionService.completeChild( + tasks: tasks, + childTaskId: childTaskId, + updatedAt: context.now, + operationId: context.operationId, + existingActivities: existingActivities, + ), + ); + } + + /// Complete a parent and force-complete unfinished direct children. + Future> completeParentTask({ + required ApplicationOperationContext context, + required String parentTaskId, + DateTime? expectedUpdatedAt, + }) { + const commandCode = ApplicationCommandCode.completeParentTask; + return _completeChildOrParent( + commandCode: commandCode, + context: context, + taskId: parentTaskId, + expectedUpdatedAt: expectedUpdatedAt, + apply: (tasks, existingActivities) => + childTaskCompletionService.completeParent( + tasks: tasks, + parentTaskId: parentTaskId, + updatedAt: context.now, + operationId: context.operationId, + existingActivities: existingActivities, + ), + ); + } + + /// Complete a parent from one of its direct children. + Future> completeParentFromChild({ + required ApplicationOperationContext context, + required String childTaskId, + DateTime? expectedUpdatedAt, + }) { + const commandCode = ApplicationCommandCode.completeParentFromChild; + return _completeChildOrParent( + commandCode: commandCode, + context: context, + taskId: childTaskId, + expectedUpdatedAt: expectedUpdatedAt, + apply: (tasks, existingActivities) => + childTaskCompletionService.completeParentFromChild( + tasks: tasks, + childTaskId: childTaskId, + updatedAt: context.now, + operationId: context.operationId, + existingActivities: existingActivities, + ), + ); + } + + Future> _pushFlexible({ + required ApplicationCommandCode commandCode, + required ApplicationOperationContext context, + required CivilDate localDate, + required String taskId, + required PushDestination destination, + DateTime? expectedUpdatedAt, + }) { + return applicationStore.run( + context: context, + operationName: commandCode.name, + action: (repositories) async { + var state = await _loadPlanningState(repositories, context, localDate); + var task = _taskById(state.tasks, taskId); + if (task == null) { + task = await repositories.tasks.findById(taskId); + if (task != null) { + state = state.withTask(task); + } + } + if (task == null) { + return _failure(ApplicationFailureCode.notFound, entityId: taskId); + } + final staleFailure = _staleFailure(task, expectedUpdatedAt); + if (staleFailure != null) { + return ApplicationResult.failure(staleFailure); + } + final existingActivities = await repositories.taskActivities.findAll(); + final pushResult = flexibleTaskActionService.applyPushDestination( + destination: destination, + input: state.input, + taskId: taskId, + updatedAt: context.now, + operationId: context.operationId, + existingActivities: existingActivities, + ); + final schedulingFailure = _failureForSchedulingResult( + pushResult.schedulingResult, + entityId: taskId, + ); + if (schedulingFailure != null) { + return ApplicationResult.failure(schedulingFailure); + } + return _persist( + repositories: repositories, + commandCode: commandCode, + context: context, + beforeTasks: state.tasks, + afterTasks: pushResult.schedulingResult.tasks, + activities: pushResult.activities, + schedulingResult: pushResult.schedulingResult, + readHint: ApplicationCommandReadHint( + localDate: localDate, + affectedTaskIds: + _taskIdsFromChanges(pushResult.schedulingResult.changes), + ), + ); + }, + ); + } + + Future> _completeChildOrParent({ + required ApplicationCommandCode commandCode, + required ApplicationOperationContext context, + required String taskId, + required ChildTaskCompletionResult Function( + List tasks, + List existingActivities, + ) apply, + DateTime? expectedUpdatedAt, + }) { + return applicationStore.run( + context: context, + operationName: commandCode.name, + action: (repositories) async { + final tasks = await repositories.tasks.findAll(); + final task = _taskById(tasks, taskId); + if (task == null) { + return _failure(ApplicationFailureCode.notFound, entityId: taskId); + } + final staleFailure = _staleFailure(task, expectedUpdatedAt); + if (staleFailure != null) { + return ApplicationResult.failure(staleFailure); + } + final existingActivities = await repositories.taskActivities.findAll(); + final result = apply(tasks, existingActivities); + return _persist( + repositories: repositories, + commandCode: commandCode, + context: context, + beforeTasks: tasks, + afterTasks: result.tasks, + activities: result.activities, + childTaskIds: [ + if (result.completedChildId != null) result.completedChildId!, + ...result.forceCompletedChildIds, + ], + readHint: ApplicationCommandReadHint( + affectedTaskIds: result.changedTaskIds, + refreshToday: true, + ), + ); + }, + ); + } + + Future<_PlanningState> _loadPlanningState( + ApplicationUnitOfWorkRepositories repositories, + ApplicationOperationContext context, + CivilDate localDate, + ) async { + final ownerId = context.ownerTimeZone.ownerId; + final settings = await repositories.ownerSettings.findByOwnerId(ownerId) ?? + OwnerSettings( + ownerId: ownerId, + timeZoneId: context.ownerTimeZone.timeZoneId, + ); + final dayStart = _resolve( + date: localDate, + wallTime: settings.dayStart, + timeZoneId: settings.timeZoneId, + ); + final dayEnd = _resolve( + date: localDate, + wallTime: settings.dayEnd, + timeZoneId: settings.timeZoneId, + ); + final window = SchedulingWindow(start: dayStart, end: dayEnd); + final tasks = (await repositories.tasks.findForLocalDay( + ownerId: ownerId, + localDate: localDate, + window: window, + )) + .items; + final lockedExpansion = expandLockedBlocksForDay( + blocks: (await repositories.lockedBlocks.findBlocksByOwner( + ownerId: ownerId, + )) + .items, + overrides: (await repositories.lockedBlocks.findOverridesForDate( + ownerId: ownerId, + date: localDate, + )) + .items, + date: localDate, + timeZoneId: settings.timeZoneId, + timeZoneResolver: timeZoneResolver, + resolutionOptions: resolutionOptions, + ); + + return _PlanningState( + tasks: tasks, + window: window, + lockedIntervals: lockedExpansion.schedulingIntervals, + ); + } + + DateTime _resolve({ + required CivilDate date, + required WallTime wallTime, + required String timeZoneId, + }) { + return timeZoneResolver + .resolve( + date: date, + wallTime: wallTime, + timeZoneId: timeZoneId, + options: resolutionOptions, + ) + .instant; + } + + Future> _persist({ + required ApplicationUnitOfWorkRepositories repositories, + required ApplicationCommandCode commandCode, + required ApplicationOperationContext context, + required List beforeTasks, + required List afterTasks, + required List activities, + required ApplicationCommandReadHint readHint, + SchedulingResult? schedulingResult, + List protectedFreeSlotConflicts = + const [], + List childTaskIds = const [], + }) async { + final changedTasks = _changedTasks(beforeTasks, afterTasks); + await repositories.tasks.saveAll(changedTasks); + await repositories.taskActivities.saveAll(activities); + final projectStats = await _saveProjectStatistics( + repositories: repositories, + activities: activities, + tasks: afterTasks, + ); + + return ApplicationResult.success( + ApplicationCommandResult( + commandCode: commandCode, + operationId: context.operationId, + changedTasks: changedTasks, + activities: activities, + projectStatistics: projectStats, + notices: schedulingResult?.notices ?? const [], + changes: schedulingResult?.changes ?? const [], + overlaps: schedulingResult?.overlaps ?? const [], + protectedFreeSlotConflicts: protectedFreeSlotConflicts, + childTaskIds: childTaskIds, + schedulingResult: schedulingResult, + readHint: readHint, + ), + ); + } + + Future> _saveProjectStatistics({ + required ApplicationUnitOfWorkRepositories repositories, + required List activities, + required List tasks, + }) async { + final projectIds = activities + .where((activity) => activity.code == TaskActivityCode.completed) + .map((activity) => activity.projectId) + .toSet(); + final updated = []; + + for (final projectId in projectIds) { + final current = await repositories.projectStatistics.findByProjectId( + projectId, + ) ?? + ProjectStatistics(projectId: projectId); + final result = projectStatisticsAggregationService.apply( + statistics: current, + activities: activities, + tasks: tasks, + ); + if (result.appliedActivityIds.isEmpty) { + continue; + } + await repositories.projectStatistics.save(result.statistics); + updated.add(result.statistics); + } + + return List.unmodifiable(updated); + } +} diff --git a/packages/scheduler_core/lib/src/application_layer.dart b/packages/scheduler_core/lib/src/application_layer.dart index c40e9c9..c62bae6 100644 --- a/packages/scheduler_core/lib/src/application_layer.dart +++ b/packages/scheduler_core/lib/src/application_layer.dart @@ -15,1970 +15,33 @@ import 'repositories.dart'; import 'scheduling_engine.dart'; import 'task_lifecycle.dart'; import 'time_contracts.dart'; - -/// Owner-local time context supplied to application operations. -class OwnerTimeZoneContext { - OwnerTimeZoneContext({ - required String ownerId, - required String timeZoneId, - }) : ownerId = _requiredTrimmed(ownerId, 'ownerId'), - timeZoneId = _requiredTrimmed(timeZoneId, 'timeZoneId'); - - /// Authorization-neutral owner scope. V1 does not implement accounts. - final String ownerId; - - /// IANA-style time-zone identifier chosen by the app boundary. - final String timeZoneId; -} - -/// Per-operation application context. -class ApplicationOperationContext { - ApplicationOperationContext({ - required String operationId, - required this.now, - required this.ownerTimeZone, - required this.clock, - required this.idGenerator, - }) : operationId = _requiredTrimmed(operationId, 'operationId'); - - /// Convenience constructor that captures [Clock.now] once. - factory ApplicationOperationContext.start({ - required String operationId, - required OwnerTimeZoneContext ownerTimeZone, - required Clock clock, - required IdGenerator idGenerator, - }) { - return ApplicationOperationContext( - operationId: operationId, - now: clock.now(), - ownerTimeZone: ownerTimeZone, - clock: clock, - idGenerator: idGenerator, - ); - } - - /// Exactly-once key for this user command. - final String operationId; - - /// Instant used by the operation. Use this instead of repeatedly reading the - /// clock during one command. - final DateTime now; - - /// Owner scope and time-zone data for date-sensitive rules. - final OwnerTimeZoneContext ownerTimeZone; - - /// Injected clock available to lower-level services that need one. - final Clock clock; - - /// Injected ID generator for records created by the operation. - final IdGenerator idGenerator; -} - -/// Stable application-layer result categories. -enum ApplicationFailureCode { - validation, - notFound, - conflict, - staleRevision, - noSlot, - duplicateOperation, - persistenceFailure, - unexpected, -} - -/// Typed failure returned by application commands and queries. -class ApplicationFailure { - const ApplicationFailure({ - required this.code, - this.entityId, - this.detailCode, - }); - - /// Stable machine-readable category. - final ApplicationFailureCode code; - - /// Optional stable entity id related to the failure. - final String? entityId; - - /// Optional narrower stable reason, such as a domain validation code name. - final String? detailCode; -} - -/// Result wrapper for expected application success/failure paths. -class ApplicationResult { - const ApplicationResult._({ - this.value, - this.failure, - }); - - /// Successful result carrying [value]. - factory ApplicationResult.success(T value) { - return ApplicationResult._(value: value); - } - - /// Expected failure result. - factory ApplicationResult.failure(ApplicationFailure failure) { - return ApplicationResult._(failure: failure); - } - - /// Successful value, if any. - final T? value; - - /// Typed failure, if the operation did not succeed. - final ApplicationFailure? failure; - - /// Whether the operation succeeded. - bool get isSuccess => failure == null; - - /// Whether the operation failed with a typed application failure. - bool get isFailure => failure != null; - - /// Return the value or throw if this result is a failure. - T get requireValue { - if (failure != null) { - throw StateError( - 'Application result is a failure: ${failure!.code.name}'); - } - return value as T; - } -} - -/// Exception for expected failures raised inside a unit-of-work callback. -class ApplicationFailureException implements Exception { - const ApplicationFailureException(this.failure); - - /// Failure to return from the application boundary. - final ApplicationFailure failure; -} - -/// Exception representing persistence/driver failures at repository boundaries. -class ApplicationPersistenceException implements Exception { - const ApplicationPersistenceException(); -} - -/// Persisted exactly-once operation record. -class ApplicationOperationRecord { - ApplicationOperationRecord({ - required String operationId, - required String ownerId, - required String operationName, - required this.committedAt, - }) : operationId = _requiredTrimmed(operationId, 'operationId'), - ownerId = _requiredTrimmed(ownerId, 'ownerId'), - operationName = _requiredTrimmed(operationName, 'operationName'); - - /// Exactly-once operation id. - final String operationId; - - /// Authorization-neutral owner scope. - final String ownerId; - - /// Stable use-case/command label. - final String operationName; - - /// Commit instant. - final DateTime committedAt; -} - -/// Owner-scoped acknowledgement for a one-time scheduling notice. -class NoticeAcknowledgementRecord { - NoticeAcknowledgementRecord({ - required String noticeId, - required String ownerId, - required this.acknowledgedAt, - }) : noticeId = _requiredTrimmed(noticeId, 'noticeId'), - ownerId = _requiredTrimmed(ownerId, 'ownerId'); - - /// Stable notice id generated by the read/use-case boundary. - final String noticeId; - - /// Authorization-neutral owner scope. - final String ownerId; - - /// Instant the notice was consumed. - final DateTime acknowledgedAt; -} - -/// Owner-level settings needed by application use cases. -class OwnerSettings { - OwnerSettings({ - required String ownerId, - required String timeZoneId, - WallTime? dayStart, - WallTime? dayEnd, - this.compactModeEnabled = false, - this.backlogStalenessSettings = const BacklogStalenessSettings(), - }) : ownerId = _requiredTrimmed(ownerId, 'ownerId'), - timeZoneId = _requiredTrimmed(timeZoneId, 'timeZoneId'), - dayStart = dayStart ?? WallTime(hour: 0, minute: 0), - dayEnd = dayEnd ?? WallTime(hour: 23, minute: 59) { - if (this.dayStart.compareTo(this.dayEnd) >= 0) { - throw ArgumentError.value( - {'dayStart': this.dayStart, 'dayEnd': this.dayEnd}, - 'dayEnd', - 'Day end must be after day start.', - ); - } - } - - /// Authorization-neutral owner scope. - final String ownerId; - - /// Owner time-zone id used by date-sensitive application use cases. - final String timeZoneId; - - /// Default local day start. - final WallTime dayStart; - - /// Default local day end. - final WallTime dayEnd; - - /// Persisted compact-mode preference. - final bool compactModeEnabled; - - /// Configurable backlog age thresholds. - final BacklogStalenessSettings backlogStalenessSettings; - - /// Return a copy with selected settings changed. - OwnerSettings copyWith({ - String? ownerId, - String? timeZoneId, - WallTime? dayStart, - WallTime? dayEnd, - bool? compactModeEnabled, - BacklogStalenessSettings? backlogStalenessSettings, - }) { - return OwnerSettings( - ownerId: ownerId ?? this.ownerId, - timeZoneId: timeZoneId ?? this.timeZoneId, - dayStart: dayStart ?? this.dayStart, - dayEnd: dayEnd ?? this.dayEnd, - compactModeEnabled: compactModeEnabled ?? this.compactModeEnabled, - backlogStalenessSettings: - backlogStalenessSettings ?? this.backlogStalenessSettings, - ); - } -} - -/// Repository contract for internal task activities. -abstract interface class TaskActivityRepository { - Future findById(String id); - - Future> findByOperationId(String operationId); - - Future> findByTaskId(String taskId); - - Future> findByProjectId(String projectId); - - Future> findAll(); - - Future> findByOwner({ - required String ownerId, - RepositoryPageRequest page = const RepositoryPageRequest(), - }); - - Future save(TaskActivity activity); - - Future saveAll(Iterable activities); - - Future append({ - required TaskActivity activity, - required String ownerId, - }); -} - -/// Repository contract for project statistics aggregates. -abstract interface class ProjectStatisticsRepository { - Future findByProjectId(String projectId); - - Future?> findRecordByProjectId( - String projectId, - ); - - Future> findAll(); - - Future save(ProjectStatistics statistics); - - Future saveIfRevision({ - required ProjectStatistics statistics, - required String ownerId, - required int expectedRevision, - }); -} - -/// Repository contract for owner settings. -abstract interface class OwnerSettingsRepository { - Future findByOwnerId(String ownerId); - - Future?> findRecordByOwnerId(String ownerId); - - Future save(OwnerSettings settings); - - Future saveIfRevision({ - required OwnerSettings settings, - required int expectedRevision, - }); -} - -/// Repository contract for consumed one-time notices. -abstract interface class NoticeAcknowledgementRepository { - Future findById({ - required String ownerId, - required String noticeId, - }); - - Future> findByOwnerId(String ownerId); - - Future> findByOwner({ - required String ownerId, - RepositoryPageRequest page = const RepositoryPageRequest(), - }); - - Future save(NoticeAcknowledgementRecord record); - - Future insert( - NoticeAcknowledgementRecord record, - ); -} - -/// Repository contract for exactly-once operation records. -abstract interface class ApplicationOperationRepository { - Future findById(String operationId); - - Future findByIdempotencyKey({ - required String ownerId, - required String operationId, - }); - - Future save(ApplicationOperationRecord operation); - - Future insertIfAbsent( - ApplicationOperationRecord operation, - ); -} - -/// Repository set available inside one application unit of work. -abstract interface class ApplicationUnitOfWorkRepositories { - TaskRepository get tasks; - - ProjectRepository get projects; - - LockedBlockRepository get lockedBlocks; - - SchedulingSnapshotRepository get schedulingSnapshots; - - TaskActivityRepository get taskActivities; - - ProjectStatisticsRepository get projectStatistics; - - OwnerSettingsRepository get ownerSettings; - - NoticeAcknowledgementRepository get noticeAcknowledgements; - - ApplicationOperationRepository get operations; -} - -/// Atomic application transaction boundary. -abstract interface class ApplicationUnitOfWork { - Future> run({ - required ApplicationOperationContext context, - required String operationName, - required Future> Function( - ApplicationUnitOfWorkRepositories repositories, - ) action, - }); - - Future> read({ - required Future> Function( - ApplicationUnitOfWorkRepositories repositories, - ) action, - }); -} - -/// Repository loader for common scheduler input assembly. -class ApplicationSchedulingLoader { - const ApplicationSchedulingLoader(this.repositories); - - /// Repositories for the current application operation. - final ApplicationUnitOfWorkRepositories repositories; - - /// Load scheduler input for [window] from the repository boundary. - /// - /// UI code should not persist partial [SchedulingResult] objects. Commands - /// should load authoritative state here, invoke domain services, then commit - /// resulting entities through a unit of work. - Future loadInputForWindow({ - required SchedulingWindow window, - List lockedIntervals = const [], - List requiredVisibleIntervals = const [], - }) async { - final scheduledTasks = await repositories.tasks.findScheduledInWindow( - window, - ); - return SchedulingInput( - tasks: scheduledTasks, - window: window, - lockedIntervals: lockedIntervals, - requiredVisibleIntervals: requiredVisibleIntervals, - ); - } -} - -/// In-memory unit-of-work implementation for deterministic application tests. -class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork { - InMemoryApplicationUnitOfWork({ - Iterable initialTasks = const [], - Iterable initialProjects = const [], - Iterable initialLockedBlocks = const [], - Iterable initialLockedOverrides = - const [], - Iterable initialSchedulingSnapshots = - const [], - Iterable initialTaskActivities = const [], - Iterable initialProjectStatistics = - const [], - Iterable initialOwnerSettings = const [], - Iterable initialNoticeAcknowledgements = - const [], - Iterable initialOperations = - const [], - }) : _state = _InMemoryApplicationState( - tasksById: { - for (final task in initialTasks) task.id: task, - }, - taskOwnersById: { - for (final task in initialTasks) task.id: 'owner-1', - }, - taskRevisionsById: { - for (final task in initialTasks) task.id: 1, - }, - projectsById: { - for (final project in initialProjects) project.id: project, - }, - projectOwnersById: { - for (final project in initialProjects) project.id: 'owner-1', - }, - projectRevisionsById: { - for (final project in initialProjects) project.id: 1, - }, - lockedBlocksById: { - for (final block in initialLockedBlocks) block.id: block, - }, - lockedBlockOwnersById: { - for (final block in initialLockedBlocks) block.id: 'owner-1', - }, - lockedBlockRevisionsById: { - for (final block in initialLockedBlocks) block.id: 1, - }, - lockedOverridesById: { - for (final override in initialLockedOverrides) - override.id: override, - }, - lockedOverrideOwnersById: { - for (final override in initialLockedOverrides) - override.id: 'owner-1', - }, - lockedOverrideRevisionsById: { - for (final override in initialLockedOverrides) override.id: 1, - }, - schedulingSnapshotsById: { - for (final snapshot in initialSchedulingSnapshots) - snapshot.id: snapshot, - }, - taskActivitiesById: { - for (final activity in initialTaskActivities) activity.id: activity, - }, - taskActivityOwnersById: { - for (final activity in initialTaskActivities) - activity.id: 'owner-1', - }, - projectStatisticsByProjectId: { - for (final statistics in initialProjectStatistics) - statistics.projectId: statistics, - }, - projectStatisticsOwnersByProjectId: { - for (final statistics in initialProjectStatistics) - statistics.projectId: 'owner-1', - }, - projectStatisticsRevisionsByProjectId: { - for (final statistics in initialProjectStatistics) - statistics.projectId: 1, - }, - ownerSettingsByOwnerId: { - for (final settings in initialOwnerSettings) - settings.ownerId: settings, - }, - ownerSettingsRevisionsByOwnerId: { - for (final settings in initialOwnerSettings) settings.ownerId: 1, - }, - noticeAcknowledgementsByScopedId: { - for (final acknowledgement in initialNoticeAcknowledgements) - _noticeAcknowledgementKey( - ownerId: acknowledgement.ownerId, - noticeId: acknowledgement.noticeId, - ): acknowledgement, - }, - noticeAcknowledgementRevisionsByScopedId: { - for (final acknowledgement in initialNoticeAcknowledgements) - _noticeAcknowledgementKey( - ownerId: acknowledgement.ownerId, - noticeId: acknowledgement.noticeId, - ): 1, - }, - operationsById: { - for (final operation in initialOperations) - operation.operationId: operation, - }, - ); - - _InMemoryApplicationState _state; - bool _failNextCommit = false; - - /// Current committed tasks, useful for contract tests. - List get currentTasks => - List.unmodifiable(_state.tasksById.values); - - /// Current committed projects, useful for contract tests. - List get currentProjects { - return List.unmodifiable(_state.projectsById.values); - } - - /// Current committed locked blocks, useful for contract tests. - List get currentLockedBlocks { - return List.unmodifiable(_state.lockedBlocksById.values); - } - - /// Current committed locked overrides, useful for contract tests. - List get currentLockedOverrides { - return List.unmodifiable( - _state.lockedOverridesById.values, - ); - } - - /// Current committed scheduling snapshots, useful for contract tests. - List get currentSchedulingSnapshots { - return List.unmodifiable( - _state.schedulingSnapshotsById.values, - ); - } - - /// Current committed activities, useful for contract tests. - List get currentTaskActivities { - return List.unmodifiable(_state.taskActivitiesById.values); - } - - /// Current committed project statistics, useful for contract tests. - List get currentProjectStatistics { - return List.unmodifiable( - _state.projectStatisticsByProjectId.values, - ); - } - - /// Current committed owner settings, useful for contract tests. - List get currentOwnerSettings { - return List.unmodifiable( - _state.ownerSettingsByOwnerId.values); - } - - /// Current committed notice acknowledgements, useful for contract tests. - List get currentNoticeAcknowledgements { - return List.unmodifiable( - _state.noticeAcknowledgementsByScopedId.values, - ); - } - - /// Current committed operation records, useful for contract tests. - List get currentOperations { - return List.unmodifiable( - _state.operationsById.values, - ); - } - - /// Cause the next successful transaction to fail at commit time. - void failNextCommitForTesting() { - _failNextCommit = true; - } - - @override - Future> run({ - required ApplicationOperationContext context, - required String operationName, - required Future> Function( - ApplicationUnitOfWorkRepositories repositories, - ) action, - }) async { - if (_state.operationsById.containsKey(context.operationId)) { - return ApplicationResult.failure( - ApplicationFailure( - code: ApplicationFailureCode.duplicateOperation, - entityId: context.operationId, - ), - ); - } - - final staged = _state.copy(); - final repositories = _InMemoryApplicationRepositories(staged); - - try { - final result = await action(repositories); - if (result.isFailure) { - return result; - } - - staged.operationsById[context.operationId] = ApplicationOperationRecord( - operationId: context.operationId, - ownerId: context.ownerTimeZone.ownerId, - operationName: operationName, - committedAt: context.now, - ); - - if (_failNextCommit) { - _failNextCommit = false; - throw const ApplicationPersistenceException(); - } - - _state = staged; - return result; - } on ApplicationFailureException catch (error) { - return ApplicationResult.failure(error.failure); - } on DomainValidationException catch (error) { - return ApplicationResult.failure( - ApplicationFailure( - code: ApplicationFailureCode.validation, - detailCode: error.code.name, - ), - ); - } on ArgumentError catch (error) { - return ApplicationResult.failure( - ApplicationFailure( - code: ApplicationFailureCode.validation, - detailCode: error.name, - ), - ); - } on ApplicationPersistenceException { - return ApplicationResult.failure( - const ApplicationFailure( - code: ApplicationFailureCode.persistenceFailure, - ), - ); - } catch (_) { - return ApplicationResult.failure( - const ApplicationFailure(code: ApplicationFailureCode.unexpected), - ); - } - } - - @override - Future> read({ - required Future> Function( - ApplicationUnitOfWorkRepositories repositories, - ) action, - }) async { - final staged = _state.copy(); - final repositories = _InMemoryApplicationRepositories(staged); - - try { - return await action(repositories); - } on ApplicationFailureException catch (error) { - return ApplicationResult.failure(error.failure); - } on DomainValidationException catch (error) { - return ApplicationResult.failure( - ApplicationFailure( - code: ApplicationFailureCode.validation, - detailCode: error.code.name, - ), - ); - } on ArgumentError catch (error) { - return ApplicationResult.failure( - ApplicationFailure( - code: ApplicationFailureCode.validation, - detailCode: error.name, - ), - ); - } on ApplicationPersistenceException { - return ApplicationResult.failure( - const ApplicationFailure( - code: ApplicationFailureCode.persistenceFailure, - ), - ); - } catch (_) { - return ApplicationResult.failure( - const ApplicationFailure(code: ApplicationFailureCode.unexpected), - ); - } - } -} - -class _InMemoryApplicationState { - _InMemoryApplicationState({ - required this.tasksById, - required this.taskOwnersById, - required this.taskRevisionsById, - required this.projectsById, - required this.projectOwnersById, - required this.projectRevisionsById, - required this.lockedBlocksById, - required this.lockedBlockOwnersById, - required this.lockedBlockRevisionsById, - required this.lockedOverridesById, - required this.lockedOverrideOwnersById, - required this.lockedOverrideRevisionsById, - required this.schedulingSnapshotsById, - required this.taskActivitiesById, - required this.taskActivityOwnersById, - required this.projectStatisticsByProjectId, - required this.projectStatisticsOwnersByProjectId, - required this.projectStatisticsRevisionsByProjectId, - required this.ownerSettingsByOwnerId, - required this.ownerSettingsRevisionsByOwnerId, - required this.noticeAcknowledgementsByScopedId, - required this.noticeAcknowledgementRevisionsByScopedId, - required this.operationsById, - }); - - final Map tasksById; - final Map taskOwnersById; - final Map taskRevisionsById; - final Map projectsById; - final Map projectOwnersById; - final Map projectRevisionsById; - final Map lockedBlocksById; - final Map lockedBlockOwnersById; - final Map lockedBlockRevisionsById; - final Map lockedOverridesById; - final Map lockedOverrideOwnersById; - final Map lockedOverrideRevisionsById; - final Map schedulingSnapshotsById; - final Map taskActivitiesById; - final Map taskActivityOwnersById; - final Map projectStatisticsByProjectId; - final Map projectStatisticsOwnersByProjectId; - final Map projectStatisticsRevisionsByProjectId; - final Map ownerSettingsByOwnerId; - final Map ownerSettingsRevisionsByOwnerId; - final Map - noticeAcknowledgementsByScopedId; - final Map noticeAcknowledgementRevisionsByScopedId; - final Map operationsById; - - _InMemoryApplicationState copy() { - return _InMemoryApplicationState( - tasksById: Map.of(tasksById), - taskOwnersById: Map.of(taskOwnersById), - taskRevisionsById: Map.of(taskRevisionsById), - projectsById: Map.of(projectsById), - projectOwnersById: Map.of(projectOwnersById), - projectRevisionsById: Map.of(projectRevisionsById), - lockedBlocksById: Map.of(lockedBlocksById), - lockedBlockOwnersById: Map.of(lockedBlockOwnersById), - lockedBlockRevisionsById: Map.of( - lockedBlockRevisionsById, - ), - lockedOverridesById: - Map.of(lockedOverridesById), - lockedOverrideOwnersById: Map.of( - lockedOverrideOwnersById, - ), - lockedOverrideRevisionsById: Map.of( - lockedOverrideRevisionsById, - ), - schedulingSnapshotsById: - Map.of(schedulingSnapshotsById), - taskActivitiesById: Map.of(taskActivitiesById), - taskActivityOwnersById: Map.of(taskActivityOwnersById), - projectStatisticsByProjectId: - Map.of(projectStatisticsByProjectId), - projectStatisticsOwnersByProjectId: Map.of( - projectStatisticsOwnersByProjectId, - ), - projectStatisticsRevisionsByProjectId: Map.of( - projectStatisticsRevisionsByProjectId, - ), - ownerSettingsByOwnerId: Map.of( - ownerSettingsByOwnerId, - ), - ownerSettingsRevisionsByOwnerId: Map.of( - ownerSettingsRevisionsByOwnerId, - ), - noticeAcknowledgementsByScopedId: - Map.of( - noticeAcknowledgementsByScopedId, - ), - noticeAcknowledgementRevisionsByScopedId: Map.of( - noticeAcknowledgementRevisionsByScopedId, - ), - operationsById: Map.of( - operationsById, - ), - ); - } -} - -class _InMemoryApplicationRepositories - implements ApplicationUnitOfWorkRepositories { - _InMemoryApplicationRepositories(_InMemoryApplicationState state) - : tasks = _UnitOfWorkTaskRepository( - tasksById: state.tasksById, - ownersById: state.taskOwnersById, - revisionsById: state.taskRevisionsById, - ), - projects = _UnitOfWorkProjectRepository( - projectsById: state.projectsById, - ownersById: state.projectOwnersById, - revisionsById: state.projectRevisionsById, - ), - lockedBlocks = _UnitOfWorkLockedBlockRepository( - blocksById: state.lockedBlocksById, - blockOwnersById: state.lockedBlockOwnersById, - blockRevisionsById: state.lockedBlockRevisionsById, - overridesById: state.lockedOverridesById, - overrideOwnersById: state.lockedOverrideOwnersById, - overrideRevisionsById: state.lockedOverrideRevisionsById, - ), - schedulingSnapshots = _UnitOfWorkSchedulingSnapshotRepository( - state.schedulingSnapshotsById, - ), - taskActivities = _UnitOfWorkTaskActivityRepository( - activitiesById: state.taskActivitiesById, - ownersById: state.taskActivityOwnersById, - ), - projectStatistics = _UnitOfWorkProjectStatisticsRepository( - statisticsByProjectId: state.projectStatisticsByProjectId, - ownersByProjectId: state.projectStatisticsOwnersByProjectId, - revisionsByProjectId: state.projectStatisticsRevisionsByProjectId, - ), - ownerSettings = _UnitOfWorkOwnerSettingsRepository( - settingsByOwnerId: state.ownerSettingsByOwnerId, - revisionsByOwnerId: state.ownerSettingsRevisionsByOwnerId, - ), - noticeAcknowledgements = _UnitOfWorkNoticeAcknowledgementRepository( - recordsByScopedId: state.noticeAcknowledgementsByScopedId, - revisionsByScopedId: state.noticeAcknowledgementRevisionsByScopedId, - ), - operations = _UnitOfWorkOperationRepository(state.operationsById); - - @override - final TaskRepository tasks; - - @override - final ProjectRepository projects; - - @override - final LockedBlockRepository lockedBlocks; - - @override - final SchedulingSnapshotRepository schedulingSnapshots; - - @override - final TaskActivityRepository taskActivities; - - @override - final ProjectStatisticsRepository projectStatistics; - - @override - final OwnerSettingsRepository ownerSettings; - - @override - final NoticeAcknowledgementRepository noticeAcknowledgements; - - @override - final ApplicationOperationRepository operations; -} - -class _UnitOfWorkTaskRepository implements TaskRepository { - _UnitOfWorkTaskRepository({ - required Map tasksById, - required Map ownersById, - required Map revisionsById, - }) : _tasksById = tasksById, - _ownersById = ownersById, - _revisionsById = revisionsById; - - final Map _tasksById; - final Map _ownersById; - final Map _revisionsById; - - @override - Future findById(String id) async => _tasksById[id]; - - @override - Future?> findRecordById(String id) async { - final task = _tasksById[id]; - if (task == null) { - return null; - } - return RepositoryRecord( - value: task, - ownerId: _ownerFor(id), - revision: _revisionFor(id), - ); - } - - @override - Future> findAll() async { - return List.unmodifiable(_tasksById.values); - } - - @override - Future> findByStatus(TaskStatus status) async { - return List.unmodifiable( - _tasksById.values.where((task) => task.status == status), - ); - } - - @override - Future> findByOwner({ - required String ownerId, - RepositoryPageRequest page = const RepositoryPageRequest(), - }) async { - final tasks = _tasksById.values - .where((task) => _ownerFor(task.id) == ownerId) - .toList(growable: false) - ..sort(_compareTaskIds); - return _page(tasks, page); - } - - @override - Future> findByProjectId({ - required String ownerId, - required String projectId, - RepositoryPageRequest page = const RepositoryPageRequest(), - }) async { - final tasks = _tasksById.values - .where( - (task) => - _ownerFor(task.id) == ownerId && task.projectId == projectId, - ) - .toList(growable: false) - ..sort(_compareTaskIds); - return _page(tasks, page); - } - - @override - Future> findByParentTaskId({ - required String ownerId, - required String parentTaskId, - RepositoryPageRequest page = const RepositoryPageRequest(), - }) async { - final tasks = _tasksById.values - .where( - (task) => - _ownerFor(task.id) == ownerId && - task.parentTaskId == parentTaskId, - ) - .toList(growable: false) - ..sort(_compareTaskIds); - return _page(tasks, page); - } - - @override - Future> findBacklogCandidates( - BacklogCandidateQuery query, - ) async { - final tasks = _tasksById.values - .where( - (task) => - _ownerFor(task.id) == query.ownerId && - task.status == TaskStatus.backlog && - (query.projectId == null || task.projectId == query.projectId) && - query.tags.every(task.backlogTags.contains), - ) - .toList(growable: false) - ..sort(_compareBacklogCandidates); - return _page(tasks, query.page); - } - - @override - Future> findScheduledInWindow(SchedulingWindow window) async { - return List.unmodifiable( - _tasksById.values.where((task) { - return _taskOverlapsWindow(task, window); - }), - ); - } - - @override - Future> findScheduledInWindowForOwner({ - required String ownerId, - required SchedulingWindow window, - RepositoryPageRequest page = const RepositoryPageRequest(), - }) async { - final tasks = _tasksById.values - .where( - (task) => - _ownerFor(task.id) == ownerId && - _taskOverlapsWindow(task, window), - ) - .toList(growable: false) - ..sort(_compareScheduledTasks); - return _page(tasks, page); - } - - @override - Future> findForLocalDay({ - required String ownerId, - required CivilDate localDate, - required SchedulingWindow window, - RepositoryPageRequest page = const RepositoryPageRequest(), - }) { - return findScheduledInWindowForOwner( - ownerId: ownerId, - window: window, - page: page, - ); - } - - @override - Future save(Task task) async { - _tasksById[task.id] = task; - _ownersById.putIfAbsent(task.id, () => 'owner-1'); - _revisionsById[task.id] = _revisionFor(task.id) + 1; - } - - @override - Future saveAll(Iterable tasks) async { - for (final task in tasks) { - await save(task); - } - } - - @override - Future insert({ - required Task task, - required String ownerId, - }) async { - if (_tasksById.containsKey(task.id)) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.duplicateId, - entityId: task.id, - ), - ); - } - _tasksById[task.id] = task; - _ownersById[task.id] = ownerId; - _revisionsById[task.id] = 1; - return RepositoryMutationResult.success(revision: 1); - } - - @override - Future saveIfRevision({ - required Task task, - required String ownerId, - required int expectedRevision, - }) async { - if (!_tasksById.containsKey(task.id) || _ownerFor(task.id) != ownerId) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.notFound, entityId: task.id), - ); - } - final actualRevision = _revisionFor(task.id); - if (actualRevision != expectedRevision) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.staleRevision, - entityId: task.id, - expectedRevision: expectedRevision, - actualRevision: actualRevision, - ), - ); - } - final nextRevision = actualRevision + 1; - _tasksById[task.id] = task; - _ownersById[task.id] = ownerId; - _revisionsById[task.id] = nextRevision; - return RepositoryMutationResult.success(revision: nextRevision); - } - - String _ownerFor(String id) => _ownersById[id] ?? 'owner-1'; - - int _revisionFor(String id) => _revisionsById[id] ?? 1; -} - -class _UnitOfWorkProjectRepository implements ProjectRepository { - _UnitOfWorkProjectRepository({ - required Map projectsById, - required Map ownersById, - required Map revisionsById, - }) : _projectsById = projectsById, - _ownersById = ownersById, - _revisionsById = revisionsById; - - final Map _projectsById; - final Map _ownersById; - final Map _revisionsById; - - @override - Future findById(String id) async => _projectsById[id]; - - @override - Future?> findRecordById(String id) async { - final project = _projectsById[id]; - if (project == null) { - return null; - } - return RepositoryRecord( - value: project, - ownerId: _ownerFor(id), - revision: _revisionFor(id), - archivedAt: project.archivedAt, - ); - } - - @override - Future> findAll() async { - return List.unmodifiable(_projectsById.values); - } - - @override - Future> findByOwner({ - required String ownerId, - bool includeArchived = false, - RepositoryPageRequest page = const RepositoryPageRequest(), - }) async { - final projects = _projectsById.values - .where( - (project) => - _ownerFor(project.id) == ownerId && - (includeArchived || !project.isArchived), - ) - .toList(growable: false) - ..sort(_compareProjects); - return _page(projects, page); - } - - @override - Future save(ProjectProfile project) async { - _projectsById[project.id] = project; - _ownersById.putIfAbsent(project.id, () => 'owner-1'); - _revisionsById[project.id] = _revisionFor(project.id) + 1; - } - - @override - Future insert({ - required ProjectProfile project, - required String ownerId, - }) async { - if (_projectsById.containsKey(project.id)) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.duplicateId, - entityId: project.id, - ), - ); - } - _projectsById[project.id] = project; - _ownersById[project.id] = ownerId; - _revisionsById[project.id] = 1; - return RepositoryMutationResult.success(revision: 1); - } - - @override - Future saveIfRevision({ - required ProjectProfile project, - required String ownerId, - required int expectedRevision, - }) async { - if (!_projectsById.containsKey(project.id) || - _ownerFor(project.id) != ownerId) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.notFound, - entityId: project.id, - ), - ); - } - final actualRevision = _revisionFor(project.id); - if (actualRevision != expectedRevision) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.staleRevision, - entityId: project.id, - expectedRevision: expectedRevision, - actualRevision: actualRevision, - ), - ); - } - final nextRevision = actualRevision + 1; - _projectsById[project.id] = project; - _ownersById[project.id] = ownerId; - _revisionsById[project.id] = nextRevision; - return RepositoryMutationResult.success(revision: nextRevision); - } - - @override - Future archive({ - required String projectId, - required String ownerId, - required int expectedRevision, - required DateTime archivedAt, - }) async { - final project = _projectsById[projectId]; - if (project == null) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.notFound, - entityId: projectId, - ), - ); - } - return saveIfRevision( - project: project.copyWith(archivedAt: archivedAt), - ownerId: ownerId, - expectedRevision: expectedRevision, - ); - } - - String _ownerFor(String id) => _ownersById[id] ?? 'owner-1'; - - int _revisionFor(String id) => _revisionsById[id] ?? 1; -} - -class _UnitOfWorkLockedBlockRepository implements LockedBlockRepository { - _UnitOfWorkLockedBlockRepository({ - required Map blocksById, - required Map blockOwnersById, - required Map blockRevisionsById, - required Map overridesById, - required Map overrideOwnersById, - required Map overrideRevisionsById, - }) : _blocksById = blocksById, - _blockOwnersById = blockOwnersById, - _blockRevisionsById = blockRevisionsById, - _overridesById = overridesById, - _overrideOwnersById = overrideOwnersById, - _overrideRevisionsById = overrideRevisionsById; - - final Map _blocksById; - final Map _blockOwnersById; - final Map _blockRevisionsById; - final Map _overridesById; - final Map _overrideOwnersById; - final Map _overrideRevisionsById; - - @override - Future findBlockById(String id) async => _blocksById[id]; - - @override - Future?> findBlockRecordById(String id) async { - final block = _blocksById[id]; - if (block == null) { - return null; - } - return RepositoryRecord( - value: block, - ownerId: _blockOwnerFor(id), - revision: _blockRevisionFor(id), - archivedAt: block.archivedAt, - ); - } - - @override - Future> findAllBlocks() async { - return List.unmodifiable(_blocksById.values); - } - - @override - Future> findBlocksByOwner({ - required String ownerId, - bool includeArchived = false, - RepositoryPageRequest page = const RepositoryPageRequest(), - }) async { - final blocks = _blocksById.values - .where( - (block) => - _blockOwnerFor(block.id) == ownerId && - (includeArchived || !block.isArchived), - ) - .toList(growable: false) - ..sort(_compareLockedBlocks); - return _page(blocks, page); - } - - @override - Future findOverrideById(String id) async { - return _overridesById[id]; - } - - @override - Future?> findOverrideRecordById( - String id, - ) async { - final override = _overridesById[id]; - if (override == null) { - return null; - } - return RepositoryRecord( - value: override, - ownerId: _overrideOwnerFor(id), - revision: _overrideRevisionFor(id), - ); - } - - @override - Future> findAllOverrides() async { - return List.unmodifiable(_overridesById.values); - } - - @override - Future> findOverridesForDate({ - required String ownerId, - required CivilDate date, - RepositoryPageRequest page = const RepositoryPageRequest(), - }) async { - final overrides = _overridesById.values - .where( - (override) => - _overrideOwnerFor(override.id) == ownerId && - override.date == date, - ) - .toList(growable: false) - ..sort(_compareLockedOverrides); - return _page(overrides, page); - } - - @override - Future saveBlock(LockedBlock block) async { - _blocksById[block.id] = block; - _blockOwnersById.putIfAbsent(block.id, () => 'owner-1'); - _blockRevisionsById[block.id] = _blockRevisionFor(block.id) + 1; - } - - @override - Future saveOverride(LockedBlockOverride override) async { - _overridesById[override.id] = override; - _overrideOwnersById.putIfAbsent(override.id, () => 'owner-1'); - _overrideRevisionsById[override.id] = _overrideRevisionFor(override.id) + 1; - } - - @override - Future insertBlock({ - required LockedBlock block, - required String ownerId, - }) async { - if (_blocksById.containsKey(block.id)) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.duplicateId, - entityId: block.id, - ), - ); - } - _blocksById[block.id] = block; - _blockOwnersById[block.id] = ownerId; - _blockRevisionsById[block.id] = 1; - return RepositoryMutationResult.success(revision: 1); - } - - @override - Future saveBlockIfRevision({ - required LockedBlock block, - required String ownerId, - required int expectedRevision, - }) async { - if (!_blocksById.containsKey(block.id) || - _blockOwnerFor(block.id) != ownerId) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.notFound, - entityId: block.id, - ), - ); - } - final actualRevision = _blockRevisionFor(block.id); - if (actualRevision != expectedRevision) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.staleRevision, - entityId: block.id, - expectedRevision: expectedRevision, - actualRevision: actualRevision, - ), - ); - } - final nextRevision = actualRevision + 1; - _blocksById[block.id] = block; - _blockOwnersById[block.id] = ownerId; - _blockRevisionsById[block.id] = nextRevision; - return RepositoryMutationResult.success(revision: nextRevision); - } - - @override - Future archiveBlock({ - required String blockId, - required String ownerId, - required int expectedRevision, - required DateTime archivedAt, - }) async { - final block = _blocksById[blockId]; - if (block == null) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.notFound, - entityId: blockId, - ), - ); - } - return saveBlockIfRevision( - block: block.copyWith(updatedAt: archivedAt, archivedAt: archivedAt), - ownerId: ownerId, - expectedRevision: expectedRevision, - ); - } - - @override - Future insertOverride({ - required LockedBlockOverride override, - required String ownerId, - }) async { - if (_overridesById.containsKey(override.id)) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.duplicateId, - entityId: override.id, - ), - ); - } - _overridesById[override.id] = override; - _overrideOwnersById[override.id] = ownerId; - _overrideRevisionsById[override.id] = 1; - return RepositoryMutationResult.success(revision: 1); - } - - @override - Future saveOverrideIfRevision({ - required LockedBlockOverride override, - required String ownerId, - required int expectedRevision, - }) async { - if (!_overridesById.containsKey(override.id) || - _overrideOwnerFor(override.id) != ownerId) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.notFound, - entityId: override.id, - ), - ); - } - final actualRevision = _overrideRevisionFor(override.id); - if (actualRevision != expectedRevision) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.staleRevision, - entityId: override.id, - expectedRevision: expectedRevision, - actualRevision: actualRevision, - ), - ); - } - final nextRevision = actualRevision + 1; - _overridesById[override.id] = override; - _overrideOwnersById[override.id] = ownerId; - _overrideRevisionsById[override.id] = nextRevision; - return RepositoryMutationResult.success(revision: nextRevision); - } - - String _blockOwnerFor(String id) => _blockOwnersById[id] ?? 'owner-1'; - - int _blockRevisionFor(String id) => _blockRevisionsById[id] ?? 1; - - String _overrideOwnerFor(String id) => _overrideOwnersById[id] ?? 'owner-1'; - - int _overrideRevisionFor(String id) => _overrideRevisionsById[id] ?? 1; -} - -class _UnitOfWorkSchedulingSnapshotRepository - implements SchedulingSnapshotRepository { - _UnitOfWorkSchedulingSnapshotRepository(this._snapshotsById); - - final Map _snapshotsById; - - @override - Future findById(String id) async { - return _snapshotsById[id]; - } - - @override - Future> findInWindow( - SchedulingWindow window, - ) async { - return List.unmodifiable( - _snapshotsById.values.where( - (snapshot) => snapshot.window.interval.overlaps(window.interval), - ), - ); - } - - @override - Future save(SchedulingStateSnapshot snapshot) async { - _snapshotsById[snapshot.id] = snapshot; - } -} - -class _UnitOfWorkTaskActivityRepository implements TaskActivityRepository { - _UnitOfWorkTaskActivityRepository({ - required Map activitiesById, - required Map ownersById, - }) : _activitiesById = activitiesById, - _ownersById = ownersById; - - final Map _activitiesById; - final Map _ownersById; - - @override - Future findById(String id) async => _activitiesById[id]; - - @override - Future> findByOperationId(String operationId) async { - return List.unmodifiable( - _activitiesById.values.where( - (activity) => activity.operationId == operationId, - ), - ); - } - - @override - Future> findByTaskId(String taskId) async { - return List.unmodifiable( - _activitiesById.values.where((activity) => activity.taskId == taskId), - ); - } - - @override - Future> findByProjectId(String projectId) async { - return List.unmodifiable( - _activitiesById.values.where( - (activity) => activity.projectId == projectId, - ), - ); - } - - @override - Future> findAll() async { - return List.unmodifiable(_activitiesById.values); - } - - @override - Future> findByOwner({ - required String ownerId, - RepositoryPageRequest page = const RepositoryPageRequest(), - }) async { - final activities = _activitiesById.values - .where((activity) => _ownerFor(activity.id) == ownerId) - .toList(growable: false) - ..sort(_compareActivities); - return _page(activities, page); - } - - @override - Future save(TaskActivity activity) async { - _activitiesById[activity.id] = activity; - _ownersById.putIfAbsent(activity.id, () => 'owner-1'); - } - - @override - Future saveAll(Iterable activities) async { - for (final activity in activities) { - _activitiesById[activity.id] = activity; - _ownersById.putIfAbsent(activity.id, () => 'owner-1'); - } - } - - @override - Future append({ - required TaskActivity activity, - required String ownerId, - }) async { - if (_activitiesById.containsKey(activity.id)) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.duplicateId, - entityId: activity.id, - ), - ); - } - _activitiesById[activity.id] = activity; - _ownersById[activity.id] = ownerId; - return RepositoryMutationResult.success(revision: 1); - } - - String _ownerFor(String id) => _ownersById[id] ?? 'owner-1'; -} - -class _UnitOfWorkProjectStatisticsRepository - implements ProjectStatisticsRepository { - _UnitOfWorkProjectStatisticsRepository({ - required Map statisticsByProjectId, - required Map ownersByProjectId, - required Map revisionsByProjectId, - }) : _statisticsByProjectId = statisticsByProjectId, - _ownersByProjectId = ownersByProjectId, - _revisionsByProjectId = revisionsByProjectId; - - final Map _statisticsByProjectId; - final Map _ownersByProjectId; - final Map _revisionsByProjectId; - - @override - Future findByProjectId(String projectId) async { - return _statisticsByProjectId[projectId]; - } - - @override - Future?> findRecordByProjectId( - String projectId, - ) async { - final statistics = _statisticsByProjectId[projectId]; - if (statistics == null) { - return null; - } - return RepositoryRecord( - value: statistics, - ownerId: _ownersByProjectId[projectId] ?? 'owner-1', - revision: _revisionsByProjectId[projectId] ?? 1, - ); - } - - @override - Future> findAll() async { - return List.unmodifiable(_statisticsByProjectId.values); - } - - @override - Future save(ProjectStatistics statistics) async { - _statisticsByProjectId[statistics.projectId] = statistics; - _ownersByProjectId.putIfAbsent(statistics.projectId, () => 'owner-1'); - _revisionsByProjectId[statistics.projectId] = - (_revisionsByProjectId[statistics.projectId] ?? 1) + 1; - } - - @override - Future saveIfRevision({ - required ProjectStatistics statistics, - required String ownerId, - required int expectedRevision, - }) async { - final projectId = statistics.projectId; - if (!_statisticsByProjectId.containsKey(projectId) || - (_ownersByProjectId[projectId] ?? 'owner-1') != ownerId) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.notFound, - entityId: projectId, - ), - ); - } - final actualRevision = _revisionsByProjectId[projectId] ?? 1; - if (actualRevision != expectedRevision) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.staleRevision, - entityId: projectId, - expectedRevision: expectedRevision, - actualRevision: actualRevision, - ), - ); - } - final nextRevision = actualRevision + 1; - _statisticsByProjectId[projectId] = statistics; - _ownersByProjectId[projectId] = ownerId; - _revisionsByProjectId[projectId] = nextRevision; - return RepositoryMutationResult.success(revision: nextRevision); - } -} - -class _UnitOfWorkOwnerSettingsRepository implements OwnerSettingsRepository { - _UnitOfWorkOwnerSettingsRepository({ - required Map settingsByOwnerId, - required Map revisionsByOwnerId, - }) : _settingsByOwnerId = settingsByOwnerId, - _revisionsByOwnerId = revisionsByOwnerId; - - final Map _settingsByOwnerId; - final Map _revisionsByOwnerId; - - @override - Future findByOwnerId(String ownerId) async { - return _settingsByOwnerId[ownerId]; - } - - @override - Future?> findRecordByOwnerId( - String ownerId, - ) async { - final settings = _settingsByOwnerId[ownerId]; - if (settings == null) { - return null; - } - return RepositoryRecord( - value: settings, - ownerId: ownerId, - revision: _revisionsByOwnerId[ownerId] ?? 1, - ); - } - - @override - Future save(OwnerSettings settings) async { - _settingsByOwnerId[settings.ownerId] = settings; - _revisionsByOwnerId[settings.ownerId] = - (_revisionsByOwnerId[settings.ownerId] ?? 1) + 1; - } - - @override - Future saveIfRevision({ - required OwnerSettings settings, - required int expectedRevision, - }) async { - final ownerId = settings.ownerId; - if (!_settingsByOwnerId.containsKey(ownerId)) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.notFound, - entityId: ownerId, - ), - ); - } - final actualRevision = _revisionsByOwnerId[ownerId] ?? 1; - if (actualRevision != expectedRevision) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.staleRevision, - entityId: ownerId, - expectedRevision: expectedRevision, - actualRevision: actualRevision, - ), - ); - } - final nextRevision = actualRevision + 1; - _settingsByOwnerId[ownerId] = settings; - _revisionsByOwnerId[ownerId] = nextRevision; - return RepositoryMutationResult.success(revision: nextRevision); - } -} - -class _UnitOfWorkNoticeAcknowledgementRepository - implements NoticeAcknowledgementRepository { - _UnitOfWorkNoticeAcknowledgementRepository({ - required Map recordsByScopedId, - required Map revisionsByScopedId, - }) : _recordsByScopedId = recordsByScopedId, - _revisionsByScopedId = revisionsByScopedId; - - final Map _recordsByScopedId; - final Map _revisionsByScopedId; - - @override - Future findById({ - required String ownerId, - required String noticeId, - }) async { - return _recordsByScopedId[ - _noticeAcknowledgementKey(ownerId: ownerId, noticeId: noticeId)]; - } - - @override - Future> findByOwnerId( - String ownerId, - ) async { - return List.unmodifiable( - _recordsByScopedId.values.where((record) => record.ownerId == ownerId), - ); - } - - @override - Future> findByOwner({ - required String ownerId, - RepositoryPageRequest page = const RepositoryPageRequest(), - }) async { - final records = _recordsByScopedId.values - .where((record) => record.ownerId == ownerId) - .toList(growable: false) - ..sort(_compareNoticeAcknowledgements); - return _page(records, page); - } - - @override - Future save(NoticeAcknowledgementRecord record) async { - final key = _noticeAcknowledgementKey( - ownerId: record.ownerId, - noticeId: record.noticeId, - ); - _recordsByScopedId[key] = record; - _revisionsByScopedId[key] = (_revisionsByScopedId[key] ?? 1) + 1; - } - - @override - Future insert( - NoticeAcknowledgementRecord record, - ) async { - final key = _noticeAcknowledgementKey( - ownerId: record.ownerId, - noticeId: record.noticeId, - ); - if (_recordsByScopedId.containsKey(key)) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.duplicateId, - entityId: record.noticeId, - ), - ); - } - _recordsByScopedId[key] = record; - _revisionsByScopedId[key] = 1; - return RepositoryMutationResult.success(revision: 1); - } -} - -class _UnitOfWorkOperationRepository implements ApplicationOperationRepository { - _UnitOfWorkOperationRepository(this._operationsById); - - final Map _operationsById; - - @override - Future findById(String operationId) async { - return _operationsById[operationId]; - } - - @override - Future findByIdempotencyKey({ - required String ownerId, - required String operationId, - }) async { - final operation = _operationsById[operationId]; - if (operation == null || operation.ownerId != ownerId) { - return null; - } - return operation; - } - - @override - Future save(ApplicationOperationRecord operation) async { - _operationsById[operation.operationId] = operation; - } - - @override - Future insertIfAbsent( - ApplicationOperationRecord operation, - ) async { - if (_operationsById.containsKey(operation.operationId)) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.duplicateOperation, - entityId: operation.operationId, - ), - ); - } - _operationsById[operation.operationId] = operation; - return RepositoryMutationResult.success(revision: 1); - } -} - -String _noticeAcknowledgementKey({ - required String ownerId, - required String noticeId, -}) { - return '$ownerId::$noticeId'; -} - -RepositoryPage _page(List sortedItems, RepositoryPageRequest request) { - final offset = int.tryParse(request.cursor ?? '') ?? 0; - final safeOffset = offset.clamp(0, sortedItems.length); - final end = (safeOffset + request.limit).clamp(0, sortedItems.length); - final items = sortedItems.sublist(safeOffset, end); - final nextCursor = end < sortedItems.length ? end.toString() : null; - return RepositoryPage( - items: List.unmodifiable(items), - nextCursor: nextCursor, - ); -} - -bool _taskOverlapsWindow(Task task, SchedulingWindow window) { - final start = task.scheduledStart; - final end = task.scheduledEnd; - if (start == null || end == null) { - return false; - } - return TimeInterval(start: start, end: end).overlaps(window.interval); -} - -int _compareTaskIds(Task left, Task right) => left.id.compareTo(right.id); - -int _compareScheduledTasks(Task left, Task right) { - final leftStart = left.scheduledStart; - final rightStart = right.scheduledStart; - if (leftStart != null && rightStart != null) { - final startComparison = leftStart.compareTo(rightStart); - if (startComparison != 0) { - return startComparison; - } - } - return left.id.compareTo(right.id); -} - -int _compareBacklogCandidates(Task left, Task right) { - final createdComparison = left.createdAt.compareTo(right.createdAt); - if (createdComparison != 0) { - return createdComparison; - } - return left.id.compareTo(right.id); -} - -int _compareProjects(ProjectProfile left, ProjectProfile right) { - final nameComparison = left.name.compareTo(right.name); - if (nameComparison != 0) { - return nameComparison; - } - return left.id.compareTo(right.id); -} - -int _compareLockedBlocks(LockedBlock left, LockedBlock right) { - final nameComparison = left.name.compareTo(right.name); - if (nameComparison != 0) { - return nameComparison; - } - return left.id.compareTo(right.id); -} - -int _compareLockedOverrides( - LockedBlockOverride left, - LockedBlockOverride right, -) { - final dateComparison = left.date.compareTo(right.date); - if (dateComparison != 0) { - return dateComparison; - } - final createdComparison = left.createdAt.compareTo(right.createdAt); - if (createdComparison != 0) { - return createdComparison; - } - return left.id.compareTo(right.id); -} - -int _compareActivities(TaskActivity left, TaskActivity right) { - final occurredComparison = left.occurredAt.compareTo(right.occurredAt); - if (occurredComparison != 0) { - return occurredComparison; - } - return left.id.compareTo(right.id); -} - -int _compareNoticeAcknowledgements( - NoticeAcknowledgementRecord left, - NoticeAcknowledgementRecord right, -) { - final acknowledgedComparison = - left.acknowledgedAt.compareTo(right.acknowledgedAt); - if (acknowledgedComparison != 0) { - return acknowledgedComparison; - } - return left.noticeId.compareTo(right.noticeId); -} - -String _requiredTrimmed(String value, String name) { - final trimmed = value.trim(); - if (trimmed.isEmpty) { - throw ArgumentError.value(value, name, 'Value is required.'); - } - return trimmed; -} +part 'application_layer/owner_time_zone_context.dart'; +part 'application_layer/application_operation_context.dart'; +part 'application_layer/application_failure_code.dart'; +part 'application_layer/application_failure.dart'; +part 'application_layer/application_result.dart'; +part 'application_layer/application_failure_exception.dart'; +part 'application_layer/application_persistence_exception.dart'; +part 'application_layer/application_operation_record.dart'; +part 'application_layer/notice_acknowledgement_record.dart'; +part 'application_layer/owner_settings.dart'; +part 'application_layer/task_activity_repository.dart'; +part 'application_layer/project_statistics_repository.dart'; +part 'application_layer/owner_settings_repository.dart'; +part 'application_layer/notice_acknowledgement_repository.dart'; +part 'application_layer/application_operation_repository.dart'; +part 'application_layer/application_unit_of_work_repositories.dart'; +part 'application_layer/application_unit_of_work.dart'; +part 'application_layer/application_scheduling_loader.dart'; +part 'application_layer/in_memory_application_unit_of_work.dart'; +part 'application_layer/in_memory_application_state.dart'; +part 'application_layer/in_memory_application_repositories.dart'; +part 'application_layer/unit_of_work_task_repository.dart'; +part 'application_layer/unit_of_work_project_repository.dart'; +part 'application_layer/unit_of_work_locked_block_repository.dart'; +part 'application_layer/unit_of_work_scheduling_snapshot_repository.dart'; +part 'application_layer/unit_of_work_task_activity_repository.dart'; +part 'application_layer/unit_of_work_project_statistics_repository.dart'; +part 'application_layer/unit_of_work_owner_settings_repository.dart'; +part 'application_layer/unit_of_work_notice_acknowledgement_repository.dart'; +part 'application_layer/unit_of_work_operation_repository.dart'; diff --git a/packages/scheduler_core/lib/src/application_layer/application_failure.dart b/packages/scheduler_core/lib/src/application_layer/application_failure.dart new file mode 100644 index 0000000..1beed99 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/application_failure.dart @@ -0,0 +1,19 @@ +part of '../application_layer.dart'; + +/// Typed failure returned by application commands and queries. +class ApplicationFailure { + const ApplicationFailure({ + required this.code, + this.entityId, + this.detailCode, + }); + + /// Stable machine-readable category. + final ApplicationFailureCode code; + + /// Optional stable entity id related to the failure. + final String? entityId; + + /// Optional narrower stable reason, such as a domain validation code name. + final String? detailCode; +} diff --git a/packages/scheduler_core/lib/src/application_layer/application_failure_code.dart b/packages/scheduler_core/lib/src/application_layer/application_failure_code.dart new file mode 100644 index 0000000..db9eee3 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/application_failure_code.dart @@ -0,0 +1,13 @@ +part of '../application_layer.dart'; + +/// Stable application-layer result categories. +enum ApplicationFailureCode { + validation, + notFound, + conflict, + staleRevision, + noSlot, + duplicateOperation, + persistenceFailure, + unexpected, +} diff --git a/packages/scheduler_core/lib/src/application_layer/application_failure_exception.dart b/packages/scheduler_core/lib/src/application_layer/application_failure_exception.dart new file mode 100644 index 0000000..dee317f --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/application_failure_exception.dart @@ -0,0 +1,9 @@ +part of '../application_layer.dart'; + +/// Exception for expected failures raised inside a unit-of-work callback. +class ApplicationFailureException implements Exception { + const ApplicationFailureException(this.failure); + + /// Failure to return from the application boundary. + final ApplicationFailure failure; +} diff --git a/packages/scheduler_core/lib/src/application_layer/application_operation_context.dart b/packages/scheduler_core/lib/src/application_layer/application_operation_context.dart new file mode 100644 index 0000000..aa9d187 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/application_operation_context.dart @@ -0,0 +1,44 @@ +part of '../application_layer.dart'; + +/// Per-operation application context. +class ApplicationOperationContext { + ApplicationOperationContext({ + required String operationId, + required this.now, + required this.ownerTimeZone, + required this.clock, + required this.idGenerator, + }) : operationId = _requiredTrimmed(operationId, 'operationId'); + + /// Convenience constructor that captures [Clock.now] once. + factory ApplicationOperationContext.start({ + required String operationId, + required OwnerTimeZoneContext ownerTimeZone, + required Clock clock, + required IdGenerator idGenerator, + }) { + return ApplicationOperationContext( + operationId: operationId, + now: clock.now(), + ownerTimeZone: ownerTimeZone, + clock: clock, + idGenerator: idGenerator, + ); + } + + /// Exactly-once key for this user command. + final String operationId; + + /// Instant used by the operation. Use this instead of repeatedly reading the + /// clock during one command. + final DateTime now; + + /// Owner scope and time-zone data for date-sensitive rules. + final OwnerTimeZoneContext ownerTimeZone; + + /// Injected clock available to lower-level services that need one. + final Clock clock; + + /// Injected ID generator for records created by the operation. + final IdGenerator idGenerator; +} diff --git a/packages/scheduler_core/lib/src/application_layer/application_operation_record.dart b/packages/scheduler_core/lib/src/application_layer/application_operation_record.dart new file mode 100644 index 0000000..f8fe4aa --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/application_operation_record.dart @@ -0,0 +1,25 @@ +part of '../application_layer.dart'; + +/// Persisted exactly-once operation record. +class ApplicationOperationRecord { + ApplicationOperationRecord({ + required String operationId, + required String ownerId, + required String operationName, + required this.committedAt, + }) : operationId = _requiredTrimmed(operationId, 'operationId'), + ownerId = _requiredTrimmed(ownerId, 'ownerId'), + operationName = _requiredTrimmed(operationName, 'operationName'); + + /// Exactly-once operation id. + final String operationId; + + /// Authorization-neutral owner scope. + final String ownerId; + + /// Stable use-case/command label. + final String operationName; + + /// Commit instant. + final DateTime committedAt; +} diff --git a/packages/scheduler_core/lib/src/application_layer/application_operation_repository.dart b/packages/scheduler_core/lib/src/application_layer/application_operation_repository.dart new file mode 100644 index 0000000..31bb9b0 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/application_operation_repository.dart @@ -0,0 +1,17 @@ +part of '../application_layer.dart'; + +/// Repository contract for exactly-once operation records. +abstract interface class ApplicationOperationRepository { + Future findById(String operationId); + + Future findByIdempotencyKey({ + required String ownerId, + required String operationId, + }); + + Future save(ApplicationOperationRecord operation); + + Future insertIfAbsent( + ApplicationOperationRecord operation, + ); +} diff --git a/packages/scheduler_core/lib/src/application_layer/application_persistence_exception.dart b/packages/scheduler_core/lib/src/application_layer/application_persistence_exception.dart new file mode 100644 index 0000000..e1f7e5a --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/application_persistence_exception.dart @@ -0,0 +1,6 @@ +part of '../application_layer.dart'; + +/// Exception representing persistence/driver failures at repository boundaries. +class ApplicationPersistenceException implements Exception { + const ApplicationPersistenceException(); +} diff --git a/packages/scheduler_core/lib/src/application_layer/application_result.dart b/packages/scheduler_core/lib/src/application_layer/application_result.dart new file mode 100644 index 0000000..881c807 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/application_result.dart @@ -0,0 +1,40 @@ +part of '../application_layer.dart'; + +/// Result wrapper for expected application success/failure paths. +class ApplicationResult { + const ApplicationResult._({ + this.value, + this.failure, + }); + + /// Successful result carrying [value]. + factory ApplicationResult.success(T value) { + return ApplicationResult._(value: value); + } + + /// Expected failure result. + factory ApplicationResult.failure(ApplicationFailure failure) { + return ApplicationResult._(failure: failure); + } + + /// Successful value, if any. + final T? value; + + /// Typed failure, if the operation did not succeed. + final ApplicationFailure? failure; + + /// Whether the operation succeeded. + bool get isSuccess => failure == null; + + /// Whether the operation failed with a typed application failure. + bool get isFailure => failure != null; + + /// Return the value or throw if this result is a failure. + T get requireValue { + if (failure != null) { + throw StateError( + 'Application result is a failure: ${failure!.code.name}'); + } + return value as T; + } +} diff --git a/packages/scheduler_core/lib/src/application_layer/application_scheduling_loader.dart b/packages/scheduler_core/lib/src/application_layer/application_scheduling_loader.dart new file mode 100644 index 0000000..fc0f3dc --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/application_scheduling_loader.dart @@ -0,0 +1,30 @@ +part of '../application_layer.dart'; + +/// Repository loader for common scheduler input assembly. +class ApplicationSchedulingLoader { + const ApplicationSchedulingLoader(this.repositories); + + /// Repositories for the current application operation. + final ApplicationUnitOfWorkRepositories repositories; + + /// Load scheduler input for [window] from the repository boundary. + /// + /// UI code should not persist partial [SchedulingResult] objects. Commands + /// should load authoritative state here, invoke domain services, then commit + /// resulting entities through a unit of work. + Future loadInputForWindow({ + required SchedulingWindow window, + List lockedIntervals = const [], + List requiredVisibleIntervals = const [], + }) async { + final scheduledTasks = await repositories.tasks.findScheduledInWindow( + window, + ); + return SchedulingInput( + tasks: scheduledTasks, + window: window, + lockedIntervals: lockedIntervals, + requiredVisibleIntervals: requiredVisibleIntervals, + ); + } +} diff --git a/packages/scheduler_core/lib/src/application_layer/application_unit_of_work.dart b/packages/scheduler_core/lib/src/application_layer/application_unit_of_work.dart new file mode 100644 index 0000000..c8b1a58 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/application_unit_of_work.dart @@ -0,0 +1,18 @@ +part of '../application_layer.dart'; + +/// Atomic application transaction boundary. +abstract interface class ApplicationUnitOfWork { + Future> run({ + required ApplicationOperationContext context, + required String operationName, + required Future> Function( + ApplicationUnitOfWorkRepositories repositories, + ) action, + }); + + Future> read({ + required Future> Function( + ApplicationUnitOfWorkRepositories repositories, + ) action, + }); +} diff --git a/packages/scheduler_core/lib/src/application_layer/application_unit_of_work_repositories.dart b/packages/scheduler_core/lib/src/application_layer/application_unit_of_work_repositories.dart new file mode 100644 index 0000000..cb77ce9 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/application_unit_of_work_repositories.dart @@ -0,0 +1,22 @@ +part of '../application_layer.dart'; + +/// Repository set available inside one application unit of work. +abstract interface class ApplicationUnitOfWorkRepositories { + TaskRepository get tasks; + + ProjectRepository get projects; + + LockedBlockRepository get lockedBlocks; + + SchedulingSnapshotRepository get schedulingSnapshots; + + TaskActivityRepository get taskActivities; + + ProjectStatisticsRepository get projectStatistics; + + OwnerSettingsRepository get ownerSettings; + + NoticeAcknowledgementRepository get noticeAcknowledgements; + + ApplicationOperationRepository get operations; +} diff --git a/packages/scheduler_core/lib/src/application_layer/in_memory_application_repositories.dart b/packages/scheduler_core/lib/src/application_layer/in_memory_application_repositories.dart new file mode 100644 index 0000000..c7a44c2 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/in_memory_application_repositories.dart @@ -0,0 +1,72 @@ +part of '../application_layer.dart'; + +class _InMemoryApplicationRepositories + implements ApplicationUnitOfWorkRepositories { + _InMemoryApplicationRepositories(_InMemoryApplicationState state) + : tasks = _UnitOfWorkTaskRepository( + tasksById: state.tasksById, + ownersById: state.taskOwnersById, + revisionsById: state.taskRevisionsById, + ), + projects = _UnitOfWorkProjectRepository( + projectsById: state.projectsById, + ownersById: state.projectOwnersById, + revisionsById: state.projectRevisionsById, + ), + lockedBlocks = _UnitOfWorkLockedBlockRepository( + blocksById: state.lockedBlocksById, + blockOwnersById: state.lockedBlockOwnersById, + blockRevisionsById: state.lockedBlockRevisionsById, + overridesById: state.lockedOverridesById, + overrideOwnersById: state.lockedOverrideOwnersById, + overrideRevisionsById: state.lockedOverrideRevisionsById, + ), + schedulingSnapshots = _UnitOfWorkSchedulingSnapshotRepository( + state.schedulingSnapshotsById, + ), + taskActivities = _UnitOfWorkTaskActivityRepository( + activitiesById: state.taskActivitiesById, + ownersById: state.taskActivityOwnersById, + ), + projectStatistics = _UnitOfWorkProjectStatisticsRepository( + statisticsByProjectId: state.projectStatisticsByProjectId, + ownersByProjectId: state.projectStatisticsOwnersByProjectId, + revisionsByProjectId: state.projectStatisticsRevisionsByProjectId, + ), + ownerSettings = _UnitOfWorkOwnerSettingsRepository( + settingsByOwnerId: state.ownerSettingsByOwnerId, + revisionsByOwnerId: state.ownerSettingsRevisionsByOwnerId, + ), + noticeAcknowledgements = _UnitOfWorkNoticeAcknowledgementRepository( + recordsByScopedId: state.noticeAcknowledgementsByScopedId, + revisionsByScopedId: state.noticeAcknowledgementRevisionsByScopedId, + ), + operations = _UnitOfWorkOperationRepository(state.operationsById); + + @override + final TaskRepository tasks; + + @override + final ProjectRepository projects; + + @override + final LockedBlockRepository lockedBlocks; + + @override + final SchedulingSnapshotRepository schedulingSnapshots; + + @override + final TaskActivityRepository taskActivities; + + @override + final ProjectStatisticsRepository projectStatistics; + + @override + final OwnerSettingsRepository ownerSettings; + + @override + final NoticeAcknowledgementRepository noticeAcknowledgements; + + @override + final ApplicationOperationRepository operations; +} diff --git a/packages/scheduler_core/lib/src/application_layer/in_memory_application_state.dart b/packages/scheduler_core/lib/src/application_layer/in_memory_application_state.dart new file mode 100644 index 0000000..0c68d39 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/in_memory_application_state.dart @@ -0,0 +1,106 @@ +part of '../application_layer.dart'; + +class _InMemoryApplicationState { + _InMemoryApplicationState({ + required this.tasksById, + required this.taskOwnersById, + required this.taskRevisionsById, + required this.projectsById, + required this.projectOwnersById, + required this.projectRevisionsById, + required this.lockedBlocksById, + required this.lockedBlockOwnersById, + required this.lockedBlockRevisionsById, + required this.lockedOverridesById, + required this.lockedOverrideOwnersById, + required this.lockedOverrideRevisionsById, + required this.schedulingSnapshotsById, + required this.taskActivitiesById, + required this.taskActivityOwnersById, + required this.projectStatisticsByProjectId, + required this.projectStatisticsOwnersByProjectId, + required this.projectStatisticsRevisionsByProjectId, + required this.ownerSettingsByOwnerId, + required this.ownerSettingsRevisionsByOwnerId, + required this.noticeAcknowledgementsByScopedId, + required this.noticeAcknowledgementRevisionsByScopedId, + required this.operationsById, + }); + + final Map tasksById; + final Map taskOwnersById; + final Map taskRevisionsById; + final Map projectsById; + final Map projectOwnersById; + final Map projectRevisionsById; + final Map lockedBlocksById; + final Map lockedBlockOwnersById; + final Map lockedBlockRevisionsById; + final Map lockedOverridesById; + final Map lockedOverrideOwnersById; + final Map lockedOverrideRevisionsById; + final Map schedulingSnapshotsById; + final Map taskActivitiesById; + final Map taskActivityOwnersById; + final Map projectStatisticsByProjectId; + final Map projectStatisticsOwnersByProjectId; + final Map projectStatisticsRevisionsByProjectId; + final Map ownerSettingsByOwnerId; + final Map ownerSettingsRevisionsByOwnerId; + final Map + noticeAcknowledgementsByScopedId; + final Map noticeAcknowledgementRevisionsByScopedId; + final Map operationsById; + + _InMemoryApplicationState copy() { + return _InMemoryApplicationState( + tasksById: Map.of(tasksById), + taskOwnersById: Map.of(taskOwnersById), + taskRevisionsById: Map.of(taskRevisionsById), + projectsById: Map.of(projectsById), + projectOwnersById: Map.of(projectOwnersById), + projectRevisionsById: Map.of(projectRevisionsById), + lockedBlocksById: Map.of(lockedBlocksById), + lockedBlockOwnersById: Map.of(lockedBlockOwnersById), + lockedBlockRevisionsById: Map.of( + lockedBlockRevisionsById, + ), + lockedOverridesById: + Map.of(lockedOverridesById), + lockedOverrideOwnersById: Map.of( + lockedOverrideOwnersById, + ), + lockedOverrideRevisionsById: Map.of( + lockedOverrideRevisionsById, + ), + schedulingSnapshotsById: + Map.of(schedulingSnapshotsById), + taskActivitiesById: Map.of(taskActivitiesById), + taskActivityOwnersById: Map.of(taskActivityOwnersById), + projectStatisticsByProjectId: + Map.of(projectStatisticsByProjectId), + projectStatisticsOwnersByProjectId: Map.of( + projectStatisticsOwnersByProjectId, + ), + projectStatisticsRevisionsByProjectId: Map.of( + projectStatisticsRevisionsByProjectId, + ), + ownerSettingsByOwnerId: Map.of( + ownerSettingsByOwnerId, + ), + ownerSettingsRevisionsByOwnerId: Map.of( + ownerSettingsRevisionsByOwnerId, + ), + noticeAcknowledgementsByScopedId: + Map.of( + noticeAcknowledgementsByScopedId, + ), + noticeAcknowledgementRevisionsByScopedId: Map.of( + noticeAcknowledgementRevisionsByScopedId, + ), + operationsById: Map.of( + operationsById, + ), + ); + } +} diff --git a/packages/scheduler_core/lib/src/application_layer/in_memory_application_unit_of_work.dart b/packages/scheduler_core/lib/src/application_layer/in_memory_application_unit_of_work.dart new file mode 100644 index 0000000..7861618 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/in_memory_application_unit_of_work.dart @@ -0,0 +1,286 @@ +part of '../application_layer.dart'; + +/// In-memory unit-of-work implementation for deterministic application tests. +class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork { + InMemoryApplicationUnitOfWork({ + Iterable initialTasks = const [], + Iterable initialProjects = const [], + Iterable initialLockedBlocks = const [], + Iterable initialLockedOverrides = + const [], + Iterable initialSchedulingSnapshots = + const [], + Iterable initialTaskActivities = const [], + Iterable initialProjectStatistics = + const [], + Iterable initialOwnerSettings = const [], + Iterable initialNoticeAcknowledgements = + const [], + Iterable initialOperations = + const [], + }) : _state = _InMemoryApplicationState( + tasksById: { + for (final task in initialTasks) task.id: task, + }, + taskOwnersById: { + for (final task in initialTasks) task.id: 'owner-1', + }, + taskRevisionsById: { + for (final task in initialTasks) task.id: 1, + }, + projectsById: { + for (final project in initialProjects) project.id: project, + }, + projectOwnersById: { + for (final project in initialProjects) project.id: 'owner-1', + }, + projectRevisionsById: { + for (final project in initialProjects) project.id: 1, + }, + lockedBlocksById: { + for (final block in initialLockedBlocks) block.id: block, + }, + lockedBlockOwnersById: { + for (final block in initialLockedBlocks) block.id: 'owner-1', + }, + lockedBlockRevisionsById: { + for (final block in initialLockedBlocks) block.id: 1, + }, + lockedOverridesById: { + for (final override in initialLockedOverrides) + override.id: override, + }, + lockedOverrideOwnersById: { + for (final override in initialLockedOverrides) + override.id: 'owner-1', + }, + lockedOverrideRevisionsById: { + for (final override in initialLockedOverrides) override.id: 1, + }, + schedulingSnapshotsById: { + for (final snapshot in initialSchedulingSnapshots) + snapshot.id: snapshot, + }, + taskActivitiesById: { + for (final activity in initialTaskActivities) activity.id: activity, + }, + taskActivityOwnersById: { + for (final activity in initialTaskActivities) + activity.id: 'owner-1', + }, + projectStatisticsByProjectId: { + for (final statistics in initialProjectStatistics) + statistics.projectId: statistics, + }, + projectStatisticsOwnersByProjectId: { + for (final statistics in initialProjectStatistics) + statistics.projectId: 'owner-1', + }, + projectStatisticsRevisionsByProjectId: { + for (final statistics in initialProjectStatistics) + statistics.projectId: 1, + }, + ownerSettingsByOwnerId: { + for (final settings in initialOwnerSettings) + settings.ownerId: settings, + }, + ownerSettingsRevisionsByOwnerId: { + for (final settings in initialOwnerSettings) settings.ownerId: 1, + }, + noticeAcknowledgementsByScopedId: { + for (final acknowledgement in initialNoticeAcknowledgements) + _noticeAcknowledgementKey( + ownerId: acknowledgement.ownerId, + noticeId: acknowledgement.noticeId, + ): acknowledgement, + }, + noticeAcknowledgementRevisionsByScopedId: { + for (final acknowledgement in initialNoticeAcknowledgements) + _noticeAcknowledgementKey( + ownerId: acknowledgement.ownerId, + noticeId: acknowledgement.noticeId, + ): 1, + }, + operationsById: { + for (final operation in initialOperations) + operation.operationId: operation, + }, + ); + + _InMemoryApplicationState _state; + bool _failNextCommit = false; + + /// Current committed tasks, useful for contract tests. + List get currentTasks => + List.unmodifiable(_state.tasksById.values); + + /// Current committed projects, useful for contract tests. + List get currentProjects { + return List.unmodifiable(_state.projectsById.values); + } + + /// Current committed locked blocks, useful for contract tests. + List get currentLockedBlocks { + return List.unmodifiable(_state.lockedBlocksById.values); + } + + /// Current committed locked overrides, useful for contract tests. + List get currentLockedOverrides { + return List.unmodifiable( + _state.lockedOverridesById.values, + ); + } + + /// Current committed scheduling snapshots, useful for contract tests. + List get currentSchedulingSnapshots { + return List.unmodifiable( + _state.schedulingSnapshotsById.values, + ); + } + + /// Current committed activities, useful for contract tests. + List get currentTaskActivities { + return List.unmodifiable(_state.taskActivitiesById.values); + } + + /// Current committed project statistics, useful for contract tests. + List get currentProjectStatistics { + return List.unmodifiable( + _state.projectStatisticsByProjectId.values, + ); + } + + /// Current committed owner settings, useful for contract tests. + List get currentOwnerSettings { + return List.unmodifiable( + _state.ownerSettingsByOwnerId.values); + } + + /// Current committed notice acknowledgements, useful for contract tests. + List get currentNoticeAcknowledgements { + return List.unmodifiable( + _state.noticeAcknowledgementsByScopedId.values, + ); + } + + /// Current committed operation records, useful for contract tests. + List get currentOperations { + return List.unmodifiable( + _state.operationsById.values, + ); + } + + /// Cause the next successful transaction to fail at commit time. + void failNextCommitForTesting() { + _failNextCommit = true; + } + + @override + Future> run({ + required ApplicationOperationContext context, + required String operationName, + required Future> Function( + ApplicationUnitOfWorkRepositories repositories, + ) action, + }) async { + if (_state.operationsById.containsKey(context.operationId)) { + return ApplicationResult.failure( + ApplicationFailure( + code: ApplicationFailureCode.duplicateOperation, + entityId: context.operationId, + ), + ); + } + + final staged = _state.copy(); + final repositories = _InMemoryApplicationRepositories(staged); + + try { + final result = await action(repositories); + if (result.isFailure) { + return result; + } + + staged.operationsById[context.operationId] = ApplicationOperationRecord( + operationId: context.operationId, + ownerId: context.ownerTimeZone.ownerId, + operationName: operationName, + committedAt: context.now, + ); + + if (_failNextCommit) { + _failNextCommit = false; + throw const ApplicationPersistenceException(); + } + + _state = staged; + return result; + } on ApplicationFailureException catch (error) { + return ApplicationResult.failure(error.failure); + } on DomainValidationException catch (error) { + return ApplicationResult.failure( + ApplicationFailure( + code: ApplicationFailureCode.validation, + detailCode: error.code.name, + ), + ); + } on ArgumentError catch (error) { + return ApplicationResult.failure( + ApplicationFailure( + code: ApplicationFailureCode.validation, + detailCode: error.name, + ), + ); + } on ApplicationPersistenceException { + return ApplicationResult.failure( + const ApplicationFailure( + code: ApplicationFailureCode.persistenceFailure, + ), + ); + } catch (_) { + return ApplicationResult.failure( + const ApplicationFailure(code: ApplicationFailureCode.unexpected), + ); + } + } + + @override + Future> read({ + required Future> Function( + ApplicationUnitOfWorkRepositories repositories, + ) action, + }) async { + final staged = _state.copy(); + final repositories = _InMemoryApplicationRepositories(staged); + + try { + return await action(repositories); + } on ApplicationFailureException catch (error) { + return ApplicationResult.failure(error.failure); + } on DomainValidationException catch (error) { + return ApplicationResult.failure( + ApplicationFailure( + code: ApplicationFailureCode.validation, + detailCode: error.code.name, + ), + ); + } on ArgumentError catch (error) { + return ApplicationResult.failure( + ApplicationFailure( + code: ApplicationFailureCode.validation, + detailCode: error.name, + ), + ); + } on ApplicationPersistenceException { + return ApplicationResult.failure( + const ApplicationFailure( + code: ApplicationFailureCode.persistenceFailure, + ), + ); + } catch (_) { + return ApplicationResult.failure( + const ApplicationFailure(code: ApplicationFailureCode.unexpected), + ); + } + } +} diff --git a/packages/scheduler_core/lib/src/application_layer/notice_acknowledgement_record.dart b/packages/scheduler_core/lib/src/application_layer/notice_acknowledgement_record.dart new file mode 100644 index 0000000..4e5f1c7 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/notice_acknowledgement_record.dart @@ -0,0 +1,20 @@ +part of '../application_layer.dart'; + +/// Owner-scoped acknowledgement for a one-time scheduling notice. +class NoticeAcknowledgementRecord { + NoticeAcknowledgementRecord({ + required String noticeId, + required String ownerId, + required this.acknowledgedAt, + }) : noticeId = _requiredTrimmed(noticeId, 'noticeId'), + ownerId = _requiredTrimmed(ownerId, 'ownerId'); + + /// Stable notice id generated by the read/use-case boundary. + final String noticeId; + + /// Authorization-neutral owner scope. + final String ownerId; + + /// Instant the notice was consumed. + final DateTime acknowledgedAt; +} diff --git a/packages/scheduler_core/lib/src/application_layer/notice_acknowledgement_repository.dart b/packages/scheduler_core/lib/src/application_layer/notice_acknowledgement_repository.dart new file mode 100644 index 0000000..7711608 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/notice_acknowledgement_repository.dart @@ -0,0 +1,22 @@ +part of '../application_layer.dart'; + +/// Repository contract for consumed one-time notices. +abstract interface class NoticeAcknowledgementRepository { + Future findById({ + required String ownerId, + required String noticeId, + }); + + Future> findByOwnerId(String ownerId); + + Future> findByOwner({ + required String ownerId, + RepositoryPageRequest page = const RepositoryPageRequest(), + }); + + Future save(NoticeAcknowledgementRecord record); + + Future insert( + NoticeAcknowledgementRecord record, + ); +} diff --git a/packages/scheduler_core/lib/src/application_layer/owner_settings.dart b/packages/scheduler_core/lib/src/application_layer/owner_settings.dart new file mode 100644 index 0000000..dca2743 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/owner_settings.dart @@ -0,0 +1,62 @@ +part of '../application_layer.dart'; + +/// Owner-level settings needed by application use cases. +class OwnerSettings { + OwnerSettings({ + required String ownerId, + required String timeZoneId, + WallTime? dayStart, + WallTime? dayEnd, + this.compactModeEnabled = false, + this.backlogStalenessSettings = const BacklogStalenessSettings(), + }) : ownerId = _requiredTrimmed(ownerId, 'ownerId'), + timeZoneId = _requiredTrimmed(timeZoneId, 'timeZoneId'), + dayStart = dayStart ?? WallTime(hour: 0, minute: 0), + dayEnd = dayEnd ?? WallTime(hour: 23, minute: 59) { + if (this.dayStart.compareTo(this.dayEnd) >= 0) { + throw ArgumentError.value( + {'dayStart': this.dayStart, 'dayEnd': this.dayEnd}, + 'dayEnd', + 'Day end must be after day start.', + ); + } + } + + /// Authorization-neutral owner scope. + final String ownerId; + + /// Owner time-zone id used by date-sensitive application use cases. + final String timeZoneId; + + /// Default local day start. + final WallTime dayStart; + + /// Default local day end. + final WallTime dayEnd; + + /// Persisted compact-mode preference. + final bool compactModeEnabled; + + /// Configurable backlog age thresholds. + final BacklogStalenessSettings backlogStalenessSettings; + + /// Return a copy with selected settings changed. + OwnerSettings copyWith({ + String? ownerId, + String? timeZoneId, + WallTime? dayStart, + WallTime? dayEnd, + bool? compactModeEnabled, + BacklogStalenessSettings? backlogStalenessSettings, + }) { + return OwnerSettings( + ownerId: ownerId ?? this.ownerId, + timeZoneId: timeZoneId ?? this.timeZoneId, + dayStart: dayStart ?? this.dayStart, + dayEnd: dayEnd ?? this.dayEnd, + compactModeEnabled: compactModeEnabled ?? this.compactModeEnabled, + backlogStalenessSettings: + backlogStalenessSettings ?? this.backlogStalenessSettings, + ); + } +} diff --git a/packages/scheduler_core/lib/src/application_layer/owner_settings_repository.dart b/packages/scheduler_core/lib/src/application_layer/owner_settings_repository.dart new file mode 100644 index 0000000..990174e --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/owner_settings_repository.dart @@ -0,0 +1,15 @@ +part of '../application_layer.dart'; + +/// Repository contract for owner settings. +abstract interface class OwnerSettingsRepository { + Future findByOwnerId(String ownerId); + + Future?> findRecordByOwnerId(String ownerId); + + Future save(OwnerSettings settings); + + Future saveIfRevision({ + required OwnerSettings settings, + required int expectedRevision, + }); +} diff --git a/packages/scheduler_core/lib/src/application_layer/owner_time_zone_context.dart b/packages/scheduler_core/lib/src/application_layer/owner_time_zone_context.dart new file mode 100644 index 0000000..7e26a6c --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/owner_time_zone_context.dart @@ -0,0 +1,16 @@ +part of '../application_layer.dart'; + +/// Owner-local time context supplied to application operations. +class OwnerTimeZoneContext { + OwnerTimeZoneContext({ + required String ownerId, + required String timeZoneId, + }) : ownerId = _requiredTrimmed(ownerId, 'ownerId'), + timeZoneId = _requiredTrimmed(timeZoneId, 'timeZoneId'); + + /// Authorization-neutral owner scope. V1 does not implement accounts. + final String ownerId; + + /// IANA-style time-zone identifier chosen by the app boundary. + final String timeZoneId; +} diff --git a/packages/scheduler_core/lib/src/application_layer/project_statistics_repository.dart b/packages/scheduler_core/lib/src/application_layer/project_statistics_repository.dart new file mode 100644 index 0000000..b2656ea --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/project_statistics_repository.dart @@ -0,0 +1,20 @@ +part of '../application_layer.dart'; + +/// Repository contract for project statistics aggregates. +abstract interface class ProjectStatisticsRepository { + Future findByProjectId(String projectId); + + Future?> findRecordByProjectId( + String projectId, + ); + + Future> findAll(); + + Future save(ProjectStatistics statistics); + + Future saveIfRevision({ + required ProjectStatistics statistics, + required String ownerId, + required int expectedRevision, + }); +} diff --git a/packages/scheduler_core/lib/src/application_layer/task_activity_repository.dart b/packages/scheduler_core/lib/src/application_layer/task_activity_repository.dart new file mode 100644 index 0000000..139927d --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/task_activity_repository.dart @@ -0,0 +1,28 @@ +part of '../application_layer.dart'; + +/// Repository contract for internal task activities. +abstract interface class TaskActivityRepository { + Future findById(String id); + + Future> findByOperationId(String operationId); + + Future> findByTaskId(String taskId); + + Future> findByProjectId(String projectId); + + Future> findAll(); + + Future> findByOwner({ + required String ownerId, + RepositoryPageRequest page = const RepositoryPageRequest(), + }); + + Future save(TaskActivity activity); + + Future saveAll(Iterable activities); + + Future append({ + required TaskActivity activity, + required String ownerId, + }); +} diff --git a/packages/scheduler_core/lib/src/application_layer/unit_of_work_locked_block_repository.dart b/packages/scheduler_core/lib/src/application_layer/unit_of_work_locked_block_repository.dart new file mode 100644 index 0000000..854ae7c --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/unit_of_work_locked_block_repository.dart @@ -0,0 +1,254 @@ +part of '../application_layer.dart'; + +class _UnitOfWorkLockedBlockRepository implements LockedBlockRepository { + _UnitOfWorkLockedBlockRepository({ + required Map blocksById, + required Map blockOwnersById, + required Map blockRevisionsById, + required Map overridesById, + required Map overrideOwnersById, + required Map overrideRevisionsById, + }) : _blocksById = blocksById, + _blockOwnersById = blockOwnersById, + _blockRevisionsById = blockRevisionsById, + _overridesById = overridesById, + _overrideOwnersById = overrideOwnersById, + _overrideRevisionsById = overrideRevisionsById; + + final Map _blocksById; + final Map _blockOwnersById; + final Map _blockRevisionsById; + final Map _overridesById; + final Map _overrideOwnersById; + final Map _overrideRevisionsById; + + @override + Future findBlockById(String id) async => _blocksById[id]; + + @override + Future?> findBlockRecordById(String id) async { + final block = _blocksById[id]; + if (block == null) { + return null; + } + return RepositoryRecord( + value: block, + ownerId: _blockOwnerFor(id), + revision: _blockRevisionFor(id), + archivedAt: block.archivedAt, + ); + } + + @override + Future> findAllBlocks() async { + return List.unmodifiable(_blocksById.values); + } + + @override + Future> findBlocksByOwner({ + required String ownerId, + bool includeArchived = false, + RepositoryPageRequest page = const RepositoryPageRequest(), + }) async { + final blocks = _blocksById.values + .where( + (block) => + _blockOwnerFor(block.id) == ownerId && + (includeArchived || !block.isArchived), + ) + .toList(growable: false) + ..sort(_compareLockedBlocks); + return _page(blocks, page); + } + + @override + Future findOverrideById(String id) async { + return _overridesById[id]; + } + + @override + Future?> findOverrideRecordById( + String id, + ) async { + final override = _overridesById[id]; + if (override == null) { + return null; + } + return RepositoryRecord( + value: override, + ownerId: _overrideOwnerFor(id), + revision: _overrideRevisionFor(id), + ); + } + + @override + Future> findAllOverrides() async { + return List.unmodifiable(_overridesById.values); + } + + @override + Future> findOverridesForDate({ + required String ownerId, + required CivilDate date, + RepositoryPageRequest page = const RepositoryPageRequest(), + }) async { + final overrides = _overridesById.values + .where( + (override) => + _overrideOwnerFor(override.id) == ownerId && + override.date == date, + ) + .toList(growable: false) + ..sort(_compareLockedOverrides); + return _page(overrides, page); + } + + @override + Future saveBlock(LockedBlock block) async { + _blocksById[block.id] = block; + _blockOwnersById.putIfAbsent(block.id, () => 'owner-1'); + _blockRevisionsById[block.id] = _blockRevisionFor(block.id) + 1; + } + + @override + Future saveOverride(LockedBlockOverride override) async { + _overridesById[override.id] = override; + _overrideOwnersById.putIfAbsent(override.id, () => 'owner-1'); + _overrideRevisionsById[override.id] = _overrideRevisionFor(override.id) + 1; + } + + @override + Future insertBlock({ + required LockedBlock block, + required String ownerId, + }) async { + if (_blocksById.containsKey(block.id)) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.duplicateId, + entityId: block.id, + ), + ); + } + _blocksById[block.id] = block; + _blockOwnersById[block.id] = ownerId; + _blockRevisionsById[block.id] = 1; + return RepositoryMutationResult.success(revision: 1); + } + + @override + Future saveBlockIfRevision({ + required LockedBlock block, + required String ownerId, + required int expectedRevision, + }) async { + if (!_blocksById.containsKey(block.id) || + _blockOwnerFor(block.id) != ownerId) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.notFound, + entityId: block.id, + ), + ); + } + final actualRevision = _blockRevisionFor(block.id); + if (actualRevision != expectedRevision) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.staleRevision, + entityId: block.id, + expectedRevision: expectedRevision, + actualRevision: actualRevision, + ), + ); + } + final nextRevision = actualRevision + 1; + _blocksById[block.id] = block; + _blockOwnersById[block.id] = ownerId; + _blockRevisionsById[block.id] = nextRevision; + return RepositoryMutationResult.success(revision: nextRevision); + } + + @override + Future archiveBlock({ + required String blockId, + required String ownerId, + required int expectedRevision, + required DateTime archivedAt, + }) async { + final block = _blocksById[blockId]; + if (block == null) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.notFound, + entityId: blockId, + ), + ); + } + return saveBlockIfRevision( + block: block.copyWith(updatedAt: archivedAt, archivedAt: archivedAt), + ownerId: ownerId, + expectedRevision: expectedRevision, + ); + } + + @override + Future insertOverride({ + required LockedBlockOverride override, + required String ownerId, + }) async { + if (_overridesById.containsKey(override.id)) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.duplicateId, + entityId: override.id, + ), + ); + } + _overridesById[override.id] = override; + _overrideOwnersById[override.id] = ownerId; + _overrideRevisionsById[override.id] = 1; + return RepositoryMutationResult.success(revision: 1); + } + + @override + Future saveOverrideIfRevision({ + required LockedBlockOverride override, + required String ownerId, + required int expectedRevision, + }) async { + if (!_overridesById.containsKey(override.id) || + _overrideOwnerFor(override.id) != ownerId) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.notFound, + entityId: override.id, + ), + ); + } + final actualRevision = _overrideRevisionFor(override.id); + if (actualRevision != expectedRevision) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.staleRevision, + entityId: override.id, + expectedRevision: expectedRevision, + actualRevision: actualRevision, + ), + ); + } + final nextRevision = actualRevision + 1; + _overridesById[override.id] = override; + _overrideOwnersById[override.id] = ownerId; + _overrideRevisionsById[override.id] = nextRevision; + return RepositoryMutationResult.success(revision: nextRevision); + } + + String _blockOwnerFor(String id) => _blockOwnersById[id] ?? 'owner-1'; + + int _blockRevisionFor(String id) => _blockRevisionsById[id] ?? 1; + + String _overrideOwnerFor(String id) => _overrideOwnersById[id] ?? 'owner-1'; + + int _overrideRevisionFor(String id) => _overrideRevisionsById[id] ?? 1; +} diff --git a/packages/scheduler_core/lib/src/application_layer/unit_of_work_notice_acknowledgement_repository.dart b/packages/scheduler_core/lib/src/application_layer/unit_of_work_notice_acknowledgement_repository.dart new file mode 100644 index 0000000..e8cbeee --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/unit_of_work_notice_acknowledgement_repository.dart @@ -0,0 +1,74 @@ +part of '../application_layer.dart'; + +class _UnitOfWorkNoticeAcknowledgementRepository + implements NoticeAcknowledgementRepository { + _UnitOfWorkNoticeAcknowledgementRepository({ + required Map recordsByScopedId, + required Map revisionsByScopedId, + }) : _recordsByScopedId = recordsByScopedId, + _revisionsByScopedId = revisionsByScopedId; + + final Map _recordsByScopedId; + final Map _revisionsByScopedId; + + @override + Future findById({ + required String ownerId, + required String noticeId, + }) async { + return _recordsByScopedId[ + _noticeAcknowledgementKey(ownerId: ownerId, noticeId: noticeId)]; + } + + @override + Future> findByOwnerId( + String ownerId, + ) async { + return List.unmodifiable( + _recordsByScopedId.values.where((record) => record.ownerId == ownerId), + ); + } + + @override + Future> findByOwner({ + required String ownerId, + RepositoryPageRequest page = const RepositoryPageRequest(), + }) async { + final records = _recordsByScopedId.values + .where((record) => record.ownerId == ownerId) + .toList(growable: false) + ..sort(_compareNoticeAcknowledgements); + return _page(records, page); + } + + @override + Future save(NoticeAcknowledgementRecord record) async { + final key = _noticeAcknowledgementKey( + ownerId: record.ownerId, + noticeId: record.noticeId, + ); + _recordsByScopedId[key] = record; + _revisionsByScopedId[key] = (_revisionsByScopedId[key] ?? 1) + 1; + } + + @override + Future insert( + NoticeAcknowledgementRecord record, + ) async { + final key = _noticeAcknowledgementKey( + ownerId: record.ownerId, + noticeId: record.noticeId, + ); + if (_recordsByScopedId.containsKey(key)) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.duplicateId, + entityId: record.noticeId, + ), + ); + } + _recordsByScopedId[key] = record; + _revisionsByScopedId[key] = 1; + return RepositoryMutationResult.success(revision: 1); + } +} diff --git a/packages/scheduler_core/lib/src/application_layer/unit_of_work_operation_repository.dart b/packages/scheduler_core/lib/src/application_layer/unit_of_work_operation_repository.dart new file mode 100644 index 0000000..9c2bda6 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/unit_of_work_operation_repository.dart @@ -0,0 +1,154 @@ +part of '../application_layer.dart'; + +class _UnitOfWorkOperationRepository implements ApplicationOperationRepository { + _UnitOfWorkOperationRepository(this._operationsById); + + final Map _operationsById; + + @override + Future findById(String operationId) async { + return _operationsById[operationId]; + } + + @override + Future findByIdempotencyKey({ + required String ownerId, + required String operationId, + }) async { + final operation = _operationsById[operationId]; + if (operation == null || operation.ownerId != ownerId) { + return null; + } + return operation; + } + + @override + Future save(ApplicationOperationRecord operation) async { + _operationsById[operation.operationId] = operation; + } + + @override + Future insertIfAbsent( + ApplicationOperationRecord operation, + ) async { + if (_operationsById.containsKey(operation.operationId)) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.duplicateOperation, + entityId: operation.operationId, + ), + ); + } + _operationsById[operation.operationId] = operation; + return RepositoryMutationResult.success(revision: 1); + } +} + +String _noticeAcknowledgementKey({ + required String ownerId, + required String noticeId, +}) { + return '$ownerId::$noticeId'; +} + +RepositoryPage _page(List sortedItems, RepositoryPageRequest request) { + final offset = int.tryParse(request.cursor ?? '') ?? 0; + final safeOffset = offset.clamp(0, sortedItems.length); + final end = (safeOffset + request.limit).clamp(0, sortedItems.length); + final items = sortedItems.sublist(safeOffset, end); + final nextCursor = end < sortedItems.length ? end.toString() : null; + return RepositoryPage( + items: List.unmodifiable(items), + nextCursor: nextCursor, + ); +} + +bool _taskOverlapsWindow(Task task, SchedulingWindow window) { + final start = task.scheduledStart; + final end = task.scheduledEnd; + if (start == null || end == null) { + return false; + } + return TimeInterval(start: start, end: end).overlaps(window.interval); +} + +int _compareTaskIds(Task left, Task right) => left.id.compareTo(right.id); + +int _compareScheduledTasks(Task left, Task right) { + final leftStart = left.scheduledStart; + final rightStart = right.scheduledStart; + if (leftStart != null && rightStart != null) { + final startComparison = leftStart.compareTo(rightStart); + if (startComparison != 0) { + return startComparison; + } + } + return left.id.compareTo(right.id); +} + +int _compareBacklogCandidates(Task left, Task right) { + final createdComparison = left.createdAt.compareTo(right.createdAt); + if (createdComparison != 0) { + return createdComparison; + } + return left.id.compareTo(right.id); +} + +int _compareProjects(ProjectProfile left, ProjectProfile right) { + final nameComparison = left.name.compareTo(right.name); + if (nameComparison != 0) { + return nameComparison; + } + return left.id.compareTo(right.id); +} + +int _compareLockedBlocks(LockedBlock left, LockedBlock right) { + final nameComparison = left.name.compareTo(right.name); + if (nameComparison != 0) { + return nameComparison; + } + return left.id.compareTo(right.id); +} + +int _compareLockedOverrides( + LockedBlockOverride left, + LockedBlockOverride right, +) { + final dateComparison = left.date.compareTo(right.date); + if (dateComparison != 0) { + return dateComparison; + } + final createdComparison = left.createdAt.compareTo(right.createdAt); + if (createdComparison != 0) { + return createdComparison; + } + return left.id.compareTo(right.id); +} + +int _compareActivities(TaskActivity left, TaskActivity right) { + final occurredComparison = left.occurredAt.compareTo(right.occurredAt); + if (occurredComparison != 0) { + return occurredComparison; + } + return left.id.compareTo(right.id); +} + +int _compareNoticeAcknowledgements( + NoticeAcknowledgementRecord left, + NoticeAcknowledgementRecord right, +) { + final acknowledgedComparison = + left.acknowledgedAt.compareTo(right.acknowledgedAt); + if (acknowledgedComparison != 0) { + return acknowledgedComparison; + } + return left.noticeId.compareTo(right.noticeId); +} + +String _requiredTrimmed(String value, String name) { + final trimmed = value.trim(); + if (trimmed.isEmpty) { + throw ArgumentError.value(value, name, 'Value is required.'); + } + return trimmed; +} diff --git a/packages/scheduler_core/lib/src/application_layer/unit_of_work_owner_settings_repository.dart b/packages/scheduler_core/lib/src/application_layer/unit_of_work_owner_settings_repository.dart new file mode 100644 index 0000000..2665551 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/unit_of_work_owner_settings_repository.dart @@ -0,0 +1,70 @@ +part of '../application_layer.dart'; + +class _UnitOfWorkOwnerSettingsRepository implements OwnerSettingsRepository { + _UnitOfWorkOwnerSettingsRepository({ + required Map settingsByOwnerId, + required Map revisionsByOwnerId, + }) : _settingsByOwnerId = settingsByOwnerId, + _revisionsByOwnerId = revisionsByOwnerId; + + final Map _settingsByOwnerId; + final Map _revisionsByOwnerId; + + @override + Future findByOwnerId(String ownerId) async { + return _settingsByOwnerId[ownerId]; + } + + @override + Future?> findRecordByOwnerId( + String ownerId, + ) async { + final settings = _settingsByOwnerId[ownerId]; + if (settings == null) { + return null; + } + return RepositoryRecord( + value: settings, + ownerId: ownerId, + revision: _revisionsByOwnerId[ownerId] ?? 1, + ); + } + + @override + Future save(OwnerSettings settings) async { + _settingsByOwnerId[settings.ownerId] = settings; + _revisionsByOwnerId[settings.ownerId] = + (_revisionsByOwnerId[settings.ownerId] ?? 1) + 1; + } + + @override + Future saveIfRevision({ + required OwnerSettings settings, + required int expectedRevision, + }) async { + final ownerId = settings.ownerId; + if (!_settingsByOwnerId.containsKey(ownerId)) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.notFound, + entityId: ownerId, + ), + ); + } + final actualRevision = _revisionsByOwnerId[ownerId] ?? 1; + if (actualRevision != expectedRevision) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.staleRevision, + entityId: ownerId, + expectedRevision: expectedRevision, + actualRevision: actualRevision, + ), + ); + } + final nextRevision = actualRevision + 1; + _settingsByOwnerId[ownerId] = settings; + _revisionsByOwnerId[ownerId] = nextRevision; + return RepositoryMutationResult.success(revision: nextRevision); + } +} diff --git a/packages/scheduler_core/lib/src/application_layer/unit_of_work_project_repository.dart b/packages/scheduler_core/lib/src/application_layer/unit_of_work_project_repository.dart new file mode 100644 index 0000000..1ead197 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/unit_of_work_project_repository.dart @@ -0,0 +1,140 @@ +part of '../application_layer.dart'; + +class _UnitOfWorkProjectRepository implements ProjectRepository { + _UnitOfWorkProjectRepository({ + required Map projectsById, + required Map ownersById, + required Map revisionsById, + }) : _projectsById = projectsById, + _ownersById = ownersById, + _revisionsById = revisionsById; + + final Map _projectsById; + final Map _ownersById; + final Map _revisionsById; + + @override + Future findById(String id) async => _projectsById[id]; + + @override + Future?> findRecordById(String id) async { + final project = _projectsById[id]; + if (project == null) { + return null; + } + return RepositoryRecord( + value: project, + ownerId: _ownerFor(id), + revision: _revisionFor(id), + archivedAt: project.archivedAt, + ); + } + + @override + Future> findAll() async { + return List.unmodifiable(_projectsById.values); + } + + @override + Future> findByOwner({ + required String ownerId, + bool includeArchived = false, + RepositoryPageRequest page = const RepositoryPageRequest(), + }) async { + final projects = _projectsById.values + .where( + (project) => + _ownerFor(project.id) == ownerId && + (includeArchived || !project.isArchived), + ) + .toList(growable: false) + ..sort(_compareProjects); + return _page(projects, page); + } + + @override + Future save(ProjectProfile project) async { + _projectsById[project.id] = project; + _ownersById.putIfAbsent(project.id, () => 'owner-1'); + _revisionsById[project.id] = _revisionFor(project.id) + 1; + } + + @override + Future insert({ + required ProjectProfile project, + required String ownerId, + }) async { + if (_projectsById.containsKey(project.id)) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.duplicateId, + entityId: project.id, + ), + ); + } + _projectsById[project.id] = project; + _ownersById[project.id] = ownerId; + _revisionsById[project.id] = 1; + return RepositoryMutationResult.success(revision: 1); + } + + @override + Future saveIfRevision({ + required ProjectProfile project, + required String ownerId, + required int expectedRevision, + }) async { + if (!_projectsById.containsKey(project.id) || + _ownerFor(project.id) != ownerId) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.notFound, + entityId: project.id, + ), + ); + } + final actualRevision = _revisionFor(project.id); + if (actualRevision != expectedRevision) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.staleRevision, + entityId: project.id, + expectedRevision: expectedRevision, + actualRevision: actualRevision, + ), + ); + } + final nextRevision = actualRevision + 1; + _projectsById[project.id] = project; + _ownersById[project.id] = ownerId; + _revisionsById[project.id] = nextRevision; + return RepositoryMutationResult.success(revision: nextRevision); + } + + @override + Future archive({ + required String projectId, + required String ownerId, + required int expectedRevision, + required DateTime archivedAt, + }) async { + final project = _projectsById[projectId]; + if (project == null) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.notFound, + entityId: projectId, + ), + ); + } + return saveIfRevision( + project: project.copyWith(archivedAt: archivedAt), + ownerId: ownerId, + expectedRevision: expectedRevision, + ); + } + + String _ownerFor(String id) => _ownersById[id] ?? 'owner-1'; + + int _revisionFor(String id) => _revisionsById[id] ?? 1; +} diff --git a/packages/scheduler_core/lib/src/application_layer/unit_of_work_project_statistics_repository.dart b/packages/scheduler_core/lib/src/application_layer/unit_of_work_project_statistics_repository.dart new file mode 100644 index 0000000..89a4382 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/unit_of_work_project_statistics_repository.dart @@ -0,0 +1,83 @@ +part of '../application_layer.dart'; + +class _UnitOfWorkProjectStatisticsRepository + implements ProjectStatisticsRepository { + _UnitOfWorkProjectStatisticsRepository({ + required Map statisticsByProjectId, + required Map ownersByProjectId, + required Map revisionsByProjectId, + }) : _statisticsByProjectId = statisticsByProjectId, + _ownersByProjectId = ownersByProjectId, + _revisionsByProjectId = revisionsByProjectId; + + final Map _statisticsByProjectId; + final Map _ownersByProjectId; + final Map _revisionsByProjectId; + + @override + Future findByProjectId(String projectId) async { + return _statisticsByProjectId[projectId]; + } + + @override + Future?> findRecordByProjectId( + String projectId, + ) async { + final statistics = _statisticsByProjectId[projectId]; + if (statistics == null) { + return null; + } + return RepositoryRecord( + value: statistics, + ownerId: _ownersByProjectId[projectId] ?? 'owner-1', + revision: _revisionsByProjectId[projectId] ?? 1, + ); + } + + @override + Future> findAll() async { + return List.unmodifiable(_statisticsByProjectId.values); + } + + @override + Future save(ProjectStatistics statistics) async { + _statisticsByProjectId[statistics.projectId] = statistics; + _ownersByProjectId.putIfAbsent(statistics.projectId, () => 'owner-1'); + _revisionsByProjectId[statistics.projectId] = + (_revisionsByProjectId[statistics.projectId] ?? 1) + 1; + } + + @override + Future saveIfRevision({ + required ProjectStatistics statistics, + required String ownerId, + required int expectedRevision, + }) async { + final projectId = statistics.projectId; + if (!_statisticsByProjectId.containsKey(projectId) || + (_ownersByProjectId[projectId] ?? 'owner-1') != ownerId) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.notFound, + entityId: projectId, + ), + ); + } + final actualRevision = _revisionsByProjectId[projectId] ?? 1; + if (actualRevision != expectedRevision) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.staleRevision, + entityId: projectId, + expectedRevision: expectedRevision, + actualRevision: actualRevision, + ), + ); + } + final nextRevision = actualRevision + 1; + _statisticsByProjectId[projectId] = statistics; + _ownersByProjectId[projectId] = ownerId; + _revisionsByProjectId[projectId] = nextRevision; + return RepositoryMutationResult.success(revision: nextRevision); + } +} diff --git a/packages/scheduler_core/lib/src/application_layer/unit_of_work_scheduling_snapshot_repository.dart b/packages/scheduler_core/lib/src/application_layer/unit_of_work_scheduling_snapshot_repository.dart new file mode 100644 index 0000000..bb2c427 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/unit_of_work_scheduling_snapshot_repository.dart @@ -0,0 +1,29 @@ +part of '../application_layer.dart'; + +class _UnitOfWorkSchedulingSnapshotRepository + implements SchedulingSnapshotRepository { + _UnitOfWorkSchedulingSnapshotRepository(this._snapshotsById); + + final Map _snapshotsById; + + @override + Future findById(String id) async { + return _snapshotsById[id]; + } + + @override + Future> findInWindow( + SchedulingWindow window, + ) async { + return List.unmodifiable( + _snapshotsById.values.where( + (snapshot) => snapshot.window.interval.overlaps(window.interval), + ), + ); + } + + @override + Future save(SchedulingStateSnapshot snapshot) async { + _snapshotsById[snapshot.id] = snapshot; + } +} diff --git a/packages/scheduler_core/lib/src/application_layer/unit_of_work_task_activity_repository.dart b/packages/scheduler_core/lib/src/application_layer/unit_of_work_task_activity_repository.dart new file mode 100644 index 0000000..61412f4 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/unit_of_work_task_activity_repository.dart @@ -0,0 +1,91 @@ +part of '../application_layer.dart'; + +class _UnitOfWorkTaskActivityRepository implements TaskActivityRepository { + _UnitOfWorkTaskActivityRepository({ + required Map activitiesById, + required Map ownersById, + }) : _activitiesById = activitiesById, + _ownersById = ownersById; + + final Map _activitiesById; + final Map _ownersById; + + @override + Future findById(String id) async => _activitiesById[id]; + + @override + Future> findByOperationId(String operationId) async { + return List.unmodifiable( + _activitiesById.values.where( + (activity) => activity.operationId == operationId, + ), + ); + } + + @override + Future> findByTaskId(String taskId) async { + return List.unmodifiable( + _activitiesById.values.where((activity) => activity.taskId == taskId), + ); + } + + @override + Future> findByProjectId(String projectId) async { + return List.unmodifiable( + _activitiesById.values.where( + (activity) => activity.projectId == projectId, + ), + ); + } + + @override + Future> findAll() async { + return List.unmodifiable(_activitiesById.values); + } + + @override + Future> findByOwner({ + required String ownerId, + RepositoryPageRequest page = const RepositoryPageRequest(), + }) async { + final activities = _activitiesById.values + .where((activity) => _ownerFor(activity.id) == ownerId) + .toList(growable: false) + ..sort(_compareActivities); + return _page(activities, page); + } + + @override + Future save(TaskActivity activity) async { + _activitiesById[activity.id] = activity; + _ownersById.putIfAbsent(activity.id, () => 'owner-1'); + } + + @override + Future saveAll(Iterable activities) async { + for (final activity in activities) { + _activitiesById[activity.id] = activity; + _ownersById.putIfAbsent(activity.id, () => 'owner-1'); + } + } + + @override + Future append({ + required TaskActivity activity, + required String ownerId, + }) async { + if (_activitiesById.containsKey(activity.id)) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.duplicateId, + entityId: activity.id, + ), + ); + } + _activitiesById[activity.id] = activity; + _ownersById[activity.id] = ownerId; + return RepositoryMutationResult.success(revision: 1); + } + + String _ownerFor(String id) => _ownersById[id] ?? 'owner-1'; +} diff --git a/packages/scheduler_core/lib/src/application_layer/unit_of_work_task_repository.dart b/packages/scheduler_core/lib/src/application_layer/unit_of_work_task_repository.dart new file mode 100644 index 0000000..743916f --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/unit_of_work_task_repository.dart @@ -0,0 +1,212 @@ +part of '../application_layer.dart'; + +class _UnitOfWorkTaskRepository implements TaskRepository { + _UnitOfWorkTaskRepository({ + required Map tasksById, + required Map ownersById, + required Map revisionsById, + }) : _tasksById = tasksById, + _ownersById = ownersById, + _revisionsById = revisionsById; + + final Map _tasksById; + final Map _ownersById; + final Map _revisionsById; + + @override + Future findById(String id) async => _tasksById[id]; + + @override + Future?> findRecordById(String id) async { + final task = _tasksById[id]; + if (task == null) { + return null; + } + return RepositoryRecord( + value: task, + ownerId: _ownerFor(id), + revision: _revisionFor(id), + ); + } + + @override + Future> findAll() async { + return List.unmodifiable(_tasksById.values); + } + + @override + Future> findByStatus(TaskStatus status) async { + return List.unmodifiable( + _tasksById.values.where((task) => task.status == status), + ); + } + + @override + Future> findByOwner({ + required String ownerId, + RepositoryPageRequest page = const RepositoryPageRequest(), + }) async { + final tasks = _tasksById.values + .where((task) => _ownerFor(task.id) == ownerId) + .toList(growable: false) + ..sort(_compareTaskIds); + return _page(tasks, page); + } + + @override + Future> findByProjectId({ + required String ownerId, + required String projectId, + RepositoryPageRequest page = const RepositoryPageRequest(), + }) async { + final tasks = _tasksById.values + .where( + (task) => + _ownerFor(task.id) == ownerId && task.projectId == projectId, + ) + .toList(growable: false) + ..sort(_compareTaskIds); + return _page(tasks, page); + } + + @override + Future> findByParentTaskId({ + required String ownerId, + required String parentTaskId, + RepositoryPageRequest page = const RepositoryPageRequest(), + }) async { + final tasks = _tasksById.values + .where( + (task) => + _ownerFor(task.id) == ownerId && + task.parentTaskId == parentTaskId, + ) + .toList(growable: false) + ..sort(_compareTaskIds); + return _page(tasks, page); + } + + @override + Future> findBacklogCandidates( + BacklogCandidateQuery query, + ) async { + final tasks = _tasksById.values + .where( + (task) => + _ownerFor(task.id) == query.ownerId && + task.status == TaskStatus.backlog && + (query.projectId == null || task.projectId == query.projectId) && + query.tags.every(task.backlogTags.contains), + ) + .toList(growable: false) + ..sort(_compareBacklogCandidates); + return _page(tasks, query.page); + } + + @override + Future> findScheduledInWindow(SchedulingWindow window) async { + return List.unmodifiable( + _tasksById.values.where((task) { + return _taskOverlapsWindow(task, window); + }), + ); + } + + @override + Future> findScheduledInWindowForOwner({ + required String ownerId, + required SchedulingWindow window, + RepositoryPageRequest page = const RepositoryPageRequest(), + }) async { + final tasks = _tasksById.values + .where( + (task) => + _ownerFor(task.id) == ownerId && + _taskOverlapsWindow(task, window), + ) + .toList(growable: false) + ..sort(_compareScheduledTasks); + return _page(tasks, page); + } + + @override + Future> findForLocalDay({ + required String ownerId, + required CivilDate localDate, + required SchedulingWindow window, + RepositoryPageRequest page = const RepositoryPageRequest(), + }) { + return findScheduledInWindowForOwner( + ownerId: ownerId, + window: window, + page: page, + ); + } + + @override + Future save(Task task) async { + _tasksById[task.id] = task; + _ownersById.putIfAbsent(task.id, () => 'owner-1'); + _revisionsById[task.id] = _revisionFor(task.id) + 1; + } + + @override + Future saveAll(Iterable tasks) async { + for (final task in tasks) { + await save(task); + } + } + + @override + Future insert({ + required Task task, + required String ownerId, + }) async { + if (_tasksById.containsKey(task.id)) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.duplicateId, + entityId: task.id, + ), + ); + } + _tasksById[task.id] = task; + _ownersById[task.id] = ownerId; + _revisionsById[task.id] = 1; + return RepositoryMutationResult.success(revision: 1); + } + + @override + Future saveIfRevision({ + required Task task, + required String ownerId, + required int expectedRevision, + }) async { + if (!_tasksById.containsKey(task.id) || _ownerFor(task.id) != ownerId) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.notFound, entityId: task.id), + ); + } + final actualRevision = _revisionFor(task.id); + if (actualRevision != expectedRevision) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.staleRevision, + entityId: task.id, + expectedRevision: expectedRevision, + actualRevision: actualRevision, + ), + ); + } + final nextRevision = actualRevision + 1; + _tasksById[task.id] = task; + _ownersById[task.id] = ownerId; + _revisionsById[task.id] = nextRevision; + return RepositoryMutationResult.success(revision: nextRevision); + } + + String _ownerFor(String id) => _ownersById[id] ?? 'owner-1'; + + int _revisionFor(String id) => _revisionsById[id] ?? 1; +} diff --git a/packages/scheduler_core/lib/src/application_management.dart b/packages/scheduler_core/lib/src/application_management.dart index 26bcda1..5aecbbb 100644 --- a/packages/scheduler_core/lib/src/application_management.dart +++ b/packages/scheduler_core/lib/src/application_management.dart @@ -14,765 +14,19 @@ import 'locked_time.dart'; import 'models.dart'; import 'project_statistics.dart'; import 'time_contracts.dart'; - -/// Stable management command identifiers. -enum ApplicationManagementCommandCode { - createProject, - updateProject, - archiveProject, - createLockedBlock, - updateLockedBlock, - archiveLockedBlock, - addLockedBlockOverride, - removeLockedBlockOccurrence, - replaceLockedBlockOccurrence, - updateOwnerSettings, - acknowledgeNotice, -} - -/// Typed warning codes for settings changes that reinterpret local dates/times. -enum OwnerSettingsWarningCode { - timeZoneChanged, - planningWindowChanged, -} - -/// Backlog query request. -class GetBacklogRequest { - const GetBacklogRequest({ - required this.context, - this.filter, - this.sortKey = BacklogSortKey.priority, - }); - - /// Read context containing owner scope and read instant. - final ApplicationOperationContext context; - - /// Optional user-facing backlog filter. - final BacklogFilter? filter; - - /// User-facing sort order. - final BacklogSortKey sortKey; -} - -/// One backlog row with its configured staleness marker. -class BacklogItemReadModel { - const BacklogItemReadModel({ - required this.task, - required this.stalenessMarker, - }); - - /// Source backlog task. - final Task task; - - /// Green/blue/purple marker using owner settings. - final BacklogStalenessMarker stalenessMarker; -} - -/// Query result for the backlog surface. -class BacklogQueryResult { - BacklogQueryResult({ - required this.ownerId, - required this.settings, - required List items, - }) : items = List.unmodifiable(items); - - /// Authorization-neutral owner scope. - final String ownerId; - - /// Effective owner settings used by the query. - final OwnerSettings settings; - - /// Filtered and sorted backlog rows. - final List items; -} - -/// Query request for project defaults and learned suggestions. -class GetProjectDefaultsRequest { - const GetProjectDefaultsRequest({ - required this.context, - this.includeArchived = false, - }); - - /// Read context containing owner scope and read instant. - final ApplicationOperationContext context; - - /// Whether archived projects should be included. - final bool includeArchived; -} - -/// Project defaults query result. -class ProjectDefaultsQueryResult { - ProjectDefaultsQueryResult({ - required this.ownerId, - required List projects, - }) : projects = List.unmodifiable(projects); - - /// Authorization-neutral owner scope. - final String ownerId; - - /// Configured defaults plus separately surfaced learned suggestions. - final List projects; -} - -/// Draft used when creating a project. -class ProjectProfileDraft { - const ProjectProfileDraft({ - this.id, - required this.name, - required this.colorKey, - this.defaultPriority = PriorityLevel.medium, - this.defaultReward = RewardLevel.notSet, - this.defaultDifficulty = DifficultyLevel.notSet, - this.defaultReminderProfile = ReminderProfile.gentle, - this.defaultDurationMinutes, - }); - - final String? id; - final String name; - final String colorKey; - final PriorityLevel defaultPriority; - final RewardLevel defaultReward; - final DifficultyLevel defaultDifficulty; - final ReminderProfile defaultReminderProfile; - final int? defaultDurationMinutes; -} - -/// Patch used when updating configured project defaults. -class ProjectProfileUpdate { - const ProjectProfileUpdate({ - this.name, - this.colorKey, - this.defaultPriority, - this.defaultReward, - this.defaultDifficulty, - this.defaultReminderProfile, - this.defaultDurationMinutes, - this.clearDefaultDuration = false, - }); - - final String? name; - final String? colorKey; - final PriorityLevel? defaultPriority; - final RewardLevel? defaultReward; - final DifficultyLevel? defaultDifficulty; - final ReminderProfile? defaultReminderProfile; - final int? defaultDurationMinutes; - final bool clearDefaultDuration; -} - -/// Draft used when creating a locked block. -class LockedBlockDraft { - const LockedBlockDraft({ - this.id, - required this.name, - required this.startTime, - required this.endTime, - this.date, - this.recurrence, - this.hiddenByDefault = true, - this.projectId, - }); - - final String? id; - final String name; - final ClockTime startTime; - final ClockTime endTime; - final CivilDate? date; - final LockedBlockRecurrence? recurrence; - final bool hiddenByDefault; - final String? projectId; -} - -/// Patch used when updating a locked block definition. -class LockedBlockUpdate { - const LockedBlockUpdate({ - this.name, - this.startTime, - this.endTime, - this.date, - this.recurrence, - this.hiddenByDefault, - this.projectId, - }); - - final String? name; - final ClockTime? startTime; - final ClockTime? endTime; - final CivilDate? date; - final LockedBlockRecurrence? recurrence; - final bool? hiddenByDefault; - final String? projectId; -} - -/// Result for owner settings updates. -class OwnerSettingsUpdateResult { - OwnerSettingsUpdateResult({ - required this.settings, - Iterable warnings = - const [], - }) : warnings = List.unmodifiable(warnings); - - /// Persisted owner settings. - final OwnerSettings settings; - - /// Typed warnings for changes that may need migration/UI confirmation. - final List warnings; -} - -/// Patch used when updating owner settings. -class OwnerSettingsUpdate { - const OwnerSettingsUpdate({ - this.timeZoneId, - this.dayStart, - this.dayEnd, - this.compactModeEnabled, - this.backlogStalenessSettings, - }); - - final String? timeZoneId; - final WallTime? dayStart; - final WallTime? dayEnd; - final bool? compactModeEnabled; - final BacklogStalenessSettings? backlogStalenessSettings; -} - -/// Result for notice acknowledgement commands. -class NoticeAcknowledgementResult { - const NoticeAcknowledgementResult({ - required this.record, - required this.alreadyAcknowledged, - }); - - /// Persisted acknowledgement record. - final NoticeAcknowledgementRecord record; - - /// Whether the notice had already been acknowledged before this command. - final bool alreadyAcknowledged; -} - -/// V1 management facade used by future Flutter state management. -class V1ApplicationManagementUseCases { - const V1ApplicationManagementUseCases({ - required this.applicationStore, - this.projectSuggestionService = const ProjectSuggestionService(), - }); - - /// Atomic application boundary. - final ApplicationUnitOfWork applicationStore; - - /// Learned project suggestion policy. - final ProjectSuggestionService projectSuggestionService; - - /// Load backlog candidates through the repository status filter, then apply - /// existing filter/sort/staleness policies in memory. - Future> getBacklog( - GetBacklogRequest request, - ) { - return applicationStore.read( - action: (repositories) async { - final ownerId = request.context.ownerTimeZone.ownerId; - final settings = await _ownerSettingsOrDefault( - repositories, - request.context.ownerTimeZone, - ); - final candidates = await repositories.tasks.findByStatus( - TaskStatus.backlog, - ); - final filtered = request.filter == null - ? candidates - : BacklogView( - tasks: candidates, - now: request.context.now, - stalenessSettings: settings.backlogStalenessSettings, - ).filter(request.filter!); - final view = BacklogView( - tasks: filtered, - now: request.context.now, - stalenessSettings: settings.backlogStalenessSettings, - ); - final sorted = view.sorted(request.sortKey); - - return ApplicationResult.success( - BacklogQueryResult( - ownerId: ownerId, - settings: settings, - items: [ - for (final task in sorted) - BacklogItemReadModel( - task: task, - stalenessMarker: view.stalenessMarkerFor(task), - ), - ], - ), - ); - }, - ); - } - - /// Load configured project defaults with learned suggestions kept separate. - Future> getProjectDefaults( - GetProjectDefaultsRequest request, - ) { - return applicationStore.read( - action: (repositories) async { - final projects = (await repositories.projects.findByOwner( - ownerId: request.context.ownerTimeZone.ownerId, - includeArchived: request.includeArchived, - )) - .items; - final resolutions = []; - final sortedProjects = [...projects] - ..sort((a, b) => a.name.compareTo(b.name)); - - for (final project in sortedProjects) { - if (project.isArchived && !request.includeArchived) { - continue; - } - final statistics = - await repositories.projectStatistics.findByProjectId(project.id); - resolutions.add( - projectSuggestionService.resolveDefaults( - project: project, - statistics: - statistics ?? ProjectStatistics(projectId: project.id), - ), - ); - } - - return ApplicationResult.success( - ProjectDefaultsQueryResult( - ownerId: request.context.ownerTimeZone.ownerId, - projects: resolutions, - ), - ); - }, - ); - } - - Future> createProject({ - required ApplicationOperationContext context, - required ProjectProfileDraft draft, - }) { - return applicationStore.run( - context: context, - operationName: ApplicationManagementCommandCode.createProject.name, - action: (repositories) async { - final id = draft.id ?? context.idGenerator.nextId(); - final existing = await repositories.projects.findById(id); - if (existing != null) { - return _failure( - ApplicationFailureCode.conflict, - entityId: id, - detailCode: 'projectAlreadyExists', - ); - } - final project = ProjectProfile( - id: id, - name: draft.name, - colorKey: draft.colorKey, - defaultPriority: draft.defaultPriority, - defaultReward: draft.defaultReward, - defaultDifficulty: draft.defaultDifficulty, - defaultReminderProfile: draft.defaultReminderProfile, - defaultDurationMinutes: draft.defaultDurationMinutes, - ); - await repositories.projects.save(project); - return ApplicationResult.success(project); - }, - ); - } - - Future> updateProject({ - required ApplicationOperationContext context, - required String projectId, - required ProjectProfileUpdate update, - }) { - return applicationStore.run( - context: context, - operationName: ApplicationManagementCommandCode.updateProject.name, - action: (repositories) async { - final project = await _requireProject(repositories, projectId); - final updated = project.copyWith( - name: update.name, - colorKey: update.colorKey, - defaultPriority: update.defaultPriority, - defaultReward: update.defaultReward, - defaultDifficulty: update.defaultDifficulty, - defaultReminderProfile: update.defaultReminderProfile, - defaultDurationMinutes: update.defaultDurationMinutes, - clearDefaultDuration: update.clearDefaultDuration, - ); - await repositories.projects.save(updated); - return ApplicationResult.success(updated); - }, - ); - } - - Future> archiveProject({ - required ApplicationOperationContext context, - required String projectId, - }) { - return applicationStore.run( - context: context, - operationName: ApplicationManagementCommandCode.archiveProject.name, - action: (repositories) async { - final project = await _requireProject(repositories, projectId); - final archived = project.copyWith(archivedAt: context.now); - await repositories.projects.save(archived); - return ApplicationResult.success(archived); - }, - ); - } - - Future> createLockedBlock({ - required ApplicationOperationContext context, - required LockedBlockDraft draft, - }) { - return applicationStore.run( - context: context, - operationName: ApplicationManagementCommandCode.createLockedBlock.name, - action: (repositories) async { - final id = draft.id ?? context.idGenerator.nextId(); - final existing = await repositories.lockedBlocks.findBlockById(id); - if (existing != null) { - return _failure( - ApplicationFailureCode.conflict, - entityId: id, - detailCode: 'lockedBlockAlreadyExists', - ); - } - final block = LockedBlock( - id: id, - name: draft.name, - startTime: draft.startTime, - endTime: draft.endTime, - date: draft.date, - recurrence: draft.recurrence, - hiddenByDefault: draft.hiddenByDefault, - projectId: draft.projectId, - createdAt: context.now, - updatedAt: context.now, - ); - await repositories.lockedBlocks.saveBlock(block); - return ApplicationResult.success(block); - }, - ); - } - - Future> updateLockedBlock({ - required ApplicationOperationContext context, - required String lockedBlockId, - required LockedBlockUpdate update, - }) { - return applicationStore.run( - context: context, - operationName: ApplicationManagementCommandCode.updateLockedBlock.name, - action: (repositories) async { - final block = await _requireLockedBlock(repositories, lockedBlockId); - final updated = block.copyWith( - name: update.name, - startTime: update.startTime, - endTime: update.endTime, - date: update.date, - recurrence: update.recurrence, - hiddenByDefault: update.hiddenByDefault, - projectId: update.projectId, - updatedAt: context.now, - ); - await repositories.lockedBlocks.saveBlock(updated); - return ApplicationResult.success(updated); - }, - ); - } - - Future> archiveLockedBlock({ - required ApplicationOperationContext context, - required String lockedBlockId, - }) { - return applicationStore.run( - context: context, - operationName: ApplicationManagementCommandCode.archiveLockedBlock.name, - action: (repositories) async { - final block = await _requireLockedBlock(repositories, lockedBlockId); - final archived = block.copyWith( - updatedAt: context.now, - archivedAt: context.now, - ); - await repositories.lockedBlocks.saveBlock(archived); - return ApplicationResult.success(archived); - }, - ); - } - - Future> addOneDayLockedOverride({ - required ApplicationOperationContext context, - required CivilDate date, - required String name, - required ClockTime startTime, - required ClockTime endTime, - bool hiddenByDefault = true, - String? projectId, - }) { - return applicationStore.run( - context: context, - operationName: - ApplicationManagementCommandCode.addLockedBlockOverride.name, - action: (repositories) async { - final override = LockedBlockOverride.add( - id: context.idGenerator.nextId(), - date: date, - name: name, - startTime: startTime, - endTime: endTime, - hiddenByDefault: hiddenByDefault, - projectId: projectId, - createdAt: context.now, - ); - await repositories.lockedBlocks.saveOverride(override); - return ApplicationResult.success(override); - }, - ); - } - - Future> removeOneDayLockedOccurrence({ - required ApplicationOperationContext context, - required String lockedBlockId, - required CivilDate date, - }) { - return applicationStore.run( - context: context, - operationName: - ApplicationManagementCommandCode.removeLockedBlockOccurrence.name, - action: (repositories) async { - await _requireLockedBlock(repositories, lockedBlockId); - final override = LockedBlockOverride.remove( - id: context.idGenerator.nextId(), - lockedBlockId: lockedBlockId, - date: date, - createdAt: context.now, - ); - await repositories.lockedBlocks.saveOverride(override); - return ApplicationResult.success(override); - }, - ); - } - - Future> replaceOneDayLockedOccurrence({ - required ApplicationOperationContext context, - required String lockedBlockId, - required CivilDate date, - required ClockTime startTime, - required ClockTime endTime, - String? name, - bool hiddenByDefault = true, - String? projectId, - }) { - return applicationStore.run( - context: context, - operationName: - ApplicationManagementCommandCode.replaceLockedBlockOccurrence.name, - action: (repositories) async { - await _requireLockedBlock(repositories, lockedBlockId); - final override = LockedBlockOverride.replace( - id: context.idGenerator.nextId(), - lockedBlockId: lockedBlockId, - date: date, - startTime: startTime, - endTime: endTime, - name: name, - hiddenByDefault: hiddenByDefault, - projectId: projectId, - createdAt: context.now, - ); - await repositories.lockedBlocks.saveOverride(override); - return ApplicationResult.success(override); - }, - ); - } - - Future> updateOwnerSettings({ - required ApplicationOperationContext context, - required OwnerSettingsUpdate update, - }) { - return applicationStore.run( - context: context, - operationName: ApplicationManagementCommandCode.updateOwnerSettings.name, - action: (repositories) async { - final current = await _ownerSettingsOrDefault( - repositories, - context.ownerTimeZone, - ); - final stalenessSettings = update.backlogStalenessSettings; - if (stalenessSettings != null && - (stalenessSettings.greenMaxAge.isNegative || - stalenessSettings.blueMaxAge.isNegative || - stalenessSettings.blueMaxAge < stalenessSettings.greenMaxAge)) { - return _failure( - ApplicationFailureCode.validation, - detailCode: 'invalidBacklogStalenessThresholds', - ); - } - final updated = current.copyWith( - timeZoneId: update.timeZoneId, - dayStart: update.dayStart, - dayEnd: update.dayEnd, - compactModeEnabled: update.compactModeEnabled, - backlogStalenessSettings: update.backlogStalenessSettings, - ); - final warnings = [ - if (update.timeZoneId != null && - update.timeZoneId != current.timeZoneId) - OwnerSettingsWarningCode.timeZoneChanged, - if ((update.dayStart != null && - update.dayStart != current.dayStart) || - (update.dayEnd != null && update.dayEnd != current.dayEnd)) - OwnerSettingsWarningCode.planningWindowChanged, - ]; - await repositories.ownerSettings.save(updated); - return ApplicationResult.success( - OwnerSettingsUpdateResult(settings: updated, warnings: warnings), - ); - }, - ); - } - - Future> acknowledgeNotice({ - required ApplicationOperationContext context, - required String noticeId, - }) { - return applicationStore.run( - context: context, - operationName: ApplicationManagementCommandCode.acknowledgeNotice.name, - action: (repositories) async { - final ownerId = context.ownerTimeZone.ownerId; - final parsed = _parseNoticeId(noticeId); - if (parsed == null) { - return _failure( - ApplicationFailureCode.validation, - entityId: noticeId, - detailCode: 'invalidNoticeId', - ); - } - final snapshot = - await repositories.schedulingSnapshots.findById(parsed.snapshotId); - if (snapshot == null || parsed.noticeIndex >= snapshot.notices.length) { - return _failure( - ApplicationFailureCode.notFound, - entityId: noticeId, - detailCode: 'noticeNotFound', - ); - } - final existing = await repositories.noticeAcknowledgements.findById( - ownerId: ownerId, - noticeId: noticeId, - ); - if (existing != null) { - return ApplicationResult.success( - NoticeAcknowledgementResult( - record: existing, - alreadyAcknowledged: true, - ), - ); - } - - final record = NoticeAcknowledgementRecord( - noticeId: noticeId, - ownerId: ownerId, - acknowledgedAt: context.now, - ); - await repositories.noticeAcknowledgements.save(record); - return ApplicationResult.success( - NoticeAcknowledgementResult( - record: record, - alreadyAcknowledged: false, - ), - ); - }, - ); - } - - Future _requireProject( - ApplicationUnitOfWorkRepositories repositories, - String projectId, - ) async { - final project = await repositories.projects.findById(projectId); - if (project == null) { - throw ApplicationFailureException( - ApplicationFailure( - code: ApplicationFailureCode.notFound, - entityId: projectId, - detailCode: 'projectNotFound', - ), - ); - } - return project; - } - - Future _requireLockedBlock( - ApplicationUnitOfWorkRepositories repositories, - String lockedBlockId, - ) async { - final block = await repositories.lockedBlocks.findBlockById(lockedBlockId); - if (block == null) { - throw ApplicationFailureException( - ApplicationFailure( - code: ApplicationFailureCode.notFound, - entityId: lockedBlockId, - detailCode: 'lockedBlockNotFound', - ), - ); - } - return block; - } -} - -Future _ownerSettingsOrDefault( - ApplicationUnitOfWorkRepositories repositories, - OwnerTimeZoneContext ownerTimeZone, -) async { - return await repositories.ownerSettings - .findByOwnerId(ownerTimeZone.ownerId) ?? - OwnerSettings( - ownerId: ownerTimeZone.ownerId, - timeZoneId: ownerTimeZone.timeZoneId, - ); -} - -ApplicationResult _failure( - ApplicationFailureCode code, { - String? entityId, - String? detailCode, -}) { - return ApplicationResult.failure( - ApplicationFailure( - code: code, - entityId: entityId, - detailCode: detailCode, - ), - ); -} - -_ParsedNoticeId? _parseNoticeId(String noticeId) { - final separator = noticeId.lastIndexOf(':'); - if (separator <= 0 || separator == noticeId.length - 1) { - return null; - } - final noticeIndex = int.tryParse(noticeId.substring(separator + 1)); - if (noticeIndex == null || noticeIndex < 0) { - return null; - } - return _ParsedNoticeId( - snapshotId: noticeId.substring(0, separator), - noticeIndex: noticeIndex, - ); -} - -class _ParsedNoticeId { - const _ParsedNoticeId({ - required this.snapshotId, - required this.noticeIndex, - }); - - final String snapshotId; - final int noticeIndex; -} +part 'application_management/application_management_command_code.dart'; +part 'application_management/owner_settings_warning_code.dart'; +part 'application_management/get_backlog_request.dart'; +part 'application_management/backlog_item_read_model.dart'; +part 'application_management/backlog_query_result.dart'; +part 'application_management/get_project_defaults_request.dart'; +part 'application_management/project_defaults_query_result.dart'; +part 'application_management/project_profile_draft.dart'; +part 'application_management/project_profile_update.dart'; +part 'application_management/locked_block_draft.dart'; +part 'application_management/locked_block_update.dart'; +part 'application_management/owner_settings_update_result.dart'; +part 'application_management/owner_settings_update.dart'; +part 'application_management/notice_acknowledgement_result.dart'; +part 'application_management/v1_application_management_use_cases.dart'; +part 'application_management/parsed_notice_id.dart'; diff --git a/packages/scheduler_core/lib/src/application_management/application_management_command_code.dart b/packages/scheduler_core/lib/src/application_management/application_management_command_code.dart new file mode 100644 index 0000000..ffcf978 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_management/application_management_command_code.dart @@ -0,0 +1,16 @@ +part of '../application_management.dart'; + +/// Stable management command identifiers. +enum ApplicationManagementCommandCode { + createProject, + updateProject, + archiveProject, + createLockedBlock, + updateLockedBlock, + archiveLockedBlock, + addLockedBlockOverride, + removeLockedBlockOccurrence, + replaceLockedBlockOccurrence, + updateOwnerSettings, + acknowledgeNotice, +} diff --git a/packages/scheduler_core/lib/src/application_management/backlog_item_read_model.dart b/packages/scheduler_core/lib/src/application_management/backlog_item_read_model.dart new file mode 100644 index 0000000..6fa8906 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_management/backlog_item_read_model.dart @@ -0,0 +1,15 @@ +part of '../application_management.dart'; + +/// One backlog row with its configured staleness marker. +class BacklogItemReadModel { + const BacklogItemReadModel({ + required this.task, + required this.stalenessMarker, + }); + + /// Source backlog task. + final Task task; + + /// Green/blue/purple marker using owner settings. + final BacklogStalenessMarker stalenessMarker; +} diff --git a/packages/scheduler_core/lib/src/application_management/backlog_query_result.dart b/packages/scheduler_core/lib/src/application_management/backlog_query_result.dart new file mode 100644 index 0000000..921971e --- /dev/null +++ b/packages/scheduler_core/lib/src/application_management/backlog_query_result.dart @@ -0,0 +1,19 @@ +part of '../application_management.dart'; + +/// Query result for the backlog surface. +class BacklogQueryResult { + BacklogQueryResult({ + required this.ownerId, + required this.settings, + required List items, + }) : items = List.unmodifiable(items); + + /// Authorization-neutral owner scope. + final String ownerId; + + /// Effective owner settings used by the query. + final OwnerSettings settings; + + /// Filtered and sorted backlog rows. + final List items; +} diff --git a/packages/scheduler_core/lib/src/application_management/get_backlog_request.dart b/packages/scheduler_core/lib/src/application_management/get_backlog_request.dart new file mode 100644 index 0000000..3892b02 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_management/get_backlog_request.dart @@ -0,0 +1,19 @@ +part of '../application_management.dart'; + +/// Backlog query request. +class GetBacklogRequest { + const GetBacklogRequest({ + required this.context, + this.filter, + this.sortKey = BacklogSortKey.priority, + }); + + /// Read context containing owner scope and read instant. + final ApplicationOperationContext context; + + /// Optional user-facing backlog filter. + final BacklogFilter? filter; + + /// User-facing sort order. + final BacklogSortKey sortKey; +} diff --git a/packages/scheduler_core/lib/src/application_management/get_project_defaults_request.dart b/packages/scheduler_core/lib/src/application_management/get_project_defaults_request.dart new file mode 100644 index 0000000..d5490aa --- /dev/null +++ b/packages/scheduler_core/lib/src/application_management/get_project_defaults_request.dart @@ -0,0 +1,15 @@ +part of '../application_management.dart'; + +/// Query request for project defaults and learned suggestions. +class GetProjectDefaultsRequest { + const GetProjectDefaultsRequest({ + required this.context, + this.includeArchived = false, + }); + + /// Read context containing owner scope and read instant. + final ApplicationOperationContext context; + + /// Whether archived projects should be included. + final bool includeArchived; +} diff --git a/packages/scheduler_core/lib/src/application_management/locked_block_draft.dart b/packages/scheduler_core/lib/src/application_management/locked_block_draft.dart new file mode 100644 index 0000000..33bc3b3 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_management/locked_block_draft.dart @@ -0,0 +1,24 @@ +part of '../application_management.dart'; + +/// Draft used when creating a locked block. +class LockedBlockDraft { + const LockedBlockDraft({ + this.id, + required this.name, + required this.startTime, + required this.endTime, + this.date, + this.recurrence, + this.hiddenByDefault = true, + this.projectId, + }); + + final String? id; + final String name; + final ClockTime startTime; + final ClockTime endTime; + final CivilDate? date; + final LockedBlockRecurrence? recurrence; + final bool hiddenByDefault; + final String? projectId; +} diff --git a/packages/scheduler_core/lib/src/application_management/locked_block_update.dart b/packages/scheduler_core/lib/src/application_management/locked_block_update.dart new file mode 100644 index 0000000..46f8f0b --- /dev/null +++ b/packages/scheduler_core/lib/src/application_management/locked_block_update.dart @@ -0,0 +1,22 @@ +part of '../application_management.dart'; + +/// Patch used when updating a locked block definition. +class LockedBlockUpdate { + const LockedBlockUpdate({ + this.name, + this.startTime, + this.endTime, + this.date, + this.recurrence, + this.hiddenByDefault, + this.projectId, + }); + + final String? name; + final ClockTime? startTime; + final ClockTime? endTime; + final CivilDate? date; + final LockedBlockRecurrence? recurrence; + final bool? hiddenByDefault; + final String? projectId; +} diff --git a/packages/scheduler_core/lib/src/application_management/notice_acknowledgement_result.dart b/packages/scheduler_core/lib/src/application_management/notice_acknowledgement_result.dart new file mode 100644 index 0000000..2c5b300 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_management/notice_acknowledgement_result.dart @@ -0,0 +1,15 @@ +part of '../application_management.dart'; + +/// Result for notice acknowledgement commands. +class NoticeAcknowledgementResult { + const NoticeAcknowledgementResult({ + required this.record, + required this.alreadyAcknowledged, + }); + + /// Persisted acknowledgement record. + final NoticeAcknowledgementRecord record; + + /// Whether the notice had already been acknowledged before this command. + final bool alreadyAcknowledged; +} diff --git a/packages/scheduler_core/lib/src/application_management/owner_settings_update.dart b/packages/scheduler_core/lib/src/application_management/owner_settings_update.dart new file mode 100644 index 0000000..1631108 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_management/owner_settings_update.dart @@ -0,0 +1,18 @@ +part of '../application_management.dart'; + +/// Patch used when updating owner settings. +class OwnerSettingsUpdate { + const OwnerSettingsUpdate({ + this.timeZoneId, + this.dayStart, + this.dayEnd, + this.compactModeEnabled, + this.backlogStalenessSettings, + }); + + final String? timeZoneId; + final WallTime? dayStart; + final WallTime? dayEnd; + final bool? compactModeEnabled; + final BacklogStalenessSettings? backlogStalenessSettings; +} diff --git a/packages/scheduler_core/lib/src/application_management/owner_settings_update_result.dart b/packages/scheduler_core/lib/src/application_management/owner_settings_update_result.dart new file mode 100644 index 0000000..22fa941 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_management/owner_settings_update_result.dart @@ -0,0 +1,16 @@ +part of '../application_management.dart'; + +/// Result for owner settings updates. +class OwnerSettingsUpdateResult { + OwnerSettingsUpdateResult({ + required this.settings, + Iterable warnings = + const [], + }) : warnings = List.unmodifiable(warnings); + + /// Persisted owner settings. + final OwnerSettings settings; + + /// Typed warnings for changes that may need migration/UI confirmation. + final List warnings; +} diff --git a/packages/scheduler_core/lib/src/application_management/owner_settings_warning_code.dart b/packages/scheduler_core/lib/src/application_management/owner_settings_warning_code.dart new file mode 100644 index 0000000..fbac4b9 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_management/owner_settings_warning_code.dart @@ -0,0 +1,7 @@ +part of '../application_management.dart'; + +/// Typed warning codes for settings changes that reinterpret local dates/times. +enum OwnerSettingsWarningCode { + timeZoneChanged, + planningWindowChanged, +} diff --git a/packages/scheduler_core/lib/src/application_management/parsed_notice_id.dart b/packages/scheduler_core/lib/src/application_management/parsed_notice_id.dart new file mode 100644 index 0000000..d187142 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_management/parsed_notice_id.dart @@ -0,0 +1,11 @@ +part of '../application_management.dart'; + +class _ParsedNoticeId { + const _ParsedNoticeId({ + required this.snapshotId, + required this.noticeIndex, + }); + + final String snapshotId; + final int noticeIndex; +} diff --git a/packages/scheduler_core/lib/src/application_management/project_defaults_query_result.dart b/packages/scheduler_core/lib/src/application_management/project_defaults_query_result.dart new file mode 100644 index 0000000..9960b65 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_management/project_defaults_query_result.dart @@ -0,0 +1,15 @@ +part of '../application_management.dart'; + +/// Project defaults query result. +class ProjectDefaultsQueryResult { + ProjectDefaultsQueryResult({ + required this.ownerId, + required List projects, + }) : projects = List.unmodifiable(projects); + + /// Authorization-neutral owner scope. + final String ownerId; + + /// Configured defaults plus separately surfaced learned suggestions. + final List projects; +} diff --git a/packages/scheduler_core/lib/src/application_management/project_profile_draft.dart b/packages/scheduler_core/lib/src/application_management/project_profile_draft.dart new file mode 100644 index 0000000..99c7f83 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_management/project_profile_draft.dart @@ -0,0 +1,24 @@ +part of '../application_management.dart'; + +/// Draft used when creating a project. +class ProjectProfileDraft { + const ProjectProfileDraft({ + this.id, + required this.name, + required this.colorKey, + this.defaultPriority = PriorityLevel.medium, + this.defaultReward = RewardLevel.notSet, + this.defaultDifficulty = DifficultyLevel.notSet, + this.defaultReminderProfile = ReminderProfile.gentle, + this.defaultDurationMinutes, + }); + + final String? id; + final String name; + final String colorKey; + final PriorityLevel defaultPriority; + final RewardLevel defaultReward; + final DifficultyLevel defaultDifficulty; + final ReminderProfile defaultReminderProfile; + final int? defaultDurationMinutes; +} diff --git a/packages/scheduler_core/lib/src/application_management/project_profile_update.dart b/packages/scheduler_core/lib/src/application_management/project_profile_update.dart new file mode 100644 index 0000000..bf5c898 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_management/project_profile_update.dart @@ -0,0 +1,24 @@ +part of '../application_management.dart'; + +/// Patch used when updating configured project defaults. +class ProjectProfileUpdate { + const ProjectProfileUpdate({ + this.name, + this.colorKey, + this.defaultPriority, + this.defaultReward, + this.defaultDifficulty, + this.defaultReminderProfile, + this.defaultDurationMinutes, + this.clearDefaultDuration = false, + }); + + final String? name; + final String? colorKey; + final PriorityLevel? defaultPriority; + final RewardLevel? defaultReward; + final DifficultyLevel? defaultDifficulty; + final ReminderProfile? defaultReminderProfile; + final int? defaultDurationMinutes; + final bool clearDefaultDuration; +} diff --git a/packages/scheduler_core/lib/src/application_management/v1_application_management_use_cases.dart b/packages/scheduler_core/lib/src/application_management/v1_application_management_use_cases.dart new file mode 100644 index 0000000..5ad379b --- /dev/null +++ b/packages/scheduler_core/lib/src/application_management/v1_application_management_use_cases.dart @@ -0,0 +1,518 @@ +part of '../application_management.dart'; + +/// V1 management facade used by future Flutter state management. +class V1ApplicationManagementUseCases { + const V1ApplicationManagementUseCases({ + required this.applicationStore, + this.projectSuggestionService = const ProjectSuggestionService(), + }); + + /// Atomic application boundary. + final ApplicationUnitOfWork applicationStore; + + /// Learned project suggestion policy. + final ProjectSuggestionService projectSuggestionService; + + /// Load backlog candidates through the repository status filter, then apply + /// existing filter/sort/staleness policies in memory. + Future> getBacklog( + GetBacklogRequest request, + ) { + return applicationStore.read( + action: (repositories) async { + final ownerId = request.context.ownerTimeZone.ownerId; + final settings = await _ownerSettingsOrDefault( + repositories, + request.context.ownerTimeZone, + ); + final candidates = await repositories.tasks.findByStatus( + TaskStatus.backlog, + ); + final filtered = request.filter == null + ? candidates + : BacklogView( + tasks: candidates, + now: request.context.now, + stalenessSettings: settings.backlogStalenessSettings, + ).filter(request.filter!); + final view = BacklogView( + tasks: filtered, + now: request.context.now, + stalenessSettings: settings.backlogStalenessSettings, + ); + final sorted = view.sorted(request.sortKey); + + return ApplicationResult.success( + BacklogQueryResult( + ownerId: ownerId, + settings: settings, + items: [ + for (final task in sorted) + BacklogItemReadModel( + task: task, + stalenessMarker: view.stalenessMarkerFor(task), + ), + ], + ), + ); + }, + ); + } + + /// Load configured project defaults with learned suggestions kept separate. + Future> getProjectDefaults( + GetProjectDefaultsRequest request, + ) { + return applicationStore.read( + action: (repositories) async { + final projects = (await repositories.projects.findByOwner( + ownerId: request.context.ownerTimeZone.ownerId, + includeArchived: request.includeArchived, + )) + .items; + final resolutions = []; + final sortedProjects = [...projects] + ..sort((a, b) => a.name.compareTo(b.name)); + + for (final project in sortedProjects) { + if (project.isArchived && !request.includeArchived) { + continue; + } + final statistics = + await repositories.projectStatistics.findByProjectId(project.id); + resolutions.add( + projectSuggestionService.resolveDefaults( + project: project, + statistics: + statistics ?? ProjectStatistics(projectId: project.id), + ), + ); + } + + return ApplicationResult.success( + ProjectDefaultsQueryResult( + ownerId: request.context.ownerTimeZone.ownerId, + projects: resolutions, + ), + ); + }, + ); + } + + Future> createProject({ + required ApplicationOperationContext context, + required ProjectProfileDraft draft, + }) { + return applicationStore.run( + context: context, + operationName: ApplicationManagementCommandCode.createProject.name, + action: (repositories) async { + final id = draft.id ?? context.idGenerator.nextId(); + final existing = await repositories.projects.findById(id); + if (existing != null) { + return _failure( + ApplicationFailureCode.conflict, + entityId: id, + detailCode: 'projectAlreadyExists', + ); + } + final project = ProjectProfile( + id: id, + name: draft.name, + colorKey: draft.colorKey, + defaultPriority: draft.defaultPriority, + defaultReward: draft.defaultReward, + defaultDifficulty: draft.defaultDifficulty, + defaultReminderProfile: draft.defaultReminderProfile, + defaultDurationMinutes: draft.defaultDurationMinutes, + ); + await repositories.projects.save(project); + return ApplicationResult.success(project); + }, + ); + } + + Future> updateProject({ + required ApplicationOperationContext context, + required String projectId, + required ProjectProfileUpdate update, + }) { + return applicationStore.run( + context: context, + operationName: ApplicationManagementCommandCode.updateProject.name, + action: (repositories) async { + final project = await _requireProject(repositories, projectId); + final updated = project.copyWith( + name: update.name, + colorKey: update.colorKey, + defaultPriority: update.defaultPriority, + defaultReward: update.defaultReward, + defaultDifficulty: update.defaultDifficulty, + defaultReminderProfile: update.defaultReminderProfile, + defaultDurationMinutes: update.defaultDurationMinutes, + clearDefaultDuration: update.clearDefaultDuration, + ); + await repositories.projects.save(updated); + return ApplicationResult.success(updated); + }, + ); + } + + Future> archiveProject({ + required ApplicationOperationContext context, + required String projectId, + }) { + return applicationStore.run( + context: context, + operationName: ApplicationManagementCommandCode.archiveProject.name, + action: (repositories) async { + final project = await _requireProject(repositories, projectId); + final archived = project.copyWith(archivedAt: context.now); + await repositories.projects.save(archived); + return ApplicationResult.success(archived); + }, + ); + } + + Future> createLockedBlock({ + required ApplicationOperationContext context, + required LockedBlockDraft draft, + }) { + return applicationStore.run( + context: context, + operationName: ApplicationManagementCommandCode.createLockedBlock.name, + action: (repositories) async { + final id = draft.id ?? context.idGenerator.nextId(); + final existing = await repositories.lockedBlocks.findBlockById(id); + if (existing != null) { + return _failure( + ApplicationFailureCode.conflict, + entityId: id, + detailCode: 'lockedBlockAlreadyExists', + ); + } + final block = LockedBlock( + id: id, + name: draft.name, + startTime: draft.startTime, + endTime: draft.endTime, + date: draft.date, + recurrence: draft.recurrence, + hiddenByDefault: draft.hiddenByDefault, + projectId: draft.projectId, + createdAt: context.now, + updatedAt: context.now, + ); + await repositories.lockedBlocks.saveBlock(block); + return ApplicationResult.success(block); + }, + ); + } + + Future> updateLockedBlock({ + required ApplicationOperationContext context, + required String lockedBlockId, + required LockedBlockUpdate update, + }) { + return applicationStore.run( + context: context, + operationName: ApplicationManagementCommandCode.updateLockedBlock.name, + action: (repositories) async { + final block = await _requireLockedBlock(repositories, lockedBlockId); + final updated = block.copyWith( + name: update.name, + startTime: update.startTime, + endTime: update.endTime, + date: update.date, + recurrence: update.recurrence, + hiddenByDefault: update.hiddenByDefault, + projectId: update.projectId, + updatedAt: context.now, + ); + await repositories.lockedBlocks.saveBlock(updated); + return ApplicationResult.success(updated); + }, + ); + } + + Future> archiveLockedBlock({ + required ApplicationOperationContext context, + required String lockedBlockId, + }) { + return applicationStore.run( + context: context, + operationName: ApplicationManagementCommandCode.archiveLockedBlock.name, + action: (repositories) async { + final block = await _requireLockedBlock(repositories, lockedBlockId); + final archived = block.copyWith( + updatedAt: context.now, + archivedAt: context.now, + ); + await repositories.lockedBlocks.saveBlock(archived); + return ApplicationResult.success(archived); + }, + ); + } + + Future> addOneDayLockedOverride({ + required ApplicationOperationContext context, + required CivilDate date, + required String name, + required ClockTime startTime, + required ClockTime endTime, + bool hiddenByDefault = true, + String? projectId, + }) { + return applicationStore.run( + context: context, + operationName: + ApplicationManagementCommandCode.addLockedBlockOverride.name, + action: (repositories) async { + final override = LockedBlockOverride.add( + id: context.idGenerator.nextId(), + date: date, + name: name, + startTime: startTime, + endTime: endTime, + hiddenByDefault: hiddenByDefault, + projectId: projectId, + createdAt: context.now, + ); + await repositories.lockedBlocks.saveOverride(override); + return ApplicationResult.success(override); + }, + ); + } + + Future> removeOneDayLockedOccurrence({ + required ApplicationOperationContext context, + required String lockedBlockId, + required CivilDate date, + }) { + return applicationStore.run( + context: context, + operationName: + ApplicationManagementCommandCode.removeLockedBlockOccurrence.name, + action: (repositories) async { + await _requireLockedBlock(repositories, lockedBlockId); + final override = LockedBlockOverride.remove( + id: context.idGenerator.nextId(), + lockedBlockId: lockedBlockId, + date: date, + createdAt: context.now, + ); + await repositories.lockedBlocks.saveOverride(override); + return ApplicationResult.success(override); + }, + ); + } + + Future> replaceOneDayLockedOccurrence({ + required ApplicationOperationContext context, + required String lockedBlockId, + required CivilDate date, + required ClockTime startTime, + required ClockTime endTime, + String? name, + bool hiddenByDefault = true, + String? projectId, + }) { + return applicationStore.run( + context: context, + operationName: + ApplicationManagementCommandCode.replaceLockedBlockOccurrence.name, + action: (repositories) async { + await _requireLockedBlock(repositories, lockedBlockId); + final override = LockedBlockOverride.replace( + id: context.idGenerator.nextId(), + lockedBlockId: lockedBlockId, + date: date, + startTime: startTime, + endTime: endTime, + name: name, + hiddenByDefault: hiddenByDefault, + projectId: projectId, + createdAt: context.now, + ); + await repositories.lockedBlocks.saveOverride(override); + return ApplicationResult.success(override); + }, + ); + } + + Future> updateOwnerSettings({ + required ApplicationOperationContext context, + required OwnerSettingsUpdate update, + }) { + return applicationStore.run( + context: context, + operationName: ApplicationManagementCommandCode.updateOwnerSettings.name, + action: (repositories) async { + final current = await _ownerSettingsOrDefault( + repositories, + context.ownerTimeZone, + ); + final stalenessSettings = update.backlogStalenessSettings; + if (stalenessSettings != null && + (stalenessSettings.greenMaxAge.isNegative || + stalenessSettings.blueMaxAge.isNegative || + stalenessSettings.blueMaxAge < stalenessSettings.greenMaxAge)) { + return _failure( + ApplicationFailureCode.validation, + detailCode: 'invalidBacklogStalenessThresholds', + ); + } + final updated = current.copyWith( + timeZoneId: update.timeZoneId, + dayStart: update.dayStart, + dayEnd: update.dayEnd, + compactModeEnabled: update.compactModeEnabled, + backlogStalenessSettings: update.backlogStalenessSettings, + ); + final warnings = [ + if (update.timeZoneId != null && + update.timeZoneId != current.timeZoneId) + OwnerSettingsWarningCode.timeZoneChanged, + if ((update.dayStart != null && + update.dayStart != current.dayStart) || + (update.dayEnd != null && update.dayEnd != current.dayEnd)) + OwnerSettingsWarningCode.planningWindowChanged, + ]; + await repositories.ownerSettings.save(updated); + return ApplicationResult.success( + OwnerSettingsUpdateResult(settings: updated, warnings: warnings), + ); + }, + ); + } + + Future> acknowledgeNotice({ + required ApplicationOperationContext context, + required String noticeId, + }) { + return applicationStore.run( + context: context, + operationName: ApplicationManagementCommandCode.acknowledgeNotice.name, + action: (repositories) async { + final ownerId = context.ownerTimeZone.ownerId; + final parsed = _parseNoticeId(noticeId); + if (parsed == null) { + return _failure( + ApplicationFailureCode.validation, + entityId: noticeId, + detailCode: 'invalidNoticeId', + ); + } + final snapshot = + await repositories.schedulingSnapshots.findById(parsed.snapshotId); + if (snapshot == null || parsed.noticeIndex >= snapshot.notices.length) { + return _failure( + ApplicationFailureCode.notFound, + entityId: noticeId, + detailCode: 'noticeNotFound', + ); + } + final existing = await repositories.noticeAcknowledgements.findById( + ownerId: ownerId, + noticeId: noticeId, + ); + if (existing != null) { + return ApplicationResult.success( + NoticeAcknowledgementResult( + record: existing, + alreadyAcknowledged: true, + ), + ); + } + + final record = NoticeAcknowledgementRecord( + noticeId: noticeId, + ownerId: ownerId, + acknowledgedAt: context.now, + ); + await repositories.noticeAcknowledgements.save(record); + return ApplicationResult.success( + NoticeAcknowledgementResult( + record: record, + alreadyAcknowledged: false, + ), + ); + }, + ); + } + + Future _requireProject( + ApplicationUnitOfWorkRepositories repositories, + String projectId, + ) async { + final project = await repositories.projects.findById(projectId); + if (project == null) { + throw ApplicationFailureException( + ApplicationFailure( + code: ApplicationFailureCode.notFound, + entityId: projectId, + detailCode: 'projectNotFound', + ), + ); + } + return project; + } + + Future _requireLockedBlock( + ApplicationUnitOfWorkRepositories repositories, + String lockedBlockId, + ) async { + final block = await repositories.lockedBlocks.findBlockById(lockedBlockId); + if (block == null) { + throw ApplicationFailureException( + ApplicationFailure( + code: ApplicationFailureCode.notFound, + entityId: lockedBlockId, + detailCode: 'lockedBlockNotFound', + ), + ); + } + return block; + } +} + +Future _ownerSettingsOrDefault( + ApplicationUnitOfWorkRepositories repositories, + OwnerTimeZoneContext ownerTimeZone, +) async { + return await repositories.ownerSettings + .findByOwnerId(ownerTimeZone.ownerId) ?? + OwnerSettings( + ownerId: ownerTimeZone.ownerId, + timeZoneId: ownerTimeZone.timeZoneId, + ); +} + +ApplicationResult _failure( + ApplicationFailureCode code, { + String? entityId, + String? detailCode, +}) { + return ApplicationResult.failure( + ApplicationFailure( + code: code, + entityId: entityId, + detailCode: detailCode, + ), + ); +} + +_ParsedNoticeId? _parseNoticeId(String noticeId) { + final separator = noticeId.lastIndexOf(':'); + if (separator <= 0 || separator == noticeId.length - 1) { + return null; + } + final noticeIndex = int.tryParse(noticeId.substring(separator + 1)); + if (noticeIndex == null || noticeIndex < 0) { + return null; + } + return _ParsedNoticeId( + snapshotId: noticeId.substring(0, separator), + noticeIndex: noticeIndex, + ); +} diff --git a/packages/scheduler_core/lib/src/application_recovery.dart b/packages/scheduler_core/lib/src/application_recovery.dart index daf37ce..2e7a53a 100644 --- a/packages/scheduler_core/lib/src/application_recovery.dart +++ b/packages/scheduler_core/lib/src/application_recovery.dart @@ -14,398 +14,7 @@ import 'repositories.dart'; import 'scheduling_engine.dart'; import 'task_lifecycle.dart'; import 'time_contracts.dart'; - -/// Stable app-open recovery outcomes. -enum AppOpenRecoveryOutcome { - rolledOver, - noUnfinishedFlexibleTasks, - alreadyProcessed, -} - -/// Request for explicit app-open/start-day rollover recovery. -class RunAppOpenRecoveryRequest { - const RunAppOpenRecoveryRequest({ - required this.context, - required this.sourceDate, - this.targetDate, - }); - - /// Atomic operation context. - final ApplicationOperationContext context; - - /// Owner-local day to inspect for unfinished flexible work. - final CivilDate sourceDate; - - /// Owner-local target day. Defaults to the next local day. - final CivilDate? targetDate; - - /// Resolved target day. - CivilDate get resolvedTargetDate => targetDate ?? sourceDate.addDays(1); -} - -/// Result returned by app-open recovery. -class AppOpenRecoveryResult { - AppOpenRecoveryResult({ - required this.outcome, - required this.sourceDate, - required this.targetDate, - required this.snapshot, - required List changedTasks, - required List activities, - required List notices, - required List changes, - }) : changedTasks = List.unmodifiable(changedTasks), - activities = List.unmodifiable(activities), - notices = List.unmodifiable(notices), - changes = List.unmodifiable(changes); - - /// High-level deterministic outcome. - final AppOpenRecoveryOutcome outcome; - - /// Owner-local source day that was checked. - final CivilDate sourceDate; - - /// Owner-local target day used for placement and notice surfacing. - final CivilDate targetDate; - - /// Persisted deterministic source-date snapshot. - final SchedulingStateSnapshot snapshot; - - /// Tasks changed by this operation. - final List changedTasks; - - /// Movement activities persisted with changed tasks. - final List activities; - - /// Notices returned to the caller. - final List notices; - - /// Machine-readable movement records. - final List changes; -} - -/// Explicit app-open recovery facade. -class V1AppOpenRecoveryUseCases { - const V1AppOpenRecoveryUseCases({ - required this.applicationStore, - required this.timeZoneResolver, - this.resolutionOptions = const TimeZoneResolutionOptions(), - this.schedulingEngine = const SchedulingEngine(), - }); - - /// Atomic application boundary. - final ApplicationUnitOfWork applicationStore; - - /// Explicit owner-local date resolver. - final TimeZoneResolver timeZoneResolver; - - /// DST/nonexistent/repeated local time policy. - final TimeZoneResolutionOptions resolutionOptions; - - /// Pure scheduling engine. - final SchedulingEngine schedulingEngine; - - /// Run source-day rollover at most once for one owner/source-date key. - Future> runAppOpenRecovery( - RunAppOpenRecoveryRequest request, - ) { - return applicationStore.run( - context: request.context, - operationName: 'appOpenRecovery', - action: (repositories) async { - final context = request.context; - final ownerId = context.ownerTimeZone.ownerId; - final sourceDate = request.sourceDate; - final targetDate = request.resolvedTargetDate; - final snapshotId = _rolloverSnapshotId( - ownerId: ownerId, - sourceDate: sourceDate, - ); - final existingSnapshot = - await repositories.schedulingSnapshots.findById(snapshotId); - if (existingSnapshot != null) { - return ApplicationResult.success( - AppOpenRecoveryResult( - outcome: AppOpenRecoveryOutcome.alreadyProcessed, - sourceDate: sourceDate, - targetDate: targetDate, - snapshot: existingSnapshot, - changedTasks: const [], - activities: const [], - notices: existingSnapshot.notices, - changes: existingSnapshot.changes, - ), - ); - } - - final settings = await _ownerSettingsOrDefault( - repositories, - context.ownerTimeZone, - ); - final sourceWindow = _windowForDate(sourceDate, settings); - final targetWindow = _windowForDate(targetDate, settings); - final tasks = await _loadSourceAndTargetTasks( - repositories, - sourceWindow: sourceWindow, - targetWindow: targetWindow, - ); - final blocks = (await repositories.lockedBlocks.findBlocksByOwner( - ownerId: context.ownerTimeZone.ownerId, - )) - .items; - final overrides = (await repositories.lockedBlocks.findOverridesForDate( - ownerId: context.ownerTimeZone.ownerId, - date: targetDate, - )) - .items; - final lockedExpansion = expandLockedBlocksForDay( - blocks: blocks, - overrides: overrides, - date: targetDate, - timeZoneId: settings.timeZoneId, - timeZoneResolver: timeZoneResolver, - resolutionOptions: resolutionOptions, - ); - final input = SchedulingInput( - tasks: tasks, - window: targetWindow, - lockedIntervals: lockedExpansion.schedulingIntervals, - ); - final schedulingResult = - schedulingEngine.rollOverUnfinishedFlexibleTasks( - input: input, - sourceWindow: sourceWindow, - updatedAt: context.now, - ); - - if (schedulingResult.outcomeCode == SchedulingOutcomeCode.overflow || - schedulingResult.outcomeCode == SchedulingOutcomeCode.noSlot) { - return ApplicationResult.failure( - ApplicationFailure( - code: ApplicationFailureCode.noSlot, - entityId: snapshotId, - detailCode: schedulingResult.outcomeCode.name, - ), - ); - } - - final changedTasks = _changedTasks(tasks, schedulingResult.tasks); - final activities = _activitiesForRolloverChanges( - result: schedulingResult, - beforeTasks: tasks, - afterTasks: schedulingResult.tasks, - operationId: context.operationId, - occurredAt: context.now, - ); - final rolloverNotice = _rolloverNotice( - sourceWindow: sourceWindow, - result: schedulingResult, - ); - final snapshot = SchedulingStateSnapshot( - id: snapshotId, - capturedAt: context.now, - window: targetWindow, - tasks: schedulingResult.tasks, - lockedIntervals: input.lockedIntervals, - notices: [ - if (rolloverNotice != null) rolloverNotice, - ], - changes: schedulingResult.changes, - operationName: 'appOpenRecovery', - ); - - await repositories.tasks.saveAll(changedTasks); - await repositories.taskActivities.saveAll(activities); - await repositories.schedulingSnapshots.save(snapshot); - - return ApplicationResult.success( - AppOpenRecoveryResult( - outcome: rolloverNotice == null - ? AppOpenRecoveryOutcome.noUnfinishedFlexibleTasks - : AppOpenRecoveryOutcome.rolledOver, - sourceDate: sourceDate, - targetDate: targetDate, - snapshot: snapshot, - changedTasks: changedTasks, - activities: activities, - notices: snapshot.notices, - changes: schedulingResult.changes, - ), - ); - }, - ); - } - - SchedulingWindow _windowForDate(CivilDate date, OwnerSettings settings) { - return SchedulingWindow( - start: _resolve( - date: date, - wallTime: settings.dayStart, - timeZoneId: settings.timeZoneId, - ), - end: _resolve( - date: date, - wallTime: settings.dayEnd, - timeZoneId: settings.timeZoneId, - ), - ); - } - - DateTime _resolve({ - required CivilDate date, - required WallTime wallTime, - required String timeZoneId, - }) { - return timeZoneResolver - .resolve( - date: date, - wallTime: wallTime, - timeZoneId: timeZoneId, - options: resolutionOptions, - ) - .instant; - } -} - -Future _ownerSettingsOrDefault( - ApplicationUnitOfWorkRepositories repositories, - OwnerTimeZoneContext ownerTimeZone, -) async { - return await repositories.ownerSettings - .findByOwnerId(ownerTimeZone.ownerId) ?? - OwnerSettings( - ownerId: ownerTimeZone.ownerId, - timeZoneId: ownerTimeZone.timeZoneId, - ); -} - -Future> _loadSourceAndTargetTasks( - ApplicationUnitOfWorkRepositories repositories, { - required SchedulingWindow sourceWindow, - required SchedulingWindow targetWindow, -}) async { - final byId = {}; - for (final task in await repositories.tasks.findScheduledInWindow( - sourceWindow, - )) { - byId[task.id] = task; - } - for (final task in await repositories.tasks.findScheduledInWindow( - targetWindow, - )) { - byId[task.id] = task; - } - return List.unmodifiable(byId.values); -} - -SchedulingNotice? _rolloverNotice({ - required SchedulingWindow sourceWindow, - required SchedulingResult result, -}) { - if (result.outcomeCode != SchedulingOutcomeCode.success) { - return null; - } - final rolledTaskIds = result.changes - .where( - (change) => - change.previousStart != null && - change.previousEnd != null && - sourceWindow.contains( - TimeInterval( - start: change.previousStart!, end: change.previousEnd!), - ), - ) - .map((change) => change.taskId) - .toSet(); - if (rolledTaskIds.isEmpty) { - return null; - } - - return SchedulingNotice( - '${rolledTaskIds.length} unfinished flexible tasks were moved to today.', - type: SchedulingNoticeType.moved, - movementCode: SchedulingMovementCode.unfinishedFlexibleTasksRolledOver, - parameters: { - 'count': rolledTaskIds.length, - 'taskIds': List.unmodifiable(rolledTaskIds), - }, - ); -} - -List _activitiesForRolloverChanges({ - required SchedulingResult result, - required List beforeTasks, - required List afterTasks, - required String operationId, - required DateTime occurredAt, -}) { - if (result.outcomeCode != SchedulingOutcomeCode.success) { - return const []; - } - final beforeById = { - for (final task in beforeTasks) task.id: task, - }; - final afterById = { - for (final task in afterTasks) task.id: task, - }; - final activities = []; - - for (final change in result.changes) { - final before = beforeById[change.taskId]; - final after = afterById[change.taskId]; - if (before == null || after == null) { - continue; - } - activities.add( - TaskActivity( - id: '$operationId:${change.taskId}:automaticPush', - operationId: operationId, - code: TaskActivityCode.automaticallyPushed, - taskId: change.taskId, - projectId: before.projectId, - occurredAt: occurredAt, - metadata: { - 'previousStatus': before.status.name, - 'nextStatus': after.status.name, - 'previousStart': change.previousStart, - 'previousEnd': change.previousEnd, - 'nextStart': change.nextStart, - 'nextEnd': change.nextEnd, - }, - ), - ); - } - - return List.unmodifiable(activities); -} - -List _changedTasks(List beforeTasks, List afterTasks) { - final beforeById = { - for (final task in beforeTasks) task.id: task, - }; - final changed = []; - - for (final task in afterTasks) { - final before = beforeById[task.id]; - if (before == null || _taskChanged(before, task)) { - changed.add(task); - } - } - - return List.unmodifiable(changed); -} - -bool _taskChanged(Task before, Task after) { - return before.status != after.status || - before.scheduledStart != after.scheduledStart || - before.scheduledEnd != after.scheduledEnd || - before.updatedAt != after.updatedAt || - before.stats != after.stats; -} - -String _rolloverSnapshotId({ - required String ownerId, - required CivilDate sourceDate, -}) { - return 'rollover:$ownerId:${sourceDate.toIsoString()}'; -} +part 'application_recovery/app_open_recovery_outcome.dart'; +part 'application_recovery/run_app_open_recovery_request.dart'; +part 'application_recovery/app_open_recovery_result.dart'; +part 'application_recovery/v1_app_open_recovery_use_cases.dart'; diff --git a/packages/scheduler_core/lib/src/application_recovery/app_open_recovery_outcome.dart b/packages/scheduler_core/lib/src/application_recovery/app_open_recovery_outcome.dart new file mode 100644 index 0000000..23db761 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_recovery/app_open_recovery_outcome.dart @@ -0,0 +1,8 @@ +part of '../application_recovery.dart'; + +/// Stable app-open recovery outcomes. +enum AppOpenRecoveryOutcome { + rolledOver, + noUnfinishedFlexibleTasks, + alreadyProcessed, +} diff --git a/packages/scheduler_core/lib/src/application_recovery/app_open_recovery_result.dart b/packages/scheduler_core/lib/src/application_recovery/app_open_recovery_result.dart new file mode 100644 index 0000000..b0c30e4 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_recovery/app_open_recovery_result.dart @@ -0,0 +1,42 @@ +part of '../application_recovery.dart'; + +/// Result returned by app-open recovery. +class AppOpenRecoveryResult { + AppOpenRecoveryResult({ + required this.outcome, + required this.sourceDate, + required this.targetDate, + required this.snapshot, + required List changedTasks, + required List activities, + required List notices, + required List changes, + }) : changedTasks = List.unmodifiable(changedTasks), + activities = List.unmodifiable(activities), + notices = List.unmodifiable(notices), + changes = List.unmodifiable(changes); + + /// High-level deterministic outcome. + final AppOpenRecoveryOutcome outcome; + + /// Owner-local source day that was checked. + final CivilDate sourceDate; + + /// Owner-local target day used for placement and notice surfacing. + final CivilDate targetDate; + + /// Persisted deterministic source-date snapshot. + final SchedulingStateSnapshot snapshot; + + /// Tasks changed by this operation. + final List changedTasks; + + /// Movement activities persisted with changed tasks. + final List activities; + + /// Notices returned to the caller. + final List notices; + + /// Machine-readable movement records. + final List changes; +} diff --git a/packages/scheduler_core/lib/src/application_recovery/run_app_open_recovery_request.dart b/packages/scheduler_core/lib/src/application_recovery/run_app_open_recovery_request.dart new file mode 100644 index 0000000..b79ad6d --- /dev/null +++ b/packages/scheduler_core/lib/src/application_recovery/run_app_open_recovery_request.dart @@ -0,0 +1,22 @@ +part of '../application_recovery.dart'; + +/// Request for explicit app-open/start-day rollover recovery. +class RunAppOpenRecoveryRequest { + const RunAppOpenRecoveryRequest({ + required this.context, + required this.sourceDate, + this.targetDate, + }); + + /// Atomic operation context. + final ApplicationOperationContext context; + + /// Owner-local day to inspect for unfinished flexible work. + final CivilDate sourceDate; + + /// Owner-local target day. Defaults to the next local day. + final CivilDate? targetDate; + + /// Resolved target day. + CivilDate get resolvedTargetDate => targetDate ?? sourceDate.addDays(1); +} diff --git a/packages/scheduler_core/lib/src/application_recovery/v1_app_open_recovery_use_cases.dart b/packages/scheduler_core/lib/src/application_recovery/v1_app_open_recovery_use_cases.dart new file mode 100644 index 0000000..f214d23 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_recovery/v1_app_open_recovery_use_cases.dart @@ -0,0 +1,327 @@ +part of '../application_recovery.dart'; + +/// Explicit app-open recovery facade. +class V1AppOpenRecoveryUseCases { + const V1AppOpenRecoveryUseCases({ + required this.applicationStore, + required this.timeZoneResolver, + this.resolutionOptions = const TimeZoneResolutionOptions(), + this.schedulingEngine = const SchedulingEngine(), + }); + + /// Atomic application boundary. + final ApplicationUnitOfWork applicationStore; + + /// Explicit owner-local date resolver. + final TimeZoneResolver timeZoneResolver; + + /// DST/nonexistent/repeated local time policy. + final TimeZoneResolutionOptions resolutionOptions; + + /// Pure scheduling engine. + final SchedulingEngine schedulingEngine; + + /// Run source-day rollover at most once for one owner/source-date key. + Future> runAppOpenRecovery( + RunAppOpenRecoveryRequest request, + ) { + return applicationStore.run( + context: request.context, + operationName: 'appOpenRecovery', + action: (repositories) async { + final context = request.context; + final ownerId = context.ownerTimeZone.ownerId; + final sourceDate = request.sourceDate; + final targetDate = request.resolvedTargetDate; + final snapshotId = _rolloverSnapshotId( + ownerId: ownerId, + sourceDate: sourceDate, + ); + final existingSnapshot = + await repositories.schedulingSnapshots.findById(snapshotId); + if (existingSnapshot != null) { + return ApplicationResult.success( + AppOpenRecoveryResult( + outcome: AppOpenRecoveryOutcome.alreadyProcessed, + sourceDate: sourceDate, + targetDate: targetDate, + snapshot: existingSnapshot, + changedTasks: const [], + activities: const [], + notices: existingSnapshot.notices, + changes: existingSnapshot.changes, + ), + ); + } + + final settings = await _ownerSettingsOrDefault( + repositories, + context.ownerTimeZone, + ); + final sourceWindow = _windowForDate(sourceDate, settings); + final targetWindow = _windowForDate(targetDate, settings); + final tasks = await _loadSourceAndTargetTasks( + repositories, + sourceWindow: sourceWindow, + targetWindow: targetWindow, + ); + final blocks = (await repositories.lockedBlocks.findBlocksByOwner( + ownerId: context.ownerTimeZone.ownerId, + )) + .items; + final overrides = (await repositories.lockedBlocks.findOverridesForDate( + ownerId: context.ownerTimeZone.ownerId, + date: targetDate, + )) + .items; + final lockedExpansion = expandLockedBlocksForDay( + blocks: blocks, + overrides: overrides, + date: targetDate, + timeZoneId: settings.timeZoneId, + timeZoneResolver: timeZoneResolver, + resolutionOptions: resolutionOptions, + ); + final input = SchedulingInput( + tasks: tasks, + window: targetWindow, + lockedIntervals: lockedExpansion.schedulingIntervals, + ); + final schedulingResult = + schedulingEngine.rollOverUnfinishedFlexibleTasks( + input: input, + sourceWindow: sourceWindow, + updatedAt: context.now, + ); + + if (schedulingResult.outcomeCode == SchedulingOutcomeCode.overflow || + schedulingResult.outcomeCode == SchedulingOutcomeCode.noSlot) { + return ApplicationResult.failure( + ApplicationFailure( + code: ApplicationFailureCode.noSlot, + entityId: snapshotId, + detailCode: schedulingResult.outcomeCode.name, + ), + ); + } + + final changedTasks = _changedTasks(tasks, schedulingResult.tasks); + final activities = _activitiesForRolloverChanges( + result: schedulingResult, + beforeTasks: tasks, + afterTasks: schedulingResult.tasks, + operationId: context.operationId, + occurredAt: context.now, + ); + final rolloverNotice = _rolloverNotice( + sourceWindow: sourceWindow, + result: schedulingResult, + ); + final snapshot = SchedulingStateSnapshot( + id: snapshotId, + capturedAt: context.now, + window: targetWindow, + tasks: schedulingResult.tasks, + lockedIntervals: input.lockedIntervals, + notices: [ + if (rolloverNotice != null) rolloverNotice, + ], + changes: schedulingResult.changes, + operationName: 'appOpenRecovery', + ); + + await repositories.tasks.saveAll(changedTasks); + await repositories.taskActivities.saveAll(activities); + await repositories.schedulingSnapshots.save(snapshot); + + return ApplicationResult.success( + AppOpenRecoveryResult( + outcome: rolloverNotice == null + ? AppOpenRecoveryOutcome.noUnfinishedFlexibleTasks + : AppOpenRecoveryOutcome.rolledOver, + sourceDate: sourceDate, + targetDate: targetDate, + snapshot: snapshot, + changedTasks: changedTasks, + activities: activities, + notices: snapshot.notices, + changes: schedulingResult.changes, + ), + ); + }, + ); + } + + SchedulingWindow _windowForDate(CivilDate date, OwnerSettings settings) { + return SchedulingWindow( + start: _resolve( + date: date, + wallTime: settings.dayStart, + timeZoneId: settings.timeZoneId, + ), + end: _resolve( + date: date, + wallTime: settings.dayEnd, + timeZoneId: settings.timeZoneId, + ), + ); + } + + DateTime _resolve({ + required CivilDate date, + required WallTime wallTime, + required String timeZoneId, + }) { + return timeZoneResolver + .resolve( + date: date, + wallTime: wallTime, + timeZoneId: timeZoneId, + options: resolutionOptions, + ) + .instant; + } +} + +Future _ownerSettingsOrDefault( + ApplicationUnitOfWorkRepositories repositories, + OwnerTimeZoneContext ownerTimeZone, +) async { + return await repositories.ownerSettings + .findByOwnerId(ownerTimeZone.ownerId) ?? + OwnerSettings( + ownerId: ownerTimeZone.ownerId, + timeZoneId: ownerTimeZone.timeZoneId, + ); +} + +Future> _loadSourceAndTargetTasks( + ApplicationUnitOfWorkRepositories repositories, { + required SchedulingWindow sourceWindow, + required SchedulingWindow targetWindow, +}) async { + final byId = {}; + for (final task in await repositories.tasks.findScheduledInWindow( + sourceWindow, + )) { + byId[task.id] = task; + } + for (final task in await repositories.tasks.findScheduledInWindow( + targetWindow, + )) { + byId[task.id] = task; + } + return List.unmodifiable(byId.values); +} + +SchedulingNotice? _rolloverNotice({ + required SchedulingWindow sourceWindow, + required SchedulingResult result, +}) { + if (result.outcomeCode != SchedulingOutcomeCode.success) { + return null; + } + final rolledTaskIds = result.changes + .where( + (change) => + change.previousStart != null && + change.previousEnd != null && + sourceWindow.contains( + TimeInterval( + start: change.previousStart!, end: change.previousEnd!), + ), + ) + .map((change) => change.taskId) + .toSet(); + if (rolledTaskIds.isEmpty) { + return null; + } + + return SchedulingNotice( + '${rolledTaskIds.length} unfinished flexible tasks were moved to today.', + type: SchedulingNoticeType.moved, + movementCode: SchedulingMovementCode.unfinishedFlexibleTasksRolledOver, + parameters: { + 'count': rolledTaskIds.length, + 'taskIds': List.unmodifiable(rolledTaskIds), + }, + ); +} + +List _activitiesForRolloverChanges({ + required SchedulingResult result, + required List beforeTasks, + required List afterTasks, + required String operationId, + required DateTime occurredAt, +}) { + if (result.outcomeCode != SchedulingOutcomeCode.success) { + return const []; + } + final beforeById = { + for (final task in beforeTasks) task.id: task, + }; + final afterById = { + for (final task in afterTasks) task.id: task, + }; + final activities = []; + + for (final change in result.changes) { + final before = beforeById[change.taskId]; + final after = afterById[change.taskId]; + if (before == null || after == null) { + continue; + } + activities.add( + TaskActivity( + id: '$operationId:${change.taskId}:automaticPush', + operationId: operationId, + code: TaskActivityCode.automaticallyPushed, + taskId: change.taskId, + projectId: before.projectId, + occurredAt: occurredAt, + metadata: { + 'previousStatus': before.status.name, + 'nextStatus': after.status.name, + 'previousStart': change.previousStart, + 'previousEnd': change.previousEnd, + 'nextStart': change.nextStart, + 'nextEnd': change.nextEnd, + }, + ), + ); + } + + return List.unmodifiable(activities); +} + +List _changedTasks(List beforeTasks, List afterTasks) { + final beforeById = { + for (final task in beforeTasks) task.id: task, + }; + final changed = []; + + for (final task in afterTasks) { + final before = beforeById[task.id]; + if (before == null || _taskChanged(before, task)) { + changed.add(task); + } + } + + return List.unmodifiable(changed); +} + +bool _taskChanged(Task before, Task after) { + return before.status != after.status || + before.scheduledStart != after.scheduledStart || + before.scheduledEnd != after.scheduledEnd || + before.updatedAt != after.updatedAt || + before.stats != after.stats; +} + +String _rolloverSnapshotId({ + required String ownerId, + required CivilDate sourceDate, +}) { + return 'rollover:$ownerId:${sourceDate.toIsoString()}'; +} diff --git a/packages/scheduler_core/lib/src/backlog.dart b/packages/scheduler_core/lib/src/backlog.dart index fbda319..3bc4a7f 100644 --- a/packages/scheduler_core/lib/src/backlog.dart +++ b/packages/scheduler_core/lib/src/backlog.dart @@ -9,291 +9,9 @@ library; // scheduler so the engine can focus on moving tasks through time. import 'models.dart'; - -/// Derived backlog filters for a unified backlog list. -/// -/// These filters do not store separate task collections. They are projections -/// over the same master task list. That is important because a task can move -/// between today's timeline and backlog by changing [Task.status], without -/// needing to copy it between separate stores. -enum BacklogFilter { - /// Uncategorized captured tasks in the default inbox project. - inbox, - - /// Tasks that have been manually or automatically pushed at least once. - pushed, - - /// Critical tasks that have missed at least once and need recovery attention. - criticalMissed, - - /// Someday/maybe tasks that are intentionally kept out of normal pressure. - wishlist, - - /// Tasks whose [Task.updatedAt] age exceeds the configured stale threshold. - stale, - - /// Tasks still missing a reward estimate. Useful during cleanup/review. - noRewardSet, -} - -/// Sort options for a unified backlog list. -/// -/// Sort keys are intentionally product-facing rather than database-facing. For -/// example, `rewardVsEffort` maps to a simple derived score instead of a stored -/// field. Persistence can later index the underlying fields if needed. -enum BacklogSortKey { - /// Highest priority first. - priority, - - /// Best simple reward-minus-difficulty score first. - rewardVsEffort, - - /// Oldest created task first. - age, - - /// Lexicographic project id grouping. Future UI can replace this with project - /// display order while keeping the same public key. - project, - - /// Most frequently pushed tasks first. - timesPushed, -} - -/// Visual age bucket for backlog display. -/// -/// This supports the design rule that old backlog items should visually age -/// from green to blue to purple. The enum names describe semantic buckets; UI -/// code should translate them into actual theme colors. -enum BacklogStalenessMarker { - /// Fresh backlog item. Default: created within seven days. - green, - - /// Aging backlog item. Default: created within thirty days. - blue, - - /// Old/stale backlog item. Default: created more than thirty days ago. - purple, -} - -/// Configurable thresholds for backlog age markers. -/// -/// The defaults match the current design spec: less than a week is fresh, less -/// than a month is aging, and anything older is stale. Keeping the thresholds in -/// a value object makes future settings/preferences easy to inject in tests or -/// user configuration. -class BacklogStalenessSettings { - const BacklogStalenessSettings({ - this.greenMaxAge = const Duration(days: 7), - this.blueMaxAge = const Duration(days: 30), - }); - - /// Maximum age that still counts as fresh/green. - final Duration greenMaxAge; - - /// Maximum age that still counts as aging/blue. Anything older is purple. - final Duration blueMaxAge; - - /// Return the visual age marker for [task] relative to [now]. - /// - /// This uses [Task.createdAt], not [Task.updatedAt], because the marker is - /// meant to show how long the idea has existed in the system. A task edited - /// yesterday but created two months ago should still feel old in the backlog. - BacklogStalenessMarker markerFor({ - required Task task, - required DateTime now, - }) { - final age = now.difference(task.createdAt); - - if (age <= greenMaxAge) { - return BacklogStalenessMarker.green; - } - - if (age <= blueMaxAge) { - return BacklogStalenessMarker.blue; - } - - return BacklogStalenessMarker.purple; - } -} - -/// Read-only backlog projection over the unified task list. -/// -/// [BacklogView] is a query/helper object. It does not mutate tasks or own data; -/// it receives the current task list and exposes common backlog slices for UI. -/// That keeps backlog display logic out of widgets and avoids duplicating the -/// same filtering rules in multiple screens. -class BacklogView { - BacklogView({ - required List tasks, - required this.now, - this.staleAfter = const Duration(days: 31), - this.stalenessSettings = const BacklogStalenessSettings(), - }) : tasks = List.unmodifiable(tasks); - - /// Master task list supplied by the caller. Only `status == backlog` items are - /// shown by this view. - final List tasks; - - /// Clock value supplied by the caller so age/staleness behavior is testable. - final DateTime now; - - /// Age since [Task.createdAt] that qualifies for the `stale` filter. - /// - /// V1 does not yet store a separate "entered backlog at" timestamp. Until - /// persistence adds that field, both stale filtering and visual staleness use - /// task creation age so they do not disagree after a task edit. - final Duration staleAfter; - - /// Color-bucket threshold configuration for backlog aging indicators. - final BacklogStalenessSettings stalenessSettings; - - /// All tasks currently in backlog status. - /// - /// The returned list is a snapshot. It is not intended to be modified and then - /// written back; state changes should go through scheduling/action services. - List get backlogTasks { - return tasks.where((task) => task.isBacklog).toList(growable: false); - } - - /// Return backlog tasks matching a single user-facing filter. - /// - /// Filtering always starts from [backlogTasks], so a completed or planned task - /// will never appear here even if it has matching statistics. - List filter(BacklogFilter filter) { - return backlogTasks.where((task) => _matchesFilter(task, filter)).toList( - growable: false, - ); - } - - /// Return all backlog tasks sorted by a user-facing ordering. - /// - /// A new list is created before sorting so the original [tasks] list is never - /// reordered by a read operation. The final list is unmodifiable to make that - /// intent explicit to callers. - List sorted(BacklogSortKey sortKey) { - final sortedTasks = backlogTasks - .asMap() - .entries - .map( - (entry) => _IndexedTask( - task: entry.value, - originalIndex: entry.key, - ), - ) - .toList(growable: false); - sortedTasks.sort((a, b) { - final comparison = _compareTasks(a.task, b.task, sortKey); - if (comparison != 0) { - return comparison; - } - - return a.originalIndex.compareTo(b.originalIndex); - }); - - return List.unmodifiable( - sortedTasks.map((indexedTask) => indexedTask.task), - ); - } - - /// Return the green/blue/purple marker for one task. - BacklogStalenessMarker stalenessMarkerFor(Task task) { - return stalenessSettings.markerFor(task: task, now: now); - } - - /// Private predicate implementing every [BacklogFilter] option. - /// - /// Keeping this as a switch expression makes new filters obvious: add the enum - /// value and the compiler forces this method to handle it. - bool _matchesFilter(Task task, BacklogFilter filter) { - return switch (filter) { - BacklogFilter.inbox => task.projectId == 'inbox', - BacklogFilter.pushed => - task.stats.manuallyPushedCount > 0 || task.stats.autoPushedCount > 0, - BacklogFilter.criticalMissed => - task.type == TaskType.critical && task.stats.missedCount > 0, - BacklogFilter.wishlist => task.backlogTags.contains(BacklogTag.wishlist), - BacklogFilter.stale => now.difference(task.createdAt) >= staleAfter, - BacklogFilter.noRewardSet => task.reward == RewardLevel.notSet, - }; - } - - /// Comparison callback used by [sorted]. - /// - /// Sort directions are encoded here. Higher priority/reward/push counts should - /// appear earlier, while older age uses the earliest [Task.createdAt] first. - int _compareTasks(Task a, Task b, BacklogSortKey sortKey) { - return switch (sortKey) { - BacklogSortKey.priority => - _priorityRank(b.priority).compareTo(_priorityRank(a.priority)), - BacklogSortKey.rewardVsEffort => - _rewardVsEffortScore(b).compareTo(_rewardVsEffortScore(a)), - BacklogSortKey.age => a.createdAt.compareTo(b.createdAt), - BacklogSortKey.project => a.projectId.compareTo(b.projectId), - BacklogSortKey.timesPushed => _timesPushed(b).compareTo(_timesPushed(a)), - }; - } -} - -/// Convert nullable priority into a stable numeric rank for sorting. -/// -/// Null priority is treated like medium so partially imported data behaves like -/// normal starter tasks instead of sinking to the bottom. -int _priorityRank(PriorityLevel? priority) { - return switch (priority) { - PriorityLevel.veryLow => 0, - PriorityLevel.low => 1, - PriorityLevel.medium || null => 2, - PriorityLevel.high => 3, - PriorityLevel.veryHigh => 4, - }; -} - -/// Convert reward enum values to numeric ranks for derived scoring. -int _rewardRank(RewardLevel reward) { - return switch (reward) { - RewardLevel.notSet => 0, - RewardLevel.veryLow => 1, - RewardLevel.low => 2, - RewardLevel.medium => 3, - RewardLevel.high => 4, - RewardLevel.veryHigh => 5, - }; -} - -/// Convert difficulty enum values to numeric ranks for derived scoring. -int _difficultyRank(DifficultyLevel difficulty) { - return switch (difficulty) { - DifficultyLevel.notSet => 0, - DifficultyLevel.veryEasy => 1, - DifficultyLevel.easy => 2, - DifficultyLevel.medium => 3, - DifficultyLevel.hard => 4, - DifficultyLevel.veryHard => 5, - }; -} - -/// Simple motivation score: reward minus difficulty. -/// -/// Positive scores suggest high payoff for lower activation cost. Negative scores -/// suggest high effort for lower payoff. This is deliberately simple for V1 and -/// can be replaced by richer heuristics later without changing the public sort -/// key. -int _rewardVsEffortScore(Task task) { - return _rewardRank(task.reward) - _difficultyRank(task.difficulty); -} - -/// Total manual and automatic pushes recorded on the task. -int _timesPushed(Task task) { - return task.stats.manuallyPushedCount + task.stats.autoPushedCount; -} - -/// Backlog task paired with its source-list position for stable sorting. -class _IndexedTask { - const _IndexedTask({ - required this.task, - required this.originalIndex, - }); - - final Task task; - final int originalIndex; -} +part 'backlog/backlog_filter.dart'; +part 'backlog/backlog_sort_key.dart'; +part 'backlog/backlog_staleness_marker.dart'; +part 'backlog/backlog_staleness_settings.dart'; +part 'backlog/backlog_view.dart'; +part 'backlog/indexed_task.dart'; diff --git a/packages/scheduler_core/lib/src/backlog/backlog_filter.dart b/packages/scheduler_core/lib/src/backlog/backlog_filter.dart new file mode 100644 index 0000000..f36ce61 --- /dev/null +++ b/packages/scheduler_core/lib/src/backlog/backlog_filter.dart @@ -0,0 +1,27 @@ +part of '../backlog.dart'; + +/// Derived backlog filters for a unified backlog list. +/// +/// These filters do not store separate task collections. They are projections +/// over the same master task list. That is important because a task can move +/// between today's timeline and backlog by changing [Task.status], without +/// needing to copy it between separate stores. +enum BacklogFilter { + /// Uncategorized captured tasks in the default inbox project. + inbox, + + /// Tasks that have been manually or automatically pushed at least once. + pushed, + + /// Critical tasks that have missed at least once and need recovery attention. + criticalMissed, + + /// Someday/maybe tasks that are intentionally kept out of normal pressure. + wishlist, + + /// Tasks whose [Task.updatedAt] age exceeds the configured stale threshold. + stale, + + /// Tasks still missing a reward estimate. Useful during cleanup/review. + noRewardSet, +} diff --git a/packages/scheduler_core/lib/src/backlog/backlog_sort_key.dart b/packages/scheduler_core/lib/src/backlog/backlog_sort_key.dart new file mode 100644 index 0000000..1269794 --- /dev/null +++ b/packages/scheduler_core/lib/src/backlog/backlog_sort_key.dart @@ -0,0 +1,24 @@ +part of '../backlog.dart'; + +/// Sort options for a unified backlog list. +/// +/// Sort keys are intentionally product-facing rather than database-facing. For +/// example, `rewardVsEffort` maps to a simple derived score instead of a stored +/// field. Persistence can later index the underlying fields if needed. +enum BacklogSortKey { + /// Highest priority first. + priority, + + /// Best simple reward-minus-difficulty score first. + rewardVsEffort, + + /// Oldest created task first. + age, + + /// Lexicographic project id grouping. Future UI can replace this with project + /// display order while keeping the same public key. + project, + + /// Most frequently pushed tasks first. + timesPushed, +} diff --git a/packages/scheduler_core/lib/src/backlog/backlog_staleness_marker.dart b/packages/scheduler_core/lib/src/backlog/backlog_staleness_marker.dart new file mode 100644 index 0000000..10f22f2 --- /dev/null +++ b/packages/scheduler_core/lib/src/backlog/backlog_staleness_marker.dart @@ -0,0 +1,17 @@ +part of '../backlog.dart'; + +/// Visual age bucket for backlog display. +/// +/// This supports the design rule that old backlog items should visually age +/// from green to blue to purple. The enum names describe semantic buckets; UI +/// code should translate them into actual theme colors. +enum BacklogStalenessMarker { + /// Fresh backlog item. Default: created within seven days. + green, + + /// Aging backlog item. Default: created within thirty days. + blue, + + /// Old/stale backlog item. Default: created more than thirty days ago. + purple, +} diff --git a/packages/scheduler_core/lib/src/backlog/backlog_staleness_settings.dart b/packages/scheduler_core/lib/src/backlog/backlog_staleness_settings.dart new file mode 100644 index 0000000..2229ee7 --- /dev/null +++ b/packages/scheduler_core/lib/src/backlog/backlog_staleness_settings.dart @@ -0,0 +1,42 @@ +part of '../backlog.dart'; + +/// Configurable thresholds for backlog age markers. +/// +/// The defaults match the current design spec: less than a week is fresh, less +/// than a month is aging, and anything older is stale. Keeping the thresholds in +/// a value object makes future settings/preferences easy to inject in tests or +/// user configuration. +class BacklogStalenessSettings { + const BacklogStalenessSettings({ + this.greenMaxAge = const Duration(days: 7), + this.blueMaxAge = const Duration(days: 30), + }); + + /// Maximum age that still counts as fresh/green. + final Duration greenMaxAge; + + /// Maximum age that still counts as aging/blue. Anything older is purple. + final Duration blueMaxAge; + + /// Return the visual age marker for [task] relative to [now]. + /// + /// This uses [Task.createdAt], not [Task.updatedAt], because the marker is + /// meant to show how long the idea has existed in the system. A task edited + /// yesterday but created two months ago should still feel old in the backlog. + BacklogStalenessMarker markerFor({ + required Task task, + required DateTime now, + }) { + final age = now.difference(task.createdAt); + + if (age <= greenMaxAge) { + return BacklogStalenessMarker.green; + } + + if (age <= blueMaxAge) { + return BacklogStalenessMarker.blue; + } + + return BacklogStalenessMarker.purple; + } +} diff --git a/packages/scheduler_core/lib/src/backlog/backlog_view.dart b/packages/scheduler_core/lib/src/backlog/backlog_view.dart new file mode 100644 index 0000000..61b948d --- /dev/null +++ b/packages/scheduler_core/lib/src/backlog/backlog_view.dart @@ -0,0 +1,172 @@ +part of '../backlog.dart'; + +/// Read-only backlog projection over the unified task list. +/// +/// [BacklogView] is a query/helper object. It does not mutate tasks or own data; +/// it receives the current task list and exposes common backlog slices for UI. +/// That keeps backlog display logic out of widgets and avoids duplicating the +/// same filtering rules in multiple screens. +class BacklogView { + BacklogView({ + required List tasks, + required this.now, + this.staleAfter = const Duration(days: 31), + this.stalenessSettings = const BacklogStalenessSettings(), + }) : tasks = List.unmodifiable(tasks); + + /// Master task list supplied by the caller. Only `status == backlog` items are + /// shown by this view. + final List tasks; + + /// Clock value supplied by the caller so age/staleness behavior is testable. + final DateTime now; + + /// Age since [Task.createdAt] that qualifies for the `stale` filter. + /// + /// V1 does not yet store a separate "entered backlog at" timestamp. Until + /// persistence adds that field, both stale filtering and visual staleness use + /// task creation age so they do not disagree after a task edit. + final Duration staleAfter; + + /// Color-bucket threshold configuration for backlog aging indicators. + final BacklogStalenessSettings stalenessSettings; + + /// All tasks currently in backlog status. + /// + /// The returned list is a snapshot. It is not intended to be modified and then + /// written back; state changes should go through scheduling/action services. + List get backlogTasks { + return tasks.where((task) => task.isBacklog).toList(growable: false); + } + + /// Return backlog tasks matching a single user-facing filter. + /// + /// Filtering always starts from [backlogTasks], so a completed or planned task + /// will never appear here even if it has matching statistics. + List filter(BacklogFilter filter) { + return backlogTasks.where((task) => _matchesFilter(task, filter)).toList( + growable: false, + ); + } + + /// Return all backlog tasks sorted by a user-facing ordering. + /// + /// A new list is created before sorting so the original [tasks] list is never + /// reordered by a read operation. The final list is unmodifiable to make that + /// intent explicit to callers. + List sorted(BacklogSortKey sortKey) { + final sortedTasks = backlogTasks + .asMap() + .entries + .map( + (entry) => _IndexedTask( + task: entry.value, + originalIndex: entry.key, + ), + ) + .toList(growable: false); + sortedTasks.sort((a, b) { + final comparison = _compareTasks(a.task, b.task, sortKey); + if (comparison != 0) { + return comparison; + } + + return a.originalIndex.compareTo(b.originalIndex); + }); + + return List.unmodifiable( + sortedTasks.map((indexedTask) => indexedTask.task), + ); + } + + /// Return the green/blue/purple marker for one task. + BacklogStalenessMarker stalenessMarkerFor(Task task) { + return stalenessSettings.markerFor(task: task, now: now); + } + + /// Private predicate implementing every [BacklogFilter] option. + /// + /// Keeping this as a switch expression makes new filters obvious: add the enum + /// value and the compiler forces this method to handle it. + bool _matchesFilter(Task task, BacklogFilter filter) { + return switch (filter) { + BacklogFilter.inbox => task.projectId == 'inbox', + BacklogFilter.pushed => + task.stats.manuallyPushedCount > 0 || task.stats.autoPushedCount > 0, + BacklogFilter.criticalMissed => + task.type == TaskType.critical && task.stats.missedCount > 0, + BacklogFilter.wishlist => task.backlogTags.contains(BacklogTag.wishlist), + BacklogFilter.stale => now.difference(task.createdAt) >= staleAfter, + BacklogFilter.noRewardSet => task.reward == RewardLevel.notSet, + }; + } + + /// Comparison callback used by [sorted]. + /// + /// Sort directions are encoded here. Higher priority/reward/push counts should + /// appear earlier, while older age uses the earliest [Task.createdAt] first. + int _compareTasks(Task a, Task b, BacklogSortKey sortKey) { + return switch (sortKey) { + BacklogSortKey.priority => + _priorityRank(b.priority).compareTo(_priorityRank(a.priority)), + BacklogSortKey.rewardVsEffort => + _rewardVsEffortScore(b).compareTo(_rewardVsEffortScore(a)), + BacklogSortKey.age => a.createdAt.compareTo(b.createdAt), + BacklogSortKey.project => a.projectId.compareTo(b.projectId), + BacklogSortKey.timesPushed => _timesPushed(b).compareTo(_timesPushed(a)), + }; + } +} + +/// Convert nullable priority into a stable numeric rank for sorting. +/// +/// Null priority is treated like medium so partially imported data behaves like +/// normal starter tasks instead of sinking to the bottom. +int _priorityRank(PriorityLevel? priority) { + return switch (priority) { + PriorityLevel.veryLow => 0, + PriorityLevel.low => 1, + PriorityLevel.medium || null => 2, + PriorityLevel.high => 3, + PriorityLevel.veryHigh => 4, + }; +} + +/// Convert reward enum values to numeric ranks for derived scoring. +int _rewardRank(RewardLevel reward) { + return switch (reward) { + RewardLevel.notSet => 0, + RewardLevel.veryLow => 1, + RewardLevel.low => 2, + RewardLevel.medium => 3, + RewardLevel.high => 4, + RewardLevel.veryHigh => 5, + }; +} + +/// Convert difficulty enum values to numeric ranks for derived scoring. +int _difficultyRank(DifficultyLevel difficulty) { + return switch (difficulty) { + DifficultyLevel.notSet => 0, + DifficultyLevel.veryEasy => 1, + DifficultyLevel.easy => 2, + DifficultyLevel.medium => 3, + DifficultyLevel.hard => 4, + DifficultyLevel.veryHard => 5, + }; +} + +/// Simple motivation score: reward minus difficulty. +/// +/// Positive scores suggest high payoff for lower activation cost. Negative scores +/// suggest high effort for lower payoff. This is deliberately simple for V1 and +/// can be replaced by richer heuristics later without changing the public sort +/// key. +int _rewardVsEffortScore(Task task) { + return _rewardRank(task.reward) - _difficultyRank(task.difficulty); +} + +/// Total manual and automatic pushes recorded on the task. +int _timesPushed(Task task) { + return task.stats.manuallyPushedCount + task.stats.autoPushedCount; +} diff --git a/packages/scheduler_core/lib/src/backlog/indexed_task.dart b/packages/scheduler_core/lib/src/backlog/indexed_task.dart new file mode 100644 index 0000000..ded17d1 --- /dev/null +++ b/packages/scheduler_core/lib/src/backlog/indexed_task.dart @@ -0,0 +1,12 @@ +part of '../backlog.dart'; + +/// Backlog task paired with its source-list position for stable sorting. +class _IndexedTask { + const _IndexedTask({ + required this.task, + required this.originalIndex, + }); + + final Task task; + final int originalIndex; +} diff --git a/packages/scheduler_core/lib/src/child_tasks.dart b/packages/scheduler_core/lib/src/child_tasks.dart index 8f23fb8..6041b64 100644 --- a/packages/scheduler_core/lib/src/child_tasks.dart +++ b/packages/scheduler_core/lib/src/child_tasks.dart @@ -10,746 +10,12 @@ library; import 'models.dart'; import 'task_lifecycle.dart'; import 'time_contracts.dart'; - -/// Row-style input for creating one child task. -/// -/// Priority is nullable on purpose. A null priority means the user did not set -/// one, so callers should preserve row insertion order instead of treating the -/// child as medium priority. -class ChildTaskEntry { - const ChildTaskEntry({ - required this.id, - required this.title, - required this.createdAt, - this.priority, - this.reward = RewardLevel.notSet, - this.difficulty = DifficultyLevel.notSet, - this.durationMinutes, - this.projectId, - this.updatedAt, - }); - - /// Caller-generated child id. - final String id; - - /// User-entered child title. - final String title; - - /// Creation timestamp supplied by the caller. - final DateTime createdAt; - - /// Optional explicit child priority. - final PriorityLevel? priority; - - /// Optional explicit child reward. Missing reward stays `notSet`. - final RewardLevel reward; - - /// Optional explicit child difficulty. - final DifficultyLevel difficulty; - - /// Optional child duration estimate. - final int? durationMinutes; - - /// Optional project override. Null means inherit the parent project. - final String? projectId; - - /// Optional update timestamp. - final DateTime? updatedAt; - - /// Convert this entry into a child [Task] owned by [parent]. - Task toTask({ - required Task parent, - }) { - final trimmedTitle = title.trim(); - if (trimmedTitle.isEmpty) { - throw ArgumentError.value(title, 'title', 'Title is required.'); - } - - return Task( - id: id, - title: trimmedTitle, - projectId: projectId ?? parent.projectId, - type: TaskType.flexible, - status: TaskStatus.backlog, - priority: priority, - reward: reward, - difficulty: difficulty, - durationMinutes: durationMinutes, - parentTaskId: parent.id, - createdAt: createdAt, - updatedAt: updatedAt ?? createdAt, - ); - } -} - -/// Command input for breaking one parent task into direct children. -class ChildTaskBreakUpRequest { - ChildTaskBreakUpRequest({ - required String parentTaskId, - required List entries, - required String operationId, - }) : parentTaskId = _requiredTrimmed(parentTaskId, 'parentTaskId'), - entries = List.unmodifiable(entries), - operationId = _requiredTrimmed(operationId, 'operationId'); - - /// Parent task that will own every created child. - final String parentTaskId; - - /// Ordered child rows supplied by the caller. - final List entries; - - /// Idempotency key for the application command that creates the children. - final String operationId; -} - -/// Result from breaking a parent task into direct children. -class ChildTaskBreakUpResult { - ChildTaskBreakUpResult({ - required List tasks, - required this.parentTask, - required List childTasks, - required this.operationId, - List createdChildTaskIds = const [], - List activities = const [], - }) : tasks = List.unmodifiable(tasks), - childTasks = List.unmodifiable(childTasks), - createdChildTaskIds = List.unmodifiable(createdChildTaskIds), - activities = List.unmodifiable(activities); - - /// Replacement task list including newly created children. - final List tasks; - - /// Parent that owns the created children. - final Task parentTask; - - /// Created children in request order. - final List childTasks; - - /// Idempotency key for the command. - final String operationId; - - /// Child ids created by this command. - final List createdChildTaskIds; - - /// Activity records produced by this command. - /// - /// V1 child creation is represented by task inserts, so this is currently - /// empty while still giving application use cases one atomic mutation shape. - final List activities; -} - -/// Creates direct child tasks from ordered child-entry rows. -class ChildTaskBreakUpService { - const ChildTaskBreakUpService(); - - /// Break `request.parentTaskId` into direct child tasks. - /// - /// This service validates the full requested set before returning any - /// mutations. Persistence and transaction wiring are handled by later blocks. - ChildTaskBreakUpResult breakUp({ - required List tasks, - required ChildTaskBreakUpRequest request, - }) { - final parent = _taskById(tasks, request.parentTaskId); - if (parent == null) { - throw ArgumentError.value( - request.parentTaskId, - 'parentTaskId', - 'Parent not found.', - ); - } - if (request.entries.isEmpty) { - throw ArgumentError.value( - request.entries, - 'entries', - 'At least one child task is required.', - ); - } - - final existingIds = tasks.map((task) => task.id).toSet(); - final requestedIds = {}; - for (final entry in request.entries) { - final childId = entry.id.trim(); - if (childId.isEmpty) { - throw ArgumentError.value(entry.id, 'id', 'Child id is required.'); - } - if (childId == parent.id) { - throw ArgumentError.value( - entry.id, - 'id', - 'Child id cannot match the parent id.', - ); - } - if (!requestedIds.add(childId)) { - throw ArgumentError.value( - entry.id, - 'id', - 'Child ids must be unique within one break-up command.', - ); - } - if (existingIds.contains(childId)) { - throw ArgumentError.value( - entry.id, - 'id', - 'Child id is already used by another task.', - ); - } - } - - final childTasks = request.entries - .map((entry) => entry.toTask(parent: parent)) - .toList(growable: false); - - return ChildTaskBreakUpResult( - tasks: List.unmodifiable([...tasks, ...childTasks]), - parentTask: parent, - childTasks: childTasks, - operationId: request.operationId, - createdChildTaskIds: - childTasks.map((child) => child.id).toList(growable: false), - ); - } -} - -/// Read-only parent/child projection over a task list. -/// -/// This is intentionally not a scheduler. It answers ownership questions such -/// as "which tasks belong to this parent?" while leaving task placement and -/// completion rules to other domain services. -class ChildTaskView { - ChildTaskView({ - required List tasks, - }) : tasks = List.unmodifiable(tasks); - - /// Source task list supplied by the caller. - final List tasks; - - /// Direct children owned by [parent], preserving source-list order. - List childrenOf(Task parent) { - return List.unmodifiable( - tasks.where((task) => task.parentTaskId == parent.id), - ); - } - - /// Direct children sorted by explicit priority while preserving row order ties. - /// - /// Children without priority sort after explicit priorities. Within each - /// priority group, including the no-priority group, source-list order is kept. - List childrenOfSortedByPriority(Task parent) { - final indexedChildren = childrenOf(parent) - .asMap() - .entries - .map( - (entry) => _IndexedChild( - task: entry.value, - originalIndex: entry.key, - ), - ) - .toList(growable: false); - - indexedChildren.sort((a, b) { - final priorityComparison = _priorityRank(b.task.priority) - .compareTo(_priorityRank(a.task.priority)); - if (priorityComparison != 0) { - return priorityComparison; - } - - return a.originalIndex.compareTo(b.originalIndex); - }); - - return List.unmodifiable( - indexedChildren.map((child) => child.task), - ); - } - - /// Parent task for [child], or null when the parent is not in [tasks]. - Task? parentOf(Task child) { - final parentId = child.parentTaskId; - if (parentId == null) { - return null; - } - - for (final task in tasks) { - if (task.id == parentId) { - return task; - } - } - - return null; - } - - /// Whether [child] is directly owned by [parent]. - bool isChildOf({ - required Task child, - required Task parent, - }) { - return child.parentTaskId == parent.id; - } - - /// Aggregate direct child status counts for [parent]. - ChildTaskSummary summaryFor(Task parent) { - return ChildTaskSummary.fromChildren(childrenOf(parent)); - } - - /// Whether this helper would auto-complete [parent]. - bool parentShouldAutoComplete(Task parent) { - return summaryFor(parent).allChildrenCompleted; - } -} - -/// Result from child/parent completion propagation. -class ChildTaskCompletionResult { - ChildTaskCompletionResult({ - required List tasks, - required List changedTaskIds, - this.completedChildId, - this.completedParentId, - List forceCompletedChildIds = const [], - List activities = const [], - List transitionOutcomeCodes = - const [], - this.autoCompletedParent = false, - this.canCompleteParentExplicitly = false, - }) : tasks = List.unmodifiable(tasks), - changedTaskIds = List.unmodifiable(changedTaskIds), - forceCompletedChildIds = - List.unmodifiable(forceCompletedChildIds), - activities = List.unmodifiable(activities), - transitionOutcomeCodes = List.unmodifiable( - transitionOutcomeCodes); - - /// Replacement task list after the completion action. - final List tasks; - - /// Task ids whose completion state changed. - final List changedTaskIds; - - /// Child id completed by the initial child-complete action, when applicable. - final String? completedChildId; - - /// Parent id completed by the action, when applicable. - final String? completedParentId; - - /// Direct child ids completed by explicit parent-complete behavior. - final List forceCompletedChildIds; - - /// Internal activities produced by parent/child completion propagation. - final List activities; - - /// Transition outcomes returned while applying completion propagation. - final List transitionOutcomeCodes; - - /// Whether the parent was completed because all direct children are complete. - final bool autoCompletedParent; - - /// Whether callers may offer an explicit parent-complete action. - final bool canCompleteParentExplicitly; -} - -/// Applies direct parent/child completion rules. -/// -/// This service intentionally handles only one ownership level. It does not walk -/// dependency graphs, and it only completes sibling children when the caller -/// explicitly completes the parent. -class ChildTaskCompletionService { - const ChildTaskCompletionService({ - this.clock = const SystemClock(), - this.transitionService = const TaskTransitionService(), - }); - - /// Clock boundary used when a completion timestamp is not supplied. - final Clock clock; - - /// Canonical lifecycle transition dependency. - final TaskTransitionService transitionService; - - /// Complete one child and auto-complete its parent only when all children are complete. - ChildTaskCompletionResult completeChild({ - required List tasks, - required String childTaskId, - DateTime? updatedAt, - String? operationId, - Iterable existingActivities = const [], - Iterable appliedActivityIds = const [], - }) { - final now = updatedAt ?? clock.now(); - final child = _taskById(tasks, childTaskId); - if (child == null) { - throw ArgumentError.value(childTaskId, 'childTaskId', 'Task not found.'); - } - - final parentId = child.parentTaskId; - if (parentId == null) { - throw ArgumentError.value( - childTaskId, 'childTaskId', 'Task is not a child.'); - } - - final parent = _taskById(tasks, parentId); - if (parent == null) { - throw ArgumentError.value(parentId, 'parentTaskId', 'Parent not found.'); - } - - final opId = - operationId ?? _defaultOperationId('complete-child', child.id, now); - final childTransition = _completeWithTransition( - task: child, - operationId: opId, - occurredAt: now, - existingActivities: existingActivities, - appliedActivityIds: appliedActivityIds, - metadata: { - 'completionSource': 'child', - 'parentTaskId': parent.id, - }, - ); - final withChildCompleted = _replaceTask(tasks, childTransition.task); - final view = ChildTaskView(tasks: withChildCompleted); - final shouldCompleteParent = parent.status != TaskStatus.completed && - view.parentShouldAutoComplete(parent); - final activities = [...childTransition.activities]; - final outcomes = [childTransition.outcomeCode]; - - if (!shouldCompleteParent) { - return ChildTaskCompletionResult( - tasks: List.unmodifiable(withChildCompleted), - changedTaskIds: childTransition.applied ? [child.id] : const [], - completedChildId: child.id, - canCompleteParentExplicitly: parent.status != TaskStatus.completed, - activities: activities, - transitionOutcomeCodes: outcomes, - ); - } - - final parentTransition = _completeWithTransition( - task: parent, - operationId: opId, - occurredAt: now, - existingActivities: existingActivities, - appliedActivityIds: appliedActivityIds, - metadata: { - 'completionSource': 'autoParentAfterLastChild', - 'completedChildTaskId': child.id, - }, - ); - activities.addAll(parentTransition.activities); - outcomes.add(parentTransition.outcomeCode); - - final updatedParent = parentTransition.task; - final completedTasks = _replaceTask(withChildCompleted, updatedParent); - final changedTaskIds = [ - if (childTransition.applied) child.id, - if (parentTransition.applied) parent.id, - ]; - - return ChildTaskCompletionResult( - tasks: List.unmodifiable(completedTasks), - changedTaskIds: List.unmodifiable(changedTaskIds), - completedChildId: child.id, - completedParentId: parent.id, - autoCompletedParent: true, - activities: activities, - transitionOutcomeCodes: outcomes, - ); - } - - /// Complete a parent from one of its direct children and force-complete siblings. - ChildTaskCompletionResult completeParentFromChild({ - required List tasks, - required String childTaskId, - DateTime? updatedAt, - String? operationId, - Iterable existingActivities = const [], - Iterable appliedActivityIds = const [], - }) { - final child = _taskById(tasks, childTaskId); - if (child == null) { - throw ArgumentError.value(childTaskId, 'childTaskId', 'Task not found.'); - } - final parentId = child.parentTaskId; - if (parentId == null) { - throw ArgumentError.value( - childTaskId, - 'childTaskId', - 'Task is not a child.', - ); - } - - return completeParent( - tasks: tasks, - parentTaskId: parentId, - updatedAt: updatedAt, - operationId: operationId, - initiatingChildTaskId: child.id, - existingActivities: existingActivities, - appliedActivityIds: appliedActivityIds, - ); - } - - /// Explicitly complete a parent and any direct children not already completed. - ChildTaskCompletionResult completeParent({ - required List tasks, - required String parentTaskId, - DateTime? updatedAt, - String? operationId, - String? initiatingChildTaskId, - Iterable existingActivities = const [], - Iterable appliedActivityIds = const [], - }) { - final now = updatedAt ?? clock.now(); - final parent = _taskById(tasks, parentTaskId); - if (parent == null) { - throw ArgumentError.value( - parentTaskId, 'parentTaskId', 'Parent not found.'); - } - - final directChildren = ChildTaskView(tasks: tasks).childrenOf(parent); - if (initiatingChildTaskId != null && - !directChildren.any((child) => child.id == initiatingChildTaskId)) { - throw ArgumentError.value( - initiatingChildTaskId, - 'initiatingChildTaskId', - 'Initiating task must be a direct child of the parent.', - ); - } - - final opId = - operationId ?? _defaultOperationId('complete-parent', parent.id, now); - final forceCompletedChildIds = []; - final changedTaskIds = []; - final activities = []; - final outcomes = []; - final updatedTasks = []; - - for (final task in tasks) { - if (task.id == parent.id) { - final transition = _completeWithTransition( - task: task, - operationId: opId, - occurredAt: now, - existingActivities: existingActivities, - appliedActivityIds: appliedActivityIds, - metadata: { - 'completionSource': - initiatingChildTaskId == null ? 'parent' : 'parentFromChild', - if (initiatingChildTaskId != null) - 'initiatingChildTaskId': initiatingChildTaskId, - }, - ); - final completedParent = transition.task; - updatedTasks.add(completedParent); - activities.addAll(transition.activities); - outcomes.add(transition.outcomeCode); - if (transition.applied) { - changedTaskIds.add(task.id); - } - continue; - } - - final isDirectChild = directChildren.any((child) => child.id == task.id); - if (isDirectChild && task.status != TaskStatus.completed) { - final transition = _completeWithTransition( - task: task, - operationId: opId, - occurredAt: now, - existingActivities: existingActivities, - appliedActivityIds: appliedActivityIds, - metadata: { - 'completionSource': initiatingChildTaskId == null - ? 'parentForce' - : 'childForceParent', - 'parentTaskId': parent.id, - if (initiatingChildTaskId != null) - 'initiatingChildTaskId': initiatingChildTaskId, - }, - ); - updatedTasks.add(transition.task); - activities.addAll(transition.activities); - outcomes.add(transition.outcomeCode); - if (transition.applied) { - forceCompletedChildIds.add(task.id); - changedTaskIds.add(task.id); - } - continue; - } - - updatedTasks.add(task); - } - - return ChildTaskCompletionResult( - tasks: List.unmodifiable(updatedTasks), - changedTaskIds: List.unmodifiable(changedTaskIds), - completedParentId: parent.id, - forceCompletedChildIds: List.unmodifiable(forceCompletedChildIds), - activities: List.unmodifiable(activities), - transitionOutcomeCodes: List.unmodifiable( - outcomes, - ), - ); - } - - TaskTransitionResult _completeWithTransition({ - required Task task, - required String operationId, - required DateTime occurredAt, - required Iterable existingActivities, - required Iterable appliedActivityIds, - required Map metadata, - }) { - return transitionService.apply( - task: task, - transitionCode: TaskTransitionCode.complete, - operationId: operationId, - activityId: _activityId( - operationId, - task.id, - TaskActivityCode.completed, - ), - occurredAt: occurredAt, - existingActivities: existingActivities, - appliedActivityIds: appliedActivityIds, - metadata: metadata, - ); - } -} - -/// Status counts for a parent's direct children. -class ChildTaskSummary { - const ChildTaskSummary({ - required this.totalCount, - required this.plannedCount, - required this.activeCount, - required this.completedCount, - required this.missedCount, - required this.cancelledCount, - required this.noLongerRelevantCount, - required this.backlogCount, - }); - - /// Build counts from [children]. - factory ChildTaskSummary.fromChildren(List children) { - var plannedCount = 0; - var activeCount = 0; - var completedCount = 0; - var missedCount = 0; - var cancelledCount = 0; - var noLongerRelevantCount = 0; - var backlogCount = 0; - - for (final child in children) { - switch (child.status) { - case TaskStatus.planned: - plannedCount += 1; - case TaskStatus.active: - activeCount += 1; - case TaskStatus.completed: - completedCount += 1; - case TaskStatus.missed: - missedCount += 1; - case TaskStatus.cancelled: - cancelledCount += 1; - case TaskStatus.noLongerRelevant: - noLongerRelevantCount += 1; - case TaskStatus.backlog: - backlogCount += 1; - } - } - - return ChildTaskSummary( - totalCount: children.length, - plannedCount: plannedCount, - activeCount: activeCount, - completedCount: completedCount, - missedCount: missedCount, - cancelledCount: cancelledCount, - noLongerRelevantCount: noLongerRelevantCount, - backlogCount: backlogCount, - ); - } - - /// Total direct children counted. - final int totalCount; - - /// Children in planned status. - final int plannedCount; - - /// Children in active status. - final int activeCount; - - /// Children in completed status. - final int completedCount; - - /// Children in missed status. - final int missedCount; - - /// Children in cancelled status. - final int cancelledCount; - - /// Children marked no longer relevant. - final int noLongerRelevantCount; - - /// Children currently in backlog. - final int backlogCount; - - /// Whether at least one direct child exists. - bool get hasChildren => totalCount > 0; - - /// Whether every direct child is completed. - bool get allChildrenCompleted => hasChildren && completedCount == totalCount; -} - -class _IndexedChild { - const _IndexedChild({ - required this.task, - required this.originalIndex, - }); - - final Task task; - final int originalIndex; -} - -int _priorityRank(PriorityLevel? priority) { - return switch (priority) { - PriorityLevel.veryLow => 0, - PriorityLevel.low => 1, - PriorityLevel.medium => 2, - PriorityLevel.high => 3, - PriorityLevel.veryHigh => 4, - null => -1, - }; -} - -Task? _taskById(List tasks, String taskId) { - for (final task in tasks) { - if (task.id == taskId) { - return task; - } - } - - return null; -} - -List _replaceTask(List tasks, Task replacement) { - return tasks - .map((task) => task.id == replacement.id ? replacement : task) - .toList(growable: false); -} - -String _defaultOperationId(String actionName, String taskId, DateTime at) { - return '$actionName:$taskId:${at.toIso8601String()}'; -} - -String _activityId( - String operationId, - String taskId, - TaskActivityCode activityCode, -) { - return '$operationId:$taskId:${activityCode.name}'; -} - -String _requiredTrimmed(String value, String name) { - final trimmed = value.trim(); - if (trimmed.isEmpty) { - throw ArgumentError.value(value, name, 'Value is required.'); - } - return trimmed; -} +part 'child_tasks/child_task_entry.dart'; +part 'child_tasks/child_task_break_up_request.dart'; +part 'child_tasks/child_task_break_up_result.dart'; +part 'child_tasks/child_task_break_up_service.dart'; +part 'child_tasks/child_task_view.dart'; +part 'child_tasks/child_task_completion_result.dart'; +part 'child_tasks/child_task_completion_service.dart'; +part 'child_tasks/child_task_summary.dart'; +part 'child_tasks/indexed_child.dart'; diff --git a/packages/scheduler_core/lib/src/child_tasks/child_task_break_up_request.dart b/packages/scheduler_core/lib/src/child_tasks/child_task_break_up_request.dart new file mode 100644 index 0000000..37c26d4 --- /dev/null +++ b/packages/scheduler_core/lib/src/child_tasks/child_task_break_up_request.dart @@ -0,0 +1,21 @@ +part of '../child_tasks.dart'; + +/// Command input for breaking one parent task into direct children. +class ChildTaskBreakUpRequest { + ChildTaskBreakUpRequest({ + required String parentTaskId, + required List entries, + required String operationId, + }) : parentTaskId = _requiredTrimmed(parentTaskId, 'parentTaskId'), + entries = List.unmodifiable(entries), + operationId = _requiredTrimmed(operationId, 'operationId'); + + /// Parent task that will own every created child. + final String parentTaskId; + + /// Ordered child rows supplied by the caller. + final List entries; + + /// Idempotency key for the application command that creates the children. + final String operationId; +} diff --git a/packages/scheduler_core/lib/src/child_tasks/child_task_break_up_result.dart b/packages/scheduler_core/lib/src/child_tasks/child_task_break_up_result.dart new file mode 100644 index 0000000..21280ae --- /dev/null +++ b/packages/scheduler_core/lib/src/child_tasks/child_task_break_up_result.dart @@ -0,0 +1,37 @@ +part of '../child_tasks.dart'; + +/// Result from breaking a parent task into direct children. +class ChildTaskBreakUpResult { + ChildTaskBreakUpResult({ + required List tasks, + required this.parentTask, + required List childTasks, + required this.operationId, + List createdChildTaskIds = const [], + List activities = const [], + }) : tasks = List.unmodifiable(tasks), + childTasks = List.unmodifiable(childTasks), + createdChildTaskIds = List.unmodifiable(createdChildTaskIds), + activities = List.unmodifiable(activities); + + /// Replacement task list including newly created children. + final List tasks; + + /// Parent that owns the created children. + final Task parentTask; + + /// Created children in request order. + final List childTasks; + + /// Idempotency key for the command. + final String operationId; + + /// Child ids created by this command. + final List createdChildTaskIds; + + /// Activity records produced by this command. + /// + /// V1 child creation is represented by task inserts, so this is currently + /// empty while still giving application use cases one atomic mutation shape. + final List activities; +} diff --git a/packages/scheduler_core/lib/src/child_tasks/child_task_break_up_service.dart b/packages/scheduler_core/lib/src/child_tasks/child_task_break_up_service.dart new file mode 100644 index 0000000..2391462 --- /dev/null +++ b/packages/scheduler_core/lib/src/child_tasks/child_task_break_up_service.dart @@ -0,0 +1,74 @@ +part of '../child_tasks.dart'; + +/// Creates direct child tasks from ordered child-entry rows. +class ChildTaskBreakUpService { + const ChildTaskBreakUpService(); + + /// Break `request.parentTaskId` into direct child tasks. + /// + /// This service validates the full requested set before returning any + /// mutations. Persistence and transaction wiring are handled by later blocks. + ChildTaskBreakUpResult breakUp({ + required List tasks, + required ChildTaskBreakUpRequest request, + }) { + final parent = _taskById(tasks, request.parentTaskId); + if (parent == null) { + throw ArgumentError.value( + request.parentTaskId, + 'parentTaskId', + 'Parent not found.', + ); + } + if (request.entries.isEmpty) { + throw ArgumentError.value( + request.entries, + 'entries', + 'At least one child task is required.', + ); + } + + final existingIds = tasks.map((task) => task.id).toSet(); + final requestedIds = {}; + for (final entry in request.entries) { + final childId = entry.id.trim(); + if (childId.isEmpty) { + throw ArgumentError.value(entry.id, 'id', 'Child id is required.'); + } + if (childId == parent.id) { + throw ArgumentError.value( + entry.id, + 'id', + 'Child id cannot match the parent id.', + ); + } + if (!requestedIds.add(childId)) { + throw ArgumentError.value( + entry.id, + 'id', + 'Child ids must be unique within one break-up command.', + ); + } + if (existingIds.contains(childId)) { + throw ArgumentError.value( + entry.id, + 'id', + 'Child id is already used by another task.', + ); + } + } + + final childTasks = request.entries + .map((entry) => entry.toTask(parent: parent)) + .toList(growable: false); + + return ChildTaskBreakUpResult( + tasks: List.unmodifiable([...tasks, ...childTasks]), + parentTask: parent, + childTasks: childTasks, + operationId: request.operationId, + createdChildTaskIds: + childTasks.map((child) => child.id).toList(growable: false), + ); + } +} diff --git a/packages/scheduler_core/lib/src/child_tasks/child_task_completion_result.dart b/packages/scheduler_core/lib/src/child_tasks/child_task_completion_result.dart new file mode 100644 index 0000000..e50bf2d --- /dev/null +++ b/packages/scheduler_core/lib/src/child_tasks/child_task_completion_result.dart @@ -0,0 +1,50 @@ +part of '../child_tasks.dart'; + +/// Result from child/parent completion propagation. +class ChildTaskCompletionResult { + ChildTaskCompletionResult({ + required List tasks, + required List changedTaskIds, + this.completedChildId, + this.completedParentId, + List forceCompletedChildIds = const [], + List activities = const [], + List transitionOutcomeCodes = + const [], + this.autoCompletedParent = false, + this.canCompleteParentExplicitly = false, + }) : tasks = List.unmodifiable(tasks), + changedTaskIds = List.unmodifiable(changedTaskIds), + forceCompletedChildIds = + List.unmodifiable(forceCompletedChildIds), + activities = List.unmodifiable(activities), + transitionOutcomeCodes = List.unmodifiable( + transitionOutcomeCodes); + + /// Replacement task list after the completion action. + final List tasks; + + /// Task ids whose completion state changed. + final List changedTaskIds; + + /// Child id completed by the initial child-complete action, when applicable. + final String? completedChildId; + + /// Parent id completed by the action, when applicable. + final String? completedParentId; + + /// Direct child ids completed by explicit parent-complete behavior. + final List forceCompletedChildIds; + + /// Internal activities produced by parent/child completion propagation. + final List activities; + + /// Transition outcomes returned while applying completion propagation. + final List transitionOutcomeCodes; + + /// Whether the parent was completed because all direct children are complete. + final bool autoCompletedParent; + + /// Whether callers may offer an explicit parent-complete action. + final bool canCompleteParentExplicitly; +} diff --git a/packages/scheduler_core/lib/src/child_tasks/child_task_completion_service.dart b/packages/scheduler_core/lib/src/child_tasks/child_task_completion_service.dart new file mode 100644 index 0000000..2976786 --- /dev/null +++ b/packages/scheduler_core/lib/src/child_tasks/child_task_completion_service.dart @@ -0,0 +1,267 @@ +part of '../child_tasks.dart'; + +/// Applies direct parent/child completion rules. +/// +/// This service intentionally handles only one ownership level. It does not walk +/// dependency graphs, and it only completes sibling children when the caller +/// explicitly completes the parent. +class ChildTaskCompletionService { + const ChildTaskCompletionService({ + this.clock = const SystemClock(), + this.transitionService = const TaskTransitionService(), + }); + + /// Clock boundary used when a completion timestamp is not supplied. + final Clock clock; + + /// Canonical lifecycle transition dependency. + final TaskTransitionService transitionService; + + /// Complete one child and auto-complete its parent only when all children are complete. + ChildTaskCompletionResult completeChild({ + required List tasks, + required String childTaskId, + DateTime? updatedAt, + String? operationId, + Iterable existingActivities = const [], + Iterable appliedActivityIds = const [], + }) { + final now = updatedAt ?? clock.now(); + final child = _taskById(tasks, childTaskId); + if (child == null) { + throw ArgumentError.value(childTaskId, 'childTaskId', 'Task not found.'); + } + + final parentId = child.parentTaskId; + if (parentId == null) { + throw ArgumentError.value( + childTaskId, 'childTaskId', 'Task is not a child.'); + } + + final parent = _taskById(tasks, parentId); + if (parent == null) { + throw ArgumentError.value(parentId, 'parentTaskId', 'Parent not found.'); + } + + final opId = + operationId ?? _defaultOperationId('complete-child', child.id, now); + final childTransition = _completeWithTransition( + task: child, + operationId: opId, + occurredAt: now, + existingActivities: existingActivities, + appliedActivityIds: appliedActivityIds, + metadata: { + 'completionSource': 'child', + 'parentTaskId': parent.id, + }, + ); + final withChildCompleted = _replaceTask(tasks, childTransition.task); + final view = ChildTaskView(tasks: withChildCompleted); + final shouldCompleteParent = parent.status != TaskStatus.completed && + view.parentShouldAutoComplete(parent); + final activities = [...childTransition.activities]; + final outcomes = [childTransition.outcomeCode]; + + if (!shouldCompleteParent) { + return ChildTaskCompletionResult( + tasks: List.unmodifiable(withChildCompleted), + changedTaskIds: childTransition.applied ? [child.id] : const [], + completedChildId: child.id, + canCompleteParentExplicitly: parent.status != TaskStatus.completed, + activities: activities, + transitionOutcomeCodes: outcomes, + ); + } + + final parentTransition = _completeWithTransition( + task: parent, + operationId: opId, + occurredAt: now, + existingActivities: existingActivities, + appliedActivityIds: appliedActivityIds, + metadata: { + 'completionSource': 'autoParentAfterLastChild', + 'completedChildTaskId': child.id, + }, + ); + activities.addAll(parentTransition.activities); + outcomes.add(parentTransition.outcomeCode); + + final updatedParent = parentTransition.task; + final completedTasks = _replaceTask(withChildCompleted, updatedParent); + final changedTaskIds = [ + if (childTransition.applied) child.id, + if (parentTransition.applied) parent.id, + ]; + + return ChildTaskCompletionResult( + tasks: List.unmodifiable(completedTasks), + changedTaskIds: List.unmodifiable(changedTaskIds), + completedChildId: child.id, + completedParentId: parent.id, + autoCompletedParent: true, + activities: activities, + transitionOutcomeCodes: outcomes, + ); + } + + /// Complete a parent from one of its direct children and force-complete siblings. + ChildTaskCompletionResult completeParentFromChild({ + required List tasks, + required String childTaskId, + DateTime? updatedAt, + String? operationId, + Iterable existingActivities = const [], + Iterable appliedActivityIds = const [], + }) { + final child = _taskById(tasks, childTaskId); + if (child == null) { + throw ArgumentError.value(childTaskId, 'childTaskId', 'Task not found.'); + } + final parentId = child.parentTaskId; + if (parentId == null) { + throw ArgumentError.value( + childTaskId, + 'childTaskId', + 'Task is not a child.', + ); + } + + return completeParent( + tasks: tasks, + parentTaskId: parentId, + updatedAt: updatedAt, + operationId: operationId, + initiatingChildTaskId: child.id, + existingActivities: existingActivities, + appliedActivityIds: appliedActivityIds, + ); + } + + /// Explicitly complete a parent and any direct children not already completed. + ChildTaskCompletionResult completeParent({ + required List tasks, + required String parentTaskId, + DateTime? updatedAt, + String? operationId, + String? initiatingChildTaskId, + Iterable existingActivities = const [], + Iterable appliedActivityIds = const [], + }) { + final now = updatedAt ?? clock.now(); + final parent = _taskById(tasks, parentTaskId); + if (parent == null) { + throw ArgumentError.value( + parentTaskId, 'parentTaskId', 'Parent not found.'); + } + + final directChildren = ChildTaskView(tasks: tasks).childrenOf(parent); + if (initiatingChildTaskId != null && + !directChildren.any((child) => child.id == initiatingChildTaskId)) { + throw ArgumentError.value( + initiatingChildTaskId, + 'initiatingChildTaskId', + 'Initiating task must be a direct child of the parent.', + ); + } + + final opId = + operationId ?? _defaultOperationId('complete-parent', parent.id, now); + final forceCompletedChildIds = []; + final changedTaskIds = []; + final activities = []; + final outcomes = []; + final updatedTasks = []; + + for (final task in tasks) { + if (task.id == parent.id) { + final transition = _completeWithTransition( + task: task, + operationId: opId, + occurredAt: now, + existingActivities: existingActivities, + appliedActivityIds: appliedActivityIds, + metadata: { + 'completionSource': + initiatingChildTaskId == null ? 'parent' : 'parentFromChild', + if (initiatingChildTaskId != null) + 'initiatingChildTaskId': initiatingChildTaskId, + }, + ); + final completedParent = transition.task; + updatedTasks.add(completedParent); + activities.addAll(transition.activities); + outcomes.add(transition.outcomeCode); + if (transition.applied) { + changedTaskIds.add(task.id); + } + continue; + } + + final isDirectChild = directChildren.any((child) => child.id == task.id); + if (isDirectChild && task.status != TaskStatus.completed) { + final transition = _completeWithTransition( + task: task, + operationId: opId, + occurredAt: now, + existingActivities: existingActivities, + appliedActivityIds: appliedActivityIds, + metadata: { + 'completionSource': initiatingChildTaskId == null + ? 'parentForce' + : 'childForceParent', + 'parentTaskId': parent.id, + if (initiatingChildTaskId != null) + 'initiatingChildTaskId': initiatingChildTaskId, + }, + ); + updatedTasks.add(transition.task); + activities.addAll(transition.activities); + outcomes.add(transition.outcomeCode); + if (transition.applied) { + forceCompletedChildIds.add(task.id); + changedTaskIds.add(task.id); + } + continue; + } + + updatedTasks.add(task); + } + + return ChildTaskCompletionResult( + tasks: List.unmodifiable(updatedTasks), + changedTaskIds: List.unmodifiable(changedTaskIds), + completedParentId: parent.id, + forceCompletedChildIds: List.unmodifiable(forceCompletedChildIds), + activities: List.unmodifiable(activities), + transitionOutcomeCodes: List.unmodifiable( + outcomes, + ), + ); + } + + TaskTransitionResult _completeWithTransition({ + required Task task, + required String operationId, + required DateTime occurredAt, + required Iterable existingActivities, + required Iterable appliedActivityIds, + required Map metadata, + }) { + return transitionService.apply( + task: task, + transitionCode: TaskTransitionCode.complete, + operationId: operationId, + activityId: _activityId( + operationId, + task.id, + TaskActivityCode.completed, + ), + occurredAt: occurredAt, + existingActivities: existingActivities, + appliedActivityIds: appliedActivityIds, + metadata: metadata, + ); + } +} diff --git a/packages/scheduler_core/lib/src/child_tasks/child_task_entry.dart b/packages/scheduler_core/lib/src/child_tasks/child_task_entry.dart new file mode 100644 index 0000000..0bb6e1d --- /dev/null +++ b/packages/scheduler_core/lib/src/child_tasks/child_task_entry.dart @@ -0,0 +1,72 @@ +part of '../child_tasks.dart'; + +/// Row-style input for creating one child task. +/// +/// Priority is nullable on purpose. A null priority means the user did not set +/// one, so callers should preserve row insertion order instead of treating the +/// child as medium priority. +class ChildTaskEntry { + const ChildTaskEntry({ + required this.id, + required this.title, + required this.createdAt, + this.priority, + this.reward = RewardLevel.notSet, + this.difficulty = DifficultyLevel.notSet, + this.durationMinutes, + this.projectId, + this.updatedAt, + }); + + /// Caller-generated child id. + final String id; + + /// User-entered child title. + final String title; + + /// Creation timestamp supplied by the caller. + final DateTime createdAt; + + /// Optional explicit child priority. + final PriorityLevel? priority; + + /// Optional explicit child reward. Missing reward stays `notSet`. + final RewardLevel reward; + + /// Optional explicit child difficulty. + final DifficultyLevel difficulty; + + /// Optional child duration estimate. + final int? durationMinutes; + + /// Optional project override. Null means inherit the parent project. + final String? projectId; + + /// Optional update timestamp. + final DateTime? updatedAt; + + /// Convert this entry into a child [Task] owned by [parent]. + Task toTask({ + required Task parent, + }) { + final trimmedTitle = title.trim(); + if (trimmedTitle.isEmpty) { + throw ArgumentError.value(title, 'title', 'Title is required.'); + } + + return Task( + id: id, + title: trimmedTitle, + projectId: projectId ?? parent.projectId, + type: TaskType.flexible, + status: TaskStatus.backlog, + priority: priority, + reward: reward, + difficulty: difficulty, + durationMinutes: durationMinutes, + parentTaskId: parent.id, + createdAt: createdAt, + updatedAt: updatedAt ?? createdAt, + ); + } +} diff --git a/packages/scheduler_core/lib/src/child_tasks/child_task_summary.dart b/packages/scheduler_core/lib/src/child_tasks/child_task_summary.dart new file mode 100644 index 0000000..faf1fc2 --- /dev/null +++ b/packages/scheduler_core/lib/src/child_tasks/child_task_summary.dart @@ -0,0 +1,86 @@ +part of '../child_tasks.dart'; + +/// Status counts for a parent's direct children. +class ChildTaskSummary { + const ChildTaskSummary({ + required this.totalCount, + required this.plannedCount, + required this.activeCount, + required this.completedCount, + required this.missedCount, + required this.cancelledCount, + required this.noLongerRelevantCount, + required this.backlogCount, + }); + + /// Build counts from [children]. + factory ChildTaskSummary.fromChildren(List children) { + var plannedCount = 0; + var activeCount = 0; + var completedCount = 0; + var missedCount = 0; + var cancelledCount = 0; + var noLongerRelevantCount = 0; + var backlogCount = 0; + + for (final child in children) { + switch (child.status) { + case TaskStatus.planned: + plannedCount += 1; + case TaskStatus.active: + activeCount += 1; + case TaskStatus.completed: + completedCount += 1; + case TaskStatus.missed: + missedCount += 1; + case TaskStatus.cancelled: + cancelledCount += 1; + case TaskStatus.noLongerRelevant: + noLongerRelevantCount += 1; + case TaskStatus.backlog: + backlogCount += 1; + } + } + + return ChildTaskSummary( + totalCount: children.length, + plannedCount: plannedCount, + activeCount: activeCount, + completedCount: completedCount, + missedCount: missedCount, + cancelledCount: cancelledCount, + noLongerRelevantCount: noLongerRelevantCount, + backlogCount: backlogCount, + ); + } + + /// Total direct children counted. + final int totalCount; + + /// Children in planned status. + final int plannedCount; + + /// Children in active status. + final int activeCount; + + /// Children in completed status. + final int completedCount; + + /// Children in missed status. + final int missedCount; + + /// Children in cancelled status. + final int cancelledCount; + + /// Children marked no longer relevant. + final int noLongerRelevantCount; + + /// Children currently in backlog. + final int backlogCount; + + /// Whether at least one direct child exists. + bool get hasChildren => totalCount > 0; + + /// Whether every direct child is completed. + bool get allChildrenCompleted => hasChildren && completedCount == totalCount; +} diff --git a/packages/scheduler_core/lib/src/child_tasks/child_task_view.dart b/packages/scheduler_core/lib/src/child_tasks/child_task_view.dart new file mode 100644 index 0000000..f25e3cf --- /dev/null +++ b/packages/scheduler_core/lib/src/child_tasks/child_task_view.dart @@ -0,0 +1,87 @@ +part of '../child_tasks.dart'; + +/// Read-only parent/child projection over a task list. +/// +/// This is intentionally not a scheduler. It answers ownership questions such +/// as "which tasks belong to this parent?" while leaving task placement and +/// completion rules to other domain services. +class ChildTaskView { + ChildTaskView({ + required List tasks, + }) : tasks = List.unmodifiable(tasks); + + /// Source task list supplied by the caller. + final List tasks; + + /// Direct children owned by [parent], preserving source-list order. + List childrenOf(Task parent) { + return List.unmodifiable( + tasks.where((task) => task.parentTaskId == parent.id), + ); + } + + /// Direct children sorted by explicit priority while preserving row order ties. + /// + /// Children without priority sort after explicit priorities. Within each + /// priority group, including the no-priority group, source-list order is kept. + List childrenOfSortedByPriority(Task parent) { + final indexedChildren = childrenOf(parent) + .asMap() + .entries + .map( + (entry) => _IndexedChild( + task: entry.value, + originalIndex: entry.key, + ), + ) + .toList(growable: false); + + indexedChildren.sort((a, b) { + final priorityComparison = _priorityRank(b.task.priority) + .compareTo(_priorityRank(a.task.priority)); + if (priorityComparison != 0) { + return priorityComparison; + } + + return a.originalIndex.compareTo(b.originalIndex); + }); + + return List.unmodifiable( + indexedChildren.map((child) => child.task), + ); + } + + /// Parent task for [child], or null when the parent is not in [tasks]. + Task? parentOf(Task child) { + final parentId = child.parentTaskId; + if (parentId == null) { + return null; + } + + for (final task in tasks) { + if (task.id == parentId) { + return task; + } + } + + return null; + } + + /// Whether [child] is directly owned by [parent]. + bool isChildOf({ + required Task child, + required Task parent, + }) { + return child.parentTaskId == parent.id; + } + + /// Aggregate direct child status counts for [parent]. + ChildTaskSummary summaryFor(Task parent) { + return ChildTaskSummary.fromChildren(childrenOf(parent)); + } + + /// Whether this helper would auto-complete [parent]. + bool parentShouldAutoComplete(Task parent) { + return summaryFor(parent).allChildrenCompleted; + } +} diff --git a/packages/scheduler_core/lib/src/child_tasks/indexed_child.dart b/packages/scheduler_core/lib/src/child_tasks/indexed_child.dart new file mode 100644 index 0000000..790926e --- /dev/null +++ b/packages/scheduler_core/lib/src/child_tasks/indexed_child.dart @@ -0,0 +1,58 @@ +part of '../child_tasks.dart'; + +class _IndexedChild { + const _IndexedChild({ + required this.task, + required this.originalIndex, + }); + + final Task task; + final int originalIndex; +} + +int _priorityRank(PriorityLevel? priority) { + return switch (priority) { + PriorityLevel.veryLow => 0, + PriorityLevel.low => 1, + PriorityLevel.medium => 2, + PriorityLevel.high => 3, + PriorityLevel.veryHigh => 4, + null => -1, + }; +} + +Task? _taskById(List tasks, String taskId) { + for (final task in tasks) { + if (task.id == taskId) { + return task; + } + } + + return null; +} + +List _replaceTask(List tasks, Task replacement) { + return tasks + .map((task) => task.id == replacement.id ? replacement : task) + .toList(growable: false); +} + +String _defaultOperationId(String actionName, String taskId, DateTime at) { + return '$actionName:$taskId:${at.toIso8601String()}'; +} + +String _activityId( + String operationId, + String taskId, + TaskActivityCode activityCode, +) { + return '$operationId:$taskId:${activityCode.name}'; +} + +String _requiredTrimmed(String value, String name) { + final trimmed = value.trim(); + if (trimmed.isEmpty) { + throw ArgumentError.value(value, name, 'Value is required.'); + } + return trimmed; +} diff --git a/packages/scheduler_core/lib/src/document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping.dart index b084b1e..1638bf4 100644 --- a/packages/scheduler_core/lib/src/document_mapping.dart +++ b/packages/scheduler_core/lib/src/document_mapping.dart @@ -17,479 +17,31 @@ import 'scheduling_engine.dart'; import 'task_lifecycle.dart'; import 'task_statistics.dart'; import 'time_contracts.dart'; - -/// Stable document mapping failure categories. -enum DocumentMappingFailureCode { - missingField, - wrongType, - unknownCode, - invalidSchemaVersion, - invalidRevision, - invalidValue, -} - -/// Typed mapping failure for malformed V1 documents. -class DocumentMappingException implements Exception { - const DocumentMappingException({ - required this.code, - this.fieldName, - this.detailCode, - }); - - /// Stable category for caller branching. - final DocumentMappingFailureCode code; - - /// Field related to the failure, when known. - final String? fieldName; - - /// Optional narrower reason. - final String? detailCode; - - @override - String toString() { - return 'DocumentMappingException($code, field: $fieldName, detail: ' - '$detailCode)'; - } -} - -/// Common metadata decoded from a V1 top-level document. -class DocumentMetadata { - const DocumentMetadata({ - required this.id, - required this.ownerId, - required this.revision, - required this.createdAt, - required this.updatedAt, - }); - - final String id; - final String ownerId; - final int revision; - final DateTime createdAt; - final DateTime updatedAt; -} - -/// Stable enum encode/decode helpers for V1 document maps. -abstract final class PersistenceEnumMapping { - static String? encodeNullable(Enum? value) { - return value == null ? null : encode(value); - } - - static String encode(Enum value) { - if (value is TaskType) return encodeTaskType(value); - if (value is TaskStatus) return encodeTaskStatus(value); - if (value is PriorityLevel) return encodePriority(value); - if (value is RewardLevel) return encodeReward(value); - if (value is DifficultyLevel) return encodeDifficulty(value); - if (value is ReminderProfile) return encodeReminderProfile(value); - if (value is BacklogTag) return encodeBacklogTag(value); - if (value is LockedWeekday) return encodeLockedWeekday(value); - if (value is LockedBlockOverrideType) { - return encodeLockedBlockOverrideType(value); - } - if (value is TaskActivityCode) return encodeTaskActivityCode(value); - if (value is SchedulingNoticeType) return encodeSchedulingNoticeType(value); - if (value is SchedulingIssueCode) return encodeSchedulingIssueCode(value); - if (value is SchedulingMovementCode) { - return encodeSchedulingMovementCode(value); - } - if (value is SchedulingConflictCode) { - return encodeSchedulingConflictCode(value); - } - if (value is ProjectCompletionTimeBucket) { - return encodeProjectCompletionTimeBucket(value); - } - throw DocumentMappingException( - code: DocumentMappingFailureCode.unknownCode, - detailCode: value.runtimeType.toString(), - ); - } - - static String encodeTaskType(TaskType value) => switch (value) { - TaskType.flexible => 'flexible', - TaskType.inflexible => 'inflexible', - TaskType.critical => 'critical', - TaskType.locked => 'locked', - TaskType.surprise => 'surprise', - TaskType.freeSlot => 'free_slot', - }; - - static TaskType decodeTaskType(String value) { - return _decodeCode(_taskTypeByCode, value, 'TaskType'); - } - - static String encodeTaskStatus(TaskStatus value) => switch (value) { - TaskStatus.planned => 'planned', - TaskStatus.active => 'active', - TaskStatus.completed => 'completed', - TaskStatus.missed => 'missed', - TaskStatus.cancelled => 'cancelled', - TaskStatus.noLongerRelevant => 'no_longer_relevant', - TaskStatus.backlog => 'backlog', - }; - - static TaskStatus decodeTaskStatus(String value) { - return _decodeCode(_taskStatusByCode, value, 'TaskStatus'); - } - - static String encodePriority(PriorityLevel value) => switch (value) { - PriorityLevel.veryLow => 'very_low', - PriorityLevel.low => 'low', - PriorityLevel.medium => 'medium', - PriorityLevel.high => 'high', - PriorityLevel.veryHigh => 'very_high', - }; - - static PriorityLevel? decodeNullablePriority(String? value) { - return value == null - ? null - : _decodeCode(_priorityByCode, value, 'PriorityLevel'); - } - - static String encodeReward(RewardLevel value) => switch (value) { - RewardLevel.notSet => 'not_set', - RewardLevel.veryLow => 'very_low', - RewardLevel.low => 'low', - RewardLevel.medium => 'medium', - RewardLevel.high => 'high', - RewardLevel.veryHigh => 'very_high', - }; - - static RewardLevel decodeReward(String value) { - return _decodeCode(_rewardByCode, value, 'RewardLevel'); - } - - static String encodeDifficulty(DifficultyLevel value) => switch (value) { - DifficultyLevel.notSet => 'not_set', - DifficultyLevel.veryEasy => 'very_easy', - DifficultyLevel.easy => 'easy', - DifficultyLevel.medium => 'medium', - DifficultyLevel.hard => 'hard', - DifficultyLevel.veryHard => 'very_hard', - }; - - static DifficultyLevel decodeDifficulty(String value) { - return _decodeCode(_difficultyByCode, value, 'DifficultyLevel'); - } - - static String encodeReminderProfile(ReminderProfile value) => switch (value) { - ReminderProfile.silent => 'silent', - ReminderProfile.gentle => 'gentle', - ReminderProfile.persistent => 'persistent', - ReminderProfile.strict => 'strict', - }; - - static ReminderProfile? decodeNullableReminderProfile(String? value) { - return value == null - ? null - : _decodeCode(_reminderProfileByCode, value, 'ReminderProfile'); - } - - static String encodeBacklogTag(BacklogTag value) => switch (value) { - BacklogTag.wishlist => 'wishlist', - }; - - static BacklogTag decodeBacklogTag(String value) { - return _decodeCode(_backlogTagByCode, value, 'BacklogTag'); - } - - static String encodeLockedWeekday(LockedWeekday value) => switch (value) { - LockedWeekday.monday => 'monday', - LockedWeekday.tuesday => 'tuesday', - LockedWeekday.wednesday => 'wednesday', - LockedWeekday.thursday => 'thursday', - LockedWeekday.friday => 'friday', - LockedWeekday.saturday => 'saturday', - LockedWeekday.sunday => 'sunday', - }; - - static LockedWeekday decodeLockedWeekday(String value) { - return _decodeCode(_lockedWeekdayByCode, value, 'LockedWeekday'); - } - - static String encodeLockedBlockOverrideType( - LockedBlockOverrideType value, - ) => - switch (value) { - LockedBlockOverrideType.remove => 'remove', - LockedBlockOverrideType.replace => 'replace', - LockedBlockOverrideType.add => 'add', - }; - - static LockedBlockOverrideType decodeLockedBlockOverrideType(String value) { - return _decodeCode( - _lockedBlockOverrideTypeByCode, - value, - 'LockedBlockOverrideType', - ); - } - - static String encodeTaskActivityCode(TaskActivityCode value) => - switch (value) { - TaskActivityCode.completed => 'completed', - TaskActivityCode.missed => 'missed', - TaskActivityCode.cancelled => 'cancelled', - TaskActivityCode.noLongerRelevant => 'no_longer_relevant', - TaskActivityCode.manuallyPushed => 'manually_pushed', - TaskActivityCode.automaticallyPushed => 'automatically_pushed', - TaskActivityCode.movedToBacklog => 'moved_to_backlog', - TaskActivityCode.restoredFromBacklog => 'restored_from_backlog', - TaskActivityCode.activated => 'activated', - }; - - static TaskActivityCode decodeTaskActivityCode(String value) { - return _decodeCode(_taskActivityCodeByCode, value, 'TaskActivityCode'); - } - - static String encodeSchedulingNoticeType(SchedulingNoticeType value) => - switch (value) { - SchedulingNoticeType.info => 'info', - SchedulingNoticeType.moved => 'moved', - SchedulingNoticeType.overlap => 'overlap', - SchedulingNoticeType.noFit => 'no_fit', - SchedulingNoticeType.overflow => 'overflow', - }; - - static SchedulingNoticeType decodeSchedulingNoticeType(String value) { - return _decodeCode( - _schedulingNoticeTypeByCode, - value, - 'SchedulingNoticeType', - ); - } - - static String encodeSchedulingIssueCode(SchedulingIssueCode value) => - switch (value) { - SchedulingIssueCode.taskNotFound => 'task_not_found', - SchedulingIssueCode.invalidTaskState => 'invalid_task_state', - SchedulingIssueCode.missingDuration => 'missing_duration', - SchedulingIssueCode.missingScheduledSlot => 'missing_scheduled_slot', - SchedulingIssueCode.nonPositiveDuration => 'non_positive_duration', - SchedulingIssueCode.noAvailableSlot => 'no_available_slot', - SchedulingIssueCode.unfinishedTasksCouldNotFit => - 'unfinished_tasks_could_not_fit', - SchedulingIssueCode.noUnfinishedFlexibleTasks => - 'no_unfinished_flexible_tasks', - SchedulingIssueCode.duplicateSurpriseLog => 'duplicate_surprise_log', - }; - - static SchedulingIssueCode? decodeNullableSchedulingIssueCode(String? value) { - return value == null - ? null - : _decodeCode( - _schedulingIssueCodeByCode, - value, - 'SchedulingIssueCode', - ); - } - - static String encodeSchedulingMovementCode(SchedulingMovementCode value) => - switch (value) { - SchedulingMovementCode.backlogTaskInserted => 'backlog_task_inserted', - SchedulingMovementCode.flexibleTaskMovedToMakeRoom => - 'flexible_task_moved_to_make_room', - SchedulingMovementCode.flexibleTaskPushedToNextAvailableSlot => - 'flexible_task_pushed_to_next_available_slot', - SchedulingMovementCode.flexibleTaskMovedToTomorrow => - 'flexible_task_moved_to_tomorrow', - SchedulingMovementCode.unfinishedFlexibleTasksRolledOver => - 'unfinished_flexible_tasks_rolled_over', - SchedulingMovementCode.flexibleTaskMovedToBacklog => - 'flexible_task_moved_to_backlog', - SchedulingMovementCode.requiredCommitmentScheduled => - 'required_commitment_scheduled', - }; - - static SchedulingMovementCode? decodeNullableSchedulingMovementCode( - String? value, - ) { - return value == null - ? null - : _decodeCode( - _schedulingMovementCodeByCode, - value, - 'SchedulingMovementCode', - ); - } - - static String encodeSchedulingConflictCode(SchedulingConflictCode value) => - switch (value) { - SchedulingConflictCode.flexibleTaskOverlapsBlockedTime => - 'flexible_task_overlaps_blocked_time', - SchedulingConflictCode.surpriseTaskOverlapsRequiredVisibleTime => - 'surprise_task_overlaps_required_visible_time', - SchedulingConflictCode.requiredCommitmentOverlapsProtectedFreeSlot => - 'required_commitment_overlaps_protected_free_slot', - }; - - static SchedulingConflictCode? decodeNullableSchedulingConflictCode( - String? value, - ) { - return value == null - ? null - : _decodeCode( - _schedulingConflictCodeByCode, - value, - 'SchedulingConflictCode', - ); - } - - static String encodeProjectCompletionTimeBucket( - ProjectCompletionTimeBucket value, - ) => - switch (value) { - ProjectCompletionTimeBucket.overnight => 'overnight', - ProjectCompletionTimeBucket.morning => 'morning', - ProjectCompletionTimeBucket.afternoon => 'afternoon', - ProjectCompletionTimeBucket.evening => 'evening', - ProjectCompletionTimeBucket.night => 'night', - }; - - static ProjectCompletionTimeBucket decodeProjectCompletionTimeBucket( - String value, - ) { - return _decodeCode( - _projectCompletionTimeBucketByCode, - value, - 'ProjectCompletionTimeBucket', - ); - } -} - -/// Document mapping for [Task]. -abstract final class TaskDocumentMapping { - static Map toDocument( - Task task, { - required String ownerId, - int revision = 1, - bool clearSchedule = false, - DateTime? backlogEnteredAt, - String? backlogEnteredAtProvenance, - }) { - final scheduledStart = clearSchedule ? null : task.scheduledStart; - final scheduledEnd = clearSchedule ? null : task.scheduledEnd; - - return { - ..._commonFields( - id: task.id, - ownerId: ownerId, - revision: revision, - createdAt: task.createdAt, - updatedAt: task.updatedAt, - ), - TaskDocumentFields.title: task.title, - TaskDocumentFields.projectId: task.projectId, - TaskDocumentFields.type: PersistenceEnumMapping.encodeTaskType(task.type), - TaskDocumentFields.status: - PersistenceEnumMapping.encodeTaskStatus(task.status), - TaskDocumentFields.priority: task.priority == null - ? null - : PersistenceEnumMapping.encodePriority(task.priority!), - TaskDocumentFields.reward: - PersistenceEnumMapping.encodeReward(task.reward), - TaskDocumentFields.difficulty: - PersistenceEnumMapping.encodeDifficulty(task.difficulty), - TaskDocumentFields.durationMinutes: task.durationMinutes, - TaskDocumentFields.scheduledStart: _dateTimeToStored(scheduledStart), - TaskDocumentFields.scheduledEnd: _dateTimeToStored(scheduledEnd), - TaskDocumentFields.actualStart: _dateTimeToStored(task.actualStart), - TaskDocumentFields.actualEnd: _dateTimeToStored(task.actualEnd), - TaskDocumentFields.completedAt: _dateTimeToStored(task.completedAt), - TaskDocumentFields.parentTaskId: task.parentTaskId, - TaskDocumentFields.backlogTags: task.backlogTags - .map(PersistenceEnumMapping.encodeBacklogTag) - .toList(), - TaskDocumentFields.reminderOverride: task.reminderOverride == null - ? null - : PersistenceEnumMapping.encodeReminderProfile( - task.reminderOverride!), - TaskDocumentFields.stats: TaskStatisticsDocumentMapping.toDocument( - task.stats, - ), - TaskDocumentFields.backlogEnteredAt: _dateTimeToStored(backlogEnteredAt), - TaskDocumentFields.backlogEnteredAtProvenance: backlogEnteredAtProvenance, - }; - } - - static Task fromDocument(Map document) { - return _mapInvalidValues(() { - _readCommon(document); - return Task( - id: _requiredString(document, TaskDocumentFields.id), - title: _requiredString(document, TaskDocumentFields.title), - projectId: _requiredString(document, TaskDocumentFields.projectId), - type: PersistenceEnumMapping.decodeTaskType( - _requiredString(document, TaskDocumentFields.type), - ), - status: PersistenceEnumMapping.decodeTaskStatus( - _requiredString(document, TaskDocumentFields.status), - ), - priority: PersistenceEnumMapping.decodeNullablePriority( - _requiredNullableString(document, TaskDocumentFields.priority), - ), - reward: PersistenceEnumMapping.decodeReward( - _requiredString(document, TaskDocumentFields.reward), - ), - difficulty: PersistenceEnumMapping.decodeDifficulty( - _requiredString(document, TaskDocumentFields.difficulty), - ), - durationMinutes: - _requiredNullableInt(document, TaskDocumentFields.durationMinutes), - scheduledStart: _requiredNullableDateTime( - document, - TaskDocumentFields.scheduledStart, - ), - scheduledEnd: _requiredNullableDateTime( - document, - TaskDocumentFields.scheduledEnd, - ), - actualStart: _requiredNullableDateTime( - document, - TaskDocumentFields.actualStart, - ), - actualEnd: _requiredNullableDateTime( - document, - TaskDocumentFields.actualEnd, - ), - completedAt: _requiredNullableDateTime( - document, - TaskDocumentFields.completedAt, - ), - parentTaskId: _requiredNullableString( - document, - TaskDocumentFields.parentTaskId, - ), - backlogTags: _backlogTagsFromDocument(document), - reminderOverride: PersistenceEnumMapping.decodeNullableReminderProfile( - _requiredNullableString( - document, - TaskDocumentFields.reminderOverride, - ), - ), - createdAt: _requiredDateTime(document, TaskDocumentFields.createdAt), - updatedAt: _requiredDateTime(document, TaskDocumentFields.updatedAt), - stats: TaskStatisticsDocumentMapping.fromDocument( - _requiredMap(document, TaskDocumentFields.stats), - ), - ).._validateTaskDocumentMetadata(document); - }); - } - - static Set _backlogTagsFromDocument( - Map document, - ) { - final rawTags = _requiredList(document, TaskDocumentFields.backlogTags); - return rawTags.map((tag) { - if (tag is! String) { - throw DocumentMappingException( - code: DocumentMappingFailureCode.wrongType, - fieldName: TaskDocumentFields.backlogTags, - ); - } - return PersistenceEnumMapping.decodeBacklogTag(tag); - }).toSet(); - } -} +part 'document_mapping/document_mapping_failure_code.dart'; +part 'document_mapping/document_mapping_exception.dart'; +part 'document_mapping/document_metadata.dart'; +part 'document_mapping/persistence_enum_mapping.dart'; +part 'document_mapping/task_document_mapping.dart'; +part 'document_mapping/task_statistics_document_mapping.dart'; +part 'document_mapping/project_document_mapping.dart'; +part 'document_mapping/project_statistics_document_mapping.dart'; +part 'document_mapping/locked_block_recurrence_document_mapping.dart'; +part 'document_mapping/locked_block_document_mapping.dart'; +part 'document_mapping/locked_block_override_document_mapping.dart'; +part 'document_mapping/task_activity_document_mapping.dart'; +part 'document_mapping/owner_settings_document_mapping.dart'; +part 'document_mapping/backlog_staleness_document_mapping.dart'; +part 'document_mapping/notice_acknowledgement_document_mapping.dart'; +part 'document_mapping/application_operation_document_mapping.dart'; +part 'document_mapping/scheduling_snapshot_document_mapping.dart'; +part 'document_mapping/scheduling_window_document_mapping.dart'; +part 'document_mapping/time_interval_document_mapping.dart'; +part 'document_mapping/scheduling_notice_document_mapping.dart'; +part 'document_mapping/scheduling_change_document_mapping.dart'; +part 'document_mapping/scheduling_overlap_document_mapping.dart'; +part 'document_mapping/task_document_extension.dart'; +part 'document_mapping/task_statistics_document_extension.dart'; +part 'document_mapping/project_statistics_document_extension.dart'; extension on Task { void _validateTaskDocumentMetadata(Map document) { @@ -501,266 +53,6 @@ extension on Task { } } -/// Document mapping for [TaskStatistics]. -abstract final class TaskStatisticsDocumentMapping { - static Map toDocument(TaskStatistics stats) { - return { - TaskStatisticsDocumentFields.skippedDuringBurnoutCount: - stats.skippedDuringBurnoutCount, - TaskStatisticsDocumentFields.manuallyPushedCount: - stats.manuallyPushedCount, - TaskStatisticsDocumentFields.autoPushedCount: stats.autoPushedCount, - TaskStatisticsDocumentFields.movedToBacklogCount: - stats.movedToBacklogCount, - TaskStatisticsDocumentFields.restoredFromBacklogCount: - stats.restoredFromBacklogCount, - TaskStatisticsDocumentFields.missedCount: stats.missedCount, - TaskStatisticsDocumentFields.cancelledCount: stats.cancelledCount, - TaskStatisticsDocumentFields.completedLateCount: stats.completedLateCount, - TaskStatisticsDocumentFields.completedDuringLockedHoursCount: - stats.completedDuringLockedHoursCount, - TaskStatisticsDocumentFields.completedDuringLockedHoursMinutes: - stats.completedDuringLockedHoursMinutes, - TaskStatisticsDocumentFields.completedAfterShieldCount: - stats.completedAfterShieldCount, - TaskStatisticsDocumentFields.completedAfterPushCount: - stats.completedAfterPushCount, - TaskStatisticsDocumentFields.totalPushesBeforeCompletion: - stats.totalPushesBeforeCompletion, - }; - } - - static TaskStatistics fromDocument(Map document) { - return _mapInvalidValues(() { - return TaskStatistics( - skippedDuringBurnoutCount: _requiredInt( - document, - TaskStatisticsDocumentFields.skippedDuringBurnoutCount, - ), - manuallyPushedCount: _requiredInt( - document, - TaskStatisticsDocumentFields.manuallyPushedCount, - ), - autoPushedCount: _requiredInt( - document, - TaskStatisticsDocumentFields.autoPushedCount, - ), - movedToBacklogCount: _requiredInt( - document, - TaskStatisticsDocumentFields.movedToBacklogCount, - ), - restoredFromBacklogCount: _requiredInt( - document, - TaskStatisticsDocumentFields.restoredFromBacklogCount, - ), - missedCount: _requiredInt( - document, - TaskStatisticsDocumentFields.missedCount, - ), - cancelledCount: _requiredInt( - document, - TaskStatisticsDocumentFields.cancelledCount, - ), - completedLateCount: _requiredInt( - document, - TaskStatisticsDocumentFields.completedLateCount, - ), - completedDuringLockedHoursCount: _requiredInt( - document, - TaskStatisticsDocumentFields.completedDuringLockedHoursCount, - ), - completedDuringLockedHoursMinutes: _requiredInt( - document, - TaskStatisticsDocumentFields.completedDuringLockedHoursMinutes, - ), - completedAfterShieldCount: _requiredInt( - document, - TaskStatisticsDocumentFields.completedAfterShieldCount, - ), - completedAfterPushCount: _requiredInt( - document, - TaskStatisticsDocumentFields.completedAfterPushCount, - ), - totalPushesBeforeCompletion: _requiredInt( - document, - TaskStatisticsDocumentFields.totalPushesBeforeCompletion, - ), - ); - }); - } -} - -/// Document mapping for [ProjectProfile]. -abstract final class ProjectDocumentMapping { - static Map toDocument( - ProjectProfile project, { - required String ownerId, - required DateTime createdAt, - required DateTime updatedAt, - int revision = 1, - }) { - return { - ..._commonFields( - id: project.id, - ownerId: ownerId, - revision: revision, - createdAt: createdAt, - updatedAt: updatedAt, - ), - ProjectDocumentFields.name: project.name, - ProjectDocumentFields.colorKey: project.colorKey, - ProjectDocumentFields.defaultPriority: - PersistenceEnumMapping.encodePriority(project.defaultPriority), - ProjectDocumentFields.defaultReward: - PersistenceEnumMapping.encodeReward(project.defaultReward), - ProjectDocumentFields.defaultDifficulty: - PersistenceEnumMapping.encodeDifficulty(project.defaultDifficulty), - ProjectDocumentFields.defaultReminderProfile: - PersistenceEnumMapping.encodeReminderProfile( - project.defaultReminderProfile, - ), - ProjectDocumentFields.defaultDurationMinutes: - project.defaultDurationMinutes, - ProjectDocumentFields.archivedAt: _dateTimeToStored(project.archivedAt), - }; - } - - static ProjectProfile fromDocument(Map document) { - return _mapInvalidValues(() { - _readCommon(document); - return ProjectProfile( - id: _requiredString(document, ProjectDocumentFields.id), - name: _requiredString(document, ProjectDocumentFields.name), - colorKey: _requiredString(document, ProjectDocumentFields.colorKey), - defaultPriority: PersistenceEnumMapping.decodeNullablePriority( - _requiredString(document, ProjectDocumentFields.defaultPriority), - )!, - defaultReward: PersistenceEnumMapping.decodeReward( - _requiredString(document, ProjectDocumentFields.defaultReward), - ), - defaultDifficulty: PersistenceEnumMapping.decodeDifficulty( - _requiredString(document, ProjectDocumentFields.defaultDifficulty), - ), - defaultReminderProfile: - PersistenceEnumMapping.decodeNullableReminderProfile( - _requiredString( - document, - ProjectDocumentFields.defaultReminderProfile, - ), - )!, - defaultDurationMinutes: _requiredNullableInt( - document, - ProjectDocumentFields.defaultDurationMinutes, - ), - archivedAt: _requiredNullableDateTime( - document, - ProjectDocumentFields.archivedAt, - ), - ); - }); - } -} - -/// Document mapping for [ProjectStatistics]. -abstract final class ProjectStatisticsDocumentMapping { - static Map toDocument( - ProjectStatistics statistics, { - required String ownerId, - required DateTime createdAt, - required DateTime updatedAt, - int revision = 1, - Iterable appliedActivityIds = const [], - }) { - return { - ..._commonFields( - id: statistics.projectId, - ownerId: ownerId, - revision: revision, - createdAt: createdAt, - updatedAt: updatedAt, - ), - ProjectStatisticsDocumentFields.projectId: statistics.projectId, - ProjectStatisticsDocumentFields.completedTaskCount: - statistics.completedTaskCount, - ProjectStatisticsDocumentFields.durationMinuteCounts: - _intKeyCountMapToDocument(statistics.durationMinuteCounts), - ProjectStatisticsDocumentFields.completionTimeBucketCounts: - _enumCountMapToDocument( - statistics.completionTimeBucketCounts, - PersistenceEnumMapping.encodeProjectCompletionTimeBucket, - ), - ProjectStatisticsDocumentFields.totalPushesBeforeCompletion: - statistics.totalPushesBeforeCompletion, - ProjectStatisticsDocumentFields.completedAfterPushCount: - statistics.completedAfterPushCount, - ProjectStatisticsDocumentFields.rewardCounts: _enumCountMapToDocument( - statistics.rewardCounts, - PersistenceEnumMapping.encodeReward, - ), - ProjectStatisticsDocumentFields.difficultyCounts: _enumCountMapToDocument( - statistics.difficultyCounts, - PersistenceEnumMapping.encodeDifficulty, - ), - ProjectStatisticsDocumentFields.reminderProfileCounts: - _enumCountMapToDocument( - statistics.reminderProfileCounts, - PersistenceEnumMapping.encodeReminderProfile, - ), - ProjectStatisticsDocumentFields.appliedActivityIds: - appliedActivityIds.toList(growable: false), - }; - } - - static ProjectStatistics fromDocument(Map document) { - return _mapInvalidValues(() { - _readCommon(document); - return ProjectStatistics( - projectId: _requiredString( - document, - ProjectStatisticsDocumentFields.projectId, - ), - completedTaskCount: _requiredInt( - document, - ProjectStatisticsDocumentFields.completedTaskCount, - ), - durationMinuteCounts: _intKeyCountMapFromDocument( - document, - ProjectStatisticsDocumentFields.durationMinuteCounts, - ), - completionTimeBucketCounts: _enumCountMapFromDocument( - document, - ProjectStatisticsDocumentFields.completionTimeBucketCounts, - PersistenceEnumMapping.decodeProjectCompletionTimeBucket, - ), - totalPushesBeforeCompletion: _requiredInt( - document, - ProjectStatisticsDocumentFields.totalPushesBeforeCompletion, - ), - completedAfterPushCount: _requiredInt( - document, - ProjectStatisticsDocumentFields.completedAfterPushCount, - ), - rewardCounts: _enumCountMapFromDocument( - document, - ProjectStatisticsDocumentFields.rewardCounts, - PersistenceEnumMapping.decodeReward, - ), - difficultyCounts: _enumCountMapFromDocument( - document, - ProjectStatisticsDocumentFields.difficultyCounts, - PersistenceEnumMapping.decodeDifficulty, - ), - reminderProfileCounts: _enumCountMapFromDocument( - document, - ProjectStatisticsDocumentFields.reminderProfileCounts, - (value) => - PersistenceEnumMapping.decodeNullableReminderProfile(value)!, - ), - ).._validateProjectStatisticsDocumentMetadata(document); - }); - } -} - extension on ProjectStatistics { void _validateProjectStatisticsDocumentMetadata( Map document, @@ -779,556 +71,6 @@ extension on ProjectStatistics { } } -/// Document mapping for [LockedBlockRecurrence]. -abstract final class LockedBlockRecurrenceDocumentMapping { - static Map toDocument(LockedBlockRecurrence recurrence) { - return { - LockedBlockRecurrenceDocumentFields.type: 'weekly', - LockedBlockRecurrenceDocumentFields.weekdays: recurrence.weekdays - .map(PersistenceEnumMapping.encodeLockedWeekday) - .toList(growable: false), - }; - } - - static LockedBlockRecurrence fromDocument(Map document) { - return _mapInvalidValues(() { - final type = _requiredString( - document, - LockedBlockRecurrenceDocumentFields.type, - ); - if (type != 'weekly') { - throw DocumentMappingException( - code: DocumentMappingFailureCode.unknownCode, - fieldName: LockedBlockRecurrenceDocumentFields.type, - detailCode: type, - ); - } - final weekdays = _requiredList( - document, - LockedBlockRecurrenceDocumentFields.weekdays, - ).map((value) { - if (value is! String) { - throw const DocumentMappingException( - code: DocumentMappingFailureCode.wrongType, - fieldName: LockedBlockRecurrenceDocumentFields.weekdays, - ); - } - return PersistenceEnumMapping.decodeLockedWeekday(value); - }).toSet(); - return LockedBlockRecurrence.weekly(weekdays: weekdays); - }); - } -} - -/// Document mapping for [LockedBlock]. -abstract final class LockedBlockDocumentMapping { - static Map toDocument( - LockedBlock block, { - required String ownerId, - int revision = 1, - }) { - return { - ..._commonFields( - id: block.id, - ownerId: ownerId, - revision: revision, - createdAt: block.createdAt, - updatedAt: block.updatedAt, - ), - LockedBlockDocumentFields.name: block.name, - LockedBlockDocumentFields.startTime: - PersistenceWallTimeConvention.toStoredString(block.startTime), - LockedBlockDocumentFields.endTime: - PersistenceWallTimeConvention.toStoredString(block.endTime), - LockedBlockDocumentFields.date: _civilDateToStored(block.date), - LockedBlockDocumentFields.recurrence: block.recurrence == null - ? null - : LockedBlockRecurrenceDocumentMapping.toDocument( - block.recurrence!, - ), - LockedBlockDocumentFields.hiddenByDefault: block.hiddenByDefault, - LockedBlockDocumentFields.projectId: block.projectId, - LockedBlockDocumentFields.archivedAt: _dateTimeToStored(block.archivedAt), - }; - } - - static LockedBlock fromDocument(Map document) { - return _mapInvalidValues(() { - _readCommon(document); - final recurrenceDocument = _requiredNullableMap( - document, - LockedBlockDocumentFields.recurrence, - ); - return LockedBlock( - id: _requiredString(document, LockedBlockDocumentFields.id), - name: _requiredString(document, LockedBlockDocumentFields.name), - startTime: _requiredWallTime( - document, - LockedBlockDocumentFields.startTime, - ), - endTime: _requiredWallTime( - document, - LockedBlockDocumentFields.endTime, - ), - date: _requiredNullableCivilDate( - document, - LockedBlockDocumentFields.date, - ), - recurrence: recurrenceDocument == null - ? null - : LockedBlockRecurrenceDocumentMapping.fromDocument( - recurrenceDocument, - ), - hiddenByDefault: _requiredBool( - document, - LockedBlockDocumentFields.hiddenByDefault, - ), - projectId: _requiredNullableString( - document, - LockedBlockDocumentFields.projectId, - ), - createdAt: - _requiredDateTime(document, LockedBlockDocumentFields.createdAt), - updatedAt: - _requiredDateTime(document, LockedBlockDocumentFields.updatedAt), - archivedAt: _requiredNullableDateTime( - document, - LockedBlockDocumentFields.archivedAt, - ), - ); - }); - } -} - -/// Document mapping for [LockedBlockOverride]. -abstract final class LockedBlockOverrideDocumentMapping { - static Map toDocument( - LockedBlockOverride override, { - required String ownerId, - int revision = 1, - }) { - return { - ..._commonFields( - id: override.id, - ownerId: ownerId, - revision: revision, - createdAt: override.createdAt, - updatedAt: override.updatedAt, - ), - LockedBlockOverrideDocumentFields.lockedBlockId: override.lockedBlockId, - LockedBlockOverrideDocumentFields.date: - PersistenceCivilDateConvention.toStoredString(override.date), - LockedBlockOverrideDocumentFields.type: - PersistenceEnumMapping.encodeLockedBlockOverrideType(override.type), - LockedBlockOverrideDocumentFields.name: override.name, - LockedBlockOverrideDocumentFields.startTime: - _wallTimeToStored(override.startTime), - LockedBlockOverrideDocumentFields.endTime: - _wallTimeToStored(override.endTime), - LockedBlockOverrideDocumentFields.hiddenByDefault: - override.hiddenByDefault, - LockedBlockOverrideDocumentFields.projectId: override.projectId, - }; - } - - static LockedBlockOverride fromDocument(Map document) { - return _mapInvalidValues(() { - _readCommon(document); - return LockedBlockOverride( - id: _requiredString(document, LockedBlockOverrideDocumentFields.id), - date: _requiredCivilDate( - document, - LockedBlockOverrideDocumentFields.date, - ), - type: PersistenceEnumMapping.decodeLockedBlockOverrideType( - _requiredString(document, LockedBlockOverrideDocumentFields.type), - ), - createdAt: _requiredDateTime( - document, - LockedBlockOverrideDocumentFields.createdAt, - ), - updatedAt: _requiredDateTime( - document, - LockedBlockOverrideDocumentFields.updatedAt, - ), - lockedBlockId: _requiredNullableString( - document, - LockedBlockOverrideDocumentFields.lockedBlockId, - ), - name: _requiredNullableString( - document, - LockedBlockOverrideDocumentFields.name, - ), - startTime: _requiredNullableWallTime( - document, - LockedBlockOverrideDocumentFields.startTime, - ), - endTime: _requiredNullableWallTime( - document, - LockedBlockOverrideDocumentFields.endTime, - ), - hiddenByDefault: _requiredBool( - document, - LockedBlockOverrideDocumentFields.hiddenByDefault, - ), - projectId: _requiredNullableString( - document, - LockedBlockOverrideDocumentFields.projectId, - ), - ); - }); - } -} - -/// Document mapping for [TaskActivity]. -abstract final class TaskActivityDocumentMapping { - static Map toDocument( - TaskActivity activity, { - required String ownerId, - int revision = 1, - DateTime? createdAt, - DateTime? updatedAt, - }) { - final created = createdAt ?? activity.occurredAt; - final updated = updatedAt ?? created; - return { - ..._commonFields( - id: activity.id, - ownerId: ownerId, - revision: revision, - createdAt: created, - updatedAt: updated, - ), - TaskActivityDocumentFields.operationId: activity.operationId, - TaskActivityDocumentFields.code: - PersistenceEnumMapping.encodeTaskActivityCode(activity.code), - TaskActivityDocumentFields.taskId: activity.taskId, - TaskActivityDocumentFields.projectId: activity.projectId, - TaskActivityDocumentFields.occurredAt: - PersistenceDateTimeConvention.toStoredString(activity.occurredAt), - TaskActivityDocumentFields.metadata: _plainDocument(activity.metadata), - }; - } - - static TaskActivity fromDocument(Map document) { - return _mapInvalidValues(() { - _readCommon(document); - return TaskActivity( - id: _requiredString(document, TaskActivityDocumentFields.id), - operationId: _requiredString( - document, - TaskActivityDocumentFields.operationId, - ), - code: PersistenceEnumMapping.decodeTaskActivityCode( - _requiredString(document, TaskActivityDocumentFields.code), - ), - taskId: _requiredString(document, TaskActivityDocumentFields.taskId), - projectId: - _requiredString(document, TaskActivityDocumentFields.projectId), - occurredAt: _requiredDateTime( - document, - TaskActivityDocumentFields.occurredAt, - ), - metadata: _requiredMap(document, TaskActivityDocumentFields.metadata), - ); - }); - } -} - -/// Document mapping for [OwnerSettings]. -abstract final class OwnerSettingsDocumentMapping { - static Map toDocument( - OwnerSettings settings, { - required DateTime createdAt, - required DateTime updatedAt, - int revision = 1, - }) { - return { - ..._commonFields( - id: settings.ownerId, - ownerId: settings.ownerId, - revision: revision, - createdAt: createdAt, - updatedAt: updatedAt, - ), - OwnerSettingsDocumentFields.timeZoneId: settings.timeZoneId, - OwnerSettingsDocumentFields.dayStart: - PersistenceWallTimeConvention.toStoredString(settings.dayStart), - OwnerSettingsDocumentFields.dayEnd: - PersistenceWallTimeConvention.toStoredString(settings.dayEnd), - OwnerSettingsDocumentFields.compactModeEnabled: - settings.compactModeEnabled, - OwnerSettingsDocumentFields.backlogStaleness: - BacklogStalenessDocumentMapping.toDocument( - settings.backlogStalenessSettings, - ), - }; - } - - static OwnerSettings fromDocument(Map document) { - return _mapInvalidValues(() { - _readCommon(document); - return OwnerSettings( - ownerId: _requiredString(document, OwnerSettingsDocumentFields.ownerId), - timeZoneId: _requiredString( - document, - OwnerSettingsDocumentFields.timeZoneId, - ), - dayStart: _requiredWallTime( - document, - OwnerSettingsDocumentFields.dayStart, - ), - dayEnd: _requiredWallTime( - document, - OwnerSettingsDocumentFields.dayEnd, - ), - compactModeEnabled: _requiredBool( - document, - OwnerSettingsDocumentFields.compactModeEnabled, - ), - backlogStalenessSettings: BacklogStalenessDocumentMapping.fromDocument( - _requiredMap(document, OwnerSettingsDocumentFields.backlogStaleness), - ), - ); - }); - } -} - -/// Document mapping for [BacklogStalenessSettings]. -abstract final class BacklogStalenessDocumentMapping { - static Map toDocument(BacklogStalenessSettings settings) { - return { - BacklogStalenessDocumentFields.greenMaxAgeDays: - settings.greenMaxAge.inDays, - BacklogStalenessDocumentFields.blueMaxAgeDays: settings.blueMaxAge.inDays, - }; - } - - static BacklogStalenessSettings fromDocument(Map document) { - return _mapInvalidValues(() { - final greenDays = _requiredInt( - document, - BacklogStalenessDocumentFields.greenMaxAgeDays, - ); - final blueDays = _requiredInt( - document, - BacklogStalenessDocumentFields.blueMaxAgeDays, - ); - if (greenDays < 0 || blueDays < 0 || blueDays < greenDays) { - throw const DocumentMappingException( - code: DocumentMappingFailureCode.invalidValue, - fieldName: OwnerSettingsDocumentFields.backlogStaleness, - detailCode: 'invalidBacklogStalenessThresholds', - ); - } - return BacklogStalenessSettings( - greenMaxAge: Duration(days: greenDays), - blueMaxAge: Duration(days: blueDays), - ); - }); - } -} - -/// Document mapping for [NoticeAcknowledgementRecord]. -abstract final class NoticeAcknowledgementDocumentMapping { - static Map toDocument( - NoticeAcknowledgementRecord record, { - int revision = 1, - }) { - return { - ..._commonFields( - id: record.noticeId, - ownerId: record.ownerId, - revision: revision, - createdAt: record.acknowledgedAt, - updatedAt: record.acknowledgedAt, - ), - NoticeAcknowledgementDocumentFields.noticeId: record.noticeId, - NoticeAcknowledgementDocumentFields.acknowledgedAt: - PersistenceDateTimeConvention.toStoredString(record.acknowledgedAt), - }; - } - - static NoticeAcknowledgementRecord fromDocument( - Map document, - ) { - return _mapInvalidValues(() { - _readCommon(document); - return NoticeAcknowledgementRecord( - noticeId: _requiredString( - document, - NoticeAcknowledgementDocumentFields.noticeId, - ), - ownerId: _requiredString( - document, - NoticeAcknowledgementDocumentFields.ownerId, - ), - acknowledgedAt: _requiredDateTime( - document, - NoticeAcknowledgementDocumentFields.acknowledgedAt, - ), - ); - }); - } -} - -/// Document mapping for [ApplicationOperationRecord]. -abstract final class ApplicationOperationDocumentMapping { - static Map toDocument( - ApplicationOperationRecord record, { - int revision = 1, - }) { - return { - ..._commonFields( - id: record.operationId, - ownerId: record.ownerId, - revision: revision, - createdAt: record.committedAt, - updatedAt: record.committedAt, - ), - ApplicationOperationDocumentFields.operationId: record.operationId, - ApplicationOperationDocumentFields.operationName: record.operationName, - ApplicationOperationDocumentFields.committedAt: - PersistenceDateTimeConvention.toStoredString(record.committedAt), - }; - } - - static ApplicationOperationRecord fromDocument( - Map document, - ) { - return _mapInvalidValues(() { - _readCommon(document); - return ApplicationOperationRecord( - operationId: _requiredString( - document, - ApplicationOperationDocumentFields.operationId, - ), - ownerId: _requiredString( - document, - ApplicationOperationDocumentFields.ownerId, - ), - operationName: _requiredString( - document, - ApplicationOperationDocumentFields.operationName, - ), - committedAt: _requiredDateTime( - document, - ApplicationOperationDocumentFields.committedAt, - ), - ); - }); - } -} - -/// Document mapping for [SchedulingStateSnapshot]. -abstract final class SchedulingSnapshotDocumentMapping { - static Map toDocument( - SchedulingStateSnapshot snapshot, { - required String ownerId, - int revision = 1, - CivilDate? sourceDate, - CivilDate? targetDate, - DateTime? retentionExpiresAt, - bool truncated = false, - }) { - return { - ..._commonFields( - id: snapshot.id, - ownerId: ownerId, - revision: revision, - createdAt: snapshot.capturedAt, - updatedAt: snapshot.capturedAt, - ), - SchedulingSnapshotDocumentFields.capturedAt: - PersistenceDateTimeConvention.toStoredString(snapshot.capturedAt), - SchedulingSnapshotDocumentFields.sourceDate: - _civilDateToStored(sourceDate), - SchedulingSnapshotDocumentFields.targetDate: - _civilDateToStored(targetDate), - SchedulingSnapshotDocumentFields.window: - SchedulingWindowDocumentMapping.toDocument(snapshot.window), - SchedulingSnapshotDocumentFields.tasks: snapshot.tasks - .map( - (task) => TaskDocumentMapping.toDocument( - task, - ownerId: ownerId, - ), - ) - .toList(growable: false), - SchedulingSnapshotDocumentFields.lockedIntervals: snapshot.lockedIntervals - .map(TimeIntervalDocumentMapping.toDocument) - .toList(growable: false), - SchedulingSnapshotDocumentFields.requiredVisibleIntervals: snapshot - .requiredVisibleIntervals - .map(TimeIntervalDocumentMapping.toDocument) - .toList(growable: false), - SchedulingSnapshotDocumentFields.notices: snapshot.notices - .map(SchedulingNoticeDocumentMapping.toDocument) - .toList(growable: false), - SchedulingSnapshotDocumentFields.changes: snapshot.changes - .map(SchedulingChangeDocumentMapping.toDocument) - .toList(growable: false), - SchedulingSnapshotDocumentFields.overlaps: snapshot.overlaps - .map(SchedulingOverlapDocumentMapping.toDocument) - .toList(growable: false), - SchedulingSnapshotDocumentFields.operationName: snapshot.operationName, - SchedulingSnapshotDocumentFields.retentionExpiresAt: - _dateTimeToStored(retentionExpiresAt), - SchedulingSnapshotDocumentFields.truncated: truncated, - }; - } - - static SchedulingStateSnapshot fromDocument(Map document) { - return _mapInvalidValues(() { - _readCommon(document); - return SchedulingStateSnapshot( - id: _requiredString(document, SchedulingSnapshotDocumentFields.id), - capturedAt: _requiredDateTime( - document, - SchedulingSnapshotDocumentFields.capturedAt, - ), - window: SchedulingWindowDocumentMapping.fromDocument( - _requiredMap(document, SchedulingSnapshotDocumentFields.window), - ), - tasks: _mappedList( - document, - SchedulingSnapshotDocumentFields.tasks, - TaskDocumentMapping.fromDocument, - ), - lockedIntervals: _mappedList( - document, - SchedulingSnapshotDocumentFields.lockedIntervals, - TimeIntervalDocumentMapping.fromDocument, - ), - requiredVisibleIntervals: _mappedList( - document, - SchedulingSnapshotDocumentFields.requiredVisibleIntervals, - TimeIntervalDocumentMapping.fromDocument, - ), - notices: _mappedList( - document, - SchedulingSnapshotDocumentFields.notices, - SchedulingNoticeDocumentMapping.fromDocument, - ), - changes: _mappedList( - document, - SchedulingSnapshotDocumentFields.changes, - SchedulingChangeDocumentMapping.fromDocument, - ), - overlaps: _mappedList( - document, - SchedulingSnapshotDocumentFields.overlaps, - SchedulingOverlapDocumentMapping.fromDocument, - ), - operationName: _requiredNullableString( - document, - SchedulingSnapshotDocumentFields.operationName, - ), - ).._validateSchedulingSnapshotDocumentMetadata(document); - }); - } -} - extension on SchedulingStateSnapshot { void _validateSchedulingSnapshotDocumentMetadata( Map document, @@ -1348,792 +90,3 @@ extension on SchedulingStateSnapshot { _requiredBool(document, SchedulingSnapshotDocumentFields.truncated); } } - -/// Document mapping for [SchedulingWindow]. -abstract final class SchedulingWindowDocumentMapping { - static Map toDocument(SchedulingWindow window) { - return { - SchedulingWindowDocumentFields.start: - PersistenceDateTimeConvention.toStoredString(window.start), - SchedulingWindowDocumentFields.end: - PersistenceDateTimeConvention.toStoredString(window.end), - }; - } - - static SchedulingWindow fromDocument(Map document) { - return _mapInvalidValues(() { - return SchedulingWindow( - start: - _requiredDateTime(document, SchedulingWindowDocumentFields.start), - end: _requiredDateTime(document, SchedulingWindowDocumentFields.end), - ); - }); - } -} - -/// Document mapping for [TimeInterval]. -abstract final class TimeIntervalDocumentMapping { - static Map toDocument(TimeInterval interval) { - return { - TimeIntervalDocumentFields.start: - PersistenceDateTimeConvention.toStoredString(interval.start), - TimeIntervalDocumentFields.end: - PersistenceDateTimeConvention.toStoredString(interval.end), - TimeIntervalDocumentFields.label: interval.label, - }; - } - - static TimeInterval fromDocument(Map document) { - return _mapInvalidValues(() { - return TimeInterval( - start: _requiredDateTime(document, TimeIntervalDocumentFields.start), - end: _requiredDateTime(document, TimeIntervalDocumentFields.end), - label: _requiredNullableString( - document, - TimeIntervalDocumentFields.label, - ), - ); - }); - } -} - -/// Document mapping for [SchedulingNotice]. -abstract final class SchedulingNoticeDocumentMapping { - static Map toDocument(SchedulingNotice notice) { - return { - SchedulingNoticeDocumentFields.message: notice.message, - SchedulingNoticeDocumentFields.type: - PersistenceEnumMapping.encodeSchedulingNoticeType(notice.type), - SchedulingNoticeDocumentFields.taskId: notice.taskId, - SchedulingNoticeDocumentFields.issueCode: notice.issueCode == null - ? null - : PersistenceEnumMapping.encodeSchedulingIssueCode(notice.issueCode!), - SchedulingNoticeDocumentFields.movementCode: notice.movementCode == null - ? null - : PersistenceEnumMapping.encodeSchedulingMovementCode( - notice.movementCode!, - ), - SchedulingNoticeDocumentFields.conflictCode: notice.conflictCode == null - ? null - : PersistenceEnumMapping.encodeSchedulingConflictCode( - notice.conflictCode!, - ), - SchedulingNoticeDocumentFields.parameters: - _plainDocument(notice.parameters), - }; - } - - static SchedulingNotice fromDocument(Map document) { - return _mapInvalidValues(() { - return SchedulingNotice( - _requiredString(document, SchedulingNoticeDocumentFields.message), - type: PersistenceEnumMapping.decodeSchedulingNoticeType( - _requiredString(document, SchedulingNoticeDocumentFields.type), - ), - taskId: _requiredNullableString( - document, - SchedulingNoticeDocumentFields.taskId, - ), - issueCode: PersistenceEnumMapping.decodeNullableSchedulingIssueCode( - _requiredNullableString( - document, - SchedulingNoticeDocumentFields.issueCode, - ), - ), - movementCode: - PersistenceEnumMapping.decodeNullableSchedulingMovementCode( - _requiredNullableString( - document, - SchedulingNoticeDocumentFields.movementCode, - ), - ), - conflictCode: - PersistenceEnumMapping.decodeNullableSchedulingConflictCode( - _requiredNullableString( - document, - SchedulingNoticeDocumentFields.conflictCode, - ), - ), - parameters: _requiredMap( - document, - SchedulingNoticeDocumentFields.parameters, - ), - ); - }); - } -} - -/// Document mapping for [SchedulingChange]. -abstract final class SchedulingChangeDocumentMapping { - static Map toDocument(SchedulingChange change) { - return { - SchedulingChangeDocumentFields.taskId: change.taskId, - SchedulingChangeDocumentFields.previousStart: - _dateTimeToStored(change.previousStart), - SchedulingChangeDocumentFields.previousEnd: - _dateTimeToStored(change.previousEnd), - SchedulingChangeDocumentFields.nextStart: - _dateTimeToStored(change.nextStart), - SchedulingChangeDocumentFields.nextEnd: _dateTimeToStored(change.nextEnd), - }; - } - - static SchedulingChange fromDocument(Map document) { - return _mapInvalidValues(() { - return SchedulingChange( - taskId: - _requiredString(document, SchedulingChangeDocumentFields.taskId), - previousStart: _requiredNullableDateTime( - document, - SchedulingChangeDocumentFields.previousStart, - ), - previousEnd: _requiredNullableDateTime( - document, - SchedulingChangeDocumentFields.previousEnd, - ), - nextStart: _requiredNullableDateTime( - document, - SchedulingChangeDocumentFields.nextStart, - ), - nextEnd: _requiredNullableDateTime( - document, - SchedulingChangeDocumentFields.nextEnd, - ), - ); - }); - } -} - -/// Document mapping for [SchedulingOverlap]. -abstract final class SchedulingOverlapDocumentMapping { - static Map toDocument(SchedulingOverlap overlap) { - return { - SchedulingOverlapDocumentFields.taskId: overlap.taskId, - SchedulingOverlapDocumentFields.taskInterval: - TimeIntervalDocumentMapping.toDocument(overlap.taskInterval), - SchedulingOverlapDocumentFields.blockedInterval: - TimeIntervalDocumentMapping.toDocument(overlap.blockedInterval), - }; - } - - static SchedulingOverlap fromDocument(Map document) { - return _mapInvalidValues(() { - return SchedulingOverlap( - taskId: - _requiredString(document, SchedulingOverlapDocumentFields.taskId), - taskInterval: TimeIntervalDocumentMapping.fromDocument( - _requiredMap(document, SchedulingOverlapDocumentFields.taskInterval), - ), - blockedInterval: TimeIntervalDocumentMapping.fromDocument( - _requiredMap( - document, - SchedulingOverlapDocumentFields.blockedInterval, - ), - ), - ); - }); - } -} - -/// Convenience extension for converting a [Task] into a V1 document map. -extension TaskDocumentExtension on Task { - Map toDocument({ - required String ownerId, - int revision = 1, - bool clearSchedule = false, - DateTime? backlogEnteredAt, - String? backlogEnteredAtProvenance, - }) { - return TaskDocumentMapping.toDocument( - this, - ownerId: ownerId, - revision: revision, - clearSchedule: clearSchedule, - backlogEnteredAt: backlogEnteredAt, - backlogEnteredAtProvenance: backlogEnteredAtProvenance, - ); - } -} - -/// Convenience extension for converting [TaskStatistics] into an embedded map. -extension TaskStatisticsDocumentExtension on TaskStatistics { - Map toDocument() { - return TaskStatisticsDocumentMapping.toDocument(this); - } -} - -/// Convenience extension for converting [ProjectStatistics] into a document. -extension ProjectStatisticsDocumentExtension on ProjectStatistics { - Map toDocument({ - required String ownerId, - required DateTime createdAt, - required DateTime updatedAt, - int revision = 1, - Iterable appliedActivityIds = const [], - }) { - return ProjectStatisticsDocumentMapping.toDocument( - this, - ownerId: ownerId, - createdAt: createdAt, - updatedAt: updatedAt, - revision: revision, - appliedActivityIds: appliedActivityIds, - ); - } -} - -const _taskTypeByCode = { - 'flexible': TaskType.flexible, - 'inflexible': TaskType.inflexible, - 'critical': TaskType.critical, - 'locked': TaskType.locked, - 'surprise': TaskType.surprise, - 'free_slot': TaskType.freeSlot, -}; - -const _taskStatusByCode = { - 'planned': TaskStatus.planned, - 'active': TaskStatus.active, - 'completed': TaskStatus.completed, - 'missed': TaskStatus.missed, - 'cancelled': TaskStatus.cancelled, - 'no_longer_relevant': TaskStatus.noLongerRelevant, - 'backlog': TaskStatus.backlog, -}; - -const _priorityByCode = { - 'very_low': PriorityLevel.veryLow, - 'low': PriorityLevel.low, - 'medium': PriorityLevel.medium, - 'high': PriorityLevel.high, - 'very_high': PriorityLevel.veryHigh, -}; - -const _rewardByCode = { - 'not_set': RewardLevel.notSet, - 'very_low': RewardLevel.veryLow, - 'low': RewardLevel.low, - 'medium': RewardLevel.medium, - 'high': RewardLevel.high, - 'very_high': RewardLevel.veryHigh, -}; - -const _difficultyByCode = { - 'not_set': DifficultyLevel.notSet, - 'very_easy': DifficultyLevel.veryEasy, - 'easy': DifficultyLevel.easy, - 'medium': DifficultyLevel.medium, - 'hard': DifficultyLevel.hard, - 'very_hard': DifficultyLevel.veryHard, -}; - -const _reminderProfileByCode = { - 'silent': ReminderProfile.silent, - 'gentle': ReminderProfile.gentle, - 'persistent': ReminderProfile.persistent, - 'strict': ReminderProfile.strict, -}; - -const _backlogTagByCode = { - 'wishlist': BacklogTag.wishlist, -}; - -const _lockedWeekdayByCode = { - 'monday': LockedWeekday.monday, - 'tuesday': LockedWeekday.tuesday, - 'wednesday': LockedWeekday.wednesday, - 'thursday': LockedWeekday.thursday, - 'friday': LockedWeekday.friday, - 'saturday': LockedWeekday.saturday, - 'sunday': LockedWeekday.sunday, -}; - -const _lockedBlockOverrideTypeByCode = { - 'remove': LockedBlockOverrideType.remove, - 'replace': LockedBlockOverrideType.replace, - 'add': LockedBlockOverrideType.add, -}; - -const _taskActivityCodeByCode = { - 'completed': TaskActivityCode.completed, - 'missed': TaskActivityCode.missed, - 'cancelled': TaskActivityCode.cancelled, - 'no_longer_relevant': TaskActivityCode.noLongerRelevant, - 'manually_pushed': TaskActivityCode.manuallyPushed, - 'automatically_pushed': TaskActivityCode.automaticallyPushed, - 'moved_to_backlog': TaskActivityCode.movedToBacklog, - 'restored_from_backlog': TaskActivityCode.restoredFromBacklog, - 'activated': TaskActivityCode.activated, -}; - -const _schedulingNoticeTypeByCode = { - 'info': SchedulingNoticeType.info, - 'moved': SchedulingNoticeType.moved, - 'overlap': SchedulingNoticeType.overlap, - 'no_fit': SchedulingNoticeType.noFit, - 'overflow': SchedulingNoticeType.overflow, -}; - -const _schedulingIssueCodeByCode = { - 'task_not_found': SchedulingIssueCode.taskNotFound, - 'invalid_task_state': SchedulingIssueCode.invalidTaskState, - 'missing_duration': SchedulingIssueCode.missingDuration, - 'missing_scheduled_slot': SchedulingIssueCode.missingScheduledSlot, - 'non_positive_duration': SchedulingIssueCode.nonPositiveDuration, - 'no_available_slot': SchedulingIssueCode.noAvailableSlot, - 'unfinished_tasks_could_not_fit': - SchedulingIssueCode.unfinishedTasksCouldNotFit, - 'no_unfinished_flexible_tasks': SchedulingIssueCode.noUnfinishedFlexibleTasks, - 'duplicate_surprise_log': SchedulingIssueCode.duplicateSurpriseLog, -}; - -const _schedulingMovementCodeByCode = { - 'backlog_task_inserted': SchedulingMovementCode.backlogTaskInserted, - 'flexible_task_moved_to_make_room': - SchedulingMovementCode.flexibleTaskMovedToMakeRoom, - 'flexible_task_pushed_to_next_available_slot': - SchedulingMovementCode.flexibleTaskPushedToNextAvailableSlot, - 'flexible_task_moved_to_tomorrow': - SchedulingMovementCode.flexibleTaskMovedToTomorrow, - 'unfinished_flexible_tasks_rolled_over': - SchedulingMovementCode.unfinishedFlexibleTasksRolledOver, - 'flexible_task_moved_to_backlog': - SchedulingMovementCode.flexibleTaskMovedToBacklog, - 'required_commitment_scheduled': - SchedulingMovementCode.requiredCommitmentScheduled, -}; - -const _schedulingConflictCodeByCode = { - 'flexible_task_overlaps_blocked_time': - SchedulingConflictCode.flexibleTaskOverlapsBlockedTime, - 'surprise_task_overlaps_required_visible_time': - SchedulingConflictCode.surpriseTaskOverlapsRequiredVisibleTime, - 'required_commitment_overlaps_protected_free_slot': - SchedulingConflictCode.requiredCommitmentOverlapsProtectedFreeSlot, -}; - -const _projectCompletionTimeBucketByCode = - { - 'overnight': ProjectCompletionTimeBucket.overnight, - 'morning': ProjectCompletionTimeBucket.morning, - 'afternoon': ProjectCompletionTimeBucket.afternoon, - 'evening': ProjectCompletionTimeBucket.evening, - 'night': ProjectCompletionTimeBucket.night, -}; - -T _decodeCode(Map valuesByCode, String code, String fieldName) { - final value = valuesByCode[code]; - if (value == null) { - throw DocumentMappingException( - code: DocumentMappingFailureCode.unknownCode, - fieldName: fieldName, - detailCode: code, - ); - } - return value; -} - -Map _commonFields({ - required String id, - required String ownerId, - required int revision, - required DateTime createdAt, - required DateTime updatedAt, -}) { - if (revision <= 0) { - throw const DocumentMappingException( - code: DocumentMappingFailureCode.invalidRevision, - fieldName: DocumentFields.revision, - ); - } - return { - DocumentFields.schemaVersion: v1SchemaVersion, - DocumentFields.id: id, - DocumentFields.ownerId: ownerId, - DocumentFields.revision: revision, - DocumentFields.createdAt: - PersistenceDateTimeConvention.toStoredString(createdAt), - DocumentFields.updatedAt: - PersistenceDateTimeConvention.toStoredString(updatedAt), - }; -} - -DocumentMetadata _readCommon(Map document) { - final schemaVersion = _requiredInt(document, DocumentFields.schemaVersion); - if (schemaVersion != v1SchemaVersion) { - throw DocumentMappingException( - code: DocumentMappingFailureCode.invalidSchemaVersion, - fieldName: DocumentFields.schemaVersion, - detailCode: schemaVersion.toString(), - ); - } - final revision = _requiredInt(document, DocumentFields.revision); - if (revision <= 0) { - throw DocumentMappingException( - code: DocumentMappingFailureCode.invalidRevision, - fieldName: DocumentFields.revision, - detailCode: revision.toString(), - ); - } - return DocumentMetadata( - id: _requiredString(document, DocumentFields.id), - ownerId: _requiredString(document, DocumentFields.ownerId), - revision: revision, - createdAt: _requiredDateTime(document, DocumentFields.createdAt), - updatedAt: _requiredDateTime(document, DocumentFields.updatedAt), - ); -} - -T _mapInvalidValues(T Function() read) { - try { - return read(); - } on DocumentMappingException { - rethrow; - } on DomainValidationException catch (error) { - throw DocumentMappingException( - code: DocumentMappingFailureCode.invalidValue, - fieldName: error.name, - detailCode: error.code.name, - ); - } on FormatException catch (error) { - throw DocumentMappingException( - code: DocumentMappingFailureCode.invalidValue, - detailCode: error.message, - ); - } on ArgumentError catch (error) { - throw DocumentMappingException( - code: DocumentMappingFailureCode.invalidValue, - fieldName: error.name, - ); - } -} - -String? _dateTimeToStored(DateTime? value) { - return value == null - ? null - : PersistenceDateTimeConvention.toStoredString(value); -} - -String? _civilDateToStored(CivilDate? value) { - return value == null - ? null - : PersistenceCivilDateConvention.toStoredString(value); -} - -String? _wallTimeToStored(WallTime? value) { - return value == null - ? null - : PersistenceWallTimeConvention.toStoredString(value); -} - -String _requiredString(Map document, String fieldName) { - if (!document.containsKey(fieldName)) { - throw DocumentMappingException( - code: DocumentMappingFailureCode.missingField, - fieldName: fieldName, - ); - } - final value = document[fieldName]; - if (value is String) { - return value; - } - throw DocumentMappingException( - code: DocumentMappingFailureCode.wrongType, - fieldName: fieldName, - ); -} - -String? _requiredNullableString( - Map document, - String fieldName, -) { - if (!document.containsKey(fieldName)) { - throw DocumentMappingException( - code: DocumentMappingFailureCode.missingField, - fieldName: fieldName, - ); - } - final value = document[fieldName]; - if (value == null || value is String) { - return value as String?; - } - throw DocumentMappingException( - code: DocumentMappingFailureCode.wrongType, - fieldName: fieldName, - ); -} - -int _requiredInt(Map document, String fieldName) { - if (!document.containsKey(fieldName)) { - throw DocumentMappingException( - code: DocumentMappingFailureCode.missingField, - fieldName: fieldName, - ); - } - final value = document[fieldName]; - if (value is int) { - return value; - } - throw DocumentMappingException( - code: DocumentMappingFailureCode.wrongType, - fieldName: fieldName, - ); -} - -int? _requiredNullableInt( - Map document, - String fieldName, -) { - if (!document.containsKey(fieldName)) { - throw DocumentMappingException( - code: DocumentMappingFailureCode.missingField, - fieldName: fieldName, - ); - } - final value = document[fieldName]; - if (value == null || value is int) { - return value as int?; - } - throw DocumentMappingException( - code: DocumentMappingFailureCode.wrongType, - fieldName: fieldName, - ); -} - -bool _requiredBool(Map document, String fieldName) { - if (!document.containsKey(fieldName)) { - throw DocumentMappingException( - code: DocumentMappingFailureCode.missingField, - fieldName: fieldName, - ); - } - final value = document[fieldName]; - if (value is bool) { - return value; - } - throw DocumentMappingException( - code: DocumentMappingFailureCode.wrongType, - fieldName: fieldName, - ); -} - -DateTime _requiredDateTime(Map document, String fieldName) { - return PersistenceDateTimeConvention.fromStoredString( - _requiredString(document, fieldName), - ); -} - -DateTime? _requiredNullableDateTime( - Map document, - String fieldName, -) { - final value = _requiredNullableString(document, fieldName); - return value == null - ? null - : PersistenceDateTimeConvention.fromStoredString(value); -} - -CivilDate _requiredCivilDate(Map document, String fieldName) { - return PersistenceCivilDateConvention.fromStoredString( - _requiredString(document, fieldName), - ); -} - -CivilDate? _requiredNullableCivilDate( - Map document, - String fieldName, -) { - final value = _requiredNullableString(document, fieldName); - return value == null - ? null - : PersistenceCivilDateConvention.fromStoredString(value); -} - -WallTime _requiredWallTime(Map document, String fieldName) { - return PersistenceWallTimeConvention.fromStoredString( - _requiredString(document, fieldName), - ); -} - -WallTime? _requiredNullableWallTime( - Map document, - String fieldName, -) { - final value = _requiredNullableString(document, fieldName); - return value == null - ? null - : PersistenceWallTimeConvention.fromStoredString(value); -} - -Map _requiredMap( - Map document, - String fieldName, -) { - if (!document.containsKey(fieldName)) { - throw DocumentMappingException( - code: DocumentMappingFailureCode.missingField, - fieldName: fieldName, - ); - } - final value = document[fieldName]; - if (value is Map) { - return value; - } - if (value is Map) { - return Map.from(value); - } - throw DocumentMappingException( - code: DocumentMappingFailureCode.wrongType, - fieldName: fieldName, - ); -} - -Map? _requiredNullableMap( - Map document, - String fieldName, -) { - if (!document.containsKey(fieldName)) { - throw DocumentMappingException( - code: DocumentMappingFailureCode.missingField, - fieldName: fieldName, - ); - } - final value = document[fieldName]; - if (value == null) { - return null; - } - if (value is Map) { - return value; - } - if (value is Map) { - return Map.from(value); - } - throw DocumentMappingException( - code: DocumentMappingFailureCode.wrongType, - fieldName: fieldName, - ); -} - -List _requiredList(Map document, String fieldName) { - if (!document.containsKey(fieldName)) { - throw DocumentMappingException( - code: DocumentMappingFailureCode.missingField, - fieldName: fieldName, - ); - } - final value = document[fieldName]; - if (value is List) { - return List.unmodifiable(value); - } - throw DocumentMappingException( - code: DocumentMappingFailureCode.wrongType, - fieldName: fieldName, - ); -} - -List _mappedList( - Map document, - String fieldName, - T Function(Map document) decode, -) { - return _requiredList(document, fieldName).map((value) { - if (value is Map) { - return decode(value); - } - if (value is Map) { - return decode(Map.from(value)); - } - throw DocumentMappingException( - code: DocumentMappingFailureCode.wrongType, - fieldName: fieldName, - ); - }).toList(growable: false); -} - -Map _intKeyCountMapToDocument(Map counts) { - return { - for (final entry in counts.entries) entry.key.toString(): entry.value, - }; -} - -Map _enumCountMapToDocument( - Map counts, - String Function(T value) encode, -) { - return { - for (final entry in counts.entries) encode(entry.key): entry.value, - }; -} - -Map _intKeyCountMapFromDocument( - Map document, - String fieldName, -) { - final raw = _requiredMap(document, fieldName); - return { - for (final entry in raw.entries) - int.parse(entry.key): _countValue(entry.value, fieldName), - }; -} - -Map _enumCountMapFromDocument( - Map document, - String fieldName, - T Function(String value) decode, -) { - final raw = _requiredMap(document, fieldName); - return { - for (final entry in raw.entries) - decode(entry.key): _countValue(entry.value, fieldName), - }; -} - -int _countValue(Object? value, String fieldName) { - if (value is int) { - return value; - } - throw DocumentMappingException( - code: DocumentMappingFailureCode.wrongType, - fieldName: fieldName, - ); -} - -Map _plainDocument(Map value) { - return Map.unmodifiable( - value.map((key, value) => MapEntry(key, _plainDocumentValue(value))), - ); -} - -Object? _plainDocumentValue(Object? value) { - if (value == null || - value is String || - value is num || - value is bool || - value is DateTime) { - return value is DateTime - ? PersistenceDateTimeConvention.toStoredString(value) - : value; - } - if (value is CivilDate) { - return PersistenceCivilDateConvention.toStoredString(value); - } - if (value is WallTime) { - return PersistenceWallTimeConvention.toStoredString(value); - } - if (value is Enum) { - return PersistenceEnumMapping.encode(value); - } - if (value is List) { - return value.map(_plainDocumentValue).toList(growable: false); - } - if (value is Map) { - return _plainDocument(value); - } - if (value is Map) { - return _plainDocument(Map.from(value)); - } - throw DocumentMappingException( - code: DocumentMappingFailureCode.wrongType, - detailCode: value.runtimeType.toString(), - ); -} diff --git a/packages/scheduler_core/lib/src/document_mapping/application_operation_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/application_operation_document_mapping.dart new file mode 100644 index 0000000..4e37ca4 --- /dev/null +++ b/packages/scheduler_core/lib/src/document_mapping/application_operation_document_mapping.dart @@ -0,0 +1,49 @@ +part of '../document_mapping.dart'; + +/// Document mapping for [ApplicationOperationRecord]. +abstract final class ApplicationOperationDocumentMapping { + static Map toDocument( + ApplicationOperationRecord record, { + int revision = 1, + }) { + return { + ..._commonFields( + id: record.operationId, + ownerId: record.ownerId, + revision: revision, + createdAt: record.committedAt, + updatedAt: record.committedAt, + ), + ApplicationOperationDocumentFields.operationId: record.operationId, + ApplicationOperationDocumentFields.operationName: record.operationName, + ApplicationOperationDocumentFields.committedAt: + PersistenceDateTimeConvention.toStoredString(record.committedAt), + }; + } + + static ApplicationOperationRecord fromDocument( + Map document, + ) { + return _mapInvalidValues(() { + _readCommon(document); + return ApplicationOperationRecord( + operationId: _requiredString( + document, + ApplicationOperationDocumentFields.operationId, + ), + ownerId: _requiredString( + document, + ApplicationOperationDocumentFields.ownerId, + ), + operationName: _requiredString( + document, + ApplicationOperationDocumentFields.operationName, + ), + committedAt: _requiredDateTime( + document, + ApplicationOperationDocumentFields.committedAt, + ), + ); + }); + } +} diff --git a/packages/scheduler_core/lib/src/document_mapping/backlog_staleness_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/backlog_staleness_document_mapping.dart new file mode 100644 index 0000000..0d39b1a --- /dev/null +++ b/packages/scheduler_core/lib/src/document_mapping/backlog_staleness_document_mapping.dart @@ -0,0 +1,36 @@ +part of '../document_mapping.dart'; + +/// Document mapping for [BacklogStalenessSettings]. +abstract final class BacklogStalenessDocumentMapping { + static Map toDocument(BacklogStalenessSettings settings) { + return { + BacklogStalenessDocumentFields.greenMaxAgeDays: + settings.greenMaxAge.inDays, + BacklogStalenessDocumentFields.blueMaxAgeDays: settings.blueMaxAge.inDays, + }; + } + + static BacklogStalenessSettings fromDocument(Map document) { + return _mapInvalidValues(() { + final greenDays = _requiredInt( + document, + BacklogStalenessDocumentFields.greenMaxAgeDays, + ); + final blueDays = _requiredInt( + document, + BacklogStalenessDocumentFields.blueMaxAgeDays, + ); + if (greenDays < 0 || blueDays < 0 || blueDays < greenDays) { + throw const DocumentMappingException( + code: DocumentMappingFailureCode.invalidValue, + fieldName: OwnerSettingsDocumentFields.backlogStaleness, + detailCode: 'invalidBacklogStalenessThresholds', + ); + } + return BacklogStalenessSettings( + greenMaxAge: Duration(days: greenDays), + blueMaxAge: Duration(days: blueDays), + ); + }); + } +} diff --git a/packages/scheduler_core/lib/src/document_mapping/document_mapping_exception.dart b/packages/scheduler_core/lib/src/document_mapping/document_mapping_exception.dart new file mode 100644 index 0000000..5440dac --- /dev/null +++ b/packages/scheduler_core/lib/src/document_mapping/document_mapping_exception.dart @@ -0,0 +1,25 @@ +part of '../document_mapping.dart'; + +/// Typed mapping failure for malformed V1 documents. +class DocumentMappingException implements Exception { + const DocumentMappingException({ + required this.code, + this.fieldName, + this.detailCode, + }); + + /// Stable category for caller branching. + final DocumentMappingFailureCode code; + + /// Field related to the failure, when known. + final String? fieldName; + + /// Optional narrower reason. + final String? detailCode; + + @override + String toString() { + return 'DocumentMappingException($code, field: $fieldName, detail: ' + '$detailCode)'; + } +} diff --git a/packages/scheduler_core/lib/src/document_mapping/document_mapping_failure_code.dart b/packages/scheduler_core/lib/src/document_mapping/document_mapping_failure_code.dart new file mode 100644 index 0000000..061275b --- /dev/null +++ b/packages/scheduler_core/lib/src/document_mapping/document_mapping_failure_code.dart @@ -0,0 +1,11 @@ +part of '../document_mapping.dart'; + +/// Stable document mapping failure categories. +enum DocumentMappingFailureCode { + missingField, + wrongType, + unknownCode, + invalidSchemaVersion, + invalidRevision, + invalidValue, +} diff --git a/packages/scheduler_core/lib/src/document_mapping/document_metadata.dart b/packages/scheduler_core/lib/src/document_mapping/document_metadata.dart new file mode 100644 index 0000000..9f48044 --- /dev/null +++ b/packages/scheduler_core/lib/src/document_mapping/document_metadata.dart @@ -0,0 +1,18 @@ +part of '../document_mapping.dart'; + +/// Common metadata decoded from a V1 top-level document. +class DocumentMetadata { + const DocumentMetadata({ + required this.id, + required this.ownerId, + required this.revision, + required this.createdAt, + required this.updatedAt, + }); + + final String id; + final String ownerId; + final int revision; + final DateTime createdAt; + final DateTime updatedAt; +} diff --git a/packages/scheduler_core/lib/src/document_mapping/locked_block_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/locked_block_document_mapping.dart new file mode 100644 index 0000000..3dd6323 --- /dev/null +++ b/packages/scheduler_core/lib/src/document_mapping/locked_block_document_mapping.dart @@ -0,0 +1,81 @@ +part of '../document_mapping.dart'; + +/// Document mapping for [LockedBlock]. +abstract final class LockedBlockDocumentMapping { + static Map toDocument( + LockedBlock block, { + required String ownerId, + int revision = 1, + }) { + return { + ..._commonFields( + id: block.id, + ownerId: ownerId, + revision: revision, + createdAt: block.createdAt, + updatedAt: block.updatedAt, + ), + LockedBlockDocumentFields.name: block.name, + LockedBlockDocumentFields.startTime: + PersistenceWallTimeConvention.toStoredString(block.startTime), + LockedBlockDocumentFields.endTime: + PersistenceWallTimeConvention.toStoredString(block.endTime), + LockedBlockDocumentFields.date: _civilDateToStored(block.date), + LockedBlockDocumentFields.recurrence: block.recurrence == null + ? null + : LockedBlockRecurrenceDocumentMapping.toDocument( + block.recurrence!, + ), + LockedBlockDocumentFields.hiddenByDefault: block.hiddenByDefault, + LockedBlockDocumentFields.projectId: block.projectId, + LockedBlockDocumentFields.archivedAt: _dateTimeToStored(block.archivedAt), + }; + } + + static LockedBlock fromDocument(Map document) { + return _mapInvalidValues(() { + _readCommon(document); + final recurrenceDocument = _requiredNullableMap( + document, + LockedBlockDocumentFields.recurrence, + ); + return LockedBlock( + id: _requiredString(document, LockedBlockDocumentFields.id), + name: _requiredString(document, LockedBlockDocumentFields.name), + startTime: _requiredWallTime( + document, + LockedBlockDocumentFields.startTime, + ), + endTime: _requiredWallTime( + document, + LockedBlockDocumentFields.endTime, + ), + date: _requiredNullableCivilDate( + document, + LockedBlockDocumentFields.date, + ), + recurrence: recurrenceDocument == null + ? null + : LockedBlockRecurrenceDocumentMapping.fromDocument( + recurrenceDocument, + ), + hiddenByDefault: _requiredBool( + document, + LockedBlockDocumentFields.hiddenByDefault, + ), + projectId: _requiredNullableString( + document, + LockedBlockDocumentFields.projectId, + ), + createdAt: + _requiredDateTime(document, LockedBlockDocumentFields.createdAt), + updatedAt: + _requiredDateTime(document, LockedBlockDocumentFields.updatedAt), + archivedAt: _requiredNullableDateTime( + document, + LockedBlockDocumentFields.archivedAt, + ), + ); + }); + } +} diff --git a/packages/scheduler_core/lib/src/document_mapping/locked_block_override_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/locked_block_override_document_mapping.dart new file mode 100644 index 0000000..7525bd1 --- /dev/null +++ b/packages/scheduler_core/lib/src/document_mapping/locked_block_override_document_mapping.dart @@ -0,0 +1,81 @@ +part of '../document_mapping.dart'; + +/// Document mapping for [LockedBlockOverride]. +abstract final class LockedBlockOverrideDocumentMapping { + static Map toDocument( + LockedBlockOverride override, { + required String ownerId, + int revision = 1, + }) { + return { + ..._commonFields( + id: override.id, + ownerId: ownerId, + revision: revision, + createdAt: override.createdAt, + updatedAt: override.updatedAt, + ), + LockedBlockOverrideDocumentFields.lockedBlockId: override.lockedBlockId, + LockedBlockOverrideDocumentFields.date: + PersistenceCivilDateConvention.toStoredString(override.date), + LockedBlockOverrideDocumentFields.type: + PersistenceEnumMapping.encodeLockedBlockOverrideType(override.type), + LockedBlockOverrideDocumentFields.name: override.name, + LockedBlockOverrideDocumentFields.startTime: + _wallTimeToStored(override.startTime), + LockedBlockOverrideDocumentFields.endTime: + _wallTimeToStored(override.endTime), + LockedBlockOverrideDocumentFields.hiddenByDefault: + override.hiddenByDefault, + LockedBlockOverrideDocumentFields.projectId: override.projectId, + }; + } + + static LockedBlockOverride fromDocument(Map document) { + return _mapInvalidValues(() { + _readCommon(document); + return LockedBlockOverride( + id: _requiredString(document, LockedBlockOverrideDocumentFields.id), + date: _requiredCivilDate( + document, + LockedBlockOverrideDocumentFields.date, + ), + type: PersistenceEnumMapping.decodeLockedBlockOverrideType( + _requiredString(document, LockedBlockOverrideDocumentFields.type), + ), + createdAt: _requiredDateTime( + document, + LockedBlockOverrideDocumentFields.createdAt, + ), + updatedAt: _requiredDateTime( + document, + LockedBlockOverrideDocumentFields.updatedAt, + ), + lockedBlockId: _requiredNullableString( + document, + LockedBlockOverrideDocumentFields.lockedBlockId, + ), + name: _requiredNullableString( + document, + LockedBlockOverrideDocumentFields.name, + ), + startTime: _requiredNullableWallTime( + document, + LockedBlockOverrideDocumentFields.startTime, + ), + endTime: _requiredNullableWallTime( + document, + LockedBlockOverrideDocumentFields.endTime, + ), + hiddenByDefault: _requiredBool( + document, + LockedBlockOverrideDocumentFields.hiddenByDefault, + ), + projectId: _requiredNullableString( + document, + LockedBlockOverrideDocumentFields.projectId, + ), + ); + }); + } +} diff --git a/packages/scheduler_core/lib/src/document_mapping/locked_block_recurrence_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/locked_block_recurrence_document_mapping.dart new file mode 100644 index 0000000..043e45d --- /dev/null +++ b/packages/scheduler_core/lib/src/document_mapping/locked_block_recurrence_document_mapping.dart @@ -0,0 +1,42 @@ +part of '../document_mapping.dart'; + +/// Document mapping for [LockedBlockRecurrence]. +abstract final class LockedBlockRecurrenceDocumentMapping { + static Map toDocument(LockedBlockRecurrence recurrence) { + return { + LockedBlockRecurrenceDocumentFields.type: 'weekly', + LockedBlockRecurrenceDocumentFields.weekdays: recurrence.weekdays + .map(PersistenceEnumMapping.encodeLockedWeekday) + .toList(growable: false), + }; + } + + static LockedBlockRecurrence fromDocument(Map document) { + return _mapInvalidValues(() { + final type = _requiredString( + document, + LockedBlockRecurrenceDocumentFields.type, + ); + if (type != 'weekly') { + throw DocumentMappingException( + code: DocumentMappingFailureCode.unknownCode, + fieldName: LockedBlockRecurrenceDocumentFields.type, + detailCode: type, + ); + } + final weekdays = _requiredList( + document, + LockedBlockRecurrenceDocumentFields.weekdays, + ).map((value) { + if (value is! String) { + throw const DocumentMappingException( + code: DocumentMappingFailureCode.wrongType, + fieldName: LockedBlockRecurrenceDocumentFields.weekdays, + ); + } + return PersistenceEnumMapping.decodeLockedWeekday(value); + }).toSet(); + return LockedBlockRecurrence.weekly(weekdays: weekdays); + }); + } +} diff --git a/packages/scheduler_core/lib/src/document_mapping/notice_acknowledgement_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/notice_acknowledgement_document_mapping.dart new file mode 100644 index 0000000..22a51d4 --- /dev/null +++ b/packages/scheduler_core/lib/src/document_mapping/notice_acknowledgement_document_mapping.dart @@ -0,0 +1,44 @@ +part of '../document_mapping.dart'; + +/// Document mapping for [NoticeAcknowledgementRecord]. +abstract final class NoticeAcknowledgementDocumentMapping { + static Map toDocument( + NoticeAcknowledgementRecord record, { + int revision = 1, + }) { + return { + ..._commonFields( + id: record.noticeId, + ownerId: record.ownerId, + revision: revision, + createdAt: record.acknowledgedAt, + updatedAt: record.acknowledgedAt, + ), + NoticeAcknowledgementDocumentFields.noticeId: record.noticeId, + NoticeAcknowledgementDocumentFields.acknowledgedAt: + PersistenceDateTimeConvention.toStoredString(record.acknowledgedAt), + }; + } + + static NoticeAcknowledgementRecord fromDocument( + Map document, + ) { + return _mapInvalidValues(() { + _readCommon(document); + return NoticeAcknowledgementRecord( + noticeId: _requiredString( + document, + NoticeAcknowledgementDocumentFields.noticeId, + ), + ownerId: _requiredString( + document, + NoticeAcknowledgementDocumentFields.ownerId, + ), + acknowledgedAt: _requiredDateTime( + document, + NoticeAcknowledgementDocumentFields.acknowledgedAt, + ), + ); + }); + } +} diff --git a/packages/scheduler_core/lib/src/document_mapping/owner_settings_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/owner_settings_document_mapping.dart new file mode 100644 index 0000000..854a49a --- /dev/null +++ b/packages/scheduler_core/lib/src/document_mapping/owner_settings_document_mapping.dart @@ -0,0 +1,60 @@ +part of '../document_mapping.dart'; + +/// Document mapping for [OwnerSettings]. +abstract final class OwnerSettingsDocumentMapping { + static Map toDocument( + OwnerSettings settings, { + required DateTime createdAt, + required DateTime updatedAt, + int revision = 1, + }) { + return { + ..._commonFields( + id: settings.ownerId, + ownerId: settings.ownerId, + revision: revision, + createdAt: createdAt, + updatedAt: updatedAt, + ), + OwnerSettingsDocumentFields.timeZoneId: settings.timeZoneId, + OwnerSettingsDocumentFields.dayStart: + PersistenceWallTimeConvention.toStoredString(settings.dayStart), + OwnerSettingsDocumentFields.dayEnd: + PersistenceWallTimeConvention.toStoredString(settings.dayEnd), + OwnerSettingsDocumentFields.compactModeEnabled: + settings.compactModeEnabled, + OwnerSettingsDocumentFields.backlogStaleness: + BacklogStalenessDocumentMapping.toDocument( + settings.backlogStalenessSettings, + ), + }; + } + + static OwnerSettings fromDocument(Map document) { + return _mapInvalidValues(() { + _readCommon(document); + return OwnerSettings( + ownerId: _requiredString(document, OwnerSettingsDocumentFields.ownerId), + timeZoneId: _requiredString( + document, + OwnerSettingsDocumentFields.timeZoneId, + ), + dayStart: _requiredWallTime( + document, + OwnerSettingsDocumentFields.dayStart, + ), + dayEnd: _requiredWallTime( + document, + OwnerSettingsDocumentFields.dayEnd, + ), + compactModeEnabled: _requiredBool( + document, + OwnerSettingsDocumentFields.compactModeEnabled, + ), + backlogStalenessSettings: BacklogStalenessDocumentMapping.fromDocument( + _requiredMap(document, OwnerSettingsDocumentFields.backlogStaleness), + ), + ); + }); + } +} diff --git a/packages/scheduler_core/lib/src/document_mapping/persistence_enum_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/persistence_enum_mapping.dart new file mode 100644 index 0000000..65cdccd --- /dev/null +++ b/packages/scheduler_core/lib/src/document_mapping/persistence_enum_mapping.dart @@ -0,0 +1,288 @@ +part of '../document_mapping.dart'; + +/// Stable enum encode/decode helpers for V1 document maps. +abstract final class PersistenceEnumMapping { + static String? encodeNullable(Enum? value) { + return value == null ? null : encode(value); + } + + static String encode(Enum value) { + if (value is TaskType) return encodeTaskType(value); + if (value is TaskStatus) return encodeTaskStatus(value); + if (value is PriorityLevel) return encodePriority(value); + if (value is RewardLevel) return encodeReward(value); + if (value is DifficultyLevel) return encodeDifficulty(value); + if (value is ReminderProfile) return encodeReminderProfile(value); + if (value is BacklogTag) return encodeBacklogTag(value); + if (value is LockedWeekday) return encodeLockedWeekday(value); + if (value is LockedBlockOverrideType) { + return encodeLockedBlockOverrideType(value); + } + if (value is TaskActivityCode) return encodeTaskActivityCode(value); + if (value is SchedulingNoticeType) return encodeSchedulingNoticeType(value); + if (value is SchedulingIssueCode) return encodeSchedulingIssueCode(value); + if (value is SchedulingMovementCode) { + return encodeSchedulingMovementCode(value); + } + if (value is SchedulingConflictCode) { + return encodeSchedulingConflictCode(value); + } + if (value is ProjectCompletionTimeBucket) { + return encodeProjectCompletionTimeBucket(value); + } + throw DocumentMappingException( + code: DocumentMappingFailureCode.unknownCode, + detailCode: value.runtimeType.toString(), + ); + } + + static String encodeTaskType(TaskType value) => switch (value) { + TaskType.flexible => 'flexible', + TaskType.inflexible => 'inflexible', + TaskType.critical => 'critical', + TaskType.locked => 'locked', + TaskType.surprise => 'surprise', + TaskType.freeSlot => 'free_slot', + }; + + static TaskType decodeTaskType(String value) { + return _decodeCode(_taskTypeByCode, value, 'TaskType'); + } + + static String encodeTaskStatus(TaskStatus value) => switch (value) { + TaskStatus.planned => 'planned', + TaskStatus.active => 'active', + TaskStatus.completed => 'completed', + TaskStatus.missed => 'missed', + TaskStatus.cancelled => 'cancelled', + TaskStatus.noLongerRelevant => 'no_longer_relevant', + TaskStatus.backlog => 'backlog', + }; + + static TaskStatus decodeTaskStatus(String value) { + return _decodeCode(_taskStatusByCode, value, 'TaskStatus'); + } + + static String encodePriority(PriorityLevel value) => switch (value) { + PriorityLevel.veryLow => 'very_low', + PriorityLevel.low => 'low', + PriorityLevel.medium => 'medium', + PriorityLevel.high => 'high', + PriorityLevel.veryHigh => 'very_high', + }; + + static PriorityLevel? decodeNullablePriority(String? value) { + return value == null + ? null + : _decodeCode(_priorityByCode, value, 'PriorityLevel'); + } + + static String encodeReward(RewardLevel value) => switch (value) { + RewardLevel.notSet => 'not_set', + RewardLevel.veryLow => 'very_low', + RewardLevel.low => 'low', + RewardLevel.medium => 'medium', + RewardLevel.high => 'high', + RewardLevel.veryHigh => 'very_high', + }; + + static RewardLevel decodeReward(String value) { + return _decodeCode(_rewardByCode, value, 'RewardLevel'); + } + + static String encodeDifficulty(DifficultyLevel value) => switch (value) { + DifficultyLevel.notSet => 'not_set', + DifficultyLevel.veryEasy => 'very_easy', + DifficultyLevel.easy => 'easy', + DifficultyLevel.medium => 'medium', + DifficultyLevel.hard => 'hard', + DifficultyLevel.veryHard => 'very_hard', + }; + + static DifficultyLevel decodeDifficulty(String value) { + return _decodeCode(_difficultyByCode, value, 'DifficultyLevel'); + } + + static String encodeReminderProfile(ReminderProfile value) => switch (value) { + ReminderProfile.silent => 'silent', + ReminderProfile.gentle => 'gentle', + ReminderProfile.persistent => 'persistent', + ReminderProfile.strict => 'strict', + }; + + static ReminderProfile? decodeNullableReminderProfile(String? value) { + return value == null + ? null + : _decodeCode(_reminderProfileByCode, value, 'ReminderProfile'); + } + + static String encodeBacklogTag(BacklogTag value) => switch (value) { + BacklogTag.wishlist => 'wishlist', + }; + + static BacklogTag decodeBacklogTag(String value) { + return _decodeCode(_backlogTagByCode, value, 'BacklogTag'); + } + + static String encodeLockedWeekday(LockedWeekday value) => switch (value) { + LockedWeekday.monday => 'monday', + LockedWeekday.tuesday => 'tuesday', + LockedWeekday.wednesday => 'wednesday', + LockedWeekday.thursday => 'thursday', + LockedWeekday.friday => 'friday', + LockedWeekday.saturday => 'saturday', + LockedWeekday.sunday => 'sunday', + }; + + static LockedWeekday decodeLockedWeekday(String value) { + return _decodeCode(_lockedWeekdayByCode, value, 'LockedWeekday'); + } + + static String encodeLockedBlockOverrideType( + LockedBlockOverrideType value, + ) => + switch (value) { + LockedBlockOverrideType.remove => 'remove', + LockedBlockOverrideType.replace => 'replace', + LockedBlockOverrideType.add => 'add', + }; + + static LockedBlockOverrideType decodeLockedBlockOverrideType(String value) { + return _decodeCode( + _lockedBlockOverrideTypeByCode, + value, + 'LockedBlockOverrideType', + ); + } + + static String encodeTaskActivityCode(TaskActivityCode value) => + switch (value) { + TaskActivityCode.completed => 'completed', + TaskActivityCode.missed => 'missed', + TaskActivityCode.cancelled => 'cancelled', + TaskActivityCode.noLongerRelevant => 'no_longer_relevant', + TaskActivityCode.manuallyPushed => 'manually_pushed', + TaskActivityCode.automaticallyPushed => 'automatically_pushed', + TaskActivityCode.movedToBacklog => 'moved_to_backlog', + TaskActivityCode.restoredFromBacklog => 'restored_from_backlog', + TaskActivityCode.activated => 'activated', + }; + + static TaskActivityCode decodeTaskActivityCode(String value) { + return _decodeCode(_taskActivityCodeByCode, value, 'TaskActivityCode'); + } + + static String encodeSchedulingNoticeType(SchedulingNoticeType value) => + switch (value) { + SchedulingNoticeType.info => 'info', + SchedulingNoticeType.moved => 'moved', + SchedulingNoticeType.overlap => 'overlap', + SchedulingNoticeType.noFit => 'no_fit', + SchedulingNoticeType.overflow => 'overflow', + }; + + static SchedulingNoticeType decodeSchedulingNoticeType(String value) { + return _decodeCode( + _schedulingNoticeTypeByCode, + value, + 'SchedulingNoticeType', + ); + } + + static String encodeSchedulingIssueCode(SchedulingIssueCode value) => + switch (value) { + SchedulingIssueCode.taskNotFound => 'task_not_found', + SchedulingIssueCode.invalidTaskState => 'invalid_task_state', + SchedulingIssueCode.missingDuration => 'missing_duration', + SchedulingIssueCode.missingScheduledSlot => 'missing_scheduled_slot', + SchedulingIssueCode.nonPositiveDuration => 'non_positive_duration', + SchedulingIssueCode.noAvailableSlot => 'no_available_slot', + SchedulingIssueCode.unfinishedTasksCouldNotFit => + 'unfinished_tasks_could_not_fit', + SchedulingIssueCode.noUnfinishedFlexibleTasks => + 'no_unfinished_flexible_tasks', + SchedulingIssueCode.duplicateSurpriseLog => 'duplicate_surprise_log', + }; + + static SchedulingIssueCode? decodeNullableSchedulingIssueCode(String? value) { + return value == null + ? null + : _decodeCode( + _schedulingIssueCodeByCode, + value, + 'SchedulingIssueCode', + ); + } + + static String encodeSchedulingMovementCode(SchedulingMovementCode value) => + switch (value) { + SchedulingMovementCode.backlogTaskInserted => 'backlog_task_inserted', + SchedulingMovementCode.flexibleTaskMovedToMakeRoom => + 'flexible_task_moved_to_make_room', + SchedulingMovementCode.flexibleTaskPushedToNextAvailableSlot => + 'flexible_task_pushed_to_next_available_slot', + SchedulingMovementCode.flexibleTaskMovedToTomorrow => + 'flexible_task_moved_to_tomorrow', + SchedulingMovementCode.unfinishedFlexibleTasksRolledOver => + 'unfinished_flexible_tasks_rolled_over', + SchedulingMovementCode.flexibleTaskMovedToBacklog => + 'flexible_task_moved_to_backlog', + SchedulingMovementCode.requiredCommitmentScheduled => + 'required_commitment_scheduled', + }; + + static SchedulingMovementCode? decodeNullableSchedulingMovementCode( + String? value, + ) { + return value == null + ? null + : _decodeCode( + _schedulingMovementCodeByCode, + value, + 'SchedulingMovementCode', + ); + } + + static String encodeSchedulingConflictCode(SchedulingConflictCode value) => + switch (value) { + SchedulingConflictCode.flexibleTaskOverlapsBlockedTime => + 'flexible_task_overlaps_blocked_time', + SchedulingConflictCode.surpriseTaskOverlapsRequiredVisibleTime => + 'surprise_task_overlaps_required_visible_time', + SchedulingConflictCode.requiredCommitmentOverlapsProtectedFreeSlot => + 'required_commitment_overlaps_protected_free_slot', + }; + + static SchedulingConflictCode? decodeNullableSchedulingConflictCode( + String? value, + ) { + return value == null + ? null + : _decodeCode( + _schedulingConflictCodeByCode, + value, + 'SchedulingConflictCode', + ); + } + + static String encodeProjectCompletionTimeBucket( + ProjectCompletionTimeBucket value, + ) => + switch (value) { + ProjectCompletionTimeBucket.overnight => 'overnight', + ProjectCompletionTimeBucket.morning => 'morning', + ProjectCompletionTimeBucket.afternoon => 'afternoon', + ProjectCompletionTimeBucket.evening => 'evening', + ProjectCompletionTimeBucket.night => 'night', + }; + + static ProjectCompletionTimeBucket decodeProjectCompletionTimeBucket( + String value, + ) { + return _decodeCode( + _projectCompletionTimeBucketByCode, + value, + 'ProjectCompletionTimeBucket', + ); + } +} diff --git a/packages/scheduler_core/lib/src/document_mapping/project_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/project_document_mapping.dart new file mode 100644 index 0000000..93d4792 --- /dev/null +++ b/packages/scheduler_core/lib/src/document_mapping/project_document_mapping.dart @@ -0,0 +1,72 @@ +part of '../document_mapping.dart'; + +/// Document mapping for [ProjectProfile]. +abstract final class ProjectDocumentMapping { + static Map toDocument( + ProjectProfile project, { + required String ownerId, + required DateTime createdAt, + required DateTime updatedAt, + int revision = 1, + }) { + return { + ..._commonFields( + id: project.id, + ownerId: ownerId, + revision: revision, + createdAt: createdAt, + updatedAt: updatedAt, + ), + ProjectDocumentFields.name: project.name, + ProjectDocumentFields.colorKey: project.colorKey, + ProjectDocumentFields.defaultPriority: + PersistenceEnumMapping.encodePriority(project.defaultPriority), + ProjectDocumentFields.defaultReward: + PersistenceEnumMapping.encodeReward(project.defaultReward), + ProjectDocumentFields.defaultDifficulty: + PersistenceEnumMapping.encodeDifficulty(project.defaultDifficulty), + ProjectDocumentFields.defaultReminderProfile: + PersistenceEnumMapping.encodeReminderProfile( + project.defaultReminderProfile, + ), + ProjectDocumentFields.defaultDurationMinutes: + project.defaultDurationMinutes, + ProjectDocumentFields.archivedAt: _dateTimeToStored(project.archivedAt), + }; + } + + static ProjectProfile fromDocument(Map document) { + return _mapInvalidValues(() { + _readCommon(document); + return ProjectProfile( + id: _requiredString(document, ProjectDocumentFields.id), + name: _requiredString(document, ProjectDocumentFields.name), + colorKey: _requiredString(document, ProjectDocumentFields.colorKey), + defaultPriority: PersistenceEnumMapping.decodeNullablePriority( + _requiredString(document, ProjectDocumentFields.defaultPriority), + )!, + defaultReward: PersistenceEnumMapping.decodeReward( + _requiredString(document, ProjectDocumentFields.defaultReward), + ), + defaultDifficulty: PersistenceEnumMapping.decodeDifficulty( + _requiredString(document, ProjectDocumentFields.defaultDifficulty), + ), + defaultReminderProfile: + PersistenceEnumMapping.decodeNullableReminderProfile( + _requiredString( + document, + ProjectDocumentFields.defaultReminderProfile, + ), + )!, + defaultDurationMinutes: _requiredNullableInt( + document, + ProjectDocumentFields.defaultDurationMinutes, + ), + archivedAt: _requiredNullableDateTime( + document, + ProjectDocumentFields.archivedAt, + ), + ); + }); + } +} diff --git a/packages/scheduler_core/lib/src/document_mapping/project_statistics_document_extension.dart b/packages/scheduler_core/lib/src/document_mapping/project_statistics_document_extension.dart new file mode 100644 index 0000000..6b86bb5 --- /dev/null +++ b/packages/scheduler_core/lib/src/document_mapping/project_statistics_document_extension.dart @@ -0,0 +1,577 @@ +part of '../document_mapping.dart'; + +/// Convenience extension for converting [ProjectStatistics] into a document. +extension ProjectStatisticsDocumentExtension on ProjectStatistics { + Map toDocument({ + required String ownerId, + required DateTime createdAt, + required DateTime updatedAt, + int revision = 1, + Iterable appliedActivityIds = const [], + }) { + return ProjectStatisticsDocumentMapping.toDocument( + this, + ownerId: ownerId, + createdAt: createdAt, + updatedAt: updatedAt, + revision: revision, + appliedActivityIds: appliedActivityIds, + ); + } +} + +const _taskTypeByCode = { + 'flexible': TaskType.flexible, + 'inflexible': TaskType.inflexible, + 'critical': TaskType.critical, + 'locked': TaskType.locked, + 'surprise': TaskType.surprise, + 'free_slot': TaskType.freeSlot, +}; + +const _taskStatusByCode = { + 'planned': TaskStatus.planned, + 'active': TaskStatus.active, + 'completed': TaskStatus.completed, + 'missed': TaskStatus.missed, + 'cancelled': TaskStatus.cancelled, + 'no_longer_relevant': TaskStatus.noLongerRelevant, + 'backlog': TaskStatus.backlog, +}; + +const _priorityByCode = { + 'very_low': PriorityLevel.veryLow, + 'low': PriorityLevel.low, + 'medium': PriorityLevel.medium, + 'high': PriorityLevel.high, + 'very_high': PriorityLevel.veryHigh, +}; + +const _rewardByCode = { + 'not_set': RewardLevel.notSet, + 'very_low': RewardLevel.veryLow, + 'low': RewardLevel.low, + 'medium': RewardLevel.medium, + 'high': RewardLevel.high, + 'very_high': RewardLevel.veryHigh, +}; + +const _difficultyByCode = { + 'not_set': DifficultyLevel.notSet, + 'very_easy': DifficultyLevel.veryEasy, + 'easy': DifficultyLevel.easy, + 'medium': DifficultyLevel.medium, + 'hard': DifficultyLevel.hard, + 'very_hard': DifficultyLevel.veryHard, +}; + +const _reminderProfileByCode = { + 'silent': ReminderProfile.silent, + 'gentle': ReminderProfile.gentle, + 'persistent': ReminderProfile.persistent, + 'strict': ReminderProfile.strict, +}; + +const _backlogTagByCode = { + 'wishlist': BacklogTag.wishlist, +}; + +const _lockedWeekdayByCode = { + 'monday': LockedWeekday.monday, + 'tuesday': LockedWeekday.tuesday, + 'wednesday': LockedWeekday.wednesday, + 'thursday': LockedWeekday.thursday, + 'friday': LockedWeekday.friday, + 'saturday': LockedWeekday.saturday, + 'sunday': LockedWeekday.sunday, +}; + +const _lockedBlockOverrideTypeByCode = { + 'remove': LockedBlockOverrideType.remove, + 'replace': LockedBlockOverrideType.replace, + 'add': LockedBlockOverrideType.add, +}; + +const _taskActivityCodeByCode = { + 'completed': TaskActivityCode.completed, + 'missed': TaskActivityCode.missed, + 'cancelled': TaskActivityCode.cancelled, + 'no_longer_relevant': TaskActivityCode.noLongerRelevant, + 'manually_pushed': TaskActivityCode.manuallyPushed, + 'automatically_pushed': TaskActivityCode.automaticallyPushed, + 'moved_to_backlog': TaskActivityCode.movedToBacklog, + 'restored_from_backlog': TaskActivityCode.restoredFromBacklog, + 'activated': TaskActivityCode.activated, +}; + +const _schedulingNoticeTypeByCode = { + 'info': SchedulingNoticeType.info, + 'moved': SchedulingNoticeType.moved, + 'overlap': SchedulingNoticeType.overlap, + 'no_fit': SchedulingNoticeType.noFit, + 'overflow': SchedulingNoticeType.overflow, +}; + +const _schedulingIssueCodeByCode = { + 'task_not_found': SchedulingIssueCode.taskNotFound, + 'invalid_task_state': SchedulingIssueCode.invalidTaskState, + 'missing_duration': SchedulingIssueCode.missingDuration, + 'missing_scheduled_slot': SchedulingIssueCode.missingScheduledSlot, + 'non_positive_duration': SchedulingIssueCode.nonPositiveDuration, + 'no_available_slot': SchedulingIssueCode.noAvailableSlot, + 'unfinished_tasks_could_not_fit': + SchedulingIssueCode.unfinishedTasksCouldNotFit, + 'no_unfinished_flexible_tasks': SchedulingIssueCode.noUnfinishedFlexibleTasks, + 'duplicate_surprise_log': SchedulingIssueCode.duplicateSurpriseLog, +}; + +const _schedulingMovementCodeByCode = { + 'backlog_task_inserted': SchedulingMovementCode.backlogTaskInserted, + 'flexible_task_moved_to_make_room': + SchedulingMovementCode.flexibleTaskMovedToMakeRoom, + 'flexible_task_pushed_to_next_available_slot': + SchedulingMovementCode.flexibleTaskPushedToNextAvailableSlot, + 'flexible_task_moved_to_tomorrow': + SchedulingMovementCode.flexibleTaskMovedToTomorrow, + 'unfinished_flexible_tasks_rolled_over': + SchedulingMovementCode.unfinishedFlexibleTasksRolledOver, + 'flexible_task_moved_to_backlog': + SchedulingMovementCode.flexibleTaskMovedToBacklog, + 'required_commitment_scheduled': + SchedulingMovementCode.requiredCommitmentScheduled, +}; + +const _schedulingConflictCodeByCode = { + 'flexible_task_overlaps_blocked_time': + SchedulingConflictCode.flexibleTaskOverlapsBlockedTime, + 'surprise_task_overlaps_required_visible_time': + SchedulingConflictCode.surpriseTaskOverlapsRequiredVisibleTime, + 'required_commitment_overlaps_protected_free_slot': + SchedulingConflictCode.requiredCommitmentOverlapsProtectedFreeSlot, +}; + +const _projectCompletionTimeBucketByCode = + { + 'overnight': ProjectCompletionTimeBucket.overnight, + 'morning': ProjectCompletionTimeBucket.morning, + 'afternoon': ProjectCompletionTimeBucket.afternoon, + 'evening': ProjectCompletionTimeBucket.evening, + 'night': ProjectCompletionTimeBucket.night, +}; + +T _decodeCode(Map valuesByCode, String code, String fieldName) { + final value = valuesByCode[code]; + if (value == null) { + throw DocumentMappingException( + code: DocumentMappingFailureCode.unknownCode, + fieldName: fieldName, + detailCode: code, + ); + } + return value; +} + +Map _commonFields({ + required String id, + required String ownerId, + required int revision, + required DateTime createdAt, + required DateTime updatedAt, +}) { + if (revision <= 0) { + throw const DocumentMappingException( + code: DocumentMappingFailureCode.invalidRevision, + fieldName: DocumentFields.revision, + ); + } + return { + DocumentFields.schemaVersion: v1SchemaVersion, + DocumentFields.id: id, + DocumentFields.ownerId: ownerId, + DocumentFields.revision: revision, + DocumentFields.createdAt: + PersistenceDateTimeConvention.toStoredString(createdAt), + DocumentFields.updatedAt: + PersistenceDateTimeConvention.toStoredString(updatedAt), + }; +} + +DocumentMetadata _readCommon(Map document) { + final schemaVersion = _requiredInt(document, DocumentFields.schemaVersion); + if (schemaVersion != v1SchemaVersion) { + throw DocumentMappingException( + code: DocumentMappingFailureCode.invalidSchemaVersion, + fieldName: DocumentFields.schemaVersion, + detailCode: schemaVersion.toString(), + ); + } + final revision = _requiredInt(document, DocumentFields.revision); + if (revision <= 0) { + throw DocumentMappingException( + code: DocumentMappingFailureCode.invalidRevision, + fieldName: DocumentFields.revision, + detailCode: revision.toString(), + ); + } + return DocumentMetadata( + id: _requiredString(document, DocumentFields.id), + ownerId: _requiredString(document, DocumentFields.ownerId), + revision: revision, + createdAt: _requiredDateTime(document, DocumentFields.createdAt), + updatedAt: _requiredDateTime(document, DocumentFields.updatedAt), + ); +} + +T _mapInvalidValues(T Function() read) { + try { + return read(); + } on DocumentMappingException { + rethrow; + } on DomainValidationException catch (error) { + throw DocumentMappingException( + code: DocumentMappingFailureCode.invalidValue, + fieldName: error.name, + detailCode: error.code.name, + ); + } on FormatException catch (error) { + throw DocumentMappingException( + code: DocumentMappingFailureCode.invalidValue, + detailCode: error.message, + ); + } on ArgumentError catch (error) { + throw DocumentMappingException( + code: DocumentMappingFailureCode.invalidValue, + fieldName: error.name, + ); + } +} + +String? _dateTimeToStored(DateTime? value) { + return value == null + ? null + : PersistenceDateTimeConvention.toStoredString(value); +} + +String? _civilDateToStored(CivilDate? value) { + return value == null + ? null + : PersistenceCivilDateConvention.toStoredString(value); +} + +String? _wallTimeToStored(WallTime? value) { + return value == null + ? null + : PersistenceWallTimeConvention.toStoredString(value); +} + +String _requiredString(Map document, String fieldName) { + if (!document.containsKey(fieldName)) { + throw DocumentMappingException( + code: DocumentMappingFailureCode.missingField, + fieldName: fieldName, + ); + } + final value = document[fieldName]; + if (value is String) { + return value; + } + throw DocumentMappingException( + code: DocumentMappingFailureCode.wrongType, + fieldName: fieldName, + ); +} + +String? _requiredNullableString( + Map document, + String fieldName, +) { + if (!document.containsKey(fieldName)) { + throw DocumentMappingException( + code: DocumentMappingFailureCode.missingField, + fieldName: fieldName, + ); + } + final value = document[fieldName]; + if (value == null || value is String) { + return value as String?; + } + throw DocumentMappingException( + code: DocumentMappingFailureCode.wrongType, + fieldName: fieldName, + ); +} + +int _requiredInt(Map document, String fieldName) { + if (!document.containsKey(fieldName)) { + throw DocumentMappingException( + code: DocumentMappingFailureCode.missingField, + fieldName: fieldName, + ); + } + final value = document[fieldName]; + if (value is int) { + return value; + } + throw DocumentMappingException( + code: DocumentMappingFailureCode.wrongType, + fieldName: fieldName, + ); +} + +int? _requiredNullableInt( + Map document, + String fieldName, +) { + if (!document.containsKey(fieldName)) { + throw DocumentMappingException( + code: DocumentMappingFailureCode.missingField, + fieldName: fieldName, + ); + } + final value = document[fieldName]; + if (value == null || value is int) { + return value as int?; + } + throw DocumentMappingException( + code: DocumentMappingFailureCode.wrongType, + fieldName: fieldName, + ); +} + +bool _requiredBool(Map document, String fieldName) { + if (!document.containsKey(fieldName)) { + throw DocumentMappingException( + code: DocumentMappingFailureCode.missingField, + fieldName: fieldName, + ); + } + final value = document[fieldName]; + if (value is bool) { + return value; + } + throw DocumentMappingException( + code: DocumentMappingFailureCode.wrongType, + fieldName: fieldName, + ); +} + +DateTime _requiredDateTime(Map document, String fieldName) { + return PersistenceDateTimeConvention.fromStoredString( + _requiredString(document, fieldName), + ); +} + +DateTime? _requiredNullableDateTime( + Map document, + String fieldName, +) { + final value = _requiredNullableString(document, fieldName); + return value == null + ? null + : PersistenceDateTimeConvention.fromStoredString(value); +} + +CivilDate _requiredCivilDate(Map document, String fieldName) { + return PersistenceCivilDateConvention.fromStoredString( + _requiredString(document, fieldName), + ); +} + +CivilDate? _requiredNullableCivilDate( + Map document, + String fieldName, +) { + final value = _requiredNullableString(document, fieldName); + return value == null + ? null + : PersistenceCivilDateConvention.fromStoredString(value); +} + +WallTime _requiredWallTime(Map document, String fieldName) { + return PersistenceWallTimeConvention.fromStoredString( + _requiredString(document, fieldName), + ); +} + +WallTime? _requiredNullableWallTime( + Map document, + String fieldName, +) { + final value = _requiredNullableString(document, fieldName); + return value == null + ? null + : PersistenceWallTimeConvention.fromStoredString(value); +} + +Map _requiredMap( + Map document, + String fieldName, +) { + if (!document.containsKey(fieldName)) { + throw DocumentMappingException( + code: DocumentMappingFailureCode.missingField, + fieldName: fieldName, + ); + } + final value = document[fieldName]; + if (value is Map) { + return value; + } + if (value is Map) { + return Map.from(value); + } + throw DocumentMappingException( + code: DocumentMappingFailureCode.wrongType, + fieldName: fieldName, + ); +} + +Map? _requiredNullableMap( + Map document, + String fieldName, +) { + if (!document.containsKey(fieldName)) { + throw DocumentMappingException( + code: DocumentMappingFailureCode.missingField, + fieldName: fieldName, + ); + } + final value = document[fieldName]; + if (value == null) { + return null; + } + if (value is Map) { + return value; + } + if (value is Map) { + return Map.from(value); + } + throw DocumentMappingException( + code: DocumentMappingFailureCode.wrongType, + fieldName: fieldName, + ); +} + +List _requiredList(Map document, String fieldName) { + if (!document.containsKey(fieldName)) { + throw DocumentMappingException( + code: DocumentMappingFailureCode.missingField, + fieldName: fieldName, + ); + } + final value = document[fieldName]; + if (value is List) { + return List.unmodifiable(value); + } + throw DocumentMappingException( + code: DocumentMappingFailureCode.wrongType, + fieldName: fieldName, + ); +} + +List _mappedList( + Map document, + String fieldName, + T Function(Map document) decode, +) { + return _requiredList(document, fieldName).map((value) { + if (value is Map) { + return decode(value); + } + if (value is Map) { + return decode(Map.from(value)); + } + throw DocumentMappingException( + code: DocumentMappingFailureCode.wrongType, + fieldName: fieldName, + ); + }).toList(growable: false); +} + +Map _intKeyCountMapToDocument(Map counts) { + return { + for (final entry in counts.entries) entry.key.toString(): entry.value, + }; +} + +Map _enumCountMapToDocument( + Map counts, + String Function(T value) encode, +) { + return { + for (final entry in counts.entries) encode(entry.key): entry.value, + }; +} + +Map _intKeyCountMapFromDocument( + Map document, + String fieldName, +) { + final raw = _requiredMap(document, fieldName); + return { + for (final entry in raw.entries) + int.parse(entry.key): _countValue(entry.value, fieldName), + }; +} + +Map _enumCountMapFromDocument( + Map document, + String fieldName, + T Function(String value) decode, +) { + final raw = _requiredMap(document, fieldName); + return { + for (final entry in raw.entries) + decode(entry.key): _countValue(entry.value, fieldName), + }; +} + +int _countValue(Object? value, String fieldName) { + if (value is int) { + return value; + } + throw DocumentMappingException( + code: DocumentMappingFailureCode.wrongType, + fieldName: fieldName, + ); +} + +Map _plainDocument(Map value) { + return Map.unmodifiable( + value.map((key, value) => MapEntry(key, _plainDocumentValue(value))), + ); +} + +Object? _plainDocumentValue(Object? value) { + if (value == null || + value is String || + value is num || + value is bool || + value is DateTime) { + return value is DateTime + ? PersistenceDateTimeConvention.toStoredString(value) + : value; + } + if (value is CivilDate) { + return PersistenceCivilDateConvention.toStoredString(value); + } + if (value is WallTime) { + return PersistenceWallTimeConvention.toStoredString(value); + } + if (value is Enum) { + return PersistenceEnumMapping.encode(value); + } + if (value is List) { + return value.map(_plainDocumentValue).toList(growable: false); + } + if (value is Map) { + return _plainDocument(value); + } + if (value is Map) { + return _plainDocument(Map.from(value)); + } + throw DocumentMappingException( + code: DocumentMappingFailureCode.wrongType, + detailCode: value.runtimeType.toString(), + ); +} diff --git a/packages/scheduler_core/lib/src/document_mapping/project_statistics_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/project_statistics_document_mapping.dart new file mode 100644 index 0000000..606fe6d --- /dev/null +++ b/packages/scheduler_core/lib/src/document_mapping/project_statistics_document_mapping.dart @@ -0,0 +1,101 @@ +part of '../document_mapping.dart'; + +/// Document mapping for [ProjectStatistics]. +abstract final class ProjectStatisticsDocumentMapping { + static Map toDocument( + ProjectStatistics statistics, { + required String ownerId, + required DateTime createdAt, + required DateTime updatedAt, + int revision = 1, + Iterable appliedActivityIds = const [], + }) { + return { + ..._commonFields( + id: statistics.projectId, + ownerId: ownerId, + revision: revision, + createdAt: createdAt, + updatedAt: updatedAt, + ), + ProjectStatisticsDocumentFields.projectId: statistics.projectId, + ProjectStatisticsDocumentFields.completedTaskCount: + statistics.completedTaskCount, + ProjectStatisticsDocumentFields.durationMinuteCounts: + _intKeyCountMapToDocument(statistics.durationMinuteCounts), + ProjectStatisticsDocumentFields.completionTimeBucketCounts: + _enumCountMapToDocument( + statistics.completionTimeBucketCounts, + PersistenceEnumMapping.encodeProjectCompletionTimeBucket, + ), + ProjectStatisticsDocumentFields.totalPushesBeforeCompletion: + statistics.totalPushesBeforeCompletion, + ProjectStatisticsDocumentFields.completedAfterPushCount: + statistics.completedAfterPushCount, + ProjectStatisticsDocumentFields.rewardCounts: _enumCountMapToDocument( + statistics.rewardCounts, + PersistenceEnumMapping.encodeReward, + ), + ProjectStatisticsDocumentFields.difficultyCounts: _enumCountMapToDocument( + statistics.difficultyCounts, + PersistenceEnumMapping.encodeDifficulty, + ), + ProjectStatisticsDocumentFields.reminderProfileCounts: + _enumCountMapToDocument( + statistics.reminderProfileCounts, + PersistenceEnumMapping.encodeReminderProfile, + ), + ProjectStatisticsDocumentFields.appliedActivityIds: + appliedActivityIds.toList(growable: false), + }; + } + + static ProjectStatistics fromDocument(Map document) { + return _mapInvalidValues(() { + _readCommon(document); + return ProjectStatistics( + projectId: _requiredString( + document, + ProjectStatisticsDocumentFields.projectId, + ), + completedTaskCount: _requiredInt( + document, + ProjectStatisticsDocumentFields.completedTaskCount, + ), + durationMinuteCounts: _intKeyCountMapFromDocument( + document, + ProjectStatisticsDocumentFields.durationMinuteCounts, + ), + completionTimeBucketCounts: _enumCountMapFromDocument( + document, + ProjectStatisticsDocumentFields.completionTimeBucketCounts, + PersistenceEnumMapping.decodeProjectCompletionTimeBucket, + ), + totalPushesBeforeCompletion: _requiredInt( + document, + ProjectStatisticsDocumentFields.totalPushesBeforeCompletion, + ), + completedAfterPushCount: _requiredInt( + document, + ProjectStatisticsDocumentFields.completedAfterPushCount, + ), + rewardCounts: _enumCountMapFromDocument( + document, + ProjectStatisticsDocumentFields.rewardCounts, + PersistenceEnumMapping.decodeReward, + ), + difficultyCounts: _enumCountMapFromDocument( + document, + ProjectStatisticsDocumentFields.difficultyCounts, + PersistenceEnumMapping.decodeDifficulty, + ), + reminderProfileCounts: _enumCountMapFromDocument( + document, + ProjectStatisticsDocumentFields.reminderProfileCounts, + (value) => + PersistenceEnumMapping.decodeNullableReminderProfile(value)!, + ), + ).._validateProjectStatisticsDocumentMetadata(document); + }); + } +} diff --git a/packages/scheduler_core/lib/src/document_mapping/scheduling_change_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/scheduling_change_document_mapping.dart new file mode 100644 index 0000000..b6f5714 --- /dev/null +++ b/packages/scheduler_core/lib/src/document_mapping/scheduling_change_document_mapping.dart @@ -0,0 +1,42 @@ +part of '../document_mapping.dart'; + +/// Document mapping for [SchedulingChange]. +abstract final class SchedulingChangeDocumentMapping { + static Map toDocument(SchedulingChange change) { + return { + SchedulingChangeDocumentFields.taskId: change.taskId, + SchedulingChangeDocumentFields.previousStart: + _dateTimeToStored(change.previousStart), + SchedulingChangeDocumentFields.previousEnd: + _dateTimeToStored(change.previousEnd), + SchedulingChangeDocumentFields.nextStart: + _dateTimeToStored(change.nextStart), + SchedulingChangeDocumentFields.nextEnd: _dateTimeToStored(change.nextEnd), + }; + } + + static SchedulingChange fromDocument(Map document) { + return _mapInvalidValues(() { + return SchedulingChange( + taskId: + _requiredString(document, SchedulingChangeDocumentFields.taskId), + previousStart: _requiredNullableDateTime( + document, + SchedulingChangeDocumentFields.previousStart, + ), + previousEnd: _requiredNullableDateTime( + document, + SchedulingChangeDocumentFields.previousEnd, + ), + nextStart: _requiredNullableDateTime( + document, + SchedulingChangeDocumentFields.nextStart, + ), + nextEnd: _requiredNullableDateTime( + document, + SchedulingChangeDocumentFields.nextEnd, + ), + ); + }); + } +} diff --git a/packages/scheduler_core/lib/src/document_mapping/scheduling_notice_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/scheduling_notice_document_mapping.dart new file mode 100644 index 0000000..4096b74 --- /dev/null +++ b/packages/scheduler_core/lib/src/document_mapping/scheduling_notice_document_mapping.dart @@ -0,0 +1,67 @@ +part of '../document_mapping.dart'; + +/// Document mapping for [SchedulingNotice]. +abstract final class SchedulingNoticeDocumentMapping { + static Map toDocument(SchedulingNotice notice) { + return { + SchedulingNoticeDocumentFields.message: notice.message, + SchedulingNoticeDocumentFields.type: + PersistenceEnumMapping.encodeSchedulingNoticeType(notice.type), + SchedulingNoticeDocumentFields.taskId: notice.taskId, + SchedulingNoticeDocumentFields.issueCode: notice.issueCode == null + ? null + : PersistenceEnumMapping.encodeSchedulingIssueCode(notice.issueCode!), + SchedulingNoticeDocumentFields.movementCode: notice.movementCode == null + ? null + : PersistenceEnumMapping.encodeSchedulingMovementCode( + notice.movementCode!, + ), + SchedulingNoticeDocumentFields.conflictCode: notice.conflictCode == null + ? null + : PersistenceEnumMapping.encodeSchedulingConflictCode( + notice.conflictCode!, + ), + SchedulingNoticeDocumentFields.parameters: + _plainDocument(notice.parameters), + }; + } + + static SchedulingNotice fromDocument(Map document) { + return _mapInvalidValues(() { + return SchedulingNotice( + _requiredString(document, SchedulingNoticeDocumentFields.message), + type: PersistenceEnumMapping.decodeSchedulingNoticeType( + _requiredString(document, SchedulingNoticeDocumentFields.type), + ), + taskId: _requiredNullableString( + document, + SchedulingNoticeDocumentFields.taskId, + ), + issueCode: PersistenceEnumMapping.decodeNullableSchedulingIssueCode( + _requiredNullableString( + document, + SchedulingNoticeDocumentFields.issueCode, + ), + ), + movementCode: + PersistenceEnumMapping.decodeNullableSchedulingMovementCode( + _requiredNullableString( + document, + SchedulingNoticeDocumentFields.movementCode, + ), + ), + conflictCode: + PersistenceEnumMapping.decodeNullableSchedulingConflictCode( + _requiredNullableString( + document, + SchedulingNoticeDocumentFields.conflictCode, + ), + ), + parameters: _requiredMap( + document, + SchedulingNoticeDocumentFields.parameters, + ), + ); + }); + } +} diff --git a/packages/scheduler_core/lib/src/document_mapping/scheduling_overlap_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/scheduling_overlap_document_mapping.dart new file mode 100644 index 0000000..e2dc7e6 --- /dev/null +++ b/packages/scheduler_core/lib/src/document_mapping/scheduling_overlap_document_mapping.dart @@ -0,0 +1,32 @@ +part of '../document_mapping.dart'; + +/// Document mapping for [SchedulingOverlap]. +abstract final class SchedulingOverlapDocumentMapping { + static Map toDocument(SchedulingOverlap overlap) { + return { + SchedulingOverlapDocumentFields.taskId: overlap.taskId, + SchedulingOverlapDocumentFields.taskInterval: + TimeIntervalDocumentMapping.toDocument(overlap.taskInterval), + SchedulingOverlapDocumentFields.blockedInterval: + TimeIntervalDocumentMapping.toDocument(overlap.blockedInterval), + }; + } + + static SchedulingOverlap fromDocument(Map document) { + return _mapInvalidValues(() { + return SchedulingOverlap( + taskId: + _requiredString(document, SchedulingOverlapDocumentFields.taskId), + taskInterval: TimeIntervalDocumentMapping.fromDocument( + _requiredMap(document, SchedulingOverlapDocumentFields.taskInterval), + ), + blockedInterval: TimeIntervalDocumentMapping.fromDocument( + _requiredMap( + document, + SchedulingOverlapDocumentFields.blockedInterval, + ), + ), + ); + }); + } +} diff --git a/packages/scheduler_core/lib/src/document_mapping/scheduling_snapshot_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/scheduling_snapshot_document_mapping.dart new file mode 100644 index 0000000..7f47067 --- /dev/null +++ b/packages/scheduler_core/lib/src/document_mapping/scheduling_snapshot_document_mapping.dart @@ -0,0 +1,110 @@ +part of '../document_mapping.dart'; + +/// Document mapping for [SchedulingStateSnapshot]. +abstract final class SchedulingSnapshotDocumentMapping { + static Map toDocument( + SchedulingStateSnapshot snapshot, { + required String ownerId, + int revision = 1, + CivilDate? sourceDate, + CivilDate? targetDate, + DateTime? retentionExpiresAt, + bool truncated = false, + }) { + return { + ..._commonFields( + id: snapshot.id, + ownerId: ownerId, + revision: revision, + createdAt: snapshot.capturedAt, + updatedAt: snapshot.capturedAt, + ), + SchedulingSnapshotDocumentFields.capturedAt: + PersistenceDateTimeConvention.toStoredString(snapshot.capturedAt), + SchedulingSnapshotDocumentFields.sourceDate: + _civilDateToStored(sourceDate), + SchedulingSnapshotDocumentFields.targetDate: + _civilDateToStored(targetDate), + SchedulingSnapshotDocumentFields.window: + SchedulingWindowDocumentMapping.toDocument(snapshot.window), + SchedulingSnapshotDocumentFields.tasks: snapshot.tasks + .map( + (task) => TaskDocumentMapping.toDocument( + task, + ownerId: ownerId, + ), + ) + .toList(growable: false), + SchedulingSnapshotDocumentFields.lockedIntervals: snapshot.lockedIntervals + .map(TimeIntervalDocumentMapping.toDocument) + .toList(growable: false), + SchedulingSnapshotDocumentFields.requiredVisibleIntervals: snapshot + .requiredVisibleIntervals + .map(TimeIntervalDocumentMapping.toDocument) + .toList(growable: false), + SchedulingSnapshotDocumentFields.notices: snapshot.notices + .map(SchedulingNoticeDocumentMapping.toDocument) + .toList(growable: false), + SchedulingSnapshotDocumentFields.changes: snapshot.changes + .map(SchedulingChangeDocumentMapping.toDocument) + .toList(growable: false), + SchedulingSnapshotDocumentFields.overlaps: snapshot.overlaps + .map(SchedulingOverlapDocumentMapping.toDocument) + .toList(growable: false), + SchedulingSnapshotDocumentFields.operationName: snapshot.operationName, + SchedulingSnapshotDocumentFields.retentionExpiresAt: + _dateTimeToStored(retentionExpiresAt), + SchedulingSnapshotDocumentFields.truncated: truncated, + }; + } + + static SchedulingStateSnapshot fromDocument(Map document) { + return _mapInvalidValues(() { + _readCommon(document); + return SchedulingStateSnapshot( + id: _requiredString(document, SchedulingSnapshotDocumentFields.id), + capturedAt: _requiredDateTime( + document, + SchedulingSnapshotDocumentFields.capturedAt, + ), + window: SchedulingWindowDocumentMapping.fromDocument( + _requiredMap(document, SchedulingSnapshotDocumentFields.window), + ), + tasks: _mappedList( + document, + SchedulingSnapshotDocumentFields.tasks, + TaskDocumentMapping.fromDocument, + ), + lockedIntervals: _mappedList( + document, + SchedulingSnapshotDocumentFields.lockedIntervals, + TimeIntervalDocumentMapping.fromDocument, + ), + requiredVisibleIntervals: _mappedList( + document, + SchedulingSnapshotDocumentFields.requiredVisibleIntervals, + TimeIntervalDocumentMapping.fromDocument, + ), + notices: _mappedList( + document, + SchedulingSnapshotDocumentFields.notices, + SchedulingNoticeDocumentMapping.fromDocument, + ), + changes: _mappedList( + document, + SchedulingSnapshotDocumentFields.changes, + SchedulingChangeDocumentMapping.fromDocument, + ), + overlaps: _mappedList( + document, + SchedulingSnapshotDocumentFields.overlaps, + SchedulingOverlapDocumentMapping.fromDocument, + ), + operationName: _requiredNullableString( + document, + SchedulingSnapshotDocumentFields.operationName, + ), + ).._validateSchedulingSnapshotDocumentMetadata(document); + }); + } +} diff --git a/packages/scheduler_core/lib/src/document_mapping/scheduling_window_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/scheduling_window_document_mapping.dart new file mode 100644 index 0000000..06a5c8f --- /dev/null +++ b/packages/scheduler_core/lib/src/document_mapping/scheduling_window_document_mapping.dart @@ -0,0 +1,23 @@ +part of '../document_mapping.dart'; + +/// Document mapping for [SchedulingWindow]. +abstract final class SchedulingWindowDocumentMapping { + static Map toDocument(SchedulingWindow window) { + return { + SchedulingWindowDocumentFields.start: + PersistenceDateTimeConvention.toStoredString(window.start), + SchedulingWindowDocumentFields.end: + PersistenceDateTimeConvention.toStoredString(window.end), + }; + } + + static SchedulingWindow fromDocument(Map document) { + return _mapInvalidValues(() { + return SchedulingWindow( + start: + _requiredDateTime(document, SchedulingWindowDocumentFields.start), + end: _requiredDateTime(document, SchedulingWindowDocumentFields.end), + ); + }); + } +} diff --git a/packages/scheduler_core/lib/src/document_mapping/task_activity_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/task_activity_document_mapping.dart new file mode 100644 index 0000000..df9fdc8 --- /dev/null +++ b/packages/scheduler_core/lib/src/document_mapping/task_activity_document_mapping.dart @@ -0,0 +1,56 @@ +part of '../document_mapping.dart'; + +/// Document mapping for [TaskActivity]. +abstract final class TaskActivityDocumentMapping { + static Map toDocument( + TaskActivity activity, { + required String ownerId, + int revision = 1, + DateTime? createdAt, + DateTime? updatedAt, + }) { + final created = createdAt ?? activity.occurredAt; + final updated = updatedAt ?? created; + return { + ..._commonFields( + id: activity.id, + ownerId: ownerId, + revision: revision, + createdAt: created, + updatedAt: updated, + ), + TaskActivityDocumentFields.operationId: activity.operationId, + TaskActivityDocumentFields.code: + PersistenceEnumMapping.encodeTaskActivityCode(activity.code), + TaskActivityDocumentFields.taskId: activity.taskId, + TaskActivityDocumentFields.projectId: activity.projectId, + TaskActivityDocumentFields.occurredAt: + PersistenceDateTimeConvention.toStoredString(activity.occurredAt), + TaskActivityDocumentFields.metadata: _plainDocument(activity.metadata), + }; + } + + static TaskActivity fromDocument(Map document) { + return _mapInvalidValues(() { + _readCommon(document); + return TaskActivity( + id: _requiredString(document, TaskActivityDocumentFields.id), + operationId: _requiredString( + document, + TaskActivityDocumentFields.operationId, + ), + code: PersistenceEnumMapping.decodeTaskActivityCode( + _requiredString(document, TaskActivityDocumentFields.code), + ), + taskId: _requiredString(document, TaskActivityDocumentFields.taskId), + projectId: + _requiredString(document, TaskActivityDocumentFields.projectId), + occurredAt: _requiredDateTime( + document, + TaskActivityDocumentFields.occurredAt, + ), + metadata: _requiredMap(document, TaskActivityDocumentFields.metadata), + ); + }); + } +} diff --git a/packages/scheduler_core/lib/src/document_mapping/task_document_extension.dart b/packages/scheduler_core/lib/src/document_mapping/task_document_extension.dart new file mode 100644 index 0000000..1830657 --- /dev/null +++ b/packages/scheduler_core/lib/src/document_mapping/task_document_extension.dart @@ -0,0 +1,21 @@ +part of '../document_mapping.dart'; + +/// Convenience extension for converting a [Task] into a V1 document map. +extension TaskDocumentExtension on Task { + Map toDocument({ + required String ownerId, + int revision = 1, + bool clearSchedule = false, + DateTime? backlogEnteredAt, + String? backlogEnteredAtProvenance, + }) { + return TaskDocumentMapping.toDocument( + this, + ownerId: ownerId, + revision: revision, + clearSchedule: clearSchedule, + backlogEnteredAt: backlogEnteredAt, + backlogEnteredAtProvenance: backlogEnteredAtProvenance, + ); + } +} diff --git a/packages/scheduler_core/lib/src/document_mapping/task_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/task_document_mapping.dart new file mode 100644 index 0000000..4b51d0f --- /dev/null +++ b/packages/scheduler_core/lib/src/document_mapping/task_document_mapping.dart @@ -0,0 +1,136 @@ +part of '../document_mapping.dart'; + +/// Document mapping for [Task]. +abstract final class TaskDocumentMapping { + static Map toDocument( + Task task, { + required String ownerId, + int revision = 1, + bool clearSchedule = false, + DateTime? backlogEnteredAt, + String? backlogEnteredAtProvenance, + }) { + final scheduledStart = clearSchedule ? null : task.scheduledStart; + final scheduledEnd = clearSchedule ? null : task.scheduledEnd; + + return { + ..._commonFields( + id: task.id, + ownerId: ownerId, + revision: revision, + createdAt: task.createdAt, + updatedAt: task.updatedAt, + ), + TaskDocumentFields.title: task.title, + TaskDocumentFields.projectId: task.projectId, + TaskDocumentFields.type: PersistenceEnumMapping.encodeTaskType(task.type), + TaskDocumentFields.status: + PersistenceEnumMapping.encodeTaskStatus(task.status), + TaskDocumentFields.priority: task.priority == null + ? null + : PersistenceEnumMapping.encodePriority(task.priority!), + TaskDocumentFields.reward: + PersistenceEnumMapping.encodeReward(task.reward), + TaskDocumentFields.difficulty: + PersistenceEnumMapping.encodeDifficulty(task.difficulty), + TaskDocumentFields.durationMinutes: task.durationMinutes, + TaskDocumentFields.scheduledStart: _dateTimeToStored(scheduledStart), + TaskDocumentFields.scheduledEnd: _dateTimeToStored(scheduledEnd), + TaskDocumentFields.actualStart: _dateTimeToStored(task.actualStart), + TaskDocumentFields.actualEnd: _dateTimeToStored(task.actualEnd), + TaskDocumentFields.completedAt: _dateTimeToStored(task.completedAt), + TaskDocumentFields.parentTaskId: task.parentTaskId, + TaskDocumentFields.backlogTags: task.backlogTags + .map(PersistenceEnumMapping.encodeBacklogTag) + .toList(), + TaskDocumentFields.reminderOverride: task.reminderOverride == null + ? null + : PersistenceEnumMapping.encodeReminderProfile( + task.reminderOverride!), + TaskDocumentFields.stats: TaskStatisticsDocumentMapping.toDocument( + task.stats, + ), + TaskDocumentFields.backlogEnteredAt: _dateTimeToStored(backlogEnteredAt), + TaskDocumentFields.backlogEnteredAtProvenance: backlogEnteredAtProvenance, + }; + } + + static Task fromDocument(Map document) { + return _mapInvalidValues(() { + _readCommon(document); + return Task( + id: _requiredString(document, TaskDocumentFields.id), + title: _requiredString(document, TaskDocumentFields.title), + projectId: _requiredString(document, TaskDocumentFields.projectId), + type: PersistenceEnumMapping.decodeTaskType( + _requiredString(document, TaskDocumentFields.type), + ), + status: PersistenceEnumMapping.decodeTaskStatus( + _requiredString(document, TaskDocumentFields.status), + ), + priority: PersistenceEnumMapping.decodeNullablePriority( + _requiredNullableString(document, TaskDocumentFields.priority), + ), + reward: PersistenceEnumMapping.decodeReward( + _requiredString(document, TaskDocumentFields.reward), + ), + difficulty: PersistenceEnumMapping.decodeDifficulty( + _requiredString(document, TaskDocumentFields.difficulty), + ), + durationMinutes: + _requiredNullableInt(document, TaskDocumentFields.durationMinutes), + scheduledStart: _requiredNullableDateTime( + document, + TaskDocumentFields.scheduledStart, + ), + scheduledEnd: _requiredNullableDateTime( + document, + TaskDocumentFields.scheduledEnd, + ), + actualStart: _requiredNullableDateTime( + document, + TaskDocumentFields.actualStart, + ), + actualEnd: _requiredNullableDateTime( + document, + TaskDocumentFields.actualEnd, + ), + completedAt: _requiredNullableDateTime( + document, + TaskDocumentFields.completedAt, + ), + parentTaskId: _requiredNullableString( + document, + TaskDocumentFields.parentTaskId, + ), + backlogTags: _backlogTagsFromDocument(document), + reminderOverride: PersistenceEnumMapping.decodeNullableReminderProfile( + _requiredNullableString( + document, + TaskDocumentFields.reminderOverride, + ), + ), + createdAt: _requiredDateTime(document, TaskDocumentFields.createdAt), + updatedAt: _requiredDateTime(document, TaskDocumentFields.updatedAt), + stats: TaskStatisticsDocumentMapping.fromDocument( + _requiredMap(document, TaskDocumentFields.stats), + ), + ).._validateTaskDocumentMetadata(document); + }); + } + + static Set _backlogTagsFromDocument( + Map document, + ) { + final rawTags = _requiredList(document, TaskDocumentFields.backlogTags); + return rawTags.map((tag) { + if (tag is! String) { + throw DocumentMappingException( + code: DocumentMappingFailureCode.wrongType, + fieldName: TaskDocumentFields.backlogTags, + ); + } + return PersistenceEnumMapping.decodeBacklogTag(tag); + }).toSet(); + } +} diff --git a/packages/scheduler_core/lib/src/document_mapping/task_statistics_document_extension.dart b/packages/scheduler_core/lib/src/document_mapping/task_statistics_document_extension.dart new file mode 100644 index 0000000..fabd306 --- /dev/null +++ b/packages/scheduler_core/lib/src/document_mapping/task_statistics_document_extension.dart @@ -0,0 +1,8 @@ +part of '../document_mapping.dart'; + +/// Convenience extension for converting [TaskStatistics] into an embedded map. +extension TaskStatisticsDocumentExtension on TaskStatistics { + Map toDocument() { + return TaskStatisticsDocumentMapping.toDocument(this); + } +} diff --git a/packages/scheduler_core/lib/src/document_mapping/task_statistics_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/task_statistics_document_mapping.dart new file mode 100644 index 0000000..b503c61 --- /dev/null +++ b/packages/scheduler_core/lib/src/document_mapping/task_statistics_document_mapping.dart @@ -0,0 +1,90 @@ +part of '../document_mapping.dart'; + +/// Document mapping for [TaskStatistics]. +abstract final class TaskStatisticsDocumentMapping { + static Map toDocument(TaskStatistics stats) { + return { + TaskStatisticsDocumentFields.skippedDuringBurnoutCount: + stats.skippedDuringBurnoutCount, + TaskStatisticsDocumentFields.manuallyPushedCount: + stats.manuallyPushedCount, + TaskStatisticsDocumentFields.autoPushedCount: stats.autoPushedCount, + TaskStatisticsDocumentFields.movedToBacklogCount: + stats.movedToBacklogCount, + TaskStatisticsDocumentFields.restoredFromBacklogCount: + stats.restoredFromBacklogCount, + TaskStatisticsDocumentFields.missedCount: stats.missedCount, + TaskStatisticsDocumentFields.cancelledCount: stats.cancelledCount, + TaskStatisticsDocumentFields.completedLateCount: stats.completedLateCount, + TaskStatisticsDocumentFields.completedDuringLockedHoursCount: + stats.completedDuringLockedHoursCount, + TaskStatisticsDocumentFields.completedDuringLockedHoursMinutes: + stats.completedDuringLockedHoursMinutes, + TaskStatisticsDocumentFields.completedAfterShieldCount: + stats.completedAfterShieldCount, + TaskStatisticsDocumentFields.completedAfterPushCount: + stats.completedAfterPushCount, + TaskStatisticsDocumentFields.totalPushesBeforeCompletion: + stats.totalPushesBeforeCompletion, + }; + } + + static TaskStatistics fromDocument(Map document) { + return _mapInvalidValues(() { + return TaskStatistics( + skippedDuringBurnoutCount: _requiredInt( + document, + TaskStatisticsDocumentFields.skippedDuringBurnoutCount, + ), + manuallyPushedCount: _requiredInt( + document, + TaskStatisticsDocumentFields.manuallyPushedCount, + ), + autoPushedCount: _requiredInt( + document, + TaskStatisticsDocumentFields.autoPushedCount, + ), + movedToBacklogCount: _requiredInt( + document, + TaskStatisticsDocumentFields.movedToBacklogCount, + ), + restoredFromBacklogCount: _requiredInt( + document, + TaskStatisticsDocumentFields.restoredFromBacklogCount, + ), + missedCount: _requiredInt( + document, + TaskStatisticsDocumentFields.missedCount, + ), + cancelledCount: _requiredInt( + document, + TaskStatisticsDocumentFields.cancelledCount, + ), + completedLateCount: _requiredInt( + document, + TaskStatisticsDocumentFields.completedLateCount, + ), + completedDuringLockedHoursCount: _requiredInt( + document, + TaskStatisticsDocumentFields.completedDuringLockedHoursCount, + ), + completedDuringLockedHoursMinutes: _requiredInt( + document, + TaskStatisticsDocumentFields.completedDuringLockedHoursMinutes, + ), + completedAfterShieldCount: _requiredInt( + document, + TaskStatisticsDocumentFields.completedAfterShieldCount, + ), + completedAfterPushCount: _requiredInt( + document, + TaskStatisticsDocumentFields.completedAfterPushCount, + ), + totalPushesBeforeCompletion: _requiredInt( + document, + TaskStatisticsDocumentFields.totalPushesBeforeCompletion, + ), + ); + }); + } +} diff --git a/packages/scheduler_core/lib/src/document_mapping/time_interval_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/time_interval_document_mapping.dart new file mode 100644 index 0000000..1817f7d --- /dev/null +++ b/packages/scheduler_core/lib/src/document_mapping/time_interval_document_mapping.dart @@ -0,0 +1,27 @@ +part of '../document_mapping.dart'; + +/// Document mapping for [TimeInterval]. +abstract final class TimeIntervalDocumentMapping { + static Map toDocument(TimeInterval interval) { + return { + TimeIntervalDocumentFields.start: + PersistenceDateTimeConvention.toStoredString(interval.start), + TimeIntervalDocumentFields.end: + PersistenceDateTimeConvention.toStoredString(interval.end), + TimeIntervalDocumentFields.label: interval.label, + }; + } + + static TimeInterval fromDocument(Map document) { + return _mapInvalidValues(() { + return TimeInterval( + start: _requiredDateTime(document, TimeIntervalDocumentFields.start), + end: _requiredDateTime(document, TimeIntervalDocumentFields.end), + label: _requiredNullableString( + document, + TimeIntervalDocumentFields.label, + ), + ); + }); + } +} diff --git a/packages/scheduler_core/lib/src/document_migration.dart b/packages/scheduler_core/lib/src/document_migration.dart index 8dcc6fa..c5c172e 100644 --- a/packages/scheduler_core/lib/src/document_migration.dart +++ b/packages/scheduler_core/lib/src/document_migration.dart @@ -10,1132 +10,16 @@ library; import 'document_mapping.dart'; import 'persistence_contract.dart'; import 'time_contracts.dart'; - -/// Entity shape being migrated. -enum DocumentMigrationEntity { - task, - project, - lockedBlock, - lockedBlockOverride, -} - -/// Result state for a migration attempt. -enum DocumentMigrationStatus { - migrated, - alreadyCurrent, - failed, -} - -/// Typed categories for migration failures. -enum DocumentMigrationFailureCode { - invalidSchemaVersion, - unsupportedSchemaVersion, - legacyDocumentFailure, - currentDocumentFailure, -} - -/// Non-fatal migration notes surfaced to callers during dry-runs. -enum DocumentMigrationNoteCode { - approximatedBacklogEnteredAt, - synthesizedMetadataTimestamp, -} - -/// Provenance labels written by migrations. -abstract final class DocumentMigrationProvenance { - static const approximatedFromCreatedAt = 'approximated_from_created_at'; -} - -/// A non-fatal detail about a migrated document. -class DocumentMigrationNote { - const DocumentMigrationNote({ - required this.code, - this.fieldName, - this.detailCode, - }); - - final DocumentMigrationNoteCode code; - final String? fieldName; - final String? detailCode; -} - -/// Typed dry-run report for one document migration attempt. -class DocumentMigrationReport { - const DocumentMigrationReport({ - required this.entity, - required this.status, - required this.documentId, - required this.fromSchemaVersion, - this.targetSchemaVersion = v1SchemaVersion, - this.failureCode, - this.mappingFailureCode, - this.fieldName, - this.detailCode, - this.notes = const [], - }); - - final DocumentMigrationEntity entity; - final DocumentMigrationStatus status; - final String? documentId; - final int? fromSchemaVersion; - final int targetSchemaVersion; - final DocumentMigrationFailureCode? failureCode; - final DocumentMappingFailureCode? mappingFailureCode; - final String? fieldName; - final String? detailCode; - final List notes; - - bool get canWrite => status != DocumentMigrationStatus.failed; -} - -/// Migration output plus report. -class DocumentMigrationResult { - const DocumentMigrationResult({ - required this.report, - required this.document, - }); - - /// Migrated/current document. Null means the source must not be overwritten. - final Map? document; - - final DocumentMigrationReport report; - - bool get isSuccess => report.status != DocumentMigrationStatus.failed; - - /// True only when the caller should persist a transformed replacement. - bool get shouldWrite => report.status == DocumentMigrationStatus.migrated; -} - -/// Pure V0-to-V1 migration service. -/// -/// Version 0 is the pre-Block-15 document shape: no `schemaVersion`, no owner -/// metadata, enum values stored as Dart `.name`, and no exact backlog-entry -/// timestamp. Missing `schemaVersion` is treated as V0; unsupported future -/// versions fail closed. -class V1DocumentMigrationService { - const V1DocumentMigrationService({ - required this.ownerId, - required this.migratedAt, - }); - - final String ownerId; - final DateTime migratedAt; - - DocumentMigrationResult migrateTask(Map document) { - return _migrate( - entity: DocumentMigrationEntity.task, - document: document, - migrateV0: _taskV0ToV1, - validateCurrent: TaskDocumentMapping.fromDocument, - ); - } - - DocumentMigrationResult migrateProject(Map document) { - return _migrate( - entity: DocumentMigrationEntity.project, - document: document, - migrateV0: _projectV0ToV1, - validateCurrent: ProjectDocumentMapping.fromDocument, - ); - } - - DocumentMigrationResult migrateLockedBlock(Map document) { - return _migrate( - entity: DocumentMigrationEntity.lockedBlock, - document: document, - migrateV0: _lockedBlockV0ToV1, - validateCurrent: LockedBlockDocumentMapping.fromDocument, - ); - } - - DocumentMigrationResult migrateLockedBlockOverride( - Map document, - ) { - return _migrate( - entity: DocumentMigrationEntity.lockedBlockOverride, - document: document, - migrateV0: _lockedBlockOverrideV0ToV1, - validateCurrent: LockedBlockOverrideDocumentMapping.fromDocument, - ); - } - - DocumentMigrationResult _migrate({ - required DocumentMigrationEntity entity, - required Map document, - required _V0Migrator migrateV0, - required void Function(Map document) validateCurrent, - }) { - final documentId = _documentId(document); - final schemaRead = _readSchemaVersion(document); - if (schemaRead.failure != null) { - return _failed( - entity: entity, - documentId: documentId, - fromSchemaVersion: null, - failureCode: DocumentMigrationFailureCode.invalidSchemaVersion, - fieldName: DocumentFields.schemaVersion, - detailCode: schemaRead.failure, - ); - } - - final fromVersion = schemaRead.version; - if (fromVersion == v1SchemaVersion) { - try { - final copy = _deepCopyDocument(document); - validateCurrent(copy); - return DocumentMigrationResult( - document: copy, - report: DocumentMigrationReport( - entity: entity, - status: DocumentMigrationStatus.alreadyCurrent, - documentId: documentId, - fromSchemaVersion: fromVersion, - ), - ); - } on DocumentMappingException catch (error) { - return _failedFromMapping( - entity: entity, - documentId: documentId, - fromSchemaVersion: fromVersion, - failureCode: DocumentMigrationFailureCode.currentDocumentFailure, - error: error, - ); - } - } - - if (fromVersion > v1SchemaVersion || fromVersion < 0) { - return _failed( - entity: entity, - documentId: documentId, - fromSchemaVersion: fromVersion, - failureCode: DocumentMigrationFailureCode.unsupportedSchemaVersion, - fieldName: DocumentFields.schemaVersion, - detailCode: fromVersion.toString(), - ); - } - - if (fromVersion != 0) { - return _failed( - entity: entity, - documentId: documentId, - fromSchemaVersion: fromVersion, - failureCode: DocumentMigrationFailureCode.unsupportedSchemaVersion, - fieldName: DocumentFields.schemaVersion, - detailCode: fromVersion.toString(), - ); - } - - final notes = []; - late final Map migrated; - try { - migrated = migrateV0(document, notes); - } on _LegacyDocumentException catch (error) { - return _failed( - entity: entity, - documentId: documentId, - fromSchemaVersion: fromVersion, - failureCode: DocumentMigrationFailureCode.legacyDocumentFailure, - fieldName: error.fieldName, - detailCode: error.detailCode, - ); - } - - try { - validateCurrent(migrated); - } on DocumentMappingException catch (error) { - return _failedFromMapping( - entity: entity, - documentId: documentId, - fromSchemaVersion: fromVersion, - failureCode: DocumentMigrationFailureCode.currentDocumentFailure, - error: error, - ); - } - - return DocumentMigrationResult( - document: migrated, - report: DocumentMigrationReport( - entity: entity, - status: DocumentMigrationStatus.migrated, - documentId: documentId, - fromSchemaVersion: fromVersion, - notes: List.unmodifiable(notes), - ), - ); - } - - Map _taskV0ToV1( - Map document, - List notes, - ) { - final createdAt = _requiredInstant(document, TaskDocumentFields.createdAt); - final status = _requiredLegacyCode( - document, - TaskDocumentFields.status, - _taskStatusCodes, - ); - final isBacklog = status == 'backlog'; - if (isBacklog) { - notes.add( - const DocumentMigrationNote( - code: DocumentMigrationNoteCode.approximatedBacklogEnteredAt, - fieldName: TaskDocumentFields.backlogEnteredAt, - detailCode: DocumentMigrationProvenance.approximatedFromCreatedAt, - ), - ); - } - - return { - ..._commonV1Fields( - id: _requiredString(document, TaskDocumentFields.id), - createdAt: createdAt, - updatedAt: _requiredInstant(document, TaskDocumentFields.updatedAt), - ), - TaskDocumentFields.title: - _requiredString(document, TaskDocumentFields.title), - TaskDocumentFields.projectId: - _requiredString(document, TaskDocumentFields.projectId), - TaskDocumentFields.type: _requiredLegacyCode( - document, - TaskDocumentFields.type, - _taskTypeCodes, - ), - TaskDocumentFields.status: status, - TaskDocumentFields.priority: _nullableLegacyCode( - document, - TaskDocumentFields.priority, - _priorityCodes, - ), - TaskDocumentFields.reward: _requiredLegacyCode( - document, - TaskDocumentFields.reward, - _rewardCodes, - ), - TaskDocumentFields.difficulty: _requiredLegacyCode( - document, - TaskDocumentFields.difficulty, - _difficultyCodes, - ), - TaskDocumentFields.durationMinutes: - _requiredNullableInt(document, TaskDocumentFields.durationMinutes), - TaskDocumentFields.scheduledStart: _nullableInstant( - document, - TaskDocumentFields.scheduledStart, - ), - TaskDocumentFields.scheduledEnd: _nullableInstant( - document, - TaskDocumentFields.scheduledEnd, - ), - TaskDocumentFields.actualStart: - _nullableInstant(document, TaskDocumentFields.actualStart), - TaskDocumentFields.actualEnd: - _nullableInstant(document, TaskDocumentFields.actualEnd), - TaskDocumentFields.completedAt: - _nullableInstant(document, TaskDocumentFields.completedAt), - TaskDocumentFields.parentTaskId: - _requiredNullableString(document, TaskDocumentFields.parentTaskId), - TaskDocumentFields.backlogTags: _requiredLegacyCodeList( - document, - TaskDocumentFields.backlogTags, - _backlogTagCodes, - ), - TaskDocumentFields.reminderOverride: _nullableLegacyCode( - document, - TaskDocumentFields.reminderOverride, - _reminderProfileCodes, - ), - TaskDocumentFields.stats: - _requiredMap(document, TaskDocumentFields.stats), - TaskDocumentFields.backlogEnteredAt: isBacklog ? createdAt : null, - TaskDocumentFields.backlogEnteredAtProvenance: isBacklog - ? DocumentMigrationProvenance.approximatedFromCreatedAt - : null, - }; - } - - Map _projectV0ToV1( - Map document, - List notes, - ) { - final metadata = _metadataInstants(document, notes); - return { - ..._commonV1Fields( - id: _requiredString(document, ProjectDocumentFields.id), - createdAt: metadata.createdAt, - updatedAt: metadata.updatedAt, - ), - ProjectDocumentFields.name: - _requiredString(document, ProjectDocumentFields.name), - ProjectDocumentFields.colorKey: - _requiredString(document, ProjectDocumentFields.colorKey), - ProjectDocumentFields.defaultPriority: _requiredLegacyCode( - document, - ProjectDocumentFields.defaultPriority, - _priorityCodes, - ), - ProjectDocumentFields.defaultReward: _requiredLegacyCode( - document, - ProjectDocumentFields.defaultReward, - _rewardCodes, - ), - ProjectDocumentFields.defaultDifficulty: _requiredLegacyCode( - document, - ProjectDocumentFields.defaultDifficulty, - _difficultyCodes, - ), - ProjectDocumentFields.defaultReminderProfile: _requiredLegacyCode( - document, - ProjectDocumentFields.defaultReminderProfile, - _reminderProfileCodes, - ), - ProjectDocumentFields.defaultDurationMinutes: _requiredNullableInt( - document, - ProjectDocumentFields.defaultDurationMinutes, - ), - ProjectDocumentFields.archivedAt: - _optionalNullableInstant(document, ProjectDocumentFields.archivedAt), - }; - } - - Map _lockedBlockV0ToV1( - Map document, - List notes, - ) { - final metadata = _metadataInstants(document, notes); - return { - ..._commonV1Fields( - id: _requiredString(document, LockedBlockDocumentFields.id), - createdAt: metadata.createdAt, - updatedAt: metadata.updatedAt, - ), - LockedBlockDocumentFields.name: - _requiredString(document, LockedBlockDocumentFields.name), - LockedBlockDocumentFields.startTime: - _requiredWallTime(document, LockedBlockDocumentFields.startTime), - LockedBlockDocumentFields.endTime: - _requiredWallTime(document, LockedBlockDocumentFields.endTime), - LockedBlockDocumentFields.date: - _requiredNullableCivilDate(document, LockedBlockDocumentFields.date), - LockedBlockDocumentFields.recurrence: - _nullableRecurrence(document, LockedBlockDocumentFields.recurrence), - LockedBlockDocumentFields.hiddenByDefault: - _requiredBool(document, LockedBlockDocumentFields.hiddenByDefault), - LockedBlockDocumentFields.projectId: _requiredNullableString( - document, - LockedBlockDocumentFields.projectId, - ), - LockedBlockDocumentFields.archivedAt: _optionalNullableInstant( - document, - LockedBlockDocumentFields.archivedAt, - ), - }; - } - - Map _lockedBlockOverrideV0ToV1( - Map document, - List notes, - ) { - final metadata = _metadataInstants(document, notes); - return { - ..._commonV1Fields( - id: _requiredString(document, LockedBlockOverrideDocumentFields.id), - createdAt: metadata.createdAt, - updatedAt: metadata.updatedAt, - ), - LockedBlockOverrideDocumentFields.lockedBlockId: _requiredNullableString( - document, - LockedBlockOverrideDocumentFields.lockedBlockId, - ), - LockedBlockOverrideDocumentFields.date: _requiredCivilDate( - document, - LockedBlockOverrideDocumentFields.date, - ), - LockedBlockOverrideDocumentFields.type: _requiredLegacyCode( - document, - LockedBlockOverrideDocumentFields.type, - _lockedOverrideTypeCodes, - ), - LockedBlockOverrideDocumentFields.name: _requiredNullableString( - document, - LockedBlockOverrideDocumentFields.name, - ), - LockedBlockOverrideDocumentFields.startTime: _requiredNullableWallTime( - document, - LockedBlockOverrideDocumentFields.startTime, - ), - LockedBlockOverrideDocumentFields.endTime: _requiredNullableWallTime( - document, - LockedBlockOverrideDocumentFields.endTime, - ), - LockedBlockOverrideDocumentFields.hiddenByDefault: _requiredBool( - document, - LockedBlockOverrideDocumentFields.hiddenByDefault, - ), - LockedBlockOverrideDocumentFields.projectId: _requiredNullableString( - document, - LockedBlockOverrideDocumentFields.projectId, - ), - }; - } - - Map _commonV1Fields({ - required String id, - required String createdAt, - required String updatedAt, - }) { - return { - DocumentFields.schemaVersion: v1SchemaVersion, - DocumentFields.id: id, - DocumentFields.ownerId: ownerId, - DocumentFields.revision: 1, - DocumentFields.createdAt: createdAt, - DocumentFields.updatedAt: updatedAt, - }; - } - - _LegacyMetadataInstants _metadataInstants( - Map document, - List notes, - ) { - final createdAt = - _optionalNullableInstant(document, DocumentFields.createdAt); - final updatedAt = - _optionalNullableInstant(document, DocumentFields.updatedAt); - if (createdAt == null || updatedAt == null) { - notes.add( - const DocumentMigrationNote( - code: DocumentMigrationNoteCode.synthesizedMetadataTimestamp, - detailCode: 'migratedAt', - ), - ); - } - final fallback = PersistenceDateTimeConvention.toStoredString(migratedAt); - return _LegacyMetadataInstants( - createdAt: createdAt ?? fallback, - updatedAt: updatedAt ?? createdAt ?? fallback, - ); - } -} - -typedef _V0Migrator = Map Function( - Map document, - List notes, -); - -class _SchemaVersionRead { - const _SchemaVersionRead.version(this.version) : failure = null; - const _SchemaVersionRead.failure(this.failure) : version = 0; - - final int version; - final String? failure; -} - -class _LegacyDocumentException implements Exception { - const _LegacyDocumentException({ - required this.fieldName, - required this.detailCode, - }); - - final String fieldName; - final String detailCode; -} - -class _LegacyMetadataInstants { - const _LegacyMetadataInstants({ - required this.createdAt, - required this.updatedAt, - }); - - final String createdAt; - final String updatedAt; -} - -const _taskTypeCodes = { - 'flexible': 'flexible', - 'inflexible': 'inflexible', - 'critical': 'critical', - 'locked': 'locked', - 'surprise': 'surprise', - 'freeSlot': 'free_slot', - 'free_slot': 'free_slot', -}; - -const _taskStatusCodes = { - 'planned': 'planned', - 'active': 'active', - 'completed': 'completed', - 'missed': 'missed', - 'cancelled': 'cancelled', - 'noLongerRelevant': 'no_longer_relevant', - 'no_longer_relevant': 'no_longer_relevant', - 'backlog': 'backlog', -}; - -const _priorityCodes = { - 'veryLow': 'very_low', - 'very_low': 'very_low', - 'low': 'low', - 'medium': 'medium', - 'high': 'high', - 'veryHigh': 'very_high', - 'very_high': 'very_high', -}; - -const _rewardCodes = { - 'notSet': 'not_set', - 'not_set': 'not_set', - 'veryLow': 'very_low', - 'very_low': 'very_low', - 'low': 'low', - 'medium': 'medium', - 'high': 'high', - 'veryHigh': 'very_high', - 'very_high': 'very_high', -}; - -const _difficultyCodes = { - 'notSet': 'not_set', - 'not_set': 'not_set', - 'veryEasy': 'very_easy', - 'very_easy': 'very_easy', - 'easy': 'easy', - 'medium': 'medium', - 'hard': 'hard', - 'veryHard': 'very_hard', - 'very_hard': 'very_hard', -}; - -const _reminderProfileCodes = { - 'silent': 'silent', - 'gentle': 'gentle', - 'persistent': 'persistent', - 'strict': 'strict', -}; - -const _backlogTagCodes = { - 'wishlist': 'wishlist', -}; - -const _lockedWeekdayCodes = { - 'monday': 'monday', - 'tuesday': 'tuesday', - 'wednesday': 'wednesday', - 'thursday': 'thursday', - 'friday': 'friday', - 'saturday': 'saturday', - 'sunday': 'sunday', -}; - -const _lockedOverrideTypeCodes = { - 'remove': 'remove', - 'replace': 'replace', - 'add': 'add', -}; - -final _explicitOffsetPattern = RegExp(r'(Z|[+-]\d{2}:\d{2})$'); - -_SchemaVersionRead _readSchemaVersion(Map document) { - if (!document.containsKey(DocumentFields.schemaVersion)) { - return const _SchemaVersionRead.version(0); - } - final value = document[DocumentFields.schemaVersion]; - if (value is int) { - return _SchemaVersionRead.version(value); - } - return const _SchemaVersionRead.failure('wrongType'); -} - -String? _documentId(Map document) { - final value = document[DocumentFields.id]; - return value is String ? value : null; -} - -DocumentMigrationResult _failed({ - required DocumentMigrationEntity entity, - required String? documentId, - required int? fromSchemaVersion, - required DocumentMigrationFailureCode failureCode, - String? fieldName, - String? detailCode, -}) { - return DocumentMigrationResult( - document: null, - report: DocumentMigrationReport( - entity: entity, - status: DocumentMigrationStatus.failed, - documentId: documentId, - fromSchemaVersion: fromSchemaVersion, - failureCode: failureCode, - fieldName: fieldName, - detailCode: detailCode, - ), - ); -} - -DocumentMigrationResult _failedFromMapping({ - required DocumentMigrationEntity entity, - required String? documentId, - required int fromSchemaVersion, - required DocumentMigrationFailureCode failureCode, - required DocumentMappingException error, -}) { - return DocumentMigrationResult( - document: null, - report: DocumentMigrationReport( - entity: entity, - status: DocumentMigrationStatus.failed, - documentId: documentId, - fromSchemaVersion: fromSchemaVersion, - failureCode: failureCode, - mappingFailureCode: error.code, - fieldName: error.fieldName, - detailCode: error.detailCode, - ), - ); -} - -String _requiredString(Map document, String fieldName) { - if (!document.containsKey(fieldName)) { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'missingField', - ); - } - final value = document[fieldName]; - if (value is String) { - return value; - } - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'wrongType', - ); -} - -String? _requiredNullableString( - Map document, - String fieldName, -) { - if (!document.containsKey(fieldName)) { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'missingField', - ); - } - final value = document[fieldName]; - if (value == null || value is String) { - return value as String?; - } - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'wrongType', - ); -} - -int? _requiredNullableInt(Map document, String fieldName) { - if (!document.containsKey(fieldName)) { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'missingField', - ); - } - final value = document[fieldName]; - if (value == null || value is int) { - return value as int?; - } - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'wrongType', - ); -} - -bool _requiredBool(Map document, String fieldName) { - if (!document.containsKey(fieldName)) { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'missingField', - ); - } - final value = document[fieldName]; - if (value is bool) { - return value; - } - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'wrongType', - ); -} - -Map _requiredMap( - Map document, - String fieldName, -) { - if (!document.containsKey(fieldName)) { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'missingField', - ); - } - final value = document[fieldName]; - if (value is Map) { - return _deepCopyMap(value, fieldName); - } - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'wrongType', - ); -} - -String _requiredLegacyCode( - Map document, - String fieldName, - Map codes, -) { - final raw = _requiredString(document, fieldName); - final code = codes[raw]; - if (code == null) { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'unknownCode:$raw', - ); - } - return code; -} - -String? _nullableLegacyCode( - Map document, - String fieldName, - Map codes, -) { - final raw = _requiredNullableString(document, fieldName); - if (raw == null) { - return null; - } - final code = codes[raw]; - if (code == null) { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'unknownCode:$raw', - ); - } - return code; -} - -List _requiredLegacyCodeList( - Map document, - String fieldName, - Map codes, -) { - if (!document.containsKey(fieldName)) { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'missingField', - ); - } - final value = document[fieldName]; - if (value is! List) { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'wrongType', - ); - } - return value.map((entry) { - if (entry is! String) { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'wrongType', - ); - } - final code = codes[entry]; - if (code == null) { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'unknownCode:$entry', - ); - } - return code; - }).toList(growable: false); -} - -String _requiredInstant(Map document, String fieldName) { - if (!document.containsKey(fieldName)) { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'missingField', - ); - } - final value = document[fieldName]; - final stored = _instantToStored(value, fieldName); - if (stored == null) { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'wrongType', - ); - } - return stored; -} - -String? _nullableInstant(Map document, String fieldName) { - if (!document.containsKey(fieldName)) { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'missingField', - ); - } - return _instantToStored(document[fieldName], fieldName); -} - -String? _optionalNullableInstant( - Map document, - String fieldName, -) { - if (!document.containsKey(fieldName)) { - return null; - } - return _instantToStored(document[fieldName], fieldName); -} - -String? _instantToStored(Object? value, String fieldName) { - if (value == null) { - return null; - } - if (value is DateTime) { - if (!value.isUtc) { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'ambiguousDateTime', - ); - } - return PersistenceDateTimeConvention.toStoredString(value); - } - if (value is String) { - if (!_explicitOffsetPattern.hasMatch(value)) { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'ambiguousDateTime', - ); - } - try { - return PersistenceDateTimeConvention.toStoredString( - DateTime.parse(value), - ); - } on FormatException { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'invalidDateTime', - ); - } - } - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'wrongType', - ); -} - -String _requiredCivilDate(Map document, String fieldName) { - if (!document.containsKey(fieldName)) { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'missingField', - ); - } - final value = _civilDateToStored(document[fieldName], fieldName); - if (value == null) { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'wrongType', - ); - } - return value; -} - -String? _requiredNullableCivilDate( - Map document, - String fieldName, -) { - if (!document.containsKey(fieldName)) { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'missingField', - ); - } - return _civilDateToStored(document[fieldName], fieldName); -} - -String? _civilDateToStored(Object? value, String fieldName) { - if (value == null) { - return null; - } - if (value is String) { - try { - return PersistenceCivilDateConvention.toStoredString( - PersistenceCivilDateConvention.fromStoredString(value), - ); - } on Object { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'invalidCivilDate', - ); - } - } - if (value is DateTime) { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'ambiguousCivilDateTime', - ); - } - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'wrongType', - ); -} - -String _requiredWallTime(Map document, String fieldName) { - if (!document.containsKey(fieldName)) { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'missingField', - ); - } - final value = _wallTimeToStored(document[fieldName], fieldName); - if (value == null) { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'wrongType', - ); - } - return value; -} - -String? _requiredNullableWallTime( - Map document, - String fieldName, -) { - if (!document.containsKey(fieldName)) { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'missingField', - ); - } - return _wallTimeToStored(document[fieldName], fieldName); -} - -String? _wallTimeToStored(Object? value, String fieldName) { - if (value == null) { - return null; - } - if (value is String) { - try { - return PersistenceWallTimeConvention.toStoredString( - PersistenceWallTimeConvention.fromStoredString(value), - ); - } on Object { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'invalidWallTime', - ); - } - } - if (value is Map) { - final map = _deepCopyMap(value, fieldName); - final legacyValue = map[ClockTimeDocumentFields.value]; - if (legacyValue is String) { - return _wallTimeToStored(legacyValue, fieldName); - } - final hour = map['hour']; - final minute = map['minute']; - if (hour is int && minute is int) { - try { - return WallTime(hour: hour, minute: minute).toIsoString(); - } on Object { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'invalidWallTime', - ); - } - } - } - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'wrongType', - ); -} - -Map? _nullableRecurrence( - Map document, - String fieldName, -) { - if (!document.containsKey(fieldName)) { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'missingField', - ); - } - final value = document[fieldName]; - if (value == null) { - return null; - } - if (value is! Map) { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'wrongType', - ); - } - final recurrence = _deepCopyMap(value, fieldName); - final weekdays = recurrence[LockedBlockRecurrenceDocumentFields.weekdays]; - if (weekdays is! List) { - throw _LegacyDocumentException( - fieldName: LockedBlockRecurrenceDocumentFields.weekdays, - detailCode: 'wrongType', - ); - } - return { - LockedBlockRecurrenceDocumentFields.type: - recurrence[LockedBlockRecurrenceDocumentFields.type] ?? 'weekly', - LockedBlockRecurrenceDocumentFields.weekdays: weekdays.map((entry) { - if (entry is! String) { - throw const _LegacyDocumentException( - fieldName: LockedBlockRecurrenceDocumentFields.weekdays, - detailCode: 'wrongType', - ); - } - final code = _lockedWeekdayCodes[entry]; - if (code == null) { - throw _LegacyDocumentException( - fieldName: LockedBlockRecurrenceDocumentFields.weekdays, - detailCode: 'unknownCode:$entry', - ); - } - return code; - }).toList(growable: false), - }; -} - -Map _deepCopyDocument(Map document) { - return _deepCopyMap(document, 'document'); -} - -Map _deepCopyMap(Map map, String fieldName) { - return { - for (final entry in map.entries) - _stringKey(entry.key, fieldName): _deepCopyValue(entry.value, fieldName), - }; -} - -String _stringKey(Object? key, String fieldName) { - if (key is String) { - return key; - } - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'nonStringMapKey', - ); -} - -Object? _deepCopyValue(Object? value, String fieldName) { - if (value is Map) { - return _deepCopyMap(value, fieldName); - } - if (value is List) { - return value.map((entry) => _deepCopyValue(entry, fieldName)).toList(); - } - return value; -} +part 'document_migration/document_migration_entity.dart'; +part 'document_migration/document_migration_status.dart'; +part 'document_migration/document_migration_failure_code.dart'; +part 'document_migration/document_migration_note_code.dart'; +part 'document_migration/document_migration_provenance.dart'; +part 'document_migration/document_migration_note.dart'; +part 'document_migration/document_migration_report.dart'; +part 'document_migration/document_migration_result.dart'; +part 'document_migration/v1_document_migration_service.dart'; +part 'document_migration/v0_migrator.dart'; +part 'document_migration/schema_version_read.dart'; +part 'document_migration/legacy_document_exception.dart'; +part 'document_migration/legacy_metadata_instants.dart'; diff --git a/packages/scheduler_core/lib/src/document_migration/document_migration_entity.dart b/packages/scheduler_core/lib/src/document_migration/document_migration_entity.dart new file mode 100644 index 0000000..9730c47 --- /dev/null +++ b/packages/scheduler_core/lib/src/document_migration/document_migration_entity.dart @@ -0,0 +1,9 @@ +part of '../document_migration.dart'; + +/// Entity shape being migrated. +enum DocumentMigrationEntity { + task, + project, + lockedBlock, + lockedBlockOverride, +} diff --git a/packages/scheduler_core/lib/src/document_migration/document_migration_failure_code.dart b/packages/scheduler_core/lib/src/document_migration/document_migration_failure_code.dart new file mode 100644 index 0000000..688b5b9 --- /dev/null +++ b/packages/scheduler_core/lib/src/document_migration/document_migration_failure_code.dart @@ -0,0 +1,9 @@ +part of '../document_migration.dart'; + +/// Typed categories for migration failures. +enum DocumentMigrationFailureCode { + invalidSchemaVersion, + unsupportedSchemaVersion, + legacyDocumentFailure, + currentDocumentFailure, +} diff --git a/packages/scheduler_core/lib/src/document_migration/document_migration_note.dart b/packages/scheduler_core/lib/src/document_migration/document_migration_note.dart new file mode 100644 index 0000000..b0b3770 --- /dev/null +++ b/packages/scheduler_core/lib/src/document_migration/document_migration_note.dart @@ -0,0 +1,14 @@ +part of '../document_migration.dart'; + +/// A non-fatal detail about a migrated document. +class DocumentMigrationNote { + const DocumentMigrationNote({ + required this.code, + this.fieldName, + this.detailCode, + }); + + final DocumentMigrationNoteCode code; + final String? fieldName; + final String? detailCode; +} diff --git a/packages/scheduler_core/lib/src/document_migration/document_migration_note_code.dart b/packages/scheduler_core/lib/src/document_migration/document_migration_note_code.dart new file mode 100644 index 0000000..09da94b --- /dev/null +++ b/packages/scheduler_core/lib/src/document_migration/document_migration_note_code.dart @@ -0,0 +1,7 @@ +part of '../document_migration.dart'; + +/// Non-fatal migration notes surfaced to callers during dry-runs. +enum DocumentMigrationNoteCode { + approximatedBacklogEnteredAt, + synthesizedMetadataTimestamp, +} diff --git a/packages/scheduler_core/lib/src/document_migration/document_migration_provenance.dart b/packages/scheduler_core/lib/src/document_migration/document_migration_provenance.dart new file mode 100644 index 0000000..68be7a7 --- /dev/null +++ b/packages/scheduler_core/lib/src/document_migration/document_migration_provenance.dart @@ -0,0 +1,6 @@ +part of '../document_migration.dart'; + +/// Provenance labels written by migrations. +abstract final class DocumentMigrationProvenance { + static const approximatedFromCreatedAt = 'approximated_from_created_at'; +} diff --git a/packages/scheduler_core/lib/src/document_migration/document_migration_report.dart b/packages/scheduler_core/lib/src/document_migration/document_migration_report.dart new file mode 100644 index 0000000..27b36e6 --- /dev/null +++ b/packages/scheduler_core/lib/src/document_migration/document_migration_report.dart @@ -0,0 +1,30 @@ +part of '../document_migration.dart'; + +/// Typed dry-run report for one document migration attempt. +class DocumentMigrationReport { + const DocumentMigrationReport({ + required this.entity, + required this.status, + required this.documentId, + required this.fromSchemaVersion, + this.targetSchemaVersion = v1SchemaVersion, + this.failureCode, + this.mappingFailureCode, + this.fieldName, + this.detailCode, + this.notes = const [], + }); + + final DocumentMigrationEntity entity; + final DocumentMigrationStatus status; + final String? documentId; + final int? fromSchemaVersion; + final int targetSchemaVersion; + final DocumentMigrationFailureCode? failureCode; + final DocumentMappingFailureCode? mappingFailureCode; + final String? fieldName; + final String? detailCode; + final List notes; + + bool get canWrite => status != DocumentMigrationStatus.failed; +} diff --git a/packages/scheduler_core/lib/src/document_migration/document_migration_result.dart b/packages/scheduler_core/lib/src/document_migration/document_migration_result.dart new file mode 100644 index 0000000..4e5b08b --- /dev/null +++ b/packages/scheduler_core/lib/src/document_migration/document_migration_result.dart @@ -0,0 +1,19 @@ +part of '../document_migration.dart'; + +/// Migration output plus report. +class DocumentMigrationResult { + const DocumentMigrationResult({ + required this.report, + required this.document, + }); + + /// Migrated/current document. Null means the source must not be overwritten. + final Map? document; + + final DocumentMigrationReport report; + + bool get isSuccess => report.status != DocumentMigrationStatus.failed; + + /// True only when the caller should persist a transformed replacement. + bool get shouldWrite => report.status == DocumentMigrationStatus.migrated; +} diff --git a/packages/scheduler_core/lib/src/document_migration/document_migration_status.dart b/packages/scheduler_core/lib/src/document_migration/document_migration_status.dart new file mode 100644 index 0000000..4e953e1 --- /dev/null +++ b/packages/scheduler_core/lib/src/document_migration/document_migration_status.dart @@ -0,0 +1,8 @@ +part of '../document_migration.dart'; + +/// Result state for a migration attempt. +enum DocumentMigrationStatus { + migrated, + alreadyCurrent, + failed, +} diff --git a/packages/scheduler_core/lib/src/document_migration/legacy_document_exception.dart b/packages/scheduler_core/lib/src/document_migration/legacy_document_exception.dart new file mode 100644 index 0000000..4370a09 --- /dev/null +++ b/packages/scheduler_core/lib/src/document_migration/legacy_document_exception.dart @@ -0,0 +1,11 @@ +part of '../document_migration.dart'; + +class _LegacyDocumentException implements Exception { + const _LegacyDocumentException({ + required this.fieldName, + required this.detailCode, + }); + + final String fieldName; + final String detailCode; +} diff --git a/packages/scheduler_core/lib/src/document_migration/legacy_metadata_instants.dart b/packages/scheduler_core/lib/src/document_migration/legacy_metadata_instants.dart new file mode 100644 index 0000000..6b1b590 --- /dev/null +++ b/packages/scheduler_core/lib/src/document_migration/legacy_metadata_instants.dart @@ -0,0 +1,602 @@ +part of '../document_migration.dart'; + +class _LegacyMetadataInstants { + const _LegacyMetadataInstants({ + required this.createdAt, + required this.updatedAt, + }); + + final String createdAt; + final String updatedAt; +} + +const _taskTypeCodes = { + 'flexible': 'flexible', + 'inflexible': 'inflexible', + 'critical': 'critical', + 'locked': 'locked', + 'surprise': 'surprise', + 'freeSlot': 'free_slot', + 'free_slot': 'free_slot', +}; + +const _taskStatusCodes = { + 'planned': 'planned', + 'active': 'active', + 'completed': 'completed', + 'missed': 'missed', + 'cancelled': 'cancelled', + 'noLongerRelevant': 'no_longer_relevant', + 'no_longer_relevant': 'no_longer_relevant', + 'backlog': 'backlog', +}; + +const _priorityCodes = { + 'veryLow': 'very_low', + 'very_low': 'very_low', + 'low': 'low', + 'medium': 'medium', + 'high': 'high', + 'veryHigh': 'very_high', + 'very_high': 'very_high', +}; + +const _rewardCodes = { + 'notSet': 'not_set', + 'not_set': 'not_set', + 'veryLow': 'very_low', + 'very_low': 'very_low', + 'low': 'low', + 'medium': 'medium', + 'high': 'high', + 'veryHigh': 'very_high', + 'very_high': 'very_high', +}; + +const _difficultyCodes = { + 'notSet': 'not_set', + 'not_set': 'not_set', + 'veryEasy': 'very_easy', + 'very_easy': 'very_easy', + 'easy': 'easy', + 'medium': 'medium', + 'hard': 'hard', + 'veryHard': 'very_hard', + 'very_hard': 'very_hard', +}; + +const _reminderProfileCodes = { + 'silent': 'silent', + 'gentle': 'gentle', + 'persistent': 'persistent', + 'strict': 'strict', +}; + +const _backlogTagCodes = { + 'wishlist': 'wishlist', +}; + +const _lockedWeekdayCodes = { + 'monday': 'monday', + 'tuesday': 'tuesday', + 'wednesday': 'wednesday', + 'thursday': 'thursday', + 'friday': 'friday', + 'saturday': 'saturday', + 'sunday': 'sunday', +}; + +const _lockedOverrideTypeCodes = { + 'remove': 'remove', + 'replace': 'replace', + 'add': 'add', +}; + +final _explicitOffsetPattern = RegExp(r'(Z|[+-]\d{2}:\d{2})$'); + +_SchemaVersionRead _readSchemaVersion(Map document) { + if (!document.containsKey(DocumentFields.schemaVersion)) { + return const _SchemaVersionRead.version(0); + } + final value = document[DocumentFields.schemaVersion]; + if (value is int) { + return _SchemaVersionRead.version(value); + } + return const _SchemaVersionRead.failure('wrongType'); +} + +String? _documentId(Map document) { + final value = document[DocumentFields.id]; + return value is String ? value : null; +} + +DocumentMigrationResult _failed({ + required DocumentMigrationEntity entity, + required String? documentId, + required int? fromSchemaVersion, + required DocumentMigrationFailureCode failureCode, + String? fieldName, + String? detailCode, +}) { + return DocumentMigrationResult( + document: null, + report: DocumentMigrationReport( + entity: entity, + status: DocumentMigrationStatus.failed, + documentId: documentId, + fromSchemaVersion: fromSchemaVersion, + failureCode: failureCode, + fieldName: fieldName, + detailCode: detailCode, + ), + ); +} + +DocumentMigrationResult _failedFromMapping({ + required DocumentMigrationEntity entity, + required String? documentId, + required int fromSchemaVersion, + required DocumentMigrationFailureCode failureCode, + required DocumentMappingException error, +}) { + return DocumentMigrationResult( + document: null, + report: DocumentMigrationReport( + entity: entity, + status: DocumentMigrationStatus.failed, + documentId: documentId, + fromSchemaVersion: fromSchemaVersion, + failureCode: failureCode, + mappingFailureCode: error.code, + fieldName: error.fieldName, + detailCode: error.detailCode, + ), + ); +} + +String _requiredString(Map document, String fieldName) { + if (!document.containsKey(fieldName)) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'missingField', + ); + } + final value = document[fieldName]; + if (value is String) { + return value; + } + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'wrongType', + ); +} + +String? _requiredNullableString( + Map document, + String fieldName, +) { + if (!document.containsKey(fieldName)) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'missingField', + ); + } + final value = document[fieldName]; + if (value == null || value is String) { + return value as String?; + } + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'wrongType', + ); +} + +int? _requiredNullableInt(Map document, String fieldName) { + if (!document.containsKey(fieldName)) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'missingField', + ); + } + final value = document[fieldName]; + if (value == null || value is int) { + return value as int?; + } + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'wrongType', + ); +} + +bool _requiredBool(Map document, String fieldName) { + if (!document.containsKey(fieldName)) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'missingField', + ); + } + final value = document[fieldName]; + if (value is bool) { + return value; + } + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'wrongType', + ); +} + +Map _requiredMap( + Map document, + String fieldName, +) { + if (!document.containsKey(fieldName)) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'missingField', + ); + } + final value = document[fieldName]; + if (value is Map) { + return _deepCopyMap(value, fieldName); + } + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'wrongType', + ); +} + +String _requiredLegacyCode( + Map document, + String fieldName, + Map codes, +) { + final raw = _requiredString(document, fieldName); + final code = codes[raw]; + if (code == null) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'unknownCode:$raw', + ); + } + return code; +} + +String? _nullableLegacyCode( + Map document, + String fieldName, + Map codes, +) { + final raw = _requiredNullableString(document, fieldName); + if (raw == null) { + return null; + } + final code = codes[raw]; + if (code == null) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'unknownCode:$raw', + ); + } + return code; +} + +List _requiredLegacyCodeList( + Map document, + String fieldName, + Map codes, +) { + if (!document.containsKey(fieldName)) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'missingField', + ); + } + final value = document[fieldName]; + if (value is! List) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'wrongType', + ); + } + return value.map((entry) { + if (entry is! String) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'wrongType', + ); + } + final code = codes[entry]; + if (code == null) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'unknownCode:$entry', + ); + } + return code; + }).toList(growable: false); +} + +String _requiredInstant(Map document, String fieldName) { + if (!document.containsKey(fieldName)) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'missingField', + ); + } + final value = document[fieldName]; + final stored = _instantToStored(value, fieldName); + if (stored == null) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'wrongType', + ); + } + return stored; +} + +String? _nullableInstant(Map document, String fieldName) { + if (!document.containsKey(fieldName)) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'missingField', + ); + } + return _instantToStored(document[fieldName], fieldName); +} + +String? _optionalNullableInstant( + Map document, + String fieldName, +) { + if (!document.containsKey(fieldName)) { + return null; + } + return _instantToStored(document[fieldName], fieldName); +} + +String? _instantToStored(Object? value, String fieldName) { + if (value == null) { + return null; + } + if (value is DateTime) { + if (!value.isUtc) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'ambiguousDateTime', + ); + } + return PersistenceDateTimeConvention.toStoredString(value); + } + if (value is String) { + if (!_explicitOffsetPattern.hasMatch(value)) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'ambiguousDateTime', + ); + } + try { + return PersistenceDateTimeConvention.toStoredString( + DateTime.parse(value), + ); + } on FormatException { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'invalidDateTime', + ); + } + } + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'wrongType', + ); +} + +String _requiredCivilDate(Map document, String fieldName) { + if (!document.containsKey(fieldName)) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'missingField', + ); + } + final value = _civilDateToStored(document[fieldName], fieldName); + if (value == null) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'wrongType', + ); + } + return value; +} + +String? _requiredNullableCivilDate( + Map document, + String fieldName, +) { + if (!document.containsKey(fieldName)) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'missingField', + ); + } + return _civilDateToStored(document[fieldName], fieldName); +} + +String? _civilDateToStored(Object? value, String fieldName) { + if (value == null) { + return null; + } + if (value is String) { + try { + return PersistenceCivilDateConvention.toStoredString( + PersistenceCivilDateConvention.fromStoredString(value), + ); + } on Object { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'invalidCivilDate', + ); + } + } + if (value is DateTime) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'ambiguousCivilDateTime', + ); + } + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'wrongType', + ); +} + +String _requiredWallTime(Map document, String fieldName) { + if (!document.containsKey(fieldName)) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'missingField', + ); + } + final value = _wallTimeToStored(document[fieldName], fieldName); + if (value == null) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'wrongType', + ); + } + return value; +} + +String? _requiredNullableWallTime( + Map document, + String fieldName, +) { + if (!document.containsKey(fieldName)) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'missingField', + ); + } + return _wallTimeToStored(document[fieldName], fieldName); +} + +String? _wallTimeToStored(Object? value, String fieldName) { + if (value == null) { + return null; + } + if (value is String) { + try { + return PersistenceWallTimeConvention.toStoredString( + PersistenceWallTimeConvention.fromStoredString(value), + ); + } on Object { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'invalidWallTime', + ); + } + } + if (value is Map) { + final map = _deepCopyMap(value, fieldName); + final legacyValue = map[ClockTimeDocumentFields.value]; + if (legacyValue is String) { + return _wallTimeToStored(legacyValue, fieldName); + } + final hour = map['hour']; + final minute = map['minute']; + if (hour is int && minute is int) { + try { + return WallTime(hour: hour, minute: minute).toIsoString(); + } on Object { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'invalidWallTime', + ); + } + } + } + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'wrongType', + ); +} + +Map? _nullableRecurrence( + Map document, + String fieldName, +) { + if (!document.containsKey(fieldName)) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'missingField', + ); + } + final value = document[fieldName]; + if (value == null) { + return null; + } + if (value is! Map) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'wrongType', + ); + } + final recurrence = _deepCopyMap(value, fieldName); + final weekdays = recurrence[LockedBlockRecurrenceDocumentFields.weekdays]; + if (weekdays is! List) { + throw _LegacyDocumentException( + fieldName: LockedBlockRecurrenceDocumentFields.weekdays, + detailCode: 'wrongType', + ); + } + return { + LockedBlockRecurrenceDocumentFields.type: + recurrence[LockedBlockRecurrenceDocumentFields.type] ?? 'weekly', + LockedBlockRecurrenceDocumentFields.weekdays: weekdays.map((entry) { + if (entry is! String) { + throw const _LegacyDocumentException( + fieldName: LockedBlockRecurrenceDocumentFields.weekdays, + detailCode: 'wrongType', + ); + } + final code = _lockedWeekdayCodes[entry]; + if (code == null) { + throw _LegacyDocumentException( + fieldName: LockedBlockRecurrenceDocumentFields.weekdays, + detailCode: 'unknownCode:$entry', + ); + } + return code; + }).toList(growable: false), + }; +} + +Map _deepCopyDocument(Map document) { + return _deepCopyMap(document, 'document'); +} + +Map _deepCopyMap(Map map, String fieldName) { + return { + for (final entry in map.entries) + _stringKey(entry.key, fieldName): _deepCopyValue(entry.value, fieldName), + }; +} + +String _stringKey(Object? key, String fieldName) { + if (key is String) { + return key; + } + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'nonStringMapKey', + ); +} + +Object? _deepCopyValue(Object? value, String fieldName) { + if (value is Map) { + return _deepCopyMap(value, fieldName); + } + if (value is List) { + return value.map((entry) => _deepCopyValue(entry, fieldName)).toList(); + } + return value; +} diff --git a/packages/scheduler_core/lib/src/document_migration/schema_version_read.dart b/packages/scheduler_core/lib/src/document_migration/schema_version_read.dart new file mode 100644 index 0000000..81d7f05 --- /dev/null +++ b/packages/scheduler_core/lib/src/document_migration/schema_version_read.dart @@ -0,0 +1,9 @@ +part of '../document_migration.dart'; + +class _SchemaVersionRead { + const _SchemaVersionRead.version(this.version) : failure = null; + const _SchemaVersionRead.failure(this.failure) : version = 0; + + final int version; + final String? failure; +} diff --git a/packages/scheduler_core/lib/src/document_migration/v0_migrator.dart b/packages/scheduler_core/lib/src/document_migration/v0_migrator.dart new file mode 100644 index 0000000..ac2e172 --- /dev/null +++ b/packages/scheduler_core/lib/src/document_migration/v0_migrator.dart @@ -0,0 +1,6 @@ +part of '../document_migration.dart'; + +typedef _V0Migrator = Map Function( + Map document, + List notes, +); diff --git a/packages/scheduler_core/lib/src/document_migration/v1_document_migration_service.dart b/packages/scheduler_core/lib/src/document_migration/v1_document_migration_service.dart new file mode 100644 index 0000000..adcbbc9 --- /dev/null +++ b/packages/scheduler_core/lib/src/document_migration/v1_document_migration_service.dart @@ -0,0 +1,412 @@ +part of '../document_migration.dart'; + +/// Pure V0-to-V1 migration service. +/// +/// Version 0 is the pre-Block-15 document shape: no `schemaVersion`, no owner +/// metadata, enum values stored as Dart `.name`, and no exact backlog-entry +/// timestamp. Missing `schemaVersion` is treated as V0; unsupported future +/// versions fail closed. +class V1DocumentMigrationService { + const V1DocumentMigrationService({ + required this.ownerId, + required this.migratedAt, + }); + + final String ownerId; + final DateTime migratedAt; + + DocumentMigrationResult migrateTask(Map document) { + return _migrate( + entity: DocumentMigrationEntity.task, + document: document, + migrateV0: _taskV0ToV1, + validateCurrent: TaskDocumentMapping.fromDocument, + ); + } + + DocumentMigrationResult migrateProject(Map document) { + return _migrate( + entity: DocumentMigrationEntity.project, + document: document, + migrateV0: _projectV0ToV1, + validateCurrent: ProjectDocumentMapping.fromDocument, + ); + } + + DocumentMigrationResult migrateLockedBlock(Map document) { + return _migrate( + entity: DocumentMigrationEntity.lockedBlock, + document: document, + migrateV0: _lockedBlockV0ToV1, + validateCurrent: LockedBlockDocumentMapping.fromDocument, + ); + } + + DocumentMigrationResult migrateLockedBlockOverride( + Map document, + ) { + return _migrate( + entity: DocumentMigrationEntity.lockedBlockOverride, + document: document, + migrateV0: _lockedBlockOverrideV0ToV1, + validateCurrent: LockedBlockOverrideDocumentMapping.fromDocument, + ); + } + + DocumentMigrationResult _migrate({ + required DocumentMigrationEntity entity, + required Map document, + required _V0Migrator migrateV0, + required void Function(Map document) validateCurrent, + }) { + final documentId = _documentId(document); + final schemaRead = _readSchemaVersion(document); + if (schemaRead.failure != null) { + return _failed( + entity: entity, + documentId: documentId, + fromSchemaVersion: null, + failureCode: DocumentMigrationFailureCode.invalidSchemaVersion, + fieldName: DocumentFields.schemaVersion, + detailCode: schemaRead.failure, + ); + } + + final fromVersion = schemaRead.version; + if (fromVersion == v1SchemaVersion) { + try { + final copy = _deepCopyDocument(document); + validateCurrent(copy); + return DocumentMigrationResult( + document: copy, + report: DocumentMigrationReport( + entity: entity, + status: DocumentMigrationStatus.alreadyCurrent, + documentId: documentId, + fromSchemaVersion: fromVersion, + ), + ); + } on DocumentMappingException catch (error) { + return _failedFromMapping( + entity: entity, + documentId: documentId, + fromSchemaVersion: fromVersion, + failureCode: DocumentMigrationFailureCode.currentDocumentFailure, + error: error, + ); + } + } + + if (fromVersion > v1SchemaVersion || fromVersion < 0) { + return _failed( + entity: entity, + documentId: documentId, + fromSchemaVersion: fromVersion, + failureCode: DocumentMigrationFailureCode.unsupportedSchemaVersion, + fieldName: DocumentFields.schemaVersion, + detailCode: fromVersion.toString(), + ); + } + + if (fromVersion != 0) { + return _failed( + entity: entity, + documentId: documentId, + fromSchemaVersion: fromVersion, + failureCode: DocumentMigrationFailureCode.unsupportedSchemaVersion, + fieldName: DocumentFields.schemaVersion, + detailCode: fromVersion.toString(), + ); + } + + final notes = []; + late final Map migrated; + try { + migrated = migrateV0(document, notes); + } on _LegacyDocumentException catch (error) { + return _failed( + entity: entity, + documentId: documentId, + fromSchemaVersion: fromVersion, + failureCode: DocumentMigrationFailureCode.legacyDocumentFailure, + fieldName: error.fieldName, + detailCode: error.detailCode, + ); + } + + try { + validateCurrent(migrated); + } on DocumentMappingException catch (error) { + return _failedFromMapping( + entity: entity, + documentId: documentId, + fromSchemaVersion: fromVersion, + failureCode: DocumentMigrationFailureCode.currentDocumentFailure, + error: error, + ); + } + + return DocumentMigrationResult( + document: migrated, + report: DocumentMigrationReport( + entity: entity, + status: DocumentMigrationStatus.migrated, + documentId: documentId, + fromSchemaVersion: fromVersion, + notes: List.unmodifiable(notes), + ), + ); + } + + Map _taskV0ToV1( + Map document, + List notes, + ) { + final createdAt = _requiredInstant(document, TaskDocumentFields.createdAt); + final status = _requiredLegacyCode( + document, + TaskDocumentFields.status, + _taskStatusCodes, + ); + final isBacklog = status == 'backlog'; + if (isBacklog) { + notes.add( + const DocumentMigrationNote( + code: DocumentMigrationNoteCode.approximatedBacklogEnteredAt, + fieldName: TaskDocumentFields.backlogEnteredAt, + detailCode: DocumentMigrationProvenance.approximatedFromCreatedAt, + ), + ); + } + + return { + ..._commonV1Fields( + id: _requiredString(document, TaskDocumentFields.id), + createdAt: createdAt, + updatedAt: _requiredInstant(document, TaskDocumentFields.updatedAt), + ), + TaskDocumentFields.title: + _requiredString(document, TaskDocumentFields.title), + TaskDocumentFields.projectId: + _requiredString(document, TaskDocumentFields.projectId), + TaskDocumentFields.type: _requiredLegacyCode( + document, + TaskDocumentFields.type, + _taskTypeCodes, + ), + TaskDocumentFields.status: status, + TaskDocumentFields.priority: _nullableLegacyCode( + document, + TaskDocumentFields.priority, + _priorityCodes, + ), + TaskDocumentFields.reward: _requiredLegacyCode( + document, + TaskDocumentFields.reward, + _rewardCodes, + ), + TaskDocumentFields.difficulty: _requiredLegacyCode( + document, + TaskDocumentFields.difficulty, + _difficultyCodes, + ), + TaskDocumentFields.durationMinutes: + _requiredNullableInt(document, TaskDocumentFields.durationMinutes), + TaskDocumentFields.scheduledStart: _nullableInstant( + document, + TaskDocumentFields.scheduledStart, + ), + TaskDocumentFields.scheduledEnd: _nullableInstant( + document, + TaskDocumentFields.scheduledEnd, + ), + TaskDocumentFields.actualStart: + _nullableInstant(document, TaskDocumentFields.actualStart), + TaskDocumentFields.actualEnd: + _nullableInstant(document, TaskDocumentFields.actualEnd), + TaskDocumentFields.completedAt: + _nullableInstant(document, TaskDocumentFields.completedAt), + TaskDocumentFields.parentTaskId: + _requiredNullableString(document, TaskDocumentFields.parentTaskId), + TaskDocumentFields.backlogTags: _requiredLegacyCodeList( + document, + TaskDocumentFields.backlogTags, + _backlogTagCodes, + ), + TaskDocumentFields.reminderOverride: _nullableLegacyCode( + document, + TaskDocumentFields.reminderOverride, + _reminderProfileCodes, + ), + TaskDocumentFields.stats: + _requiredMap(document, TaskDocumentFields.stats), + TaskDocumentFields.backlogEnteredAt: isBacklog ? createdAt : null, + TaskDocumentFields.backlogEnteredAtProvenance: isBacklog + ? DocumentMigrationProvenance.approximatedFromCreatedAt + : null, + }; + } + + Map _projectV0ToV1( + Map document, + List notes, + ) { + final metadata = _metadataInstants(document, notes); + return { + ..._commonV1Fields( + id: _requiredString(document, ProjectDocumentFields.id), + createdAt: metadata.createdAt, + updatedAt: metadata.updatedAt, + ), + ProjectDocumentFields.name: + _requiredString(document, ProjectDocumentFields.name), + ProjectDocumentFields.colorKey: + _requiredString(document, ProjectDocumentFields.colorKey), + ProjectDocumentFields.defaultPriority: _requiredLegacyCode( + document, + ProjectDocumentFields.defaultPriority, + _priorityCodes, + ), + ProjectDocumentFields.defaultReward: _requiredLegacyCode( + document, + ProjectDocumentFields.defaultReward, + _rewardCodes, + ), + ProjectDocumentFields.defaultDifficulty: _requiredLegacyCode( + document, + ProjectDocumentFields.defaultDifficulty, + _difficultyCodes, + ), + ProjectDocumentFields.defaultReminderProfile: _requiredLegacyCode( + document, + ProjectDocumentFields.defaultReminderProfile, + _reminderProfileCodes, + ), + ProjectDocumentFields.defaultDurationMinutes: _requiredNullableInt( + document, + ProjectDocumentFields.defaultDurationMinutes, + ), + ProjectDocumentFields.archivedAt: + _optionalNullableInstant(document, ProjectDocumentFields.archivedAt), + }; + } + + Map _lockedBlockV0ToV1( + Map document, + List notes, + ) { + final metadata = _metadataInstants(document, notes); + return { + ..._commonV1Fields( + id: _requiredString(document, LockedBlockDocumentFields.id), + createdAt: metadata.createdAt, + updatedAt: metadata.updatedAt, + ), + LockedBlockDocumentFields.name: + _requiredString(document, LockedBlockDocumentFields.name), + LockedBlockDocumentFields.startTime: + _requiredWallTime(document, LockedBlockDocumentFields.startTime), + LockedBlockDocumentFields.endTime: + _requiredWallTime(document, LockedBlockDocumentFields.endTime), + LockedBlockDocumentFields.date: + _requiredNullableCivilDate(document, LockedBlockDocumentFields.date), + LockedBlockDocumentFields.recurrence: + _nullableRecurrence(document, LockedBlockDocumentFields.recurrence), + LockedBlockDocumentFields.hiddenByDefault: + _requiredBool(document, LockedBlockDocumentFields.hiddenByDefault), + LockedBlockDocumentFields.projectId: _requiredNullableString( + document, + LockedBlockDocumentFields.projectId, + ), + LockedBlockDocumentFields.archivedAt: _optionalNullableInstant( + document, + LockedBlockDocumentFields.archivedAt, + ), + }; + } + + Map _lockedBlockOverrideV0ToV1( + Map document, + List notes, + ) { + final metadata = _metadataInstants(document, notes); + return { + ..._commonV1Fields( + id: _requiredString(document, LockedBlockOverrideDocumentFields.id), + createdAt: metadata.createdAt, + updatedAt: metadata.updatedAt, + ), + LockedBlockOverrideDocumentFields.lockedBlockId: _requiredNullableString( + document, + LockedBlockOverrideDocumentFields.lockedBlockId, + ), + LockedBlockOverrideDocumentFields.date: _requiredCivilDate( + document, + LockedBlockOverrideDocumentFields.date, + ), + LockedBlockOverrideDocumentFields.type: _requiredLegacyCode( + document, + LockedBlockOverrideDocumentFields.type, + _lockedOverrideTypeCodes, + ), + LockedBlockOverrideDocumentFields.name: _requiredNullableString( + document, + LockedBlockOverrideDocumentFields.name, + ), + LockedBlockOverrideDocumentFields.startTime: _requiredNullableWallTime( + document, + LockedBlockOverrideDocumentFields.startTime, + ), + LockedBlockOverrideDocumentFields.endTime: _requiredNullableWallTime( + document, + LockedBlockOverrideDocumentFields.endTime, + ), + LockedBlockOverrideDocumentFields.hiddenByDefault: _requiredBool( + document, + LockedBlockOverrideDocumentFields.hiddenByDefault, + ), + LockedBlockOverrideDocumentFields.projectId: _requiredNullableString( + document, + LockedBlockOverrideDocumentFields.projectId, + ), + }; + } + + Map _commonV1Fields({ + required String id, + required String createdAt, + required String updatedAt, + }) { + return { + DocumentFields.schemaVersion: v1SchemaVersion, + DocumentFields.id: id, + DocumentFields.ownerId: ownerId, + DocumentFields.revision: 1, + DocumentFields.createdAt: createdAt, + DocumentFields.updatedAt: updatedAt, + }; + } + + _LegacyMetadataInstants _metadataInstants( + Map document, + List notes, + ) { + final createdAt = + _optionalNullableInstant(document, DocumentFields.createdAt); + final updatedAt = + _optionalNullableInstant(document, DocumentFields.updatedAt); + if (createdAt == null || updatedAt == null) { + notes.add( + const DocumentMigrationNote( + code: DocumentMigrationNoteCode.synthesizedMetadataTimestamp, + detailCode: 'migratedAt', + ), + ); + } + final fallback = PersistenceDateTimeConvention.toStoredString(migratedAt); + return _LegacyMetadataInstants( + createdAt: createdAt ?? fallback, + updatedAt: updatedAt ?? createdAt ?? fallback, + ); + } +} diff --git a/packages/scheduler_core/lib/src/free_slots.dart b/packages/scheduler_core/lib/src/free_slots.dart index 4d681ea..1325118 100644 --- a/packages/scheduler_core/lib/src/free_slots.dart +++ b/packages/scheduler_core/lib/src/free_slots.dart @@ -10,321 +10,7 @@ library; import 'models.dart'; import 'occupancy_policy.dart'; import 'scheduling_engine.dart'; - -/// Typed outcome for explicitly placing a required visible commitment. -enum RequiredCommitmentScheduleStatus { - /// Required task was scheduled without protected rest conflicts. - scheduled, - - /// Required task was scheduled and overlaps protected rest. - scheduledWithProtectedFreeSlotConflict, - - /// No task with the requested id was present. - taskNotFound, - - /// The task is not critical or inflexible. - invalidTaskType, -} - -/// Typed overlap between an explicitly placed required commitment and rest. -class ProtectedFreeSlotConflict { - const ProtectedFreeSlotConflict({ - required this.requiredTaskId, - required this.freeSlotTaskId, - required this.requiredInterval, - required this.freeSlotInterval, - }); - - /// Critical or inflexible task that was explicitly placed. - final String requiredTaskId; - - /// Protected Free Slot task that was interrupted. - final String freeSlotTaskId; - - /// Required commitment interval. - final TimeInterval requiredInterval; - - /// Protected rest interval. - final TimeInterval freeSlotInterval; -} - -/// Result from explicitly scheduling a required visible commitment. -class RequiredCommitmentScheduleResult { - RequiredCommitmentScheduleResult({ - required this.status, - required this.schedulingResult, - required List protectedFreeSlotConflicts, - }) : protectedFreeSlotConflicts = - List.unmodifiable( - protectedFreeSlotConflicts, - ); - - /// High-level typed outcome. - final RequiredCommitmentScheduleStatus status; - - /// Replacement task list, notices, changes, and overlap details. - final SchedulingResult schedulingResult; - - /// Protected rest interruptions caused by the explicit placement. - final List protectedFreeSlotConflicts; - - /// Whether this placement interrupted protected rest. - bool get hasProtectedFreeSlotConflict { - return protectedFreeSlotConflicts.isNotEmpty; - } -} - -/// Creates and updates protected Free Slot task records. -class FreeSlotService { - const FreeSlotService(); - - /// Create a scheduled Free Slot record. - Task create({ - required String id, - required String title, - required DateTime start, - required DateTime end, - required DateTime createdAt, - String projectId = 'inbox', - DateTime? updatedAt, - }) { - final interval = TimeInterval(start: start, end: end, label: id); - - return Task( - id: id, - title: title, - projectId: projectId, - type: TaskType.freeSlot, - status: TaskStatus.planned, - priority: PriorityLevel.medium, - durationMinutes: interval.duration.inMinutes, - scheduledStart: interval.start, - scheduledEnd: interval.end, - createdAt: createdAt, - updatedAt: updatedAt ?? createdAt, - ); - } - - /// Update an existing Free Slot record's schedule and optional metadata. - Task update({ - required Task freeSlot, - required DateTime start, - required DateTime end, - DateTime? updatedAt, - String? title, - String? projectId, - }) { - if (freeSlot.type != TaskType.freeSlot) { - throw ArgumentError.value( - freeSlot.type, - 'freeSlot.type', - 'Task must be a Free Slot.', - ); - } - - final interval = TimeInterval(start: start, end: end, label: freeSlot.id); - - return freeSlot.copyWith( - title: title, - projectId: projectId, - status: TaskStatus.planned, - durationMinutes: interval.duration.inMinutes, - scheduledStart: interval.start, - scheduledEnd: interval.end, - updatedAt: updatedAt ?? freeSlot.updatedAt, - ); - } - - /// Explicitly place a critical or inflexible task. - /// - /// This operation is allowed to overlap protected Free Slots because required - /// commitments can interrupt rest. It does not move the Free Slot. Instead it - /// returns typed conflict data for the application layer to surface calmly. - RequiredCommitmentScheduleResult scheduleRequiredCommitment({ - required SchedulingInput input, - required String taskId, - required DateTime start, - required DateTime end, - DateTime? updatedAt, - }) { - final task = _taskById(input.tasks, taskId); - if (task == null) { - return _unchangedRequiredResult( - input: input, - status: RequiredCommitmentScheduleStatus.taskNotFound, - notice: SchedulingNotice( - 'Task was not found.', - type: SchedulingNoticeType.noFit, - taskId: taskId, - issueCode: SchedulingIssueCode.taskNotFound, - ), - outcomeCode: SchedulingOutcomeCode.notFound, - ); - } - - if (!task.isRequiredVisible) { - return _unchangedRequiredResult( - input: input, - status: RequiredCommitmentScheduleStatus.invalidTaskType, - notice: SchedulingNotice( - 'Only critical or inflexible tasks can be explicitly scheduled here.', - type: SchedulingNoticeType.noFit, - taskId: task.id, - issueCode: SchedulingIssueCode.invalidTaskState, - ), - outcomeCode: SchedulingOutcomeCode.invalidState, - ); - } - - final requiredInterval = - TimeInterval(start: start, end: end, label: taskId); - final conflicts = _protectedFreeSlotConflicts( - input: input, - requiredTaskId: task.id, - requiredInterval: requiredInterval, - ); - final updatedTask = task.copyWith( - status: TaskStatus.planned, - durationMinutes: requiredInterval.duration.inMinutes, - scheduledStart: requiredInterval.start, - scheduledEnd: requiredInterval.end, - updatedAt: updatedAt ?? task.updatedAt, - ); - final scheduleChanged = !_sameDateTime(task.scheduledStart, start) || - !_sameDateTime(task.scheduledEnd, end) || - task.status != TaskStatus.planned; - final updatedTasks = input.tasks.map((current) { - if (current.id == task.id) { - return updatedTask; - } - return current; - }).toList(growable: false); - final overlapDetails = conflicts.map((conflict) { - return SchedulingOverlap( - taskId: conflict.requiredTaskId, - taskInterval: conflict.requiredInterval, - blockedInterval: conflict.freeSlotInterval, - ); - }).toList(growable: false); - final notices = [ - SchedulingNotice( - conflicts.isEmpty - ? 'Required commitment scheduled.' - : 'Required commitment overlaps protected rest.', - type: conflicts.isEmpty - ? SchedulingNoticeType.moved - : SchedulingNoticeType.overlap, - taskId: task.id, - movementCode: conflicts.isEmpty - ? SchedulingMovementCode.requiredCommitmentScheduled - : null, - conflictCode: conflicts.isEmpty - ? null - : SchedulingConflictCode - .requiredCommitmentOverlapsProtectedFreeSlot, - parameters: { - 'requiredStart': requiredInterval.start, - 'requiredEnd': requiredInterval.end, - if (conflicts.isNotEmpty) 'freeSlotCount': conflicts.length, - }, - ), - ]; - final changes = [ - if (scheduleChanged) - SchedulingChange( - taskId: task.id, - previousStart: task.scheduledStart, - previousEnd: task.scheduledEnd, - nextStart: requiredInterval.start, - nextEnd: requiredInterval.end, - ), - ]; - - return RequiredCommitmentScheduleResult( - status: conflicts.isEmpty - ? RequiredCommitmentScheduleStatus.scheduled - : RequiredCommitmentScheduleStatus - .scheduledWithProtectedFreeSlotConflict, - protectedFreeSlotConflicts: conflicts, - schedulingResult: SchedulingResult( - tasks: updatedTasks, - operationCode: SchedulingOperationCode.scheduleRequiredCommitment, - outcomeCode: conflicts.isEmpty - ? SchedulingOutcomeCode.success - : SchedulingOutcomeCode.conflict, - notices: notices, - changes: changes, - overlaps: overlapDetails, - ), - ); - } -} - -RequiredCommitmentScheduleResult _unchangedRequiredResult({ - required SchedulingInput input, - required RequiredCommitmentScheduleStatus status, - required SchedulingNotice notice, - required SchedulingOutcomeCode outcomeCode, -}) { - return RequiredCommitmentScheduleResult( - status: status, - protectedFreeSlotConflicts: const [], - schedulingResult: SchedulingResult( - tasks: input.tasks, - operationCode: SchedulingOperationCode.scheduleRequiredCommitment, - outcomeCode: outcomeCode, - notices: [notice], - ), - ); -} - -List _protectedFreeSlotConflicts({ - required SchedulingInput input, - required String requiredTaskId, - required TimeInterval requiredInterval, -}) { - final conflicts = []; - - for (final entry in input.occupancyEntries) { - if (entry.category != OccupancyCategory.protectedFreeSlot) { - continue; - } - - final freeSlot = entry.task; - final freeSlotInterval = entry.interval; - if (freeSlot == null || - freeSlotInterval == null || - !requiredInterval.overlaps(freeSlotInterval)) { - continue; - } - - conflicts.add( - ProtectedFreeSlotConflict( - requiredTaskId: requiredTaskId, - freeSlotTaskId: freeSlot.id, - requiredInterval: requiredInterval, - freeSlotInterval: freeSlotInterval, - ), - ); - } - - return List.unmodifiable(conflicts); -} - -Task? _taskById(List tasks, String taskId) { - for (final task in tasks) { - if (task.id == taskId) { - return task; - } - } - - return null; -} - -bool _sameDateTime(DateTime? first, DateTime? second) { - if (first == null || second == null) { - return first == null && second == null; - } - - return first.isAtSameMomentAs(second); -} +part 'free_slots/required_commitment_schedule_status.dart'; +part 'free_slots/protected_free_slot_conflict.dart'; +part 'free_slots/required_commitment_schedule_result.dart'; +part 'free_slots/free_slot_service.dart'; diff --git a/packages/scheduler_core/lib/src/free_slots/free_slot_service.dart b/packages/scheduler_core/lib/src/free_slots/free_slot_service.dart new file mode 100644 index 0000000..8f8566a --- /dev/null +++ b/packages/scheduler_core/lib/src/free_slots/free_slot_service.dart @@ -0,0 +1,256 @@ +part of '../free_slots.dart'; + +/// Creates and updates protected Free Slot task records. +class FreeSlotService { + const FreeSlotService(); + + /// Create a scheduled Free Slot record. + Task create({ + required String id, + required String title, + required DateTime start, + required DateTime end, + required DateTime createdAt, + String projectId = 'inbox', + DateTime? updatedAt, + }) { + final interval = TimeInterval(start: start, end: end, label: id); + + return Task( + id: id, + title: title, + projectId: projectId, + type: TaskType.freeSlot, + status: TaskStatus.planned, + priority: PriorityLevel.medium, + durationMinutes: interval.duration.inMinutes, + scheduledStart: interval.start, + scheduledEnd: interval.end, + createdAt: createdAt, + updatedAt: updatedAt ?? createdAt, + ); + } + + /// Update an existing Free Slot record's schedule and optional metadata. + Task update({ + required Task freeSlot, + required DateTime start, + required DateTime end, + DateTime? updatedAt, + String? title, + String? projectId, + }) { + if (freeSlot.type != TaskType.freeSlot) { + throw ArgumentError.value( + freeSlot.type, + 'freeSlot.type', + 'Task must be a Free Slot.', + ); + } + + final interval = TimeInterval(start: start, end: end, label: freeSlot.id); + + return freeSlot.copyWith( + title: title, + projectId: projectId, + status: TaskStatus.planned, + durationMinutes: interval.duration.inMinutes, + scheduledStart: interval.start, + scheduledEnd: interval.end, + updatedAt: updatedAt ?? freeSlot.updatedAt, + ); + } + + /// Explicitly place a critical or inflexible task. + /// + /// This operation is allowed to overlap protected Free Slots because required + /// commitments can interrupt rest. It does not move the Free Slot. Instead it + /// returns typed conflict data for the application layer to surface calmly. + RequiredCommitmentScheduleResult scheduleRequiredCommitment({ + required SchedulingInput input, + required String taskId, + required DateTime start, + required DateTime end, + DateTime? updatedAt, + }) { + final task = _taskById(input.tasks, taskId); + if (task == null) { + return _unchangedRequiredResult( + input: input, + status: RequiredCommitmentScheduleStatus.taskNotFound, + notice: SchedulingNotice( + 'Task was not found.', + type: SchedulingNoticeType.noFit, + taskId: taskId, + issueCode: SchedulingIssueCode.taskNotFound, + ), + outcomeCode: SchedulingOutcomeCode.notFound, + ); + } + + if (!task.isRequiredVisible) { + return _unchangedRequiredResult( + input: input, + status: RequiredCommitmentScheduleStatus.invalidTaskType, + notice: SchedulingNotice( + 'Only critical or inflexible tasks can be explicitly scheduled here.', + type: SchedulingNoticeType.noFit, + taskId: task.id, + issueCode: SchedulingIssueCode.invalidTaskState, + ), + outcomeCode: SchedulingOutcomeCode.invalidState, + ); + } + + final requiredInterval = + TimeInterval(start: start, end: end, label: taskId); + final conflicts = _protectedFreeSlotConflicts( + input: input, + requiredTaskId: task.id, + requiredInterval: requiredInterval, + ); + final updatedTask = task.copyWith( + status: TaskStatus.planned, + durationMinutes: requiredInterval.duration.inMinutes, + scheduledStart: requiredInterval.start, + scheduledEnd: requiredInterval.end, + updatedAt: updatedAt ?? task.updatedAt, + ); + final scheduleChanged = !_sameDateTime(task.scheduledStart, start) || + !_sameDateTime(task.scheduledEnd, end) || + task.status != TaskStatus.planned; + final updatedTasks = input.tasks.map((current) { + if (current.id == task.id) { + return updatedTask; + } + return current; + }).toList(growable: false); + final overlapDetails = conflicts.map((conflict) { + return SchedulingOverlap( + taskId: conflict.requiredTaskId, + taskInterval: conflict.requiredInterval, + blockedInterval: conflict.freeSlotInterval, + ); + }).toList(growable: false); + final notices = [ + SchedulingNotice( + conflicts.isEmpty + ? 'Required commitment scheduled.' + : 'Required commitment overlaps protected rest.', + type: conflicts.isEmpty + ? SchedulingNoticeType.moved + : SchedulingNoticeType.overlap, + taskId: task.id, + movementCode: conflicts.isEmpty + ? SchedulingMovementCode.requiredCommitmentScheduled + : null, + conflictCode: conflicts.isEmpty + ? null + : SchedulingConflictCode + .requiredCommitmentOverlapsProtectedFreeSlot, + parameters: { + 'requiredStart': requiredInterval.start, + 'requiredEnd': requiredInterval.end, + if (conflicts.isNotEmpty) 'freeSlotCount': conflicts.length, + }, + ), + ]; + final changes = [ + if (scheduleChanged) + SchedulingChange( + taskId: task.id, + previousStart: task.scheduledStart, + previousEnd: task.scheduledEnd, + nextStart: requiredInterval.start, + nextEnd: requiredInterval.end, + ), + ]; + + return RequiredCommitmentScheduleResult( + status: conflicts.isEmpty + ? RequiredCommitmentScheduleStatus.scheduled + : RequiredCommitmentScheduleStatus + .scheduledWithProtectedFreeSlotConflict, + protectedFreeSlotConflicts: conflicts, + schedulingResult: SchedulingResult( + tasks: updatedTasks, + operationCode: SchedulingOperationCode.scheduleRequiredCommitment, + outcomeCode: conflicts.isEmpty + ? SchedulingOutcomeCode.success + : SchedulingOutcomeCode.conflict, + notices: notices, + changes: changes, + overlaps: overlapDetails, + ), + ); + } +} + +RequiredCommitmentScheduleResult _unchangedRequiredResult({ + required SchedulingInput input, + required RequiredCommitmentScheduleStatus status, + required SchedulingNotice notice, + required SchedulingOutcomeCode outcomeCode, +}) { + return RequiredCommitmentScheduleResult( + status: status, + protectedFreeSlotConflicts: const [], + schedulingResult: SchedulingResult( + tasks: input.tasks, + operationCode: SchedulingOperationCode.scheduleRequiredCommitment, + outcomeCode: outcomeCode, + notices: [notice], + ), + ); +} + +List _protectedFreeSlotConflicts({ + required SchedulingInput input, + required String requiredTaskId, + required TimeInterval requiredInterval, +}) { + final conflicts = []; + + for (final entry in input.occupancyEntries) { + if (entry.category != OccupancyCategory.protectedFreeSlot) { + continue; + } + + final freeSlot = entry.task; + final freeSlotInterval = entry.interval; + if (freeSlot == null || + freeSlotInterval == null || + !requiredInterval.overlaps(freeSlotInterval)) { + continue; + } + + conflicts.add( + ProtectedFreeSlotConflict( + requiredTaskId: requiredTaskId, + freeSlotTaskId: freeSlot.id, + requiredInterval: requiredInterval, + freeSlotInterval: freeSlotInterval, + ), + ); + } + + return List.unmodifiable(conflicts); +} + +Task? _taskById(List tasks, String taskId) { + for (final task in tasks) { + if (task.id == taskId) { + return task; + } + } + + return null; +} + +bool _sameDateTime(DateTime? first, DateTime? second) { + if (first == null || second == null) { + return first == null && second == null; + } + + return first.isAtSameMomentAs(second); +} diff --git a/packages/scheduler_core/lib/src/free_slots/protected_free_slot_conflict.dart b/packages/scheduler_core/lib/src/free_slots/protected_free_slot_conflict.dart new file mode 100644 index 0000000..26062ab --- /dev/null +++ b/packages/scheduler_core/lib/src/free_slots/protected_free_slot_conflict.dart @@ -0,0 +1,23 @@ +part of '../free_slots.dart'; + +/// Typed overlap between an explicitly placed required commitment and rest. +class ProtectedFreeSlotConflict { + const ProtectedFreeSlotConflict({ + required this.requiredTaskId, + required this.freeSlotTaskId, + required this.requiredInterval, + required this.freeSlotInterval, + }); + + /// Critical or inflexible task that was explicitly placed. + final String requiredTaskId; + + /// Protected Free Slot task that was interrupted. + final String freeSlotTaskId; + + /// Required commitment interval. + final TimeInterval requiredInterval; + + /// Protected rest interval. + final TimeInterval freeSlotInterval; +} diff --git a/packages/scheduler_core/lib/src/free_slots/required_commitment_schedule_result.dart b/packages/scheduler_core/lib/src/free_slots/required_commitment_schedule_result.dart new file mode 100644 index 0000000..ab0c89e --- /dev/null +++ b/packages/scheduler_core/lib/src/free_slots/required_commitment_schedule_result.dart @@ -0,0 +1,27 @@ +part of '../free_slots.dart'; + +/// Result from explicitly scheduling a required visible commitment. +class RequiredCommitmentScheduleResult { + RequiredCommitmentScheduleResult({ + required this.status, + required this.schedulingResult, + required List protectedFreeSlotConflicts, + }) : protectedFreeSlotConflicts = + List.unmodifiable( + protectedFreeSlotConflicts, + ); + + /// High-level typed outcome. + final RequiredCommitmentScheduleStatus status; + + /// Replacement task list, notices, changes, and overlap details. + final SchedulingResult schedulingResult; + + /// Protected rest interruptions caused by the explicit placement. + final List protectedFreeSlotConflicts; + + /// Whether this placement interrupted protected rest. + bool get hasProtectedFreeSlotConflict { + return protectedFreeSlotConflicts.isNotEmpty; + } +} diff --git a/packages/scheduler_core/lib/src/free_slots/required_commitment_schedule_status.dart b/packages/scheduler_core/lib/src/free_slots/required_commitment_schedule_status.dart new file mode 100644 index 0000000..7c64690 --- /dev/null +++ b/packages/scheduler_core/lib/src/free_slots/required_commitment_schedule_status.dart @@ -0,0 +1,16 @@ +part of '../free_slots.dart'; + +/// Typed outcome for explicitly placing a required visible commitment. +enum RequiredCommitmentScheduleStatus { + /// Required task was scheduled without protected rest conflicts. + scheduled, + + /// Required task was scheduled and overlaps protected rest. + scheduledWithProtectedFreeSlotConflict, + + /// No task with the requested id was present. + taskNotFound, + + /// The task is not critical or inflexible. + invalidTaskType, +} diff --git a/packages/scheduler_core/lib/src/locked_time.dart b/packages/scheduler_core/lib/src/locked_time.dart index e31a3d8..b3fc34a 100644 --- a/packages/scheduler_core/lib/src/locked_time.dart +++ b/packages/scheduler_core/lib/src/locked_time.dart @@ -10,953 +10,11 @@ library; import 'models.dart'; import 'time_contracts.dart'; - -/// Weekday value using DateTime's Monday-first convention. -/// -/// Dart represents weekdays as integers where Monday is `1` and Sunday is `7`. -/// This enum wraps those integers so the rest of the code can talk in readable -/// names while still comparing directly to [DateTime.weekday]. -enum LockedWeekday { - monday(DateTime.monday), - tuesday(DateTime.tuesday), - wednesday(DateTime.wednesday), - thursday(DateTime.thursday), - friday(DateTime.friday), - saturday(DateTime.saturday), - sunday(DateTime.sunday); - - const LockedWeekday(this.dateTimeValue); - - /// Matching [DateTime.weekday] integer value. - final int dateTimeValue; -} - -/// Backwards-compatible name for explicit wall-clock time. -typedef ClockTime = WallTime; - -/// Recurrence rule for locked time. -/// -/// The current starter implementation only supports weekly recurrence because -/// that covers the product's fixed work/stream/relationship blocks. This object -/// keeps recurrence separate from [LockedBlock] so monthly or custom recurrence -/// rules can be added later without changing the rest of the locked-time model. -class LockedBlockRecurrence { - LockedBlockRecurrence.weekly({ - required Set weekdays, - }) : weekdays = Set.unmodifiable(weekdays) { - if (weekdays.isEmpty) { - throw DomainValidationException( - code: DomainValidationCode.emptyRecurrence, - invalidValue: weekdays, - name: 'weekdays', - message: 'Recurring locked blocks need at least one weekday.', - ); - } - } - - /// Weekdays when this recurrence should produce an occurrence. - final Set weekdays; - - /// Whether this recurrence applies to [date]. - bool occursOn(CivilDate date) { - return weekdays.any((weekday) => weekday.dateTimeValue == date.weekday); - } -} - -/// Scheduling constraint that reserves time without becoming a task card. -/// -/// Locked blocks represent time the flexible scheduler should avoid: work hours, -/// appointments, sleep boundaries, streams, relationship blocks, or any other -/// commitment that should not be automatically rearranged. They are modeled -/// separately from normal tasks because the UI may show them as subtle overlays -/// rather than actionable task cards. -/// -/// A block is either: -/// - one-off, with [date] set and [recurrence] null; or -/// - recurring, with [recurrence] set and [date] usually null. -class LockedBlock { - LockedBlock({ - required String id, - required String name, - required this.startTime, - required this.endTime, - required this.createdAt, - required this.updatedAt, - this.date, - this.recurrence, - this.hiddenByDefault = true, - this.projectId, - this.archivedAt, - }) : id = - _requiredLockedString(id, 'id', DomainValidationCode.blankStableId), - name = _requiredLockedString( - name, 'name', DomainValidationCode.blankName) { - if (recurrence == null && date == null) { - throw DomainValidationException( - code: DomainValidationCode.invalidLockedBlock, - invalidValue: null, - name: 'date', - message: 'One-off locked blocks need a date.', - ); - } - if (!_clockTimeIsBefore(startTime, endTime)) { - throw DomainValidationException( - code: DomainValidationCode.invalidLockedBlock, - invalidValue: {'startTime': startTime, 'endTime': endTime}, - name: 'startTime/endTime', - message: 'Locked block end time must be after start time.', - ); - } - } - - /// Stable id for persistence and one-day overrides. - final String id; - - /// User-facing label, such as `Work`, `Stream`, or `Relationship block`. - final String name; - - /// Start time-of-day. Combined with a date during expansion. - final ClockTime startTime; - - /// End time-of-day. Combined with a date during expansion. - final ClockTime endTime; - - /// Calendar date for one-off locked blocks. Recurring blocks leave this null. - final CivilDate? date; - - /// Optional weekly recurrence. Null means this is a one-off block. - final LockedBlockRecurrence? recurrence; - - /// Whether UI should keep this block visually quiet by default. Scheduling still - /// treats hidden blocks as blocked time. - final bool hiddenByDefault; - - /// Optional project/category association for UI colors or reports. - final String? projectId; - - /// Creation timestamp for persistence/auditing. - final DateTime createdAt; - - /// Last update timestamp for persistence/auditing. - final DateTime updatedAt; - - /// When present, this block no longer creates future occurrences. - /// - /// One-day overrides are intentionally stored separately and are not deleted - /// when a block is archived. - final DateTime? archivedAt; - - /// Convenience check for whether this block expands through recurrence. - bool get isRecurring => recurrence != null; - - /// Whether this block has been archived. - bool get isArchived => archivedAt != null; - - /// Return a copy with selected locked-block details changed. - LockedBlock copyWith({ - String? id, - String? name, - ClockTime? startTime, - ClockTime? endTime, - CivilDate? date, - LockedBlockRecurrence? recurrence, - bool? hiddenByDefault, - String? projectId, - DateTime? createdAt, - DateTime? updatedAt, - DateTime? archivedAt, - bool clearArchivedAt = false, - }) { - return LockedBlock( - id: id ?? this.id, - name: name ?? this.name, - startTime: startTime ?? this.startTime, - endTime: endTime ?? this.endTime, - date: date ?? this.date, - recurrence: recurrence ?? this.recurrence, - hiddenByDefault: hiddenByDefault ?? this.hiddenByDefault, - projectId: projectId ?? this.projectId, - createdAt: createdAt ?? this.createdAt, - updatedAt: updatedAt ?? this.updatedAt, - archivedAt: clearArchivedAt ? null : (archivedAt ?? this.archivedAt), - ); - } -} - -/// Concrete locked-time occurrence for one calendar day. -/// -/// [LockedBlock] is a rule; [LockedBlockOccurrence] is the actual result on a -/// specific date. The scheduler only needs occurrences/time intervals, while UI -/// can use ids and visibility flags to explain where the blocked time came from. -class LockedBlockOccurrence { - const LockedBlockOccurrence({ - required this.name, - required this.interval, - required this.hiddenByDefault, - this.lockedBlockId, - this.overrideId, - this.projectId, - }); - - /// Display/debug label for this occurrence. - final String name; - - /// Concrete start/end on one calendar date. - final TimeInterval interval; - - /// Visibility hint for UI; does not affect scheduling. - final bool hiddenByDefault; - - /// Source recurring/one-off block id, when this came from a base block. - final String? lockedBlockId; - - /// Source override id, when this was replaced or added for one date. - final String? overrideId; - - /// Optional project/category association for UI colors or reports. - final String? projectId; - - /// Scheduler-facing interval. Visibility only affects future UI overlays. - /// - /// A fresh [TimeInterval] is returned with [name] as the label so scheduling - /// notices/debugging can identify the blocked source without depending on the - /// richer occurrence object. - TimeInterval get schedulingInterval { - return TimeInterval( - start: interval.start, - end: interval.end, - label: name, - ); - } -} - -/// Type of one-day override applied to locked time. -/// -/// Overrides let the app handle holidays, one-off appointments, or changed work -/// hours without editing the base recurring rule. -enum LockedBlockOverrideType { - /// Suppress one occurrence of a recurring locked block. - remove, - - /// Replace one occurrence with different details. - replace, - - /// Add a one-off locked occurrence that has no base recurring block. - add, -} - -/// One-day change to locked time that leaves the base recurrence unchanged. -/// -/// Overrides are applied during expansion for a single date only. This is safer -/// than modifying the recurring block because the normal schedule remains intact -/// for every other day. -/// -/// Use the named factories to create valid override shapes: -/// - [remove] references a base block and suppresses that occurrence. -/// - [replace] references a base block and swaps its details for one day. -/// - [add] creates an extra locked occurrence that has no base block. -class LockedBlockOverride { - LockedBlockOverride({ - required String id, - required this.date, - required this.type, - required this.createdAt, - required this.updatedAt, - String? lockedBlockId, - String? name, - this.startTime, - this.endTime, - this.hiddenByDefault = true, - this.projectId, - }) : id = - _requiredLockedString(id, 'id', DomainValidationCode.blankStableId), - lockedBlockId = _nullableLockedString( - lockedBlockId, - 'lockedBlockId', - DomainValidationCode.blankStableId, - ), - name = _nullableLockedString( - name, - 'name', - DomainValidationCode.blankName, - ) { - _validateOverrideShape( - type: type, - lockedBlockId: this.lockedBlockId, - name: this.name, - startTime: startTime, - endTime: endTime, - ); - } - - /// Removes a recurring occurrence for one date. - factory LockedBlockOverride.remove({ - required String id, - required String lockedBlockId, - required CivilDate date, - required DateTime createdAt, - DateTime? updatedAt, - }) { - return LockedBlockOverride( - id: id, - lockedBlockId: lockedBlockId, - date: date, - type: LockedBlockOverrideType.remove, - createdAt: createdAt, - updatedAt: updatedAt ?? createdAt, - ); - } - - /// Replaces a recurring occurrence with different times for one date. - factory LockedBlockOverride.replace({ - required String id, - required String lockedBlockId, - required CivilDate date, - required ClockTime startTime, - required ClockTime endTime, - required DateTime createdAt, - String? name, - bool hiddenByDefault = true, - String? projectId, - DateTime? updatedAt, - }) { - return LockedBlockOverride( - id: id, - lockedBlockId: lockedBlockId, - date: date, - type: LockedBlockOverrideType.replace, - name: name, - startTime: startTime, - endTime: endTime, - hiddenByDefault: hiddenByDefault, - projectId: projectId, - createdAt: createdAt, - updatedAt: updatedAt ?? createdAt, - ); - } - - /// Adds surprise locked time for one date. - factory LockedBlockOverride.add({ - required String id, - required CivilDate date, - required String name, - required ClockTime startTime, - required ClockTime endTime, - required DateTime createdAt, - bool hiddenByDefault = true, - String? projectId, - DateTime? updatedAt, - }) { - return LockedBlockOverride( - id: id, - date: date, - type: LockedBlockOverrideType.add, - name: name, - startTime: startTime, - endTime: endTime, - hiddenByDefault: hiddenByDefault, - projectId: projectId, - createdAt: createdAt, - updatedAt: updatedAt ?? createdAt, - ); - } - - /// Stable id for persistence and debugging. - final String id; - - /// Base block id affected by remove/replace overrides. Null for add overrides. - final String? lockedBlockId; - - /// Date the override applies to. - final CivilDate date; - - /// Kind of override operation. - final LockedBlockOverrideType type; - - /// Optional replacement/addition name. - final String? name; - - /// Optional replacement/addition start time. - final ClockTime? startTime; - - /// Optional replacement/addition end time. - final ClockTime? endTime; - - /// Visibility hint for UI; scheduling still blocks this time. - final bool hiddenByDefault; - - /// Optional project/category association for UI colors or reports. - final String? projectId; - - /// Creation timestamp for persistence/auditing. - final DateTime createdAt; - - /// Last update timestamp for persistence/auditing. - final DateTime updatedAt; - - /// Whether this override targets [blockId] on [occurrenceDate]. - bool appliesTo({ - required String blockId, - required CivilDate occurrenceDate, - }) { - return lockedBlockId == blockId && _sameDate(date, occurrenceDate); - } - - /// Build a concrete interval for [targetDate] when this override has enough - /// time data and applies to that date. - /// - /// Remove overrides intentionally return null because they do not create a new - /// interval. Replacement/add overrides need both [startTime] and [endTime]. - TimeInterval? intervalForDate({ - required CivilDate targetDate, - required String timeZoneId, - required TimeZoneResolver timeZoneResolver, - TimeZoneResolutionOptions resolutionOptions = - const TimeZoneResolutionOptions(), - }) { - final start = startTime; - final end = endTime; - - if (start == null || end == null || !_sameDate(date, targetDate)) { - return null; - } - - return _resolvedInterval( - date: targetDate, - startTime: start, - endTime: end, - label: name, - timeZoneId: timeZoneId, - timeZoneResolver: timeZoneResolver, - resolutionOptions: resolutionOptions, - ); - } -} - -/// Expands locked blocks and one-day overrides into concrete intervals. -/// -/// This object is the boundary between human-friendly locked block definitions -/// and scheduler-friendly intervals. It retains the occurrence details for UI -/// while exposing [schedulingIntervals] for placement algorithms. -class LockedScheduleExpansion { - const LockedScheduleExpansion({ - required this.date, - required this.occurrences, - }); - - /// Calendar date represented by this expansion, normalized to year/month/day. - final CivilDate date; - - /// Concrete locked occurrences active on [date]. - final List occurrences; - - /// Just the intervals the scheduler needs to avoid. - List get schedulingIntervals { - return List.unmodifiable( - occurrences.map((occurrence) => occurrence.schedulingInterval), - ); - } -} - -/// Returns concrete locked-time occurrences for [date]. -/// -/// Expansion order: -/// 1. Sort overrides deterministically by creation time, then id. -/// 2. For each base block that occurs on the date, collect its overrides. -/// 3. Skip the occurrence if any remove override applies. -/// 4. Use the latest replacement override if one exists. -/// 5. Add one-off `add` overrides for the date. -/// 6. Sort occurrences by start time for predictable UI/scheduler behavior. -LockedScheduleExpansion expandLockedBlocksForDay({ - required List blocks, - required List overrides, - required CivilDate date, - required String timeZoneId, - required TimeZoneResolver timeZoneResolver, - TimeZoneResolutionOptions resolutionOptions = - const TimeZoneResolutionOptions(), -}) { - final occurrences = []; - final sortedOverrides = [...overrides]..sort((a, b) { - final createdComparison = a.createdAt.compareTo(b.createdAt); - if (createdComparison != 0) { - return createdComparison; - } - - return a.id.compareTo(b.id); - }); - - for (final block in blocks) { - if (block.isArchived) { - continue; - } - if (!_blockOccursOn(block, date)) { - continue; - } - - final blockOverrides = sortedOverrides - .where( - (override) => - override.appliesTo(blockId: block.id, occurrenceDate: date), - ) - .toList(growable: false); - - if (blockOverrides.any( - (override) => override.type == LockedBlockOverrideType.remove, - )) { - continue; - } - - final replacement = _lastReplacementOverride(blockOverrides); - - occurrences.add( - replacement == null - ? _occurrenceFromBlock( - block, - date, - timeZoneId: timeZoneId, - timeZoneResolver: timeZoneResolver, - resolutionOptions: resolutionOptions, - ) - : _occurrenceFromReplacement( - block: block, - override: replacement, - date: date, - timeZoneId: timeZoneId, - timeZoneResolver: timeZoneResolver, - resolutionOptions: resolutionOptions, - ), - ); - } - - for (final override in sortedOverrides) { - if (override.type != LockedBlockOverrideType.add || - !_sameDate(override.date, date)) { - continue; - } - - final occurrence = _occurrenceFromAddedOverride( - override, - date, - timeZoneId: timeZoneId, - timeZoneResolver: timeZoneResolver, - resolutionOptions: resolutionOptions, - ); - if (occurrence != null) { - occurrences.add(occurrence); - } - } - - occurrences.sort((a, b) { - final startComparison = a.interval.start.compareTo(b.interval.start); - if (startComparison != 0) { - return startComparison; - } - - return a.name.compareTo(b.name); - }); - - return LockedScheduleExpansion( - date: date, - occurrences: List.unmodifiable(occurrences), - ); -} - -/// Return the last replacement override from an already sorted override list. -/// -/// Later-created replacements win, which lets a user correct a one-day override -/// without deleting older history first. -LockedBlockOverride? _lastReplacementOverride( - List overrides, -) { - for (final override in overrides.reversed) { - if (override.type == LockedBlockOverrideType.replace) { - return override; - } - } - - return null; -} - -/// Convenience wrapper when callers only need scheduler intervals. -List lockedSchedulingIntervalsForDay({ - required List blocks, - required List overrides, - required CivilDate date, - required String timeZoneId, - required TimeZoneResolver timeZoneResolver, - TimeZoneResolutionOptions resolutionOptions = - const TimeZoneResolutionOptions(), -}) { - return expandLockedBlocksForDay( - blocks: blocks, - overrides: overrides, - date: date, - timeZoneId: timeZoneId, - timeZoneResolver: timeZoneResolver, - resolutionOptions: resolutionOptions, - ).schedulingIntervals; -} - -/// Returns [task] with locked-hour completion statistics applied when relevant. -/// -/// This does not decide whether completion is allowed. It only records that a -/// completion overlapped locked time, which future reports can surface as a -/// boundary-leak signal. -Task trackCompletedDuringLockedHours({ - required Task task, - required List lockedIntervals, -}) { - final overlapMinutes = completedDuringLockedHoursMinutes( - task: task, - lockedIntervals: lockedIntervals, - ); - - if (overlapMinutes == 0) { - return task; - } - - return task.copyWith( - stats: task.stats.incrementCompletedDuringLockedHours(overlapMinutes), - ); -} - -/// Calculates how many scheduled task minutes overlap locked intervals. -/// -/// Multiple locked intervals may overlap each other. To avoid double-counting, -/// intersections are merged before minutes are summed. -int completedDuringLockedHoursMinutes({ - required Task task, - required List lockedIntervals, -}) { - if (!_shouldTrackLockedHourCompletion(task)) { - return 0; - } - - final taskInterval = _scheduledIntervalForTask(task); - if (taskInterval == null) { - return 0; - } - - final intersections = []; - for (final lockedInterval in lockedIntervals) { - if (!taskInterval.overlaps(lockedInterval)) { - continue; - } - - intersections.add( - TimeInterval( - start: _latest(taskInterval.start, lockedInterval.start), - end: _earliest(taskInterval.end, lockedInterval.end), - ), - ); - } - - if (intersections.isEmpty) { - return 0; - } - - intersections.sort((a, b) => a.start.compareTo(b.start)); - - var total = Duration.zero; - var current = intersections.first; - for (final interval in intersections.skip(1)) { - if (interval.start.isAfter(current.end)) { - total += current.duration; - current = interval; - continue; - } - - current = TimeInterval( - start: current.start, - end: _latest(current.end, interval.end), - ); - } - - total += current.duration; - - return total.inMinutes; -} - -/// Whether a base block should produce an occurrence on [date]. -bool _blockOccursOn(LockedBlock block, CivilDate date) { - final recurrence = block.recurrence; - if (recurrence != null) { - return recurrence.occursOn(date); - } - - return _sameDate(block.date!, date); -} - -/// Convert a base block rule into a concrete occurrence for [date]. -LockedBlockOccurrence _occurrenceFromBlock( - LockedBlock block, - CivilDate date, { - required String timeZoneId, - required TimeZoneResolver timeZoneResolver, - TimeZoneResolutionOptions resolutionOptions = - const TimeZoneResolutionOptions(), -}) { - return LockedBlockOccurrence( - lockedBlockId: block.id, - name: block.name, - interval: _resolvedInterval( - date: date, - startTime: block.startTime, - endTime: block.endTime, - label: block.name, - timeZoneId: timeZoneId, - timeZoneResolver: timeZoneResolver, - resolutionOptions: resolutionOptions, - ), - hiddenByDefault: block.hiddenByDefault, - projectId: block.projectId, - ); -} - -/// Convert a replacement override into a concrete occurrence. -/// -/// Missing override fields fall back to the original block, so a one-day change -/// can replace only the time, only the name, or only the project association. -LockedBlockOccurrence _occurrenceFromReplacement({ - required LockedBlock block, - required LockedBlockOverride override, - required CivilDate date, - required String timeZoneId, - required TimeZoneResolver timeZoneResolver, - TimeZoneResolutionOptions resolutionOptions = - const TimeZoneResolutionOptions(), -}) { - final startTime = override.startTime ?? block.startTime; - final endTime = override.endTime ?? block.endTime; - final name = override.name ?? block.name; - - return LockedBlockOccurrence( - lockedBlockId: block.id, - overrideId: override.id, - name: name, - interval: _resolvedInterval( - date: date, - startTime: startTime, - endTime: endTime, - label: name, - timeZoneId: timeZoneId, - timeZoneResolver: timeZoneResolver, - resolutionOptions: resolutionOptions, - ), - hiddenByDefault: override.hiddenByDefault, - projectId: override.projectId ?? block.projectId, - ); -} - -/// Convert an add override into a concrete occurrence when it is complete. -LockedBlockOccurrence? _occurrenceFromAddedOverride( - LockedBlockOverride override, - CivilDate date, { - required String timeZoneId, - required TimeZoneResolver timeZoneResolver, - TimeZoneResolutionOptions resolutionOptions = - const TimeZoneResolutionOptions(), -}) { - final interval = override.intervalForDate( - targetDate: date, - timeZoneId: timeZoneId, - timeZoneResolver: timeZoneResolver, - resolutionOptions: resolutionOptions, - ); - final name = override.name; - - if (interval == null || name == null) { - return null; - } - - return LockedBlockOccurrence( - overrideId: override.id, - name: name, - interval: TimeInterval( - start: interval.start, - end: interval.end, - label: name, - ), - hiddenByDefault: override.hiddenByDefault, - projectId: override.projectId, - ); -} - -/// Whether this task state/type should be checked for locked-hour completion. -bool _shouldTrackLockedHourCompletion(Task task) { - return task.status == TaskStatus.completed || task.type == TaskType.surprise; -} - -/// Build a valid scheduled interval for a task, or null if the task is unplaced. -TimeInterval? _scheduledIntervalForTask(Task task) { - final start = task.scheduledStart; - final end = task.scheduledEnd; - - if (start == null || end == null || !start.isBefore(end)) { - return null; - } - - return TimeInterval(start: start, end: end, label: task.id); -} - -/// Return whichever timestamp is earlier. -DateTime _earliest(DateTime first, DateTime second) { - return first.isBefore(second) ? first : second; -} - -/// Return whichever timestamp is later. -DateTime _latest(DateTime first, DateTime second) { - return first.isAfter(second) ? first : second; -} - -TimeInterval _resolvedInterval({ - required CivilDate date, - required ClockTime startTime, - required ClockTime endTime, - required String? label, - required String timeZoneId, - required TimeZoneResolver timeZoneResolver, - required TimeZoneResolutionOptions resolutionOptions, -}) { - final start = timeZoneResolver.resolve( - date: date, - wallTime: startTime, - timeZoneId: timeZoneId, - options: resolutionOptions, - ); - final end = timeZoneResolver.resolve( - date: date, - wallTime: endTime, - timeZoneId: timeZoneId, - options: resolutionOptions, - ); - - return TimeInterval( - start: start.instant, - end: end.instant, - label: label, - ); -} - -bool _sameDate(CivilDate first, CivilDate second) => first == second; - -String _requiredLockedString( - String value, - String name, - DomainValidationCode code, -) { - final trimmed = value.trim(); - if (trimmed.isEmpty) { - throw DomainValidationException( - code: code, - invalidValue: value, - name: name, - message: '$name cannot be blank.', - ); - } - return trimmed; -} - -String? _nullableLockedString( - String? value, - String name, - DomainValidationCode code, -) { - if (value == null) { - return null; - } - final trimmed = value.trim(); - if (trimmed.isEmpty) { - throw DomainValidationException( - code: code, - invalidValue: value, - name: name, - message: '$name cannot be blank.', - ); - } - return trimmed; -} - -bool _clockTimeIsBefore(ClockTime start, ClockTime end) { - if (start.hour != end.hour) { - return start.hour < end.hour; - } - return start.minute < end.minute; -} - -void _validateOverrideShape({ - required LockedBlockOverrideType type, - required String? lockedBlockId, - required String? name, - required ClockTime? startTime, - required ClockTime? endTime, -}) { - switch (type) { - case LockedBlockOverrideType.remove: - if (lockedBlockId == null || - name != null || - startTime != null || - endTime != null) { - throw DomainValidationException( - code: DomainValidationCode.invalidLockedOverride, - invalidValue: type, - name: 'LockedBlockOverride.remove', - message: 'Remove overrides only reference a locked block and date.', - ); - } - case LockedBlockOverrideType.replace: - if (lockedBlockId == null) { - throw DomainValidationException( - code: DomainValidationCode.invalidLockedOverride, - invalidValue: type, - name: 'lockedBlockId', - message: 'Replace overrides must reference a locked block.', - ); - } - _validateOverrideInterval(startTime: startTime, endTime: endTime); - case LockedBlockOverrideType.add: - if (lockedBlockId != null || name == null) { - throw DomainValidationException( - code: DomainValidationCode.invalidLockedOverride, - invalidValue: type, - name: 'LockedBlockOverride.add', - message: - 'Add overrides need a name and cannot reference a base block.', - ); - } - if (startTime == null || endTime == null) { - throw DomainValidationException( - code: DomainValidationCode.invalidLockedOverride, - invalidValue: type, - name: 'startTime/endTime', - message: 'Add overrides need both start and end time.', - ); - } - _validateOverrideInterval(startTime: startTime, endTime: endTime); - } -} - -void _validateOverrideInterval({ - required ClockTime? startTime, - required ClockTime? endTime, -}) { - if ((startTime == null) != (endTime == null)) { - throw DomainValidationException( - code: DomainValidationCode.invalidLockedOverride, - invalidValue: {'startTime': startTime, 'endTime': endTime}, - name: 'startTime/endTime', - message: 'Override start and end time must both be present or absent.', - ); - } - if (startTime != null && - endTime != null && - !_clockTimeIsBefore(startTime, endTime)) { - throw DomainValidationException( - code: DomainValidationCode.invalidLockedOverride, - invalidValue: {'startTime': startTime, 'endTime': endTime}, - name: 'startTime/endTime', - message: 'Override end time must be after start time.', - ); - } -} +part 'locked_time/locked_weekday.dart'; +part 'locked_time/clock_time.dart'; +part 'locked_time/locked_block_recurrence.dart'; +part 'locked_time/locked_block.dart'; +part 'locked_time/locked_block_occurrence.dart'; +part 'locked_time/locked_block_override_type.dart'; +part 'locked_time/locked_block_override.dart'; +part 'locked_time/locked_schedule_expansion.dart'; diff --git a/packages/scheduler_core/lib/src/locked_time/clock_time.dart b/packages/scheduler_core/lib/src/locked_time/clock_time.dart new file mode 100644 index 0000000..b3e9d08 --- /dev/null +++ b/packages/scheduler_core/lib/src/locked_time/clock_time.dart @@ -0,0 +1,4 @@ +part of '../locked_time.dart'; + +/// Backwards-compatible name for explicit wall-clock time. +typedef ClockTime = WallTime; diff --git a/packages/scheduler_core/lib/src/locked_time/locked_block.dart b/packages/scheduler_core/lib/src/locked_time/locked_block.dart new file mode 100644 index 0000000..ffe0c7c --- /dev/null +++ b/packages/scheduler_core/lib/src/locked_time/locked_block.dart @@ -0,0 +1,121 @@ +part of '../locked_time.dart'; + +/// Scheduling constraint that reserves time without becoming a task card. +/// +/// Locked blocks represent time the flexible scheduler should avoid: work hours, +/// appointments, sleep boundaries, streams, relationship blocks, or any other +/// commitment that should not be automatically rearranged. They are modeled +/// separately from normal tasks because the UI may show them as subtle overlays +/// rather than actionable task cards. +/// +/// A block is either: +/// - one-off, with [date] set and [recurrence] null; or +/// - recurring, with [recurrence] set and [date] usually null. +class LockedBlock { + LockedBlock({ + required String id, + required String name, + required this.startTime, + required this.endTime, + required this.createdAt, + required this.updatedAt, + this.date, + this.recurrence, + this.hiddenByDefault = true, + this.projectId, + this.archivedAt, + }) : id = + _requiredLockedString(id, 'id', DomainValidationCode.blankStableId), + name = _requiredLockedString( + name, 'name', DomainValidationCode.blankName) { + if (recurrence == null && date == null) { + throw DomainValidationException( + code: DomainValidationCode.invalidLockedBlock, + invalidValue: null, + name: 'date', + message: 'One-off locked blocks need a date.', + ); + } + if (!_clockTimeIsBefore(startTime, endTime)) { + throw DomainValidationException( + code: DomainValidationCode.invalidLockedBlock, + invalidValue: {'startTime': startTime, 'endTime': endTime}, + name: 'startTime/endTime', + message: 'Locked block end time must be after start time.', + ); + } + } + + /// Stable id for persistence and one-day overrides. + final String id; + + /// User-facing label, such as `Work`, `Stream`, or `Relationship block`. + final String name; + + /// Start time-of-day. Combined with a date during expansion. + final ClockTime startTime; + + /// End time-of-day. Combined with a date during expansion. + final ClockTime endTime; + + /// Calendar date for one-off locked blocks. Recurring blocks leave this null. + final CivilDate? date; + + /// Optional weekly recurrence. Null means this is a one-off block. + final LockedBlockRecurrence? recurrence; + + /// Whether UI should keep this block visually quiet by default. Scheduling still + /// treats hidden blocks as blocked time. + final bool hiddenByDefault; + + /// Optional project/category association for UI colors or reports. + final String? projectId; + + /// Creation timestamp for persistence/auditing. + final DateTime createdAt; + + /// Last update timestamp for persistence/auditing. + final DateTime updatedAt; + + /// When present, this block no longer creates future occurrences. + /// + /// One-day overrides are intentionally stored separately and are not deleted + /// when a block is archived. + final DateTime? archivedAt; + + /// Convenience check for whether this block expands through recurrence. + bool get isRecurring => recurrence != null; + + /// Whether this block has been archived. + bool get isArchived => archivedAt != null; + + /// Return a copy with selected locked-block details changed. + LockedBlock copyWith({ + String? id, + String? name, + ClockTime? startTime, + ClockTime? endTime, + CivilDate? date, + LockedBlockRecurrence? recurrence, + bool? hiddenByDefault, + String? projectId, + DateTime? createdAt, + DateTime? updatedAt, + DateTime? archivedAt, + bool clearArchivedAt = false, + }) { + return LockedBlock( + id: id ?? this.id, + name: name ?? this.name, + startTime: startTime ?? this.startTime, + endTime: endTime ?? this.endTime, + date: date ?? this.date, + recurrence: recurrence ?? this.recurrence, + hiddenByDefault: hiddenByDefault ?? this.hiddenByDefault, + projectId: projectId ?? this.projectId, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + archivedAt: clearArchivedAt ? null : (archivedAt ?? this.archivedAt), + ); + } +} diff --git a/packages/scheduler_core/lib/src/locked_time/locked_block_occurrence.dart b/packages/scheduler_core/lib/src/locked_time/locked_block_occurrence.dart new file mode 100644 index 0000000..7419e53 --- /dev/null +++ b/packages/scheduler_core/lib/src/locked_time/locked_block_occurrence.dart @@ -0,0 +1,48 @@ +part of '../locked_time.dart'; + +/// Concrete locked-time occurrence for one calendar day. +/// +/// [LockedBlock] is a rule; [LockedBlockOccurrence] is the actual result on a +/// specific date. The scheduler only needs occurrences/time intervals, while UI +/// can use ids and visibility flags to explain where the blocked time came from. +class LockedBlockOccurrence { + const LockedBlockOccurrence({ + required this.name, + required this.interval, + required this.hiddenByDefault, + this.lockedBlockId, + this.overrideId, + this.projectId, + }); + + /// Display/debug label for this occurrence. + final String name; + + /// Concrete start/end on one calendar date. + final TimeInterval interval; + + /// Visibility hint for UI; does not affect scheduling. + final bool hiddenByDefault; + + /// Source recurring/one-off block id, when this came from a base block. + final String? lockedBlockId; + + /// Source override id, when this was replaced or added for one date. + final String? overrideId; + + /// Optional project/category association for UI colors or reports. + final String? projectId; + + /// Scheduler-facing interval. Visibility only affects future UI overlays. + /// + /// A fresh [TimeInterval] is returned with [name] as the label so scheduling + /// notices/debugging can identify the blocked source without depending on the + /// richer occurrence object. + TimeInterval get schedulingInterval { + return TimeInterval( + start: interval.start, + end: interval.end, + label: name, + ); + } +} diff --git a/packages/scheduler_core/lib/src/locked_time/locked_block_override.dart b/packages/scheduler_core/lib/src/locked_time/locked_block_override.dart new file mode 100644 index 0000000..8979839 --- /dev/null +++ b/packages/scheduler_core/lib/src/locked_time/locked_block_override.dart @@ -0,0 +1,189 @@ +part of '../locked_time.dart'; + +/// One-day change to locked time that leaves the base recurrence unchanged. +/// +/// Overrides are applied during expansion for a single date only. This is safer +/// than modifying the recurring block because the normal schedule remains intact +/// for every other day. +/// +/// Use the named factories to create valid override shapes: +/// - [remove] references a base block and suppresses that occurrence. +/// - [replace] references a base block and swaps its details for one day. +/// - [add] creates an extra locked occurrence that has no base block. +class LockedBlockOverride { + LockedBlockOverride({ + required String id, + required this.date, + required this.type, + required this.createdAt, + required this.updatedAt, + String? lockedBlockId, + String? name, + this.startTime, + this.endTime, + this.hiddenByDefault = true, + this.projectId, + }) : id = + _requiredLockedString(id, 'id', DomainValidationCode.blankStableId), + lockedBlockId = _nullableLockedString( + lockedBlockId, + 'lockedBlockId', + DomainValidationCode.blankStableId, + ), + name = _nullableLockedString( + name, + 'name', + DomainValidationCode.blankName, + ) { + _validateOverrideShape( + type: type, + lockedBlockId: this.lockedBlockId, + name: this.name, + startTime: startTime, + endTime: endTime, + ); + } + + /// Removes a recurring occurrence for one date. + factory LockedBlockOverride.remove({ + required String id, + required String lockedBlockId, + required CivilDate date, + required DateTime createdAt, + DateTime? updatedAt, + }) { + return LockedBlockOverride( + id: id, + lockedBlockId: lockedBlockId, + date: date, + type: LockedBlockOverrideType.remove, + createdAt: createdAt, + updatedAt: updatedAt ?? createdAt, + ); + } + + /// Replaces a recurring occurrence with different times for one date. + factory LockedBlockOverride.replace({ + required String id, + required String lockedBlockId, + required CivilDate date, + required ClockTime startTime, + required ClockTime endTime, + required DateTime createdAt, + String? name, + bool hiddenByDefault = true, + String? projectId, + DateTime? updatedAt, + }) { + return LockedBlockOverride( + id: id, + lockedBlockId: lockedBlockId, + date: date, + type: LockedBlockOverrideType.replace, + name: name, + startTime: startTime, + endTime: endTime, + hiddenByDefault: hiddenByDefault, + projectId: projectId, + createdAt: createdAt, + updatedAt: updatedAt ?? createdAt, + ); + } + + /// Adds surprise locked time for one date. + factory LockedBlockOverride.add({ + required String id, + required CivilDate date, + required String name, + required ClockTime startTime, + required ClockTime endTime, + required DateTime createdAt, + bool hiddenByDefault = true, + String? projectId, + DateTime? updatedAt, + }) { + return LockedBlockOverride( + id: id, + date: date, + type: LockedBlockOverrideType.add, + name: name, + startTime: startTime, + endTime: endTime, + hiddenByDefault: hiddenByDefault, + projectId: projectId, + createdAt: createdAt, + updatedAt: updatedAt ?? createdAt, + ); + } + + /// Stable id for persistence and debugging. + final String id; + + /// Base block id affected by remove/replace overrides. Null for add overrides. + final String? lockedBlockId; + + /// Date the override applies to. + final CivilDate date; + + /// Kind of override operation. + final LockedBlockOverrideType type; + + /// Optional replacement/addition name. + final String? name; + + /// Optional replacement/addition start time. + final ClockTime? startTime; + + /// Optional replacement/addition end time. + final ClockTime? endTime; + + /// Visibility hint for UI; scheduling still blocks this time. + final bool hiddenByDefault; + + /// Optional project/category association for UI colors or reports. + final String? projectId; + + /// Creation timestamp for persistence/auditing. + final DateTime createdAt; + + /// Last update timestamp for persistence/auditing. + final DateTime updatedAt; + + /// Whether this override targets [blockId] on [occurrenceDate]. + bool appliesTo({ + required String blockId, + required CivilDate occurrenceDate, + }) { + return lockedBlockId == blockId && _sameDate(date, occurrenceDate); + } + + /// Build a concrete interval for [targetDate] when this override has enough + /// time data and applies to that date. + /// + /// Remove overrides intentionally return null because they do not create a new + /// interval. Replacement/add overrides need both [startTime] and [endTime]. + TimeInterval? intervalForDate({ + required CivilDate targetDate, + required String timeZoneId, + required TimeZoneResolver timeZoneResolver, + TimeZoneResolutionOptions resolutionOptions = + const TimeZoneResolutionOptions(), + }) { + final start = startTime; + final end = endTime; + + if (start == null || end == null || !_sameDate(date, targetDate)) { + return null; + } + + return _resolvedInterval( + date: targetDate, + startTime: start, + endTime: end, + label: name, + timeZoneId: timeZoneId, + timeZoneResolver: timeZoneResolver, + resolutionOptions: resolutionOptions, + ); + } +} diff --git a/packages/scheduler_core/lib/src/locked_time/locked_block_override_type.dart b/packages/scheduler_core/lib/src/locked_time/locked_block_override_type.dart new file mode 100644 index 0000000..dfed29e --- /dev/null +++ b/packages/scheduler_core/lib/src/locked_time/locked_block_override_type.dart @@ -0,0 +1,16 @@ +part of '../locked_time.dart'; + +/// Type of one-day override applied to locked time. +/// +/// Overrides let the app handle holidays, one-off appointments, or changed work +/// hours without editing the base recurring rule. +enum LockedBlockOverrideType { + /// Suppress one occurrence of a recurring locked block. + remove, + + /// Replace one occurrence with different details. + replace, + + /// Add a one-off locked occurrence that has no base recurring block. + add, +} diff --git a/packages/scheduler_core/lib/src/locked_time/locked_block_recurrence.dart b/packages/scheduler_core/lib/src/locked_time/locked_block_recurrence.dart new file mode 100644 index 0000000..de6b8a2 --- /dev/null +++ b/packages/scheduler_core/lib/src/locked_time/locked_block_recurrence.dart @@ -0,0 +1,30 @@ +part of '../locked_time.dart'; + +/// Recurrence rule for locked time. +/// +/// The current starter implementation only supports weekly recurrence because +/// that covers the product's fixed work/stream/relationship blocks. This object +/// keeps recurrence separate from [LockedBlock] so monthly or custom recurrence +/// rules can be added later without changing the rest of the locked-time model. +class LockedBlockRecurrence { + LockedBlockRecurrence.weekly({ + required Set weekdays, + }) : weekdays = Set.unmodifiable(weekdays) { + if (weekdays.isEmpty) { + throw DomainValidationException( + code: DomainValidationCode.emptyRecurrence, + invalidValue: weekdays, + name: 'weekdays', + message: 'Recurring locked blocks need at least one weekday.', + ); + } + } + + /// Weekdays when this recurrence should produce an occurrence. + final Set weekdays; + + /// Whether this recurrence applies to [date]. + bool occursOn(CivilDate date) { + return weekdays.any((weekday) => weekday.dateTimeValue == date.weekday); + } +} diff --git a/packages/scheduler_core/lib/src/locked_time/locked_schedule_expansion.dart b/packages/scheduler_core/lib/src/locked_time/locked_schedule_expansion.dart new file mode 100644 index 0000000..4d6ad7e --- /dev/null +++ b/packages/scheduler_core/lib/src/locked_time/locked_schedule_expansion.dart @@ -0,0 +1,529 @@ +part of '../locked_time.dart'; + +/// Expands locked blocks and one-day overrides into concrete intervals. +/// +/// This object is the boundary between human-friendly locked block definitions +/// and scheduler-friendly intervals. It retains the occurrence details for UI +/// while exposing [schedulingIntervals] for placement algorithms. +class LockedScheduleExpansion { + const LockedScheduleExpansion({ + required this.date, + required this.occurrences, + }); + + /// Calendar date represented by this expansion, normalized to year/month/day. + final CivilDate date; + + /// Concrete locked occurrences active on [date]. + final List occurrences; + + /// Just the intervals the scheduler needs to avoid. + List get schedulingIntervals { + return List.unmodifiable( + occurrences.map((occurrence) => occurrence.schedulingInterval), + ); + } +} + +/// Returns concrete locked-time occurrences for [date]. +/// +/// Expansion order: +/// 1. Sort overrides deterministically by creation time, then id. +/// 2. For each base block that occurs on the date, collect its overrides. +/// 3. Skip the occurrence if any remove override applies. +/// 4. Use the latest replacement override if one exists. +/// 5. Add one-off `add` overrides for the date. +/// 6. Sort occurrences by start time for predictable UI/scheduler behavior. +LockedScheduleExpansion expandLockedBlocksForDay({ + required List blocks, + required List overrides, + required CivilDate date, + required String timeZoneId, + required TimeZoneResolver timeZoneResolver, + TimeZoneResolutionOptions resolutionOptions = + const TimeZoneResolutionOptions(), +}) { + final occurrences = []; + final sortedOverrides = [...overrides]..sort((a, b) { + final createdComparison = a.createdAt.compareTo(b.createdAt); + if (createdComparison != 0) { + return createdComparison; + } + + return a.id.compareTo(b.id); + }); + + for (final block in blocks) { + if (block.isArchived) { + continue; + } + if (!_blockOccursOn(block, date)) { + continue; + } + + final blockOverrides = sortedOverrides + .where( + (override) => + override.appliesTo(blockId: block.id, occurrenceDate: date), + ) + .toList(growable: false); + + if (blockOverrides.any( + (override) => override.type == LockedBlockOverrideType.remove, + )) { + continue; + } + + final replacement = _lastReplacementOverride(blockOverrides); + + occurrences.add( + replacement == null + ? _occurrenceFromBlock( + block, + date, + timeZoneId: timeZoneId, + timeZoneResolver: timeZoneResolver, + resolutionOptions: resolutionOptions, + ) + : _occurrenceFromReplacement( + block: block, + override: replacement, + date: date, + timeZoneId: timeZoneId, + timeZoneResolver: timeZoneResolver, + resolutionOptions: resolutionOptions, + ), + ); + } + + for (final override in sortedOverrides) { + if (override.type != LockedBlockOverrideType.add || + !_sameDate(override.date, date)) { + continue; + } + + final occurrence = _occurrenceFromAddedOverride( + override, + date, + timeZoneId: timeZoneId, + timeZoneResolver: timeZoneResolver, + resolutionOptions: resolutionOptions, + ); + if (occurrence != null) { + occurrences.add(occurrence); + } + } + + occurrences.sort((a, b) { + final startComparison = a.interval.start.compareTo(b.interval.start); + if (startComparison != 0) { + return startComparison; + } + + return a.name.compareTo(b.name); + }); + + return LockedScheduleExpansion( + date: date, + occurrences: List.unmodifiable(occurrences), + ); +} + +/// Return the last replacement override from an already sorted override list. +/// +/// Later-created replacements win, which lets a user correct a one-day override +/// without deleting older history first. +LockedBlockOverride? _lastReplacementOverride( + List overrides, +) { + for (final override in overrides.reversed) { + if (override.type == LockedBlockOverrideType.replace) { + return override; + } + } + + return null; +} + +/// Convenience wrapper when callers only need scheduler intervals. +List lockedSchedulingIntervalsForDay({ + required List blocks, + required List overrides, + required CivilDate date, + required String timeZoneId, + required TimeZoneResolver timeZoneResolver, + TimeZoneResolutionOptions resolutionOptions = + const TimeZoneResolutionOptions(), +}) { + return expandLockedBlocksForDay( + blocks: blocks, + overrides: overrides, + date: date, + timeZoneId: timeZoneId, + timeZoneResolver: timeZoneResolver, + resolutionOptions: resolutionOptions, + ).schedulingIntervals; +} + +/// Returns [task] with locked-hour completion statistics applied when relevant. +/// +/// This does not decide whether completion is allowed. It only records that a +/// completion overlapped locked time, which future reports can surface as a +/// boundary-leak signal. +Task trackCompletedDuringLockedHours({ + required Task task, + required List lockedIntervals, +}) { + final overlapMinutes = completedDuringLockedHoursMinutes( + task: task, + lockedIntervals: lockedIntervals, + ); + + if (overlapMinutes == 0) { + return task; + } + + return task.copyWith( + stats: task.stats.incrementCompletedDuringLockedHours(overlapMinutes), + ); +} + +/// Calculates how many scheduled task minutes overlap locked intervals. +/// +/// Multiple locked intervals may overlap each other. To avoid double-counting, +/// intersections are merged before minutes are summed. +int completedDuringLockedHoursMinutes({ + required Task task, + required List lockedIntervals, +}) { + if (!_shouldTrackLockedHourCompletion(task)) { + return 0; + } + + final taskInterval = _scheduledIntervalForTask(task); + if (taskInterval == null) { + return 0; + } + + final intersections = []; + for (final lockedInterval in lockedIntervals) { + if (!taskInterval.overlaps(lockedInterval)) { + continue; + } + + intersections.add( + TimeInterval( + start: _latest(taskInterval.start, lockedInterval.start), + end: _earliest(taskInterval.end, lockedInterval.end), + ), + ); + } + + if (intersections.isEmpty) { + return 0; + } + + intersections.sort((a, b) => a.start.compareTo(b.start)); + + var total = Duration.zero; + var current = intersections.first; + for (final interval in intersections.skip(1)) { + if (interval.start.isAfter(current.end)) { + total += current.duration; + current = interval; + continue; + } + + current = TimeInterval( + start: current.start, + end: _latest(current.end, interval.end), + ); + } + + total += current.duration; + + return total.inMinutes; +} + +/// Whether a base block should produce an occurrence on [date]. +bool _blockOccursOn(LockedBlock block, CivilDate date) { + final recurrence = block.recurrence; + if (recurrence != null) { + return recurrence.occursOn(date); + } + + return _sameDate(block.date!, date); +} + +/// Convert a base block rule into a concrete occurrence for [date]. +LockedBlockOccurrence _occurrenceFromBlock( + LockedBlock block, + CivilDate date, { + required String timeZoneId, + required TimeZoneResolver timeZoneResolver, + TimeZoneResolutionOptions resolutionOptions = + const TimeZoneResolutionOptions(), +}) { + return LockedBlockOccurrence( + lockedBlockId: block.id, + name: block.name, + interval: _resolvedInterval( + date: date, + startTime: block.startTime, + endTime: block.endTime, + label: block.name, + timeZoneId: timeZoneId, + timeZoneResolver: timeZoneResolver, + resolutionOptions: resolutionOptions, + ), + hiddenByDefault: block.hiddenByDefault, + projectId: block.projectId, + ); +} + +/// Convert a replacement override into a concrete occurrence. +/// +/// Missing override fields fall back to the original block, so a one-day change +/// can replace only the time, only the name, or only the project association. +LockedBlockOccurrence _occurrenceFromReplacement({ + required LockedBlock block, + required LockedBlockOverride override, + required CivilDate date, + required String timeZoneId, + required TimeZoneResolver timeZoneResolver, + TimeZoneResolutionOptions resolutionOptions = + const TimeZoneResolutionOptions(), +}) { + final startTime = override.startTime ?? block.startTime; + final endTime = override.endTime ?? block.endTime; + final name = override.name ?? block.name; + + return LockedBlockOccurrence( + lockedBlockId: block.id, + overrideId: override.id, + name: name, + interval: _resolvedInterval( + date: date, + startTime: startTime, + endTime: endTime, + label: name, + timeZoneId: timeZoneId, + timeZoneResolver: timeZoneResolver, + resolutionOptions: resolutionOptions, + ), + hiddenByDefault: override.hiddenByDefault, + projectId: override.projectId ?? block.projectId, + ); +} + +/// Convert an add override into a concrete occurrence when it is complete. +LockedBlockOccurrence? _occurrenceFromAddedOverride( + LockedBlockOverride override, + CivilDate date, { + required String timeZoneId, + required TimeZoneResolver timeZoneResolver, + TimeZoneResolutionOptions resolutionOptions = + const TimeZoneResolutionOptions(), +}) { + final interval = override.intervalForDate( + targetDate: date, + timeZoneId: timeZoneId, + timeZoneResolver: timeZoneResolver, + resolutionOptions: resolutionOptions, + ); + final name = override.name; + + if (interval == null || name == null) { + return null; + } + + return LockedBlockOccurrence( + overrideId: override.id, + name: name, + interval: TimeInterval( + start: interval.start, + end: interval.end, + label: name, + ), + hiddenByDefault: override.hiddenByDefault, + projectId: override.projectId, + ); +} + +/// Whether this task state/type should be checked for locked-hour completion. +bool _shouldTrackLockedHourCompletion(Task task) { + return task.status == TaskStatus.completed || task.type == TaskType.surprise; +} + +/// Build a valid scheduled interval for a task, or null if the task is unplaced. +TimeInterval? _scheduledIntervalForTask(Task task) { + final start = task.scheduledStart; + final end = task.scheduledEnd; + + if (start == null || end == null || !start.isBefore(end)) { + return null; + } + + return TimeInterval(start: start, end: end, label: task.id); +} + +/// Return whichever timestamp is earlier. +DateTime _earliest(DateTime first, DateTime second) { + return first.isBefore(second) ? first : second; +} + +/// Return whichever timestamp is later. +DateTime _latest(DateTime first, DateTime second) { + return first.isAfter(second) ? first : second; +} + +TimeInterval _resolvedInterval({ + required CivilDate date, + required ClockTime startTime, + required ClockTime endTime, + required String? label, + required String timeZoneId, + required TimeZoneResolver timeZoneResolver, + required TimeZoneResolutionOptions resolutionOptions, +}) { + final start = timeZoneResolver.resolve( + date: date, + wallTime: startTime, + timeZoneId: timeZoneId, + options: resolutionOptions, + ); + final end = timeZoneResolver.resolve( + date: date, + wallTime: endTime, + timeZoneId: timeZoneId, + options: resolutionOptions, + ); + + return TimeInterval( + start: start.instant, + end: end.instant, + label: label, + ); +} + +bool _sameDate(CivilDate first, CivilDate second) => first == second; + +String _requiredLockedString( + String value, + String name, + DomainValidationCode code, +) { + final trimmed = value.trim(); + if (trimmed.isEmpty) { + throw DomainValidationException( + code: code, + invalidValue: value, + name: name, + message: '$name cannot be blank.', + ); + } + return trimmed; +} + +String? _nullableLockedString( + String? value, + String name, + DomainValidationCode code, +) { + if (value == null) { + return null; + } + final trimmed = value.trim(); + if (trimmed.isEmpty) { + throw DomainValidationException( + code: code, + invalidValue: value, + name: name, + message: '$name cannot be blank.', + ); + } + return trimmed; +} + +bool _clockTimeIsBefore(ClockTime start, ClockTime end) { + if (start.hour != end.hour) { + return start.hour < end.hour; + } + return start.minute < end.minute; +} + +void _validateOverrideShape({ + required LockedBlockOverrideType type, + required String? lockedBlockId, + required String? name, + required ClockTime? startTime, + required ClockTime? endTime, +}) { + switch (type) { + case LockedBlockOverrideType.remove: + if (lockedBlockId == null || + name != null || + startTime != null || + endTime != null) { + throw DomainValidationException( + code: DomainValidationCode.invalidLockedOverride, + invalidValue: type, + name: 'LockedBlockOverride.remove', + message: 'Remove overrides only reference a locked block and date.', + ); + } + case LockedBlockOverrideType.replace: + if (lockedBlockId == null) { + throw DomainValidationException( + code: DomainValidationCode.invalidLockedOverride, + invalidValue: type, + name: 'lockedBlockId', + message: 'Replace overrides must reference a locked block.', + ); + } + _validateOverrideInterval(startTime: startTime, endTime: endTime); + case LockedBlockOverrideType.add: + if (lockedBlockId != null || name == null) { + throw DomainValidationException( + code: DomainValidationCode.invalidLockedOverride, + invalidValue: type, + name: 'LockedBlockOverride.add', + message: + 'Add overrides need a name and cannot reference a base block.', + ); + } + if (startTime == null || endTime == null) { + throw DomainValidationException( + code: DomainValidationCode.invalidLockedOverride, + invalidValue: type, + name: 'startTime/endTime', + message: 'Add overrides need both start and end time.', + ); + } + _validateOverrideInterval(startTime: startTime, endTime: endTime); + } +} + +void _validateOverrideInterval({ + required ClockTime? startTime, + required ClockTime? endTime, +}) { + if ((startTime == null) != (endTime == null)) { + throw DomainValidationException( + code: DomainValidationCode.invalidLockedOverride, + invalidValue: {'startTime': startTime, 'endTime': endTime}, + name: 'startTime/endTime', + message: 'Override start and end time must both be present or absent.', + ); + } + if (startTime != null && + endTime != null && + !_clockTimeIsBefore(startTime, endTime)) { + throw DomainValidationException( + code: DomainValidationCode.invalidLockedOverride, + invalidValue: {'startTime': startTime, 'endTime': endTime}, + name: 'startTime/endTime', + message: 'Override end time must be after start time.', + ); + } +} diff --git a/packages/scheduler_core/lib/src/locked_time/locked_weekday.dart b/packages/scheduler_core/lib/src/locked_time/locked_weekday.dart new file mode 100644 index 0000000..04c9565 --- /dev/null +++ b/packages/scheduler_core/lib/src/locked_time/locked_weekday.dart @@ -0,0 +1,21 @@ +part of '../locked_time.dart'; + +/// Weekday value using DateTime's Monday-first convention. +/// +/// Dart represents weekdays as integers where Monday is `1` and Sunday is `7`. +/// This enum wraps those integers so the rest of the code can talk in readable +/// names while still comparing directly to [DateTime.weekday]. +enum LockedWeekday { + monday(DateTime.monday), + tuesday(DateTime.tuesday), + wednesday(DateTime.wednesday), + thursday(DateTime.thursday), + friday(DateTime.friday), + saturday(DateTime.saturday), + sunday(DateTime.sunday); + + const LockedWeekday(this.dateTimeValue); + + /// Matching [DateTime.weekday] integer value. + final int dateTimeValue; +} diff --git a/packages/scheduler_core/lib/src/models.dart b/packages/scheduler_core/lib/src/models.dart index b21ba4b..18d4c22 100644 --- a/packages/scheduler_core/lib/src/models.dart +++ b/packages/scheduler_core/lib/src/models.dart @@ -15,777 +15,15 @@ library; // 4. `TimeInterval` is the shared time-span helper used by scheduling logic. import 'task_statistics.dart'; - -/// Stable validation failure categories for domain model boundaries. -/// -/// Callers should branch on these codes instead of parsing exception text. The -/// explanatory messages may change, but these enum values are part of the V1 -/// backend contract. -enum DomainValidationCode { - blankStableId, - blankTitle, - blankProjectId, - blankName, - blankColorKey, - blankParentTaskId, - selfParent, - nonPositiveDuration, - partialScheduledInterval, - invalidScheduledInterval, - partialActualInterval, - invalidActualInterval, - invalidTimeInterval, - invalidSchedulingWindow, - invalidCivilDate, - invalidClockTime, - blankTimeZoneId, - nonexistentLocalTime, - ambiguousLocalTime, - emptyRecurrence, - invalidLockedBlock, - invalidLockedOverride, -} - -/// Typed validation error thrown by domain model constructors. -class DomainValidationException extends ArgumentError { - DomainValidationException({ - required this.code, - required Object? invalidValue, - required String name, - required String message, - }) : super.value(invalidValue, name, message); - - /// Stable category for application and persistence layers. - final DomainValidationCode code; -} - -/// Scheduling behavior category. -/// -/// This enum is one of the central concepts in the planner. The type answers -/// the question: "how should the scheduler treat this item?" It is separate -/// from [TaskStatus], which answers "where is this item in its lifecycle?" -/// -/// For example, a flexible task can be planned, completed, missed, or moved to -/// backlog. A locked item, by contrast, acts more like a calendar constraint -/// than a normal task card. Keeping behavior and lifecycle separate makes later -/// UI and persistence logic easier to reason about. -enum TaskType { - /// Movable planned work. - flexible, - - /// Required visible block that should not be moved automatically. - inflexible, - - /// Required visible task that remains actionable if missed. - critical, - - /// Hidden scheduling constraint, not a task card. - locked, - - /// Unplanned completed/logged task. - surprise, - - /// Intentional rest time. - freeSlot, -} - -/// Current lifecycle state of a task. -/// -/// Status is intentionally data-oriented: it describes what happened to a task, -/// not how important it is or how the scheduler should move it. Scheduler rules -/// combine [TaskStatus] with [TaskType]. For instance, a planned flexible task -/// can be pushed, while a planned critical task should remain visible and become -/// backlog if missed. -enum TaskStatus { - /// Scheduled or queued work that has not started. - planned, - - /// Work currently in progress. - active, - - /// Work finished by the user. - completed, - - /// Work that was not completed in its intended time. - missed, - - /// Work intentionally removed from the plan. - cancelled, - - /// Work intentionally dismissed because it no longer applies. - noLongerRelevant, - - /// Unscheduled work kept for later planning. - backlog, -} - -/// User-facing importance level. -/// -/// Priority is a relative ordering hint. It should help decide what rises to the -/// top of a queue, but it should not be treated as an absolute promise that the -/// task must happen at a specific time. The scheduling engine currently ranks -/// this with simple numeric helpers in `backlog.dart`; more advanced heuristics -/// can build on the same enum later. -enum PriorityLevel { - /// Lowest priority. - veryLow, - - /// Low priority. - low, - - /// Default middle priority. - medium, - - /// High priority. - high, - - /// Highest priority. - veryHigh, -} - -/// Expected reward or payoff from completing a task. -/// -/// Reward is meant to capture motivational payoff, not objective value. In this -/// app design it supports ADHD-friendly planning: a small, easy, high-reward task -/// may be a better momentum starter than a large low-reward task. -enum RewardLevel { - /// No reward level has been captured; this is not equivalent to low reward. - notSet, - - /// Very low expected reward. - veryLow, - - /// Low expected reward. - low, - - /// Medium expected reward. - medium, - - /// High expected reward. - high, - - /// Very high expected reward. - veryHigh, -} - -/// Expected effort or activation difficulty for a task. -/// -/// Difficulty is not the same as duration. A five-minute phone call might be -/// very hard to start, while an hour of familiar maintenance may be easy. The -/// backlog view uses this with [RewardLevel] to expose a simple -/// reward-versus-effort sort. -enum DifficultyLevel { - /// No difficulty has been captured yet. - notSet, - - /// Very easy to start or complete. - veryEasy, - - /// Easy to start or complete. - easy, - - /// Medium effort. - medium, - - /// Hard to start or complete. - hard, - - /// Very hard to start or complete. - veryHard, -} - -/// Reminder intensity preference. -/// -/// This is currently stored as project metadata rather than enforced by the core -/// scheduler. Future notification/UI layers can use it to decide how aggressive -/// reminders should be without adding reminder-specific logic to [Task]. -enum ReminderProfile { - /// No reminder nudges. - silent, - - /// Low-friction reminder nudges. - gentle, - - /// Repeated reminder nudges. - persistent, - - /// Strong reminder behavior for required items. - strict, -} - -/// Lightweight backlog-only metadata. -/// -/// Tags here are deliberately narrow. They are not meant to replace a general -/// tagging system. They identify special backlog behavior that the planner needs -/// to understand, such as "wishlist/someday" items. -enum BacklogTag { - /// Task is intentionally saved as a someday/wishlist item. - wishlist, -} - -/// Starter task model for the scheduling core. -/// -/// [Task] is the main domain object passed through the scheduler. It is written -/// as an immutable value object: operations do not mutate an existing task, they -/// return a copied task with changed fields. That approach makes scheduling -/// actions easier to test because every function receives an input list and -/// returns a new output list. -/// -/// Important modeling choices: -/// - [type] controls scheduling behavior: flexible, critical, locked, etc. -/// - [status] controls lifecycle state: planned, completed, backlog, etc. -/// - [scheduledStart] and [scheduledEnd] are optional because backlog items and -/// unscheduled captures do not have timeline placement yet. -/// - [stats] records quiet metadata for future reports. It should not clutter -/// the everyday UI unless a report or filter specifically needs it. -/// - [parentTaskId] links child tasks to a larger parent task without requiring -/// a nested object graph. That keeps persistence simple and avoids recursive -/// scheduling structures. -/// -/// This is still a starter V1 model, so behavior changes should be explicit and -/// backed by tests as the product rules settle. -class Task { - Task({ - required String id, - required String title, - required String projectId, - required this.type, - required this.status, - required this.createdAt, - required this.updatedAt, - this.priority, - this.reward = RewardLevel.notSet, - this.difficulty = DifficultyLevel.notSet, - int? durationMinutes, - this.scheduledStart, - this.scheduledEnd, - this.actualStart, - this.actualEnd, - this.completedAt, - String? parentTaskId, - Set backlogTags = const {}, - this.reminderOverride, - this.stats = const TaskStatistics(), - }) : id = _requiredTrimmed( - id, - 'id', - DomainValidationCode.blankStableId, - 'Stable id is required.', - ), - title = _requiredTrimmed( - title, - 'title', - DomainValidationCode.blankTitle, - 'Title is required.', - ), - projectId = _requiredTrimmed( - projectId, - 'projectId', - DomainValidationCode.blankProjectId, - 'Project id is required.', - ), - durationMinutes = durationMinutes, - parentTaskId = _nullableTrimmed( - parentTaskId, - 'parentTaskId', - DomainValidationCode.blankParentTaskId, - 'Parent task id cannot be blank.', - ), - backlogTags = Set.unmodifiable(backlogTags) { - _validatePositiveDuration(durationMinutes, 'durationMinutes'); - _validateOptionalInterval( - start: scheduledStart, - end: scheduledEnd, - partialCode: DomainValidationCode.partialScheduledInterval, - invalidCode: DomainValidationCode.invalidScheduledInterval, - name: 'scheduledStart/scheduledEnd', - partialMessage: - 'Scheduled start and scheduled end must both be present or absent.', - invalidMessage: 'Scheduled end must be after scheduled start.', - ); - _validateOptionalInterval( - start: actualStart, - end: actualEnd, - partialCode: DomainValidationCode.partialActualInterval, - invalidCode: DomainValidationCode.invalidActualInterval, - name: 'actualStart/actualEnd', - partialMessage: - 'Actual start and actual end must both be present or absent.', - invalidMessage: 'Actual end must be after actual start.', - ); - if (this.parentTaskId == this.id) { - throw DomainValidationException( - code: DomainValidationCode.selfParent, - invalidValue: parentTaskId, - name: 'parentTaskId', - message: 'A task cannot be its own parent.', - ); - } - } - - /// Create a minimal captured task without requiring planning details. - /// - /// Quick capture is intentionally forgiving: the user can enter only a title - /// and the system can still create a valid backlog item. Defaults route the - /// item into the inbox project as a medium-priority flexible backlog task. - /// - /// The only hard validation here is that [title] must contain non-whitespace - /// text. Scheduling validation, such as requiring a positive duration, happens - /// in `quick_capture.dart` because that depends on the requested capture flow. - factory Task.quickCapture({ - required String id, - required String title, - required DateTime createdAt, - String projectId = 'inbox', - TaskType type = TaskType.flexible, - TaskStatus status = TaskStatus.backlog, - PriorityLevel priority = PriorityLevel.medium, - RewardLevel reward = RewardLevel.notSet, - DifficultyLevel difficulty = DifficultyLevel.notSet, - Set backlogTags = const {}, - DateTime? updatedAt, - }) { - return Task( - id: id, - title: title.trim(), - projectId: projectId, - type: type, - status: status, - priority: priority, - reward: reward, - difficulty: difficulty, - createdAt: createdAt, - updatedAt: updatedAt ?? createdAt, - backlogTags: backlogTags, - ); - } - - /// Stable identifier used by persistence, UI selection, and scheduler changes. - final String id; - - /// User-facing task title. The model expects this to already be trimmed. - final String title; - - /// Owning project/profile id. `inbox` is used for uncategorized captures. - final String projectId; - - /// Scheduling behavior category. See [TaskType] for rule-level meaning. - final TaskType type; - - /// Current lifecycle state. See [TaskStatus] for state-level meaning. - final TaskStatus status; - - /// Optional importance. Most creation paths default this to medium, but it is - /// nullable to leave room for imports or legacy data that have not set it yet. - final PriorityLevel? priority; - - /// Motivational payoff used by backlog sorting and future planning hints. - final RewardLevel reward; - - /// Activation/effort estimate used by backlog sorting and future planning hints. - final DifficultyLevel difficulty; - - /// Estimated task length. Required for most scheduling operations, optional for - /// backlog capture because not every captured thought has an estimate yet. - final int? durationMinutes; - - /// Inclusive scheduled start time. Null means the task is not currently placed. - final DateTime? scheduledStart; - - /// Exclusive scheduled end time. Null means the task is not currently placed. - final DateTime? scheduledEnd; - - /// Actual work start time, when known after the fact. - final DateTime? actualStart; - - /// Actual work end time, when known after the fact. - final DateTime? actualEnd; - - /// Explicit completion timestamp, distinct from the generic update timestamp. - final DateTime? completedAt; - - /// Parent task id when this task is a child/subtask. Null means top-level task. - final String? parentTaskId; - - /// Backlog-specific flags, such as wishlist/someday behavior. - final Set backlogTags; - - /// Optional task-level reminder override. - final ReminderProfile? reminderOverride; - - /// Creation timestamp used for age/staleness sorting. - final DateTime createdAt; - - /// Last domain-level update timestamp. Scheduling actions set this when moving - /// or changing tasks so persistence and reports can detect recent activity. - final DateTime updatedAt; - - /// Quiet counters for reporting and later heuristics. - final TaskStatistics stats; - - /// Convenience predicate for the task type most scheduler movement operates on. - bool get isFlexible => type == TaskType.flexible; - - /// Critical and inflexible tasks are both visible to the user and treated as - /// blocked time by flexible scheduling. - bool get isRequiredVisible => - type == TaskType.critical || type == TaskType.inflexible; - - /// Locked tasks behave as timeline constraints, not normal interactive cards. - bool get isLocked => type == TaskType.locked; - - /// Backlog status means the task is stored for later and has no active slot. - bool get isBacklog => status == TaskStatus.backlog; - - /// Return a copy with selected fields changed. - /// - /// The core uses this instead of mutation. That matters because scheduling - /// operations often need to produce an auditable before/after result, including - /// [SchedulingChange]-style records elsewhere. - /// - /// [clearSchedule] is a deliberate escape hatch for nullable schedule fields. - /// Without it, passing null would be ambiguous: it could mean "do not change" - /// or "clear this value." When [clearSchedule] is true, both schedule fields - /// are removed even if [scheduledStart] or [scheduledEnd] are omitted. - /// - /// The other `clear*` flags provide the same explicit semantics for nullable - /// fields that the product can remove. - Task copyWith({ - String? id, - String? title, - String? projectId, - TaskType? type, - TaskStatus? status, - PriorityLevel? priority, - RewardLevel? reward, - DifficultyLevel? difficulty, - int? durationMinutes, - DateTime? scheduledStart, - DateTime? scheduledEnd, - DateTime? actualStart, - DateTime? actualEnd, - DateTime? completedAt, - String? parentTaskId, - Set? backlogTags, - ReminderProfile? reminderOverride, - DateTime? createdAt, - DateTime? updatedAt, - TaskStatistics? stats, - bool clearPriority = false, - bool clearDuration = false, - bool clearSchedule = false, - bool clearActualInterval = false, - bool clearCompletion = false, - bool clearParentTask = false, - bool clearReminderOverride = false, - }) { - return Task( - id: id ?? this.id, - title: title ?? this.title, - projectId: projectId ?? this.projectId, - type: type ?? this.type, - status: status ?? this.status, - priority: clearPriority ? null : (priority ?? this.priority), - reward: reward ?? this.reward, - difficulty: difficulty ?? this.difficulty, - durationMinutes: - clearDuration ? null : (durationMinutes ?? this.durationMinutes), - scheduledStart: - clearSchedule ? null : (scheduledStart ?? this.scheduledStart), - scheduledEnd: clearSchedule ? null : (scheduledEnd ?? this.scheduledEnd), - actualStart: - clearActualInterval ? null : (actualStart ?? this.actualStart), - actualEnd: clearActualInterval ? null : (actualEnd ?? this.actualEnd), - completedAt: clearCompletion ? null : (completedAt ?? this.completedAt), - parentTaskId: - clearParentTask ? null : (parentTaskId ?? this.parentTaskId), - backlogTags: backlogTags ?? this.backlogTags, - reminderOverride: clearReminderOverride - ? null - : (reminderOverride ?? this.reminderOverride), - createdAt: createdAt ?? this.createdAt, - updatedAt: updatedAt ?? this.updatedAt, - stats: stats ?? this.stats, - ); - } -} - -/// Starter project defaults used when creating or scheduling tasks. -/// -/// A project profile represents reusable defaults for a group of tasks. UI code -/// can let the user pick a project, then call [createTask] so new tasks inherit -/// a color, default priority, reward, difficulty, reminder profile, and duration. -/// -/// The scheduler itself mostly cares about the resulting [Task] fields. Keeping -/// project defaults separate prevents every scheduling function from needing to -/// know project configuration details. -class ProjectProfile { - ProjectProfile({ - required String id, - required String name, - required String colorKey, - this.defaultPriority = PriorityLevel.medium, - this.defaultReward = RewardLevel.notSet, - this.defaultDifficulty = DifficultyLevel.notSet, - this.defaultReminderProfile = ReminderProfile.gentle, - int? defaultDurationMinutes, - this.archivedAt, - }) : id = _requiredTrimmed( - id, - 'id', - DomainValidationCode.blankStableId, - 'Stable id is required.', - ), - name = _requiredTrimmed( - name, - 'name', - DomainValidationCode.blankName, - 'Project name is required.', - ), - colorKey = _requiredTrimmed( - colorKey, - 'colorKey', - DomainValidationCode.blankColorKey, - 'Project color key is required.', - ), - defaultDurationMinutes = defaultDurationMinutes { - _validatePositiveDuration( - defaultDurationMinutes, - 'defaultDurationMinutes', - ); - } - - /// Stable project id stored on tasks. - final String id; - - /// User-facing project name. - final String name; - - /// Theme/color token for UI rendering. This is a key, not a raw color value. - final String colorKey; - - /// Default importance assigned when a task does not override priority. - final PriorityLevel defaultPriority; - - /// Default motivational payoff assigned when a task does not override reward. - final RewardLevel defaultReward; - - /// Default activation difficulty assigned when a task does not override effort. - final DifficultyLevel defaultDifficulty; - - /// Default reminder behavior for future notification/UI layers. - final ReminderProfile defaultReminderProfile; - - /// Optional duration estimate used for newly created tasks. - final int? defaultDurationMinutes; - - /// When present, this project is hidden from normal pickers but tasks keep - /// their project ids for history and filtering. - final DateTime? archivedAt; - - /// Whether the project has been archived. - bool get isArchived => archivedAt != null; - - /// Create a task using project defaults while allowing explicit overrides. - /// - /// This keeps capture and project-default behavior in one place. Callers can - /// pass only the fields the user explicitly set; everything else falls back to - /// this profile. The created task receives this profile's [id] as [Task.projectId]. - Task createTask({ - required String id, - required String title, - required DateTime createdAt, - TaskType type = TaskType.flexible, - TaskStatus status = TaskStatus.backlog, - PriorityLevel? priority, - RewardLevel? reward, - DifficultyLevel? difficulty, - int? durationMinutes, - DateTime? scheduledStart, - DateTime? scheduledEnd, - DateTime? actualStart, - DateTime? actualEnd, - DateTime? completedAt, - String? parentTaskId, - Set backlogTags = const {}, - ReminderProfile? reminderOverride, - DateTime? updatedAt, - }) { - return Task( - id: id, - title: title, - projectId: this.id, - type: type, - status: status, - priority: priority ?? defaultPriority, - reward: reward ?? defaultReward, - difficulty: difficulty ?? defaultDifficulty, - durationMinutes: durationMinutes ?? defaultDurationMinutes, - scheduledStart: scheduledStart, - scheduledEnd: scheduledEnd, - actualStart: actualStart, - actualEnd: actualEnd, - completedAt: completedAt, - parentTaskId: parentTaskId, - backlogTags: backlogTags, - reminderOverride: reminderOverride, - createdAt: createdAt, - updatedAt: updatedAt ?? createdAt, - ); - } - - /// Return a copy with selected project defaults changed. - ProjectProfile copyWith({ - String? id, - String? name, - String? colorKey, - PriorityLevel? defaultPriority, - RewardLevel? defaultReward, - DifficultyLevel? defaultDifficulty, - ReminderProfile? defaultReminderProfile, - int? defaultDurationMinutes, - bool clearDefaultDuration = false, - DateTime? archivedAt, - bool clearArchivedAt = false, - }) { - return ProjectProfile( - id: id ?? this.id, - name: name ?? this.name, - colorKey: colorKey ?? this.colorKey, - defaultPriority: defaultPriority ?? this.defaultPriority, - defaultReward: defaultReward ?? this.defaultReward, - defaultDifficulty: defaultDifficulty ?? this.defaultDifficulty, - defaultReminderProfile: - defaultReminderProfile ?? this.defaultReminderProfile, - defaultDurationMinutes: clearDefaultDuration - ? null - : (defaultDurationMinutes ?? this.defaultDurationMinutes), - archivedAt: clearArchivedAt ? null : (archivedAt ?? this.archivedAt), - ); - } -} - -/// Starter time range value used by scheduling helpers. -/// -/// [TimeInterval] is the scheduler's neutral representation of a time span. It -/// is used for scheduled task slots, locked blocks, required visible blocks, and -/// candidate placements. The interval convention is start-inclusive and -/// end-exclusive, which avoids treating two back-to-back blocks as overlapping. -class TimeInterval { - TimeInterval({ - required this.start, - required this.end, - this.label, - }) { - if (!start.isBefore(end)) { - throw DomainValidationException( - code: DomainValidationCode.invalidTimeInterval, - invalidValue: {'start': start, 'end': end}, - name: 'TimeInterval', - message: 'Interval end must be after interval start.', - ); - } - } - - /// Inclusive beginning of the interval. - final DateTime start; - - /// Exclusive ending of the interval. - final DateTime end; - - /// Optional debug/UI label, often a task id or locked block name. - final String? label; - - /// Raw duration between [start] and [end]. Callers are responsible for only - /// constructing meaningful positive intervals when required by a rule. - Duration get duration => end.difference(start); - - /// Whether this interval shares any actual time with [other]. - /// - /// Adjacent intervals do not overlap: `9:00-10:00` and `10:00-11:00` are safe - /// to place back-to-back because the first interval's end is the second - /// interval's start. - bool overlaps(TimeInterval other) { - return start.isBefore(other.end) && end.isAfter(other.start); - } -} - -String _requiredTrimmed( - String value, - String name, - DomainValidationCode code, - String message, -) { - final trimmed = value.trim(); - if (trimmed.isEmpty) { - throw DomainValidationException( - code: code, - invalidValue: value, - name: name, - message: message, - ); - } - return trimmed; -} - -String? _nullableTrimmed( - String? value, - String name, - DomainValidationCode code, - String message, -) { - if (value == null) { - return null; - } - final trimmed = value.trim(); - if (trimmed.isEmpty) { - throw DomainValidationException( - code: code, - invalidValue: value, - name: name, - message: message, - ); - } - return trimmed; -} - -void _validatePositiveDuration(int? durationMinutes, String name) { - if (durationMinutes == null) { - return; - } - if (durationMinutes <= 0) { - throw DomainValidationException( - code: DomainValidationCode.nonPositiveDuration, - invalidValue: durationMinutes, - name: name, - message: 'Duration must be positive when present.', - ); - } -} - -void _validateOptionalInterval({ - required DateTime? start, - required DateTime? end, - required DomainValidationCode partialCode, - required DomainValidationCode invalidCode, - required String name, - required String partialMessage, - required String invalidMessage, -}) { - if ((start == null) != (end == null)) { - throw DomainValidationException( - code: partialCode, - invalidValue: {'start': start, 'end': end}, - name: name, - message: partialMessage, - ); - } - if (start != null && end != null && !start.isBefore(end)) { - throw DomainValidationException( - code: invalidCode, - invalidValue: {'start': start, 'end': end}, - name: name, - message: invalidMessage, - ); - } -} +part 'models/domain_validation_code.dart'; +part 'models/domain_validation_exception.dart'; +part 'models/task_type.dart'; +part 'models/task_status.dart'; +part 'models/priority_level.dart'; +part 'models/reward_level.dart'; +part 'models/difficulty_level.dart'; +part 'models/reminder_profile.dart'; +part 'models/backlog_tag.dart'; +part 'models/task.dart'; +part 'models/project_profile.dart'; +part 'models/time_interval.dart'; diff --git a/packages/scheduler_core/lib/src/models/backlog_tag.dart b/packages/scheduler_core/lib/src/models/backlog_tag.dart new file mode 100644 index 0000000..cf697fd --- /dev/null +++ b/packages/scheduler_core/lib/src/models/backlog_tag.dart @@ -0,0 +1,11 @@ +part of '../models.dart'; + +/// Lightweight backlog-only metadata. +/// +/// Tags here are deliberately narrow. They are not meant to replace a general +/// tagging system. They identify special backlog behavior that the planner needs +/// to understand, such as "wishlist/someday" items. +enum BacklogTag { + /// Task is intentionally saved as a someday/wishlist item. + wishlist, +} diff --git a/packages/scheduler_core/lib/src/models/difficulty_level.dart b/packages/scheduler_core/lib/src/models/difficulty_level.dart new file mode 100644 index 0000000..2ea21b6 --- /dev/null +++ b/packages/scheduler_core/lib/src/models/difficulty_level.dart @@ -0,0 +1,27 @@ +part of '../models.dart'; + +/// Expected effort or activation difficulty for a task. +/// +/// Difficulty is not the same as duration. A five-minute phone call might be +/// very hard to start, while an hour of familiar maintenance may be easy. The +/// backlog view uses this with [RewardLevel] to expose a simple +/// reward-versus-effort sort. +enum DifficultyLevel { + /// No difficulty has been captured yet. + notSet, + + /// Very easy to start or complete. + veryEasy, + + /// Easy to start or complete. + easy, + + /// Medium effort. + medium, + + /// Hard to start or complete. + hard, + + /// Very hard to start or complete. + veryHard, +} diff --git a/packages/scheduler_core/lib/src/models/domain_validation_code.dart b/packages/scheduler_core/lib/src/models/domain_validation_code.dart new file mode 100644 index 0000000..0da2848 --- /dev/null +++ b/packages/scheduler_core/lib/src/models/domain_validation_code.dart @@ -0,0 +1,31 @@ +part of '../models.dart'; + +/// Stable validation failure categories for domain model boundaries. +/// +/// Callers should branch on these codes instead of parsing exception text. The +/// explanatory messages may change, but these enum values are part of the V1 +/// backend contract. +enum DomainValidationCode { + blankStableId, + blankTitle, + blankProjectId, + blankName, + blankColorKey, + blankParentTaskId, + selfParent, + nonPositiveDuration, + partialScheduledInterval, + invalidScheduledInterval, + partialActualInterval, + invalidActualInterval, + invalidTimeInterval, + invalidSchedulingWindow, + invalidCivilDate, + invalidClockTime, + blankTimeZoneId, + nonexistentLocalTime, + ambiguousLocalTime, + emptyRecurrence, + invalidLockedBlock, + invalidLockedOverride, +} diff --git a/packages/scheduler_core/lib/src/models/domain_validation_exception.dart b/packages/scheduler_core/lib/src/models/domain_validation_exception.dart new file mode 100644 index 0000000..9b90db6 --- /dev/null +++ b/packages/scheduler_core/lib/src/models/domain_validation_exception.dart @@ -0,0 +1,14 @@ +part of '../models.dart'; + +/// Typed validation error thrown by domain model constructors. +class DomainValidationException extends ArgumentError { + DomainValidationException({ + required this.code, + required Object? invalidValue, + required String name, + required String message, + }) : super.value(invalidValue, name, message); + + /// Stable category for application and persistence layers. + final DomainValidationCode code; +} diff --git a/packages/scheduler_core/lib/src/models/priority_level.dart b/packages/scheduler_core/lib/src/models/priority_level.dart new file mode 100644 index 0000000..a29a39e --- /dev/null +++ b/packages/scheduler_core/lib/src/models/priority_level.dart @@ -0,0 +1,25 @@ +part of '../models.dart'; + +/// User-facing importance level. +/// +/// Priority is a relative ordering hint. It should help decide what rises to the +/// top of a queue, but it should not be treated as an absolute promise that the +/// task must happen at a specific time. The scheduling engine currently ranks +/// this with simple numeric helpers in `backlog.dart`; more advanced heuristics +/// can build on the same enum later. +enum PriorityLevel { + /// Lowest priority. + veryLow, + + /// Low priority. + low, + + /// Default middle priority. + medium, + + /// High priority. + high, + + /// Highest priority. + veryHigh, +} diff --git a/packages/scheduler_core/lib/src/models/project_profile.dart b/packages/scheduler_core/lib/src/models/project_profile.dart new file mode 100644 index 0000000..a1480d5 --- /dev/null +++ b/packages/scheduler_core/lib/src/models/project_profile.dart @@ -0,0 +1,156 @@ +part of '../models.dart'; + +/// Starter project defaults used when creating or scheduling tasks. +/// +/// A project profile represents reusable defaults for a group of tasks. UI code +/// can let the user pick a project, then call [createTask] so new tasks inherit +/// a color, default priority, reward, difficulty, reminder profile, and duration. +/// +/// The scheduler itself mostly cares about the resulting [Task] fields. Keeping +/// project defaults separate prevents every scheduling function from needing to +/// know project configuration details. +class ProjectProfile { + ProjectProfile({ + required String id, + required String name, + required String colorKey, + this.defaultPriority = PriorityLevel.medium, + this.defaultReward = RewardLevel.notSet, + this.defaultDifficulty = DifficultyLevel.notSet, + this.defaultReminderProfile = ReminderProfile.gentle, + int? defaultDurationMinutes, + this.archivedAt, + }) : id = _requiredTrimmed( + id, + 'id', + DomainValidationCode.blankStableId, + 'Stable id is required.', + ), + name = _requiredTrimmed( + name, + 'name', + DomainValidationCode.blankName, + 'Project name is required.', + ), + colorKey = _requiredTrimmed( + colorKey, + 'colorKey', + DomainValidationCode.blankColorKey, + 'Project color key is required.', + ), + defaultDurationMinutes = defaultDurationMinutes { + _validatePositiveDuration( + defaultDurationMinutes, + 'defaultDurationMinutes', + ); + } + + /// Stable project id stored on tasks. + final String id; + + /// User-facing project name. + final String name; + + /// Theme/color token for UI rendering. This is a key, not a raw color value. + final String colorKey; + + /// Default importance assigned when a task does not override priority. + final PriorityLevel defaultPriority; + + /// Default motivational payoff assigned when a task does not override reward. + final RewardLevel defaultReward; + + /// Default activation difficulty assigned when a task does not override effort. + final DifficultyLevel defaultDifficulty; + + /// Default reminder behavior for future notification/UI layers. + final ReminderProfile defaultReminderProfile; + + /// Optional duration estimate used for newly created tasks. + final int? defaultDurationMinutes; + + /// When present, this project is hidden from normal pickers but tasks keep + /// their project ids for history and filtering. + final DateTime? archivedAt; + + /// Whether the project has been archived. + bool get isArchived => archivedAt != null; + + /// Create a task using project defaults while allowing explicit overrides. + /// + /// This keeps capture and project-default behavior in one place. Callers can + /// pass only the fields the user explicitly set; everything else falls back to + /// this profile. The created task receives this profile's [id] as [Task.projectId]. + Task createTask({ + required String id, + required String title, + required DateTime createdAt, + TaskType type = TaskType.flexible, + TaskStatus status = TaskStatus.backlog, + PriorityLevel? priority, + RewardLevel? reward, + DifficultyLevel? difficulty, + int? durationMinutes, + DateTime? scheduledStart, + DateTime? scheduledEnd, + DateTime? actualStart, + DateTime? actualEnd, + DateTime? completedAt, + String? parentTaskId, + Set backlogTags = const {}, + ReminderProfile? reminderOverride, + DateTime? updatedAt, + }) { + return Task( + id: id, + title: title, + projectId: this.id, + type: type, + status: status, + priority: priority ?? defaultPriority, + reward: reward ?? defaultReward, + difficulty: difficulty ?? defaultDifficulty, + durationMinutes: durationMinutes ?? defaultDurationMinutes, + scheduledStart: scheduledStart, + scheduledEnd: scheduledEnd, + actualStart: actualStart, + actualEnd: actualEnd, + completedAt: completedAt, + parentTaskId: parentTaskId, + backlogTags: backlogTags, + reminderOverride: reminderOverride, + createdAt: createdAt, + updatedAt: updatedAt ?? createdAt, + ); + } + + /// Return a copy with selected project defaults changed. + ProjectProfile copyWith({ + String? id, + String? name, + String? colorKey, + PriorityLevel? defaultPriority, + RewardLevel? defaultReward, + DifficultyLevel? defaultDifficulty, + ReminderProfile? defaultReminderProfile, + int? defaultDurationMinutes, + bool clearDefaultDuration = false, + DateTime? archivedAt, + bool clearArchivedAt = false, + }) { + return ProjectProfile( + id: id ?? this.id, + name: name ?? this.name, + colorKey: colorKey ?? this.colorKey, + defaultPriority: defaultPriority ?? this.defaultPriority, + defaultReward: defaultReward ?? this.defaultReward, + defaultDifficulty: defaultDifficulty ?? this.defaultDifficulty, + defaultReminderProfile: + defaultReminderProfile ?? this.defaultReminderProfile, + defaultDurationMinutes: clearDefaultDuration + ? null + : (defaultDurationMinutes ?? this.defaultDurationMinutes), + archivedAt: clearArchivedAt ? null : (archivedAt ?? this.archivedAt), + ); + } +} diff --git a/packages/scheduler_core/lib/src/models/reminder_profile.dart b/packages/scheduler_core/lib/src/models/reminder_profile.dart new file mode 100644 index 0000000..edf1deb --- /dev/null +++ b/packages/scheduler_core/lib/src/models/reminder_profile.dart @@ -0,0 +1,20 @@ +part of '../models.dart'; + +/// Reminder intensity preference. +/// +/// This is currently stored as project metadata rather than enforced by the core +/// scheduler. Future notification/UI layers can use it to decide how aggressive +/// reminders should be without adding reminder-specific logic to [Task]. +enum ReminderProfile { + /// No reminder nudges. + silent, + + /// Low-friction reminder nudges. + gentle, + + /// Repeated reminder nudges. + persistent, + + /// Strong reminder behavior for required items. + strict, +} diff --git a/packages/scheduler_core/lib/src/models/reward_level.dart b/packages/scheduler_core/lib/src/models/reward_level.dart new file mode 100644 index 0000000..489ea4f --- /dev/null +++ b/packages/scheduler_core/lib/src/models/reward_level.dart @@ -0,0 +1,26 @@ +part of '../models.dart'; + +/// Expected reward or payoff from completing a task. +/// +/// Reward is meant to capture motivational payoff, not objective value. In this +/// app design it supports ADHD-friendly planning: a small, easy, high-reward task +/// may be a better momentum starter than a large low-reward task. +enum RewardLevel { + /// No reward level has been captured; this is not equivalent to low reward. + notSet, + + /// Very low expected reward. + veryLow, + + /// Low expected reward. + low, + + /// Medium expected reward. + medium, + + /// High expected reward. + high, + + /// Very high expected reward. + veryHigh, +} diff --git a/packages/scheduler_core/lib/src/models/task.dart b/packages/scheduler_core/lib/src/models/task.dart new file mode 100644 index 0000000..ff9c1e2 --- /dev/null +++ b/packages/scheduler_core/lib/src/models/task.dart @@ -0,0 +1,288 @@ +part of '../models.dart'; + +/// Starter task model for the scheduling core. +/// +/// [Task] is the main domain object passed through the scheduler. It is written +/// as an immutable value object: operations do not mutate an existing task, they +/// return a copied task with changed fields. That approach makes scheduling +/// actions easier to test because every function receives an input list and +/// returns a new output list. +/// +/// Important modeling choices: +/// - [type] controls scheduling behavior: flexible, critical, locked, etc. +/// - [status] controls lifecycle state: planned, completed, backlog, etc. +/// - [scheduledStart] and [scheduledEnd] are optional because backlog items and +/// unscheduled captures do not have timeline placement yet. +/// - [stats] records quiet metadata for future reports. It should not clutter +/// the everyday UI unless a report or filter specifically needs it. +/// - [parentTaskId] links child tasks to a larger parent task without requiring +/// a nested object graph. That keeps persistence simple and avoids recursive +/// scheduling structures. +/// +/// This is still a starter V1 model, so behavior changes should be explicit and +/// backed by tests as the product rules settle. +class Task { + Task({ + required String id, + required String title, + required String projectId, + required this.type, + required this.status, + required this.createdAt, + required this.updatedAt, + this.priority, + this.reward = RewardLevel.notSet, + this.difficulty = DifficultyLevel.notSet, + int? durationMinutes, + this.scheduledStart, + this.scheduledEnd, + this.actualStart, + this.actualEnd, + this.completedAt, + String? parentTaskId, + Set backlogTags = const {}, + this.reminderOverride, + this.stats = const TaskStatistics(), + }) : id = _requiredTrimmed( + id, + 'id', + DomainValidationCode.blankStableId, + 'Stable id is required.', + ), + title = _requiredTrimmed( + title, + 'title', + DomainValidationCode.blankTitle, + 'Title is required.', + ), + projectId = _requiredTrimmed( + projectId, + 'projectId', + DomainValidationCode.blankProjectId, + 'Project id is required.', + ), + durationMinutes = durationMinutes, + parentTaskId = _nullableTrimmed( + parentTaskId, + 'parentTaskId', + DomainValidationCode.blankParentTaskId, + 'Parent task id cannot be blank.', + ), + backlogTags = Set.unmodifiable(backlogTags) { + _validatePositiveDuration(durationMinutes, 'durationMinutes'); + _validateOptionalInterval( + start: scheduledStart, + end: scheduledEnd, + partialCode: DomainValidationCode.partialScheduledInterval, + invalidCode: DomainValidationCode.invalidScheduledInterval, + name: 'scheduledStart/scheduledEnd', + partialMessage: + 'Scheduled start and scheduled end must both be present or absent.', + invalidMessage: 'Scheduled end must be after scheduled start.', + ); + _validateOptionalInterval( + start: actualStart, + end: actualEnd, + partialCode: DomainValidationCode.partialActualInterval, + invalidCode: DomainValidationCode.invalidActualInterval, + name: 'actualStart/actualEnd', + partialMessage: + 'Actual start and actual end must both be present or absent.', + invalidMessage: 'Actual end must be after actual start.', + ); + if (this.parentTaskId == this.id) { + throw DomainValidationException( + code: DomainValidationCode.selfParent, + invalidValue: parentTaskId, + name: 'parentTaskId', + message: 'A task cannot be its own parent.', + ); + } + } + + /// Create a minimal captured task without requiring planning details. + /// + /// Quick capture is intentionally forgiving: the user can enter only a title + /// and the system can still create a valid backlog item. Defaults route the + /// item into the inbox project as a medium-priority flexible backlog task. + /// + /// The only hard validation here is that [title] must contain non-whitespace + /// text. Scheduling validation, such as requiring a positive duration, happens + /// in `quick_capture.dart` because that depends on the requested capture flow. + factory Task.quickCapture({ + required String id, + required String title, + required DateTime createdAt, + String projectId = 'inbox', + TaskType type = TaskType.flexible, + TaskStatus status = TaskStatus.backlog, + PriorityLevel priority = PriorityLevel.medium, + RewardLevel reward = RewardLevel.notSet, + DifficultyLevel difficulty = DifficultyLevel.notSet, + Set backlogTags = const {}, + DateTime? updatedAt, + }) { + return Task( + id: id, + title: title.trim(), + projectId: projectId, + type: type, + status: status, + priority: priority, + reward: reward, + difficulty: difficulty, + createdAt: createdAt, + updatedAt: updatedAt ?? createdAt, + backlogTags: backlogTags, + ); + } + + /// Stable identifier used by persistence, UI selection, and scheduler changes. + final String id; + + /// User-facing task title. The model expects this to already be trimmed. + final String title; + + /// Owning project/profile id. `inbox` is used for uncategorized captures. + final String projectId; + + /// Scheduling behavior category. See [TaskType] for rule-level meaning. + final TaskType type; + + /// Current lifecycle state. See [TaskStatus] for state-level meaning. + final TaskStatus status; + + /// Optional importance. Most creation paths default this to medium, but it is + /// nullable to leave room for imports or legacy data that have not set it yet. + final PriorityLevel? priority; + + /// Motivational payoff used by backlog sorting and future planning hints. + final RewardLevel reward; + + /// Activation/effort estimate used by backlog sorting and future planning hints. + final DifficultyLevel difficulty; + + /// Estimated task length. Required for most scheduling operations, optional for + /// backlog capture because not every captured thought has an estimate yet. + final int? durationMinutes; + + /// Inclusive scheduled start time. Null means the task is not currently placed. + final DateTime? scheduledStart; + + /// Exclusive scheduled end time. Null means the task is not currently placed. + final DateTime? scheduledEnd; + + /// Actual work start time, when known after the fact. + final DateTime? actualStart; + + /// Actual work end time, when known after the fact. + final DateTime? actualEnd; + + /// Explicit completion timestamp, distinct from the generic update timestamp. + final DateTime? completedAt; + + /// Parent task id when this task is a child/subtask. Null means top-level task. + final String? parentTaskId; + + /// Backlog-specific flags, such as wishlist/someday behavior. + final Set backlogTags; + + /// Optional task-level reminder override. + final ReminderProfile? reminderOverride; + + /// Creation timestamp used for age/staleness sorting. + final DateTime createdAt; + + /// Last domain-level update timestamp. Scheduling actions set this when moving + /// or changing tasks so persistence and reports can detect recent activity. + final DateTime updatedAt; + + /// Quiet counters for reporting and later heuristics. + final TaskStatistics stats; + + /// Convenience predicate for the task type most scheduler movement operates on. + bool get isFlexible => type == TaskType.flexible; + + /// Critical and inflexible tasks are both visible to the user and treated as + /// blocked time by flexible scheduling. + bool get isRequiredVisible => + type == TaskType.critical || type == TaskType.inflexible; + + /// Locked tasks behave as timeline constraints, not normal interactive cards. + bool get isLocked => type == TaskType.locked; + + /// Backlog status means the task is stored for later and has no active slot. + bool get isBacklog => status == TaskStatus.backlog; + + /// Return a copy with selected fields changed. + /// + /// The core uses this instead of mutation. That matters because scheduling + /// operations often need to produce an auditable before/after result, including + /// [SchedulingChange]-style records elsewhere. + /// + /// [clearSchedule] is a deliberate escape hatch for nullable schedule fields. + /// Without it, passing null would be ambiguous: it could mean "do not change" + /// or "clear this value." When [clearSchedule] is true, both schedule fields + /// are removed even if [scheduledStart] or [scheduledEnd] are omitted. + /// + /// The other `clear*` flags provide the same explicit semantics for nullable + /// fields that the product can remove. + Task copyWith({ + String? id, + String? title, + String? projectId, + TaskType? type, + TaskStatus? status, + PriorityLevel? priority, + RewardLevel? reward, + DifficultyLevel? difficulty, + int? durationMinutes, + DateTime? scheduledStart, + DateTime? scheduledEnd, + DateTime? actualStart, + DateTime? actualEnd, + DateTime? completedAt, + String? parentTaskId, + Set? backlogTags, + ReminderProfile? reminderOverride, + DateTime? createdAt, + DateTime? updatedAt, + TaskStatistics? stats, + bool clearPriority = false, + bool clearDuration = false, + bool clearSchedule = false, + bool clearActualInterval = false, + bool clearCompletion = false, + bool clearParentTask = false, + bool clearReminderOverride = false, + }) { + return Task( + id: id ?? this.id, + title: title ?? this.title, + projectId: projectId ?? this.projectId, + type: type ?? this.type, + status: status ?? this.status, + priority: clearPriority ? null : (priority ?? this.priority), + reward: reward ?? this.reward, + difficulty: difficulty ?? this.difficulty, + durationMinutes: + clearDuration ? null : (durationMinutes ?? this.durationMinutes), + scheduledStart: + clearSchedule ? null : (scheduledStart ?? this.scheduledStart), + scheduledEnd: clearSchedule ? null : (scheduledEnd ?? this.scheduledEnd), + actualStart: + clearActualInterval ? null : (actualStart ?? this.actualStart), + actualEnd: clearActualInterval ? null : (actualEnd ?? this.actualEnd), + completedAt: clearCompletion ? null : (completedAt ?? this.completedAt), + parentTaskId: + clearParentTask ? null : (parentTaskId ?? this.parentTaskId), + backlogTags: backlogTags ?? this.backlogTags, + reminderOverride: clearReminderOverride + ? null + : (reminderOverride ?? this.reminderOverride), + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + stats: stats ?? this.stats, + ); + } +} diff --git a/packages/scheduler_core/lib/src/models/task_status.dart b/packages/scheduler_core/lib/src/models/task_status.dart new file mode 100644 index 0000000..6c92737 --- /dev/null +++ b/packages/scheduler_core/lib/src/models/task_status.dart @@ -0,0 +1,31 @@ +part of '../models.dart'; + +/// Current lifecycle state of a task. +/// +/// Status is intentionally data-oriented: it describes what happened to a task, +/// not how important it is or how the scheduler should move it. Scheduler rules +/// combine [TaskStatus] with [TaskType]. For instance, a planned flexible task +/// can be pushed, while a planned critical task should remain visible and become +/// backlog if missed. +enum TaskStatus { + /// Scheduled or queued work that has not started. + planned, + + /// Work currently in progress. + active, + + /// Work finished by the user. + completed, + + /// Work that was not completed in its intended time. + missed, + + /// Work intentionally removed from the plan. + cancelled, + + /// Work intentionally dismissed because it no longer applies. + noLongerRelevant, + + /// Unscheduled work kept for later planning. + backlog, +} diff --git a/packages/scheduler_core/lib/src/models/task_type.dart b/packages/scheduler_core/lib/src/models/task_type.dart new file mode 100644 index 0000000..1d5c86f --- /dev/null +++ b/packages/scheduler_core/lib/src/models/task_type.dart @@ -0,0 +1,31 @@ +part of '../models.dart'; + +/// Scheduling behavior category. +/// +/// This enum is one of the central concepts in the planner. The type answers +/// the question: "how should the scheduler treat this item?" It is separate +/// from [TaskStatus], which answers "where is this item in its lifecycle?" +/// +/// For example, a flexible task can be planned, completed, missed, or moved to +/// backlog. A locked item, by contrast, acts more like a calendar constraint +/// than a normal task card. Keeping behavior and lifecycle separate makes later +/// UI and persistence logic easier to reason about. +enum TaskType { + /// Movable planned work. + flexible, + + /// Required visible block that should not be moved automatically. + inflexible, + + /// Required visible task that remains actionable if missed. + critical, + + /// Hidden scheduling constraint, not a task card. + locked, + + /// Unplanned completed/logged task. + surprise, + + /// Intentional rest time. + freeSlot, +} diff --git a/packages/scheduler_core/lib/src/models/time_interval.dart b/packages/scheduler_core/lib/src/models/time_interval.dart new file mode 100644 index 0000000..2338b08 --- /dev/null +++ b/packages/scheduler_core/lib/src/models/time_interval.dart @@ -0,0 +1,126 @@ +part of '../models.dart'; + +/// Starter time range value used by scheduling helpers. +/// +/// [TimeInterval] is the scheduler's neutral representation of a time span. It +/// is used for scheduled task slots, locked blocks, required visible blocks, and +/// candidate placements. The interval convention is start-inclusive and +/// end-exclusive, which avoids treating two back-to-back blocks as overlapping. +class TimeInterval { + TimeInterval({ + required this.start, + required this.end, + this.label, + }) { + if (!start.isBefore(end)) { + throw DomainValidationException( + code: DomainValidationCode.invalidTimeInterval, + invalidValue: {'start': start, 'end': end}, + name: 'TimeInterval', + message: 'Interval end must be after interval start.', + ); + } + } + + /// Inclusive beginning of the interval. + final DateTime start; + + /// Exclusive ending of the interval. + final DateTime end; + + /// Optional debug/UI label, often a task id or locked block name. + final String? label; + + /// Raw duration between [start] and [end]. Callers are responsible for only + /// constructing meaningful positive intervals when required by a rule. + Duration get duration => end.difference(start); + + /// Whether this interval shares any actual time with [other]. + /// + /// Adjacent intervals do not overlap: `9:00-10:00` and `10:00-11:00` are safe + /// to place back-to-back because the first interval's end is the second + /// interval's start. + bool overlaps(TimeInterval other) { + return start.isBefore(other.end) && end.isAfter(other.start); + } +} + +String _requiredTrimmed( + String value, + String name, + DomainValidationCode code, + String message, +) { + final trimmed = value.trim(); + if (trimmed.isEmpty) { + throw DomainValidationException( + code: code, + invalidValue: value, + name: name, + message: message, + ); + } + return trimmed; +} + +String? _nullableTrimmed( + String? value, + String name, + DomainValidationCode code, + String message, +) { + if (value == null) { + return null; + } + final trimmed = value.trim(); + if (trimmed.isEmpty) { + throw DomainValidationException( + code: code, + invalidValue: value, + name: name, + message: message, + ); + } + return trimmed; +} + +void _validatePositiveDuration(int? durationMinutes, String name) { + if (durationMinutes == null) { + return; + } + if (durationMinutes <= 0) { + throw DomainValidationException( + code: DomainValidationCode.nonPositiveDuration, + invalidValue: durationMinutes, + name: name, + message: 'Duration must be positive when present.', + ); + } +} + +void _validateOptionalInterval({ + required DateTime? start, + required DateTime? end, + required DomainValidationCode partialCode, + required DomainValidationCode invalidCode, + required String name, + required String partialMessage, + required String invalidMessage, +}) { + if ((start == null) != (end == null)) { + throw DomainValidationException( + code: partialCode, + invalidValue: {'start': start, 'end': end}, + name: name, + message: partialMessage, + ); + } + if (start != null && end != null && !start.isBefore(end)) { + throw DomainValidationException( + code: invalidCode, + invalidValue: {'start': start, 'end': end}, + name: name, + message: invalidMessage, + ); + } +} diff --git a/packages/scheduler_core/lib/src/occupancy_policy.dart b/packages/scheduler_core/lib/src/occupancy_policy.dart index 4cf99ec..523ee56 100644 --- a/packages/scheduler_core/lib/src/occupancy_policy.dart +++ b/packages/scheduler_core/lib/src/occupancy_policy.dart @@ -8,338 +8,7 @@ library; // non-occupying for scheduling purposes independent of how a view renders it. import 'models.dart'; - -/// Scheduling-facing occupancy category for one task or external interval. -enum OccupancyCategory { - /// Hidden locked time that constrains scheduling. - hiddenLockedConstraint, - - /// Visible critical or inflexible commitment. - requiredVisibleCommitment, - - /// Intentional protected rest. - protectedFreeSlot, - - /// Planned flexible work that automatic scheduling may move. - movablePlannedFlexible, - - /// Work currently in progress. - activeWork, - - /// Known actual historical occupancy from completed work. - completedActualOccupancy, - - /// Historical missed placement retained as context. - retainedMissedHistoricalInterval, - - /// Record that should not occupy the scheduling window. - nonOccupyingRecord, -} - -/// Where an occupancy entry came from. -enum OccupancySource { - /// A [Task] record. - task, - - /// A hidden locked interval supplied outside the task list. - lockedInterval, - - /// A visible required interval supplied outside the task list. - requiredVisibleInterval, -} - -/// Policy classification for one task or external interval. -class OccupancyEntry { - const OccupancyEntry({ - required this.source, - required this.category, - required this.interval, - required this.task, - required this.isImmovable, - required this.blocksAutomaticFlexiblePlacement, - required this.mayBeExplicitlyOverlappedByRequiredCommitment, - required this.reportsConflict, - required this.hiddenByDefault, - }); - - /// Origin of the entry. - final OccupancySource source; - - /// Scheduling-facing category. - final OccupancyCategory category; - - /// Interval used by scheduling, or null when the record is unplaced. - final TimeInterval? interval; - - /// Source task when this entry came from a task record. - final Task? task; - - /// Stable task id when present. - String? get taskId => task?.id; - - /// Whether automatic scheduling must leave this entry's placement unchanged. - final bool isImmovable; - - /// Whether flexible placement must avoid [interval]. - final bool blocksAutomaticFlexiblePlacement; - - /// Whether an explicit critical/inflexible commitment may overlap this entry. - final bool mayBeExplicitlyOverlappedByRequiredCommitment; - - /// Whether overlaps with this entry should be returned as conflicts. - final bool reportsConflict; - - /// Whether entry details should be hidden by default in user-facing output. - final bool hiddenByDefault; - - /// Whether the entry is planned flexible work movable by automatic scheduling. - bool get isMovablePlannedFlexible { - return category == OccupancyCategory.movablePlannedFlexible; - } -} - -/// UI-independent V1 occupancy policy. -class OccupancyPolicy { - const OccupancyPolicy(); - - /// Classify all task and external interval inputs for a scheduling operation. - List classify({ - required Iterable tasks, - Iterable lockedIntervals = const [], - Iterable requiredVisibleIntervals = const [], - }) { - return List.unmodifiable([ - ...tasks.map(classifyTask), - ...lockedIntervals.map(classifyLockedInterval), - ...requiredVisibleIntervals.map(classifyRequiredVisibleInterval), - ]); - } - - /// Classify one task record. - OccupancyEntry classifyTask(Task task) { - final scheduledInterval = _scheduledIntervalFor(task); - final actualInterval = _actualIntervalFor(task); - - if (_isNonOccupyingStatus(task.status)) { - return _taskEntry( - task: task, - category: OccupancyCategory.nonOccupyingRecord, - interval: null, - ); - } - - if (task.status == TaskStatus.completed) { - final completedInterval = actualInterval ?? - (task.type == TaskType.surprise ? scheduledInterval : null); - - if (completedInterval == null) { - return _taskEntry( - task: task, - category: OccupancyCategory.nonOccupyingRecord, - interval: null, - ); - } - - return _taskEntry( - task: task, - category: OccupancyCategory.completedActualOccupancy, - interval: completedInterval, - ); - } - - if (task.status == TaskStatus.missed) { - if (scheduledInterval == null) { - return _taskEntry( - task: task, - category: OccupancyCategory.nonOccupyingRecord, - interval: null, - ); - } - - return _taskEntry( - task: task, - category: OccupancyCategory.retainedMissedHistoricalInterval, - interval: scheduledInterval, - ); - } - - if (task.type == TaskType.locked) { - return _taskEntry( - task: task, - category: OccupancyCategory.hiddenLockedConstraint, - interval: scheduledInterval, - ); - } - - if (task.type == TaskType.freeSlot) { - return _taskEntry( - task: task, - category: OccupancyCategory.protectedFreeSlot, - interval: scheduledInterval, - ); - } - - if (task.status == TaskStatus.active) { - return _taskEntry( - task: task, - category: OccupancyCategory.activeWork, - interval: actualInterval ?? scheduledInterval, - ); - } - - if (task.isRequiredVisible) { - return _taskEntry( - task: task, - category: OccupancyCategory.requiredVisibleCommitment, - interval: scheduledInterval, - ); - } - - if (task.isFlexible && task.status == TaskStatus.planned) { - return _taskEntry( - task: task, - category: OccupancyCategory.movablePlannedFlexible, - interval: scheduledInterval, - ); - } - - // Surprise tasks should normally be completed records. If an unsupported - // lifecycle combination appears, keep it out of automatic placement instead - // of inventing movement behavior for it. - return _taskEntry( - task: task, - category: OccupancyCategory.nonOccupyingRecord, - interval: null, - ); - } - - /// Classify one hidden locked interval supplied outside the task list. - OccupancyEntry classifyLockedInterval(TimeInterval interval) { - return _entry( - source: OccupancySource.lockedInterval, - category: OccupancyCategory.hiddenLockedConstraint, - interval: interval, - task: null, - ); - } - - /// Classify one visible required interval supplied outside the task list. - OccupancyEntry classifyRequiredVisibleInterval(TimeInterval interval) { - return _entry( - source: OccupancySource.requiredVisibleInterval, - category: OccupancyCategory.requiredVisibleCommitment, - interval: interval, - task: null, - ); - } - - OccupancyEntry _taskEntry({ - required Task task, - required OccupancyCategory category, - required TimeInterval? interval, - }) { - return _entry( - source: OccupancySource.task, - category: category, - interval: interval, - task: task, - ); - } - - OccupancyEntry _entry({ - required OccupancySource source, - required OccupancyCategory category, - required TimeInterval? interval, - required Task? task, - }) { - final hasInterval = interval != null; - - return switch (category) { - OccupancyCategory.nonOccupyingRecord => OccupancyEntry( - source: source, - category: category, - interval: null, - task: task, - isImmovable: false, - blocksAutomaticFlexiblePlacement: false, - mayBeExplicitlyOverlappedByRequiredCommitment: false, - reportsConflict: false, - hiddenByDefault: false, - ), - OccupancyCategory.movablePlannedFlexible => OccupancyEntry( - source: source, - category: category, - interval: interval, - task: task, - isImmovable: false, - blocksAutomaticFlexiblePlacement: false, - mayBeExplicitlyOverlappedByRequiredCommitment: false, - reportsConflict: false, - hiddenByDefault: false, - ), - OccupancyCategory.protectedFreeSlot => OccupancyEntry( - source: source, - category: category, - interval: interval, - task: task, - isImmovable: true, - blocksAutomaticFlexiblePlacement: hasInterval, - mayBeExplicitlyOverlappedByRequiredCommitment: hasInterval, - reportsConflict: hasInterval, - hiddenByDefault: false, - ), - OccupancyCategory.hiddenLockedConstraint => OccupancyEntry( - source: source, - category: category, - interval: interval, - task: task, - isImmovable: true, - blocksAutomaticFlexiblePlacement: hasInterval, - mayBeExplicitlyOverlappedByRequiredCommitment: false, - reportsConflict: hasInterval, - hiddenByDefault: true, - ), - OccupancyCategory.requiredVisibleCommitment || - OccupancyCategory.activeWork || - OccupancyCategory.completedActualOccupancy || - OccupancyCategory.retainedMissedHistoricalInterval => - OccupancyEntry( - source: source, - category: category, - interval: interval, - task: task, - isImmovable: true, - blocksAutomaticFlexiblePlacement: hasInterval, - mayBeExplicitlyOverlappedByRequiredCommitment: false, - reportsConflict: hasInterval, - hiddenByDefault: false, - ), - }; - } -} - -bool _isNonOccupyingStatus(TaskStatus status) { - return status == TaskStatus.backlog || - status == TaskStatus.cancelled || - status == TaskStatus.noLongerRelevant; -} - -TimeInterval? _scheduledIntervalFor(Task task) { - final start = task.scheduledStart; - final end = task.scheduledEnd; - if (start == null || end == null) { - return null; - } - - return TimeInterval(start: start, end: end, label: task.id); -} - -TimeInterval? _actualIntervalFor(Task task) { - final start = task.actualStart; - final end = task.actualEnd; - if (start == null || end == null) { - return null; - } - - return TimeInterval(start: start, end: end, label: task.id); -} +part 'occupancy_policy/occupancy_category.dart'; +part 'occupancy_policy/occupancy_source.dart'; +part 'occupancy_policy/occupancy_entry.dart'; +part 'occupancy_policy/occupancy_policy.dart'; diff --git a/packages/scheduler_core/lib/src/occupancy_policy/occupancy_category.dart b/packages/scheduler_core/lib/src/occupancy_policy/occupancy_category.dart new file mode 100644 index 0000000..ec69b2f --- /dev/null +++ b/packages/scheduler_core/lib/src/occupancy_policy/occupancy_category.dart @@ -0,0 +1,28 @@ +part of '../occupancy_policy.dart'; + +/// Scheduling-facing occupancy category for one task or external interval. +enum OccupancyCategory { + /// Hidden locked time that constrains scheduling. + hiddenLockedConstraint, + + /// Visible critical or inflexible commitment. + requiredVisibleCommitment, + + /// Intentional protected rest. + protectedFreeSlot, + + /// Planned flexible work that automatic scheduling may move. + movablePlannedFlexible, + + /// Work currently in progress. + activeWork, + + /// Known actual historical occupancy from completed work. + completedActualOccupancy, + + /// Historical missed placement retained as context. + retainedMissedHistoricalInterval, + + /// Record that should not occupy the scheduling window. + nonOccupyingRecord, +} diff --git a/packages/scheduler_core/lib/src/occupancy_policy/occupancy_entry.dart b/packages/scheduler_core/lib/src/occupancy_policy/occupancy_entry.dart new file mode 100644 index 0000000..5d67009 --- /dev/null +++ b/packages/scheduler_core/lib/src/occupancy_policy/occupancy_entry.dart @@ -0,0 +1,51 @@ +part of '../occupancy_policy.dart'; + +/// Policy classification for one task or external interval. +class OccupancyEntry { + const OccupancyEntry({ + required this.source, + required this.category, + required this.interval, + required this.task, + required this.isImmovable, + required this.blocksAutomaticFlexiblePlacement, + required this.mayBeExplicitlyOverlappedByRequiredCommitment, + required this.reportsConflict, + required this.hiddenByDefault, + }); + + /// Origin of the entry. + final OccupancySource source; + + /// Scheduling-facing category. + final OccupancyCategory category; + + /// Interval used by scheduling, or null when the record is unplaced. + final TimeInterval? interval; + + /// Source task when this entry came from a task record. + final Task? task; + + /// Stable task id when present. + String? get taskId => task?.id; + + /// Whether automatic scheduling must leave this entry's placement unchanged. + final bool isImmovable; + + /// Whether flexible placement must avoid [interval]. + final bool blocksAutomaticFlexiblePlacement; + + /// Whether an explicit critical/inflexible commitment may overlap this entry. + final bool mayBeExplicitlyOverlappedByRequiredCommitment; + + /// Whether overlaps with this entry should be returned as conflicts. + final bool reportsConflict; + + /// Whether entry details should be hidden by default in user-facing output. + final bool hiddenByDefault; + + /// Whether the entry is planned flexible work movable by automatic scheduling. + bool get isMovablePlannedFlexible { + return category == OccupancyCategory.movablePlannedFlexible; + } +} diff --git a/packages/scheduler_core/lib/src/occupancy_policy/occupancy_policy.dart b/packages/scheduler_core/lib/src/occupancy_policy/occupancy_policy.dart new file mode 100644 index 0000000..d94798e --- /dev/null +++ b/packages/scheduler_core/lib/src/occupancy_policy/occupancy_policy.dart @@ -0,0 +1,247 @@ +part of '../occupancy_policy.dart'; + +/// UI-independent V1 occupancy policy. +class OccupancyPolicy { + const OccupancyPolicy(); + + /// Classify all task and external interval inputs for a scheduling operation. + List classify({ + required Iterable tasks, + Iterable lockedIntervals = const [], + Iterable requiredVisibleIntervals = const [], + }) { + return List.unmodifiable([ + ...tasks.map(classifyTask), + ...lockedIntervals.map(classifyLockedInterval), + ...requiredVisibleIntervals.map(classifyRequiredVisibleInterval), + ]); + } + + /// Classify one task record. + OccupancyEntry classifyTask(Task task) { + final scheduledInterval = _scheduledIntervalFor(task); + final actualInterval = _actualIntervalFor(task); + + if (_isNonOccupyingStatus(task.status)) { + return _taskEntry( + task: task, + category: OccupancyCategory.nonOccupyingRecord, + interval: null, + ); + } + + if (task.status == TaskStatus.completed) { + final completedInterval = actualInterval ?? + (task.type == TaskType.surprise ? scheduledInterval : null); + + if (completedInterval == null) { + return _taskEntry( + task: task, + category: OccupancyCategory.nonOccupyingRecord, + interval: null, + ); + } + + return _taskEntry( + task: task, + category: OccupancyCategory.completedActualOccupancy, + interval: completedInterval, + ); + } + + if (task.status == TaskStatus.missed) { + if (scheduledInterval == null) { + return _taskEntry( + task: task, + category: OccupancyCategory.nonOccupyingRecord, + interval: null, + ); + } + + return _taskEntry( + task: task, + category: OccupancyCategory.retainedMissedHistoricalInterval, + interval: scheduledInterval, + ); + } + + if (task.type == TaskType.locked) { + return _taskEntry( + task: task, + category: OccupancyCategory.hiddenLockedConstraint, + interval: scheduledInterval, + ); + } + + if (task.type == TaskType.freeSlot) { + return _taskEntry( + task: task, + category: OccupancyCategory.protectedFreeSlot, + interval: scheduledInterval, + ); + } + + if (task.status == TaskStatus.active) { + return _taskEntry( + task: task, + category: OccupancyCategory.activeWork, + interval: actualInterval ?? scheduledInterval, + ); + } + + if (task.isRequiredVisible) { + return _taskEntry( + task: task, + category: OccupancyCategory.requiredVisibleCommitment, + interval: scheduledInterval, + ); + } + + if (task.isFlexible && task.status == TaskStatus.planned) { + return _taskEntry( + task: task, + category: OccupancyCategory.movablePlannedFlexible, + interval: scheduledInterval, + ); + } + + // Surprise tasks should normally be completed records. If an unsupported + // lifecycle combination appears, keep it out of automatic placement instead + // of inventing movement behavior for it. + return _taskEntry( + task: task, + category: OccupancyCategory.nonOccupyingRecord, + interval: null, + ); + } + + /// Classify one hidden locked interval supplied outside the task list. + OccupancyEntry classifyLockedInterval(TimeInterval interval) { + return _entry( + source: OccupancySource.lockedInterval, + category: OccupancyCategory.hiddenLockedConstraint, + interval: interval, + task: null, + ); + } + + /// Classify one visible required interval supplied outside the task list. + OccupancyEntry classifyRequiredVisibleInterval(TimeInterval interval) { + return _entry( + source: OccupancySource.requiredVisibleInterval, + category: OccupancyCategory.requiredVisibleCommitment, + interval: interval, + task: null, + ); + } + + OccupancyEntry _taskEntry({ + required Task task, + required OccupancyCategory category, + required TimeInterval? interval, + }) { + return _entry( + source: OccupancySource.task, + category: category, + interval: interval, + task: task, + ); + } + + OccupancyEntry _entry({ + required OccupancySource source, + required OccupancyCategory category, + required TimeInterval? interval, + required Task? task, + }) { + final hasInterval = interval != null; + + return switch (category) { + OccupancyCategory.nonOccupyingRecord => OccupancyEntry( + source: source, + category: category, + interval: null, + task: task, + isImmovable: false, + blocksAutomaticFlexiblePlacement: false, + mayBeExplicitlyOverlappedByRequiredCommitment: false, + reportsConflict: false, + hiddenByDefault: false, + ), + OccupancyCategory.movablePlannedFlexible => OccupancyEntry( + source: source, + category: category, + interval: interval, + task: task, + isImmovable: false, + blocksAutomaticFlexiblePlacement: false, + mayBeExplicitlyOverlappedByRequiredCommitment: false, + reportsConflict: false, + hiddenByDefault: false, + ), + OccupancyCategory.protectedFreeSlot => OccupancyEntry( + source: source, + category: category, + interval: interval, + task: task, + isImmovable: true, + blocksAutomaticFlexiblePlacement: hasInterval, + mayBeExplicitlyOverlappedByRequiredCommitment: hasInterval, + reportsConflict: hasInterval, + hiddenByDefault: false, + ), + OccupancyCategory.hiddenLockedConstraint => OccupancyEntry( + source: source, + category: category, + interval: interval, + task: task, + isImmovable: true, + blocksAutomaticFlexiblePlacement: hasInterval, + mayBeExplicitlyOverlappedByRequiredCommitment: false, + reportsConflict: hasInterval, + hiddenByDefault: true, + ), + OccupancyCategory.requiredVisibleCommitment || + OccupancyCategory.activeWork || + OccupancyCategory.completedActualOccupancy || + OccupancyCategory.retainedMissedHistoricalInterval => + OccupancyEntry( + source: source, + category: category, + interval: interval, + task: task, + isImmovable: true, + blocksAutomaticFlexiblePlacement: hasInterval, + mayBeExplicitlyOverlappedByRequiredCommitment: false, + reportsConflict: hasInterval, + hiddenByDefault: false, + ), + }; + } +} + +bool _isNonOccupyingStatus(TaskStatus status) { + return status == TaskStatus.backlog || + status == TaskStatus.cancelled || + status == TaskStatus.noLongerRelevant; +} + +TimeInterval? _scheduledIntervalFor(Task task) { + final start = task.scheduledStart; + final end = task.scheduledEnd; + if (start == null || end == null) { + return null; + } + + return TimeInterval(start: start, end: end, label: task.id); +} + +TimeInterval? _actualIntervalFor(Task task) { + final start = task.actualStart; + final end = task.actualEnd; + if (start == null || end == null) { + return null; + } + + return TimeInterval(start: start, end: end, label: task.id); +} diff --git a/packages/scheduler_core/lib/src/occupancy_policy/occupancy_source.dart b/packages/scheduler_core/lib/src/occupancy_policy/occupancy_source.dart new file mode 100644 index 0000000..cf86143 --- /dev/null +++ b/packages/scheduler_core/lib/src/occupancy_policy/occupancy_source.dart @@ -0,0 +1,13 @@ +part of '../occupancy_policy.dart'; + +/// Where an occupancy entry came from. +enum OccupancySource { + /// A [Task] record. + task, + + /// A hidden locked interval supplied outside the task list. + lockedInterval, + + /// A visible required interval supplied outside the task list. + requiredVisibleInterval, +} diff --git a/packages/scheduler_core/lib/src/persistence_contract.dart b/packages/scheduler_core/lib/src/persistence_contract.dart index d7286f1..6e11363 100644 --- a/packages/scheduler_core/lib/src/persistence_contract.dart +++ b/packages/scheduler_core/lib/src/persistence_contract.dart @@ -8,1051 +8,40 @@ library; // domain models or import a database client. import 'time_contracts.dart'; +part 'persistence_contract/persistence_collections.dart'; +part 'persistence_contract/persistence_index_direction.dart'; +part 'persistence_contract/persistence_index_field.dart'; +part 'persistence_contract/persistence_index_spec.dart'; +part 'persistence_contract/repository_query_names.dart'; +part 'persistence_contract/persistence_index_catalog.dart'; +part 'persistence_contract/persistence_payload_limits.dart'; +part 'persistence_contract/persistence_guard_result.dart'; +part 'persistence_contract/persistence_payload_guard.dart'; +part 'persistence_contract/persistence_date_time_convention.dart'; +part 'persistence_contract/persistence_civil_date_convention.dart'; +part 'persistence_contract/persistence_wall_time_convention.dart'; +part 'persistence_contract/persistence_enum_name.dart'; +part 'persistence_contract/document_fields.dart'; +part 'persistence_contract/task_document_fields.dart'; +part 'persistence_contract/task_activity_document_fields.dart'; +part 'persistence_contract/task_statistics_document_fields.dart'; +part 'persistence_contract/project_document_fields.dart'; +part 'persistence_contract/project_statistics_document_fields.dart'; +part 'persistence_contract/clock_time_document_fields.dart'; +part 'persistence_contract/locked_block_recurrence_document_fields.dart'; +part 'persistence_contract/locked_block_document_fields.dart'; +part 'persistence_contract/locked_block_override_document_fields.dart'; +part 'persistence_contract/owner_settings_document_fields.dart'; +part 'persistence_contract/backlog_staleness_document_fields.dart'; +part 'persistence_contract/notice_acknowledgement_document_fields.dart'; +part 'persistence_contract/application_operation_document_fields.dart'; +part 'persistence_contract/scheduling_snapshot_document_fields.dart'; +part 'persistence_contract/scheduling_window_document_fields.dart'; +part 'persistence_contract/time_interval_document_fields.dart'; +part 'persistence_contract/scheduling_notice_document_fields.dart'; +part 'persistence_contract/scheduling_change_document_fields.dart'; +part 'persistence_contract/scheduling_overlap_document_fields.dart'; +part 'persistence_contract/persistence_document_field_sets.dart'; /// V1 document-schema version. const v1SchemaVersion = 1; - -/// Stable V1 logical collection names for persistence adapters. -abstract final class PersistenceCollections { - static const tasks = 'tasks'; - static const projects = 'projects'; - static const projectStatistics = 'project_statistics'; - static const lockedBlocks = 'locked_blocks'; - static const lockedOverrides = 'locked_overrides'; - static const taskActivities = 'task_activities'; - static const ownerSettings = 'owner_settings'; - static const noticeAcknowledgements = 'notice_acknowledgements'; - static const applicationOperations = 'application_operations'; - static const schedulingSnapshots = 'scheduling_snapshots'; - - static const authoritative = { - tasks, - projects, - projectStatistics, - lockedBlocks, - lockedOverrides, - ownerSettings, - }; - - static const internalRetained = { - taskActivities, - noticeAcknowledgements, - applicationOperations, - schedulingSnapshots, - }; -} - -/// Sort direction in an adapter-neutral index specification. -enum PersistenceIndexDirection { - ascending, - descending, -} - -/// One field in a declarative index key. -class PersistenceIndexField { - const PersistenceIndexField( - this.fieldName, { - this.direction = PersistenceIndexDirection.ascending, - }); - - final String fieldName; - final PersistenceIndexDirection direction; -} - -/// A stable index plan future adapters can translate to driver-specific calls. -class PersistenceIndexSpec { - const PersistenceIndexSpec({ - required this.name, - required this.collection, - required this.keys, - this.unique = false, - this.partialFilterFields = const {}, - this.supportsQueries = const {}, - }); - - final String name; - final String collection; - final List keys; - final bool unique; - final Set partialFilterFields; - final Set supportsQueries; -} - -/// Stable repository query identifiers for index coverage tests. -abstract final class RepositoryQueryNames { - static const taskById = 'TaskRepository.findById'; - static const taskByOwner = 'TaskRepository.findByOwner'; - static const taskByStatus = 'TaskRepository.findByStatus'; - static const taskByProject = 'TaskRepository.findByProjectId'; - static const taskByParent = 'TaskRepository.findByParentTaskId'; - static const taskBacklogCandidates = 'TaskRepository.findBacklogCandidates'; - static const taskScheduledWindow = - 'TaskRepository.findScheduledInWindowForOwner'; - static const taskLocalDay = 'TaskRepository.findForLocalDay'; - static const projectByOwner = 'ProjectRepository.findByOwner'; - static const projectById = 'ProjectRepository.findById'; - static const lockedBlockByOwner = 'LockedBlockRepository.findBlocksByOwner'; - static const lockedBlockById = 'LockedBlockRepository.findBlockById'; - static const lockedOverrideByDate = - 'LockedBlockRepository.findOverridesForDate'; - static const taskActivityByOwner = 'TaskActivityRepository.findByOwner'; - static const taskActivityByOperation = - 'TaskActivityRepository.findByOperationId'; - static const taskActivityByTask = 'TaskActivityRepository.findByTaskId'; - static const taskActivityByProject = 'TaskActivityRepository.findByProjectId'; - static const projectStatisticsByProject = - 'ProjectStatisticsRepository.findByProjectId'; - static const ownerSettingsByOwner = 'OwnerSettingsRepository.findByOwnerId'; - static const noticeAcknowledgementByOwner = - 'NoticeAcknowledgementRepository.findByOwner'; - static const noticeAcknowledgementById = - 'NoticeAcknowledgementRepository.findById'; - static const operationByIdempotencyKey = - 'ApplicationOperationRepository.findByIdempotencyKey'; - static const schedulingSnapshotById = 'SchedulingSnapshotRepository.findById'; - static const schedulingSnapshotWindow = - 'SchedulingSnapshotRepository.findInWindow'; -} - -/// Required V1 index plan. -abstract final class PersistenceIndexCatalog { - static const all = [ - PersistenceIndexSpec( - name: 'tasks_owner_id_unique', - collection: PersistenceCollections.tasks, - unique: true, - keys: [ - PersistenceIndexField(DocumentFields.ownerId), - PersistenceIndexField(DocumentFields.id), - ], - supportsQueries: { - RepositoryQueryNames.taskById, - RepositoryQueryNames.taskByOwner, - }, - ), - PersistenceIndexSpec( - name: 'tasks_owner_status_backlog_age', - collection: PersistenceCollections.tasks, - keys: [ - PersistenceIndexField(DocumentFields.ownerId), - PersistenceIndexField(TaskDocumentFields.status), - PersistenceIndexField(TaskDocumentFields.backlogEnteredAt), - PersistenceIndexField(TaskDocumentFields.createdAt), - PersistenceIndexField(DocumentFields.id), - ], - supportsQueries: { - RepositoryQueryNames.taskByStatus, - RepositoryQueryNames.taskBacklogCandidates, - }, - ), - PersistenceIndexSpec( - name: 'tasks_owner_scheduled_window', - collection: PersistenceCollections.tasks, - keys: [ - PersistenceIndexField(DocumentFields.ownerId), - PersistenceIndexField(TaskDocumentFields.scheduledStart), - PersistenceIndexField(TaskDocumentFields.scheduledEnd), - PersistenceIndexField(DocumentFields.id), - ], - partialFilterFields: { - TaskDocumentFields.scheduledStart, - TaskDocumentFields.scheduledEnd, - }, - supportsQueries: { - RepositoryQueryNames.taskScheduledWindow, - RepositoryQueryNames.taskLocalDay, - }, - ), - PersistenceIndexSpec( - name: 'tasks_owner_project_status', - collection: PersistenceCollections.tasks, - keys: [ - PersistenceIndexField(DocumentFields.ownerId), - PersistenceIndexField(TaskDocumentFields.projectId), - PersistenceIndexField(TaskDocumentFields.status), - PersistenceIndexField(DocumentFields.id), - ], - supportsQueries: { - RepositoryQueryNames.taskByProject, - RepositoryQueryNames.taskBacklogCandidates, - }, - ), - PersistenceIndexSpec( - name: 'tasks_owner_parent_status', - collection: PersistenceCollections.tasks, - keys: [ - PersistenceIndexField(DocumentFields.ownerId), - PersistenceIndexField(TaskDocumentFields.parentTaskId), - PersistenceIndexField(TaskDocumentFields.status), - PersistenceIndexField(DocumentFields.id), - ], - partialFilterFields: { - TaskDocumentFields.parentTaskId, - }, - supportsQueries: { - RepositoryQueryNames.taskByParent, - }, - ), - PersistenceIndexSpec( - name: 'projects_owner_id_unique', - collection: PersistenceCollections.projects, - unique: true, - keys: [ - PersistenceIndexField(DocumentFields.ownerId), - PersistenceIndexField(DocumentFields.id), - ], - supportsQueries: { - RepositoryQueryNames.projectById, - }, - ), - PersistenceIndexSpec( - name: 'projects_owner_archived_name', - collection: PersistenceCollections.projects, - keys: [ - PersistenceIndexField(DocumentFields.ownerId), - PersistenceIndexField(ProjectDocumentFields.archivedAt), - PersistenceIndexField(ProjectDocumentFields.name), - PersistenceIndexField(DocumentFields.id), - ], - supportsQueries: { - RepositoryQueryNames.projectByOwner, - }, - ), - PersistenceIndexSpec( - name: 'project_stats_owner_project_unique', - collection: PersistenceCollections.projectStatistics, - unique: true, - keys: [ - PersistenceIndexField(DocumentFields.ownerId), - PersistenceIndexField(ProjectStatisticsDocumentFields.projectId), - ], - supportsQueries: { - RepositoryQueryNames.projectStatisticsByProject, - }, - ), - PersistenceIndexSpec( - name: 'locked_blocks_owner_id_unique', - collection: PersistenceCollections.lockedBlocks, - unique: true, - keys: [ - PersistenceIndexField(DocumentFields.ownerId), - PersistenceIndexField(DocumentFields.id), - ], - supportsQueries: { - RepositoryQueryNames.lockedBlockById, - }, - ), - PersistenceIndexSpec( - name: 'locked_blocks_owner_archived_name', - collection: PersistenceCollections.lockedBlocks, - keys: [ - PersistenceIndexField(DocumentFields.ownerId), - PersistenceIndexField(LockedBlockDocumentFields.archivedAt), - PersistenceIndexField(LockedBlockDocumentFields.name), - PersistenceIndexField(DocumentFields.id), - ], - supportsQueries: { - RepositoryQueryNames.lockedBlockByOwner, - }, - ), - PersistenceIndexSpec( - name: 'locked_overrides_owner_id_unique', - collection: PersistenceCollections.lockedOverrides, - unique: true, - keys: [ - PersistenceIndexField(DocumentFields.ownerId), - PersistenceIndexField(DocumentFields.id), - ], - ), - PersistenceIndexSpec( - name: 'locked_overrides_owner_date_block', - collection: PersistenceCollections.lockedOverrides, - keys: [ - PersistenceIndexField(DocumentFields.ownerId), - PersistenceIndexField(LockedBlockOverrideDocumentFields.date), - PersistenceIndexField(LockedBlockOverrideDocumentFields.lockedBlockId), - PersistenceIndexField(DocumentFields.id), - ], - supportsQueries: { - RepositoryQueryNames.lockedOverrideByDate, - }, - ), - PersistenceIndexSpec( - name: 'task_activities_owner_id_unique', - collection: PersistenceCollections.taskActivities, - unique: true, - keys: [ - PersistenceIndexField(DocumentFields.ownerId), - PersistenceIndexField(DocumentFields.id), - ], - ), - PersistenceIndexSpec( - name: 'task_activities_owner_operation', - collection: PersistenceCollections.taskActivities, - keys: [ - PersistenceIndexField(DocumentFields.ownerId), - PersistenceIndexField(TaskActivityDocumentFields.operationId), - PersistenceIndexField(DocumentFields.id), - ], - supportsQueries: { - RepositoryQueryNames.taskActivityByOperation, - }, - ), - PersistenceIndexSpec( - name: 'task_activities_owner_task_time', - collection: PersistenceCollections.taskActivities, - keys: [ - PersistenceIndexField(DocumentFields.ownerId), - PersistenceIndexField(TaskActivityDocumentFields.taskId), - PersistenceIndexField(TaskActivityDocumentFields.occurredAt), - PersistenceIndexField(DocumentFields.id), - ], - supportsQueries: { - RepositoryQueryNames.taskActivityByOwner, - RepositoryQueryNames.taskActivityByTask, - }, - ), - PersistenceIndexSpec( - name: 'task_activities_owner_project_code_time', - collection: PersistenceCollections.taskActivities, - keys: [ - PersistenceIndexField(DocumentFields.ownerId), - PersistenceIndexField(TaskActivityDocumentFields.projectId), - PersistenceIndexField(TaskActivityDocumentFields.code), - PersistenceIndexField(TaskActivityDocumentFields.occurredAt), - ], - supportsQueries: { - RepositoryQueryNames.taskActivityByProject, - }, - ), - PersistenceIndexSpec( - name: 'owner_settings_owner_unique', - collection: PersistenceCollections.ownerSettings, - unique: true, - keys: [ - PersistenceIndexField(DocumentFields.ownerId), - PersistenceIndexField(DocumentFields.id), - ], - supportsQueries: { - RepositoryQueryNames.ownerSettingsByOwner, - }, - ), - PersistenceIndexSpec( - name: 'notice_ack_owner_notice_unique', - collection: PersistenceCollections.noticeAcknowledgements, - unique: true, - keys: [ - PersistenceIndexField(DocumentFields.ownerId), - PersistenceIndexField(NoticeAcknowledgementDocumentFields.noticeId), - ], - supportsQueries: { - RepositoryQueryNames.noticeAcknowledgementById, - }, - ), - PersistenceIndexSpec( - name: 'notice_ack_owner_acknowledged', - collection: PersistenceCollections.noticeAcknowledgements, - keys: [ - PersistenceIndexField(DocumentFields.ownerId), - PersistenceIndexField( - NoticeAcknowledgementDocumentFields.acknowledgedAt, - ), - PersistenceIndexField(NoticeAcknowledgementDocumentFields.noticeId), - ], - supportsQueries: { - RepositoryQueryNames.noticeAcknowledgementByOwner, - }, - ), - PersistenceIndexSpec( - name: 'operations_owner_operation_unique', - collection: PersistenceCollections.applicationOperations, - unique: true, - keys: [ - PersistenceIndexField(DocumentFields.ownerId), - PersistenceIndexField( - ApplicationOperationDocumentFields.operationId, - ), - ], - supportsQueries: { - RepositoryQueryNames.operationByIdempotencyKey, - }, - ), - PersistenceIndexSpec( - name: 'snapshots_owner_id_unique', - collection: PersistenceCollections.schedulingSnapshots, - unique: true, - keys: [ - PersistenceIndexField(DocumentFields.ownerId), - PersistenceIndexField(DocumentFields.id), - ], - supportsQueries: { - RepositoryQueryNames.schedulingSnapshotById, - }, - ), - PersistenceIndexSpec( - name: 'snapshots_owner_dates_retention', - collection: PersistenceCollections.schedulingSnapshots, - keys: [ - PersistenceIndexField(DocumentFields.ownerId), - PersistenceIndexField(SchedulingSnapshotDocumentFields.sourceDate), - PersistenceIndexField(SchedulingSnapshotDocumentFields.targetDate), - PersistenceIndexField( - SchedulingSnapshotDocumentFields.retentionExpiresAt, - ), - ], - supportsQueries: { - RepositoryQueryNames.schedulingSnapshotWindow, - }, - ), - ]; - - static Set get supportedQueries { - return { - for (final spec in all) ...spec.supportsQueries, - }; - } -} - -/// Bounded payload and retention policy for non-authoritative documents. -abstract final class PersistencePayloadLimits { - static const maxTaskActivityMetadataEntries = 50; - static const maxSchedulingSnapshotTasks = 250; - static const maxSchedulingSnapshotNotices = 100; - static const maxSchedulingSnapshotChanges = 250; - static const maxAppliedActivityIds = 1000; - static const taskActivityRetention = Duration(days: 365); - static const applicationOperationRetention = Duration(days: 90); - static const schedulingSnapshotRetention = Duration(days: 30); -} - -/// Guard result for bounded document payload checks. -class PersistenceGuardResult { - const PersistenceGuardResult._({ - required this.isValid, - this.fieldName, - this.limit, - this.actual, - }); - - factory PersistenceGuardResult.valid() { - return const PersistenceGuardResult._(isValid: true); - } - - factory PersistenceGuardResult.tooLarge({ - required String fieldName, - required int limit, - required int actual, - }) { - return PersistenceGuardResult._( - isValid: false, - fieldName: fieldName, - limit: limit, - actual: actual, - ); - } - - final bool isValid; - final String? fieldName; - final int? limit; - final int? actual; -} - -/// Pure guard helpers for bounded embedded document payloads. -abstract final class PersistencePayloadGuard { - static PersistenceGuardResult mapEntries({ - required String fieldName, - required Map value, - required int limit, - }) { - if (value.length > limit) { - return PersistenceGuardResult.tooLarge( - fieldName: fieldName, - limit: limit, - actual: value.length, - ); - } - return PersistenceGuardResult.valid(); - } - - static PersistenceGuardResult listLength({ - required String fieldName, - required Iterable value, - required int limit, - }) { - final actual = value.length; - if (actual > limit) { - return PersistenceGuardResult.tooLarge( - fieldName: fieldName, - limit: limit, - actual: actual, - ); - } - return PersistenceGuardResult.valid(); - } -} - -/// DateTime storage convention for future document persistence. -abstract final class PersistenceDateTimeConvention { - /// Human-readable convention kept close to the helper for tests and docs. - static const description = - 'Store DateTime values as UTC ISO-8601 strings and compare as UTC instants.'; - - /// Convert a DateTime into the persisted string convention. - static String toStoredString(DateTime value) { - return value.toUtc().toIso8601String(); - } - - /// Parse a persisted DateTime string back into a UTC DateTime. - static DateTime fromStoredString(String value) { - return DateTime.parse(value).toUtc(); - } - - /// Compare two DateTime values by their UTC instant. - static int compare(DateTime left, DateTime right) { - return left.toUtc().compareTo(right.toUtc()); - } -} - -/// Civil-date storage convention for future document persistence. -abstract final class PersistenceCivilDateConvention { - /// Human-readable convention kept close to the helper for tests and docs. - static const description = - 'Store CivilDate values as YYYY-MM-DD strings without timezone conversion.'; - - /// Convert a CivilDate into the persisted string convention. - static String toStoredString(CivilDate value) { - return value.toIsoString(); - } - - /// Parse a persisted CivilDate string without applying a timezone. - static CivilDate fromStoredString(String value) { - return CivilDate.fromIsoString(value); - } -} - -/// Wall-time storage convention for future document persistence. -abstract final class PersistenceWallTimeConvention { - /// Human-readable convention kept close to the helper for tests and docs. - static const description = - 'Store WallTime values as HH:MM strings without date or timezone data.'; - - /// Convert a WallTime into the persisted string convention. - static String toStoredString(WallTime value) { - return value.toIsoString(); - } - - /// Parse a persisted WallTime string without applying a date or timezone. - static WallTime fromStoredString(String value) { - return WallTime.fromIsoString(value); - } -} - -/// Legacy enum-name extension retained for older callers. -/// -/// V1 codecs use explicit stable code maps in `document_mapping.dart` instead -/// of relying on this extension. -extension PersistenceEnumName on Enum { - /// Legacy persisted name. - String get persistenceName => name; -} - -/// Shared V1 document field names. -abstract final class DocumentFields { - /// Primary id field. - static const id = '_id'; - - /// Version of the document shape. - static const schemaVersion = 'schemaVersion'; - - /// Authorization-neutral owner scope. - static const ownerId = 'ownerId'; - - /// Optimistic-concurrency revision. - static const revision = 'revision'; - - /// Creation instant. - static const createdAt = 'createdAt'; - - /// Last update instant. - static const updatedAt = 'updatedAt'; - - static const common = { - schemaVersion, - id, - ownerId, - revision, - createdAt, - updatedAt, - }; -} - -/// Document field names for [Task] documents. -abstract final class TaskDocumentFields { - static const schemaVersion = DocumentFields.schemaVersion; - static const id = DocumentFields.id; - static const ownerId = DocumentFields.ownerId; - static const revision = DocumentFields.revision; - static const title = 'title'; - static const projectId = 'projectId'; - static const type = 'type'; - static const status = 'status'; - static const priority = 'priority'; - static const reward = 'reward'; - static const difficulty = 'difficulty'; - static const durationMinutes = 'durationMinutes'; - static const scheduledStart = 'scheduledStart'; - static const scheduledEnd = 'scheduledEnd'; - static const actualStart = 'actualStart'; - static const actualEnd = 'actualEnd'; - static const completedAt = 'completedAt'; - static const parentTaskId = 'parentTaskId'; - static const backlogTags = 'backlogTags'; - static const reminderOverride = 'reminderOverride'; - static const createdAt = DocumentFields.createdAt; - static const updatedAt = DocumentFields.updatedAt; - static const stats = 'stats'; - static const backlogEnteredAt = 'backlogEnteredAt'; - static const backlogEnteredAtProvenance = 'backlogEnteredAtProvenance'; - - static const all = { - ...DocumentFields.common, - title, - projectId, - type, - status, - priority, - reward, - difficulty, - durationMinutes, - scheduledStart, - scheduledEnd, - actualStart, - actualEnd, - completedAt, - parentTaskId, - backlogTags, - reminderOverride, - stats, - backlogEnteredAt, - backlogEnteredAtProvenance, - }; -} - -/// Document field names for internal [TaskActivity] documents. -abstract final class TaskActivityDocumentFields { - static const schemaVersion = DocumentFields.schemaVersion; - static const id = DocumentFields.id; - static const ownerId = DocumentFields.ownerId; - static const revision = DocumentFields.revision; - static const createdAt = DocumentFields.createdAt; - static const updatedAt = DocumentFields.updatedAt; - static const operationId = 'operationId'; - static const code = 'code'; - static const taskId = 'taskId'; - static const projectId = 'projectId'; - static const occurredAt = 'occurredAt'; - static const metadata = 'metadata'; - - static const all = { - ...DocumentFields.common, - operationId, - code, - taskId, - projectId, - occurredAt, - metadata, - }; -} - -/// Document field names for [TaskStatistics] embedded documents. -abstract final class TaskStatisticsDocumentFields { - static const skippedDuringBurnoutCount = 'skippedDuringBurnoutCount'; - static const manuallyPushedCount = 'manuallyPushedCount'; - static const autoPushedCount = 'autoPushedCount'; - static const movedToBacklogCount = 'movedToBacklogCount'; - static const restoredFromBacklogCount = 'restoredFromBacklogCount'; - static const missedCount = 'missedCount'; - static const cancelledCount = 'cancelledCount'; - static const completedLateCount = 'completedLateCount'; - static const completedDuringLockedHoursCount = - 'completedDuringLockedHoursCount'; - static const completedDuringLockedHoursMinutes = - 'completedDuringLockedHoursMinutes'; - static const completedAfterShieldCount = 'completedAfterShieldCount'; - static const completedAfterPushCount = 'completedAfterPushCount'; - static const totalPushesBeforeCompletion = 'totalPushesBeforeCompletion'; - - static const all = { - skippedDuringBurnoutCount, - manuallyPushedCount, - autoPushedCount, - movedToBacklogCount, - restoredFromBacklogCount, - missedCount, - cancelledCount, - completedLateCount, - completedDuringLockedHoursCount, - completedDuringLockedHoursMinutes, - completedAfterShieldCount, - completedAfterPushCount, - totalPushesBeforeCompletion, - }; -} - -/// Document field names for [ProjectProfile] documents. -abstract final class ProjectDocumentFields { - static const schemaVersion = DocumentFields.schemaVersion; - static const id = DocumentFields.id; - static const ownerId = DocumentFields.ownerId; - static const revision = DocumentFields.revision; - static const createdAt = DocumentFields.createdAt; - static const updatedAt = DocumentFields.updatedAt; - static const name = 'name'; - static const colorKey = 'colorKey'; - static const defaultPriority = 'defaultPriority'; - static const defaultReward = 'defaultReward'; - static const defaultDifficulty = 'defaultDifficulty'; - static const defaultReminderProfile = 'defaultReminderProfile'; - static const defaultDurationMinutes = 'defaultDurationMinutes'; - static const archivedAt = 'archivedAt'; - - static const all = { - ...DocumentFields.common, - name, - colorKey, - defaultPriority, - defaultReward, - defaultDifficulty, - defaultReminderProfile, - defaultDurationMinutes, - archivedAt, - }; -} - -/// Document field names for [ProjectStatistics] documents. -abstract final class ProjectStatisticsDocumentFields { - static const schemaVersion = DocumentFields.schemaVersion; - static const id = DocumentFields.id; - static const ownerId = DocumentFields.ownerId; - static const revision = DocumentFields.revision; - static const createdAt = DocumentFields.createdAt; - static const updatedAt = DocumentFields.updatedAt; - static const projectId = 'projectId'; - static const completedTaskCount = 'completedTaskCount'; - static const durationMinuteCounts = 'durationMinuteCounts'; - static const completionTimeBucketCounts = 'completionTimeBucketCounts'; - static const totalPushesBeforeCompletion = 'totalPushesBeforeCompletion'; - static const completedAfterPushCount = 'completedAfterPushCount'; - static const rewardCounts = 'rewardCounts'; - static const difficultyCounts = 'difficultyCounts'; - static const reminderProfileCounts = 'reminderProfileCounts'; - static const appliedActivityIds = 'appliedActivityIds'; - - static const all = { - ...DocumentFields.common, - projectId, - completedTaskCount, - durationMinuteCounts, - completionTimeBucketCounts, - totalPushesBeforeCompletion, - completedAfterPushCount, - rewardCounts, - difficultyCounts, - reminderProfileCounts, - appliedActivityIds, - }; -} - -/// Document field names for [ClockTime] embedded documents. -abstract final class ClockTimeDocumentFields { - static const value = 'value'; - - static const all = { - value, - }; -} - -/// Document field names for [LockedBlockRecurrence] embedded documents. -abstract final class LockedBlockRecurrenceDocumentFields { - static const type = 'type'; - static const weekdays = 'weekdays'; - - static const all = { - type, - weekdays, - }; -} - -/// Document field names for [LockedBlock] documents. -abstract final class LockedBlockDocumentFields { - static const schemaVersion = DocumentFields.schemaVersion; - static const id = DocumentFields.id; - static const ownerId = DocumentFields.ownerId; - static const revision = DocumentFields.revision; - static const name = 'name'; - static const startTime = 'startTime'; - static const endTime = 'endTime'; - static const date = 'date'; - static const recurrence = 'recurrence'; - static const hiddenByDefault = 'hiddenByDefault'; - static const projectId = 'projectId'; - static const createdAt = DocumentFields.createdAt; - static const updatedAt = DocumentFields.updatedAt; - static const archivedAt = 'archivedAt'; - - static const all = { - ...DocumentFields.common, - name, - startTime, - endTime, - date, - recurrence, - hiddenByDefault, - projectId, - archivedAt, - }; -} - -/// Document field names for [LockedBlockOverride] documents. -abstract final class LockedBlockOverrideDocumentFields { - static const schemaVersion = DocumentFields.schemaVersion; - static const id = DocumentFields.id; - static const ownerId = DocumentFields.ownerId; - static const revision = DocumentFields.revision; - static const lockedBlockId = 'lockedBlockId'; - static const date = 'date'; - static const type = 'type'; - static const name = 'name'; - static const startTime = 'startTime'; - static const endTime = 'endTime'; - static const hiddenByDefault = 'hiddenByDefault'; - static const projectId = 'projectId'; - static const createdAt = DocumentFields.createdAt; - static const updatedAt = DocumentFields.updatedAt; - - static const all = { - ...DocumentFields.common, - lockedBlockId, - date, - type, - name, - startTime, - endTime, - hiddenByDefault, - projectId, - }; -} - -/// Document field names for [OwnerSettings] documents. -abstract final class OwnerSettingsDocumentFields { - static const schemaVersion = DocumentFields.schemaVersion; - static const id = DocumentFields.id; - static const ownerId = DocumentFields.ownerId; - static const revision = DocumentFields.revision; - static const createdAt = DocumentFields.createdAt; - static const updatedAt = DocumentFields.updatedAt; - static const timeZoneId = 'timeZoneId'; - static const dayStart = 'dayStart'; - static const dayEnd = 'dayEnd'; - static const compactModeEnabled = 'compactModeEnabled'; - static const backlogStaleness = 'backlogStaleness'; - - static const all = { - ...DocumentFields.common, - timeZoneId, - dayStart, - dayEnd, - compactModeEnabled, - backlogStaleness, - }; -} - -/// Document field names for backlog staleness settings. -abstract final class BacklogStalenessDocumentFields { - static const greenMaxAgeDays = 'greenMaxAgeDays'; - static const blueMaxAgeDays = 'blueMaxAgeDays'; - - static const all = { - greenMaxAgeDays, - blueMaxAgeDays, - }; -} - -/// Document field names for notice acknowledgement documents. -abstract final class NoticeAcknowledgementDocumentFields { - static const schemaVersion = DocumentFields.schemaVersion; - static const id = DocumentFields.id; - static const ownerId = DocumentFields.ownerId; - static const revision = DocumentFields.revision; - static const createdAt = DocumentFields.createdAt; - static const updatedAt = DocumentFields.updatedAt; - static const noticeId = 'noticeId'; - static const acknowledgedAt = 'acknowledgedAt'; - - static const all = { - ...DocumentFields.common, - noticeId, - acknowledgedAt, - }; -} - -/// Document field names for idempotent operation records. -abstract final class ApplicationOperationDocumentFields { - static const schemaVersion = DocumentFields.schemaVersion; - static const id = DocumentFields.id; - static const ownerId = DocumentFields.ownerId; - static const revision = DocumentFields.revision; - static const createdAt = DocumentFields.createdAt; - static const updatedAt = DocumentFields.updatedAt; - static const operationId = 'operationId'; - static const operationName = 'operationName'; - static const committedAt = 'committedAt'; - - static const all = { - ...DocumentFields.common, - operationId, - operationName, - committedAt, - }; -} - -/// Document field names for [SchedulingStateSnapshot] documents. -abstract final class SchedulingSnapshotDocumentFields { - static const schemaVersion = DocumentFields.schemaVersion; - static const id = DocumentFields.id; - static const ownerId = DocumentFields.ownerId; - static const revision = DocumentFields.revision; - static const createdAt = DocumentFields.createdAt; - static const updatedAt = DocumentFields.updatedAt; - static const capturedAt = 'capturedAt'; - static const sourceDate = 'sourceDate'; - static const targetDate = 'targetDate'; - static const window = 'window'; - static const tasks = 'tasks'; - static const lockedIntervals = 'lockedIntervals'; - static const requiredVisibleIntervals = 'requiredVisibleIntervals'; - static const notices = 'notices'; - static const changes = 'changes'; - static const overlaps = 'overlaps'; - static const operationName = 'operationName'; - static const retentionExpiresAt = 'retentionExpiresAt'; - static const truncated = 'truncated'; - - static const all = { - ...DocumentFields.common, - capturedAt, - sourceDate, - targetDate, - window, - tasks, - lockedIntervals, - requiredVisibleIntervals, - notices, - changes, - overlaps, - operationName, - retentionExpiresAt, - truncated, - }; -} - -/// Document field names for [SchedulingWindow] embedded documents. -abstract final class SchedulingWindowDocumentFields { - static const start = 'start'; - static const end = 'end'; - - static const all = { - start, - end, - }; -} - -/// Document field names for [TimeInterval] embedded documents. -abstract final class TimeIntervalDocumentFields { - static const start = 'start'; - static const end = 'end'; - static const label = 'label'; - - static const all = { - start, - end, - label, - }; -} - -/// Document field names for [SchedulingNotice] embedded documents. -abstract final class SchedulingNoticeDocumentFields { - static const message = 'message'; - static const type = 'type'; - static const taskId = 'taskId'; - static const issueCode = 'issueCode'; - static const movementCode = 'movementCode'; - static const conflictCode = 'conflictCode'; - static const parameters = 'parameters'; - - static const all = { - message, - type, - taskId, - issueCode, - movementCode, - conflictCode, - parameters, - }; -} - -/// Document field names for [SchedulingChange] embedded documents. -abstract final class SchedulingChangeDocumentFields { - static const taskId = 'taskId'; - static const previousStart = 'previousStart'; - static const previousEnd = 'previousEnd'; - static const nextStart = 'nextStart'; - static const nextEnd = 'nextEnd'; - - static const all = { - taskId, - previousStart, - previousEnd, - nextStart, - nextEnd, - }; -} - -/// Document field names for [SchedulingOverlap] embedded documents. -abstract final class SchedulingOverlapDocumentFields { - static const taskId = 'taskId'; - static const taskInterval = 'taskInterval'; - static const blockedInterval = 'blockedInterval'; - - static const all = { - taskId, - taskInterval, - blockedInterval, - }; -} - -/// Every field-name set currently committed for future document mapping. -abstract final class PersistenceDocumentFieldSets { - static const all = >[ - DocumentFields.common, - TaskDocumentFields.all, - TaskActivityDocumentFields.all, - TaskStatisticsDocumentFields.all, - ProjectDocumentFields.all, - ProjectStatisticsDocumentFields.all, - ClockTimeDocumentFields.all, - LockedBlockRecurrenceDocumentFields.all, - LockedBlockDocumentFields.all, - LockedBlockOverrideDocumentFields.all, - OwnerSettingsDocumentFields.all, - BacklogStalenessDocumentFields.all, - NoticeAcknowledgementDocumentFields.all, - ApplicationOperationDocumentFields.all, - SchedulingSnapshotDocumentFields.all, - SchedulingWindowDocumentFields.all, - TimeIntervalDocumentFields.all, - SchedulingNoticeDocumentFields.all, - SchedulingChangeDocumentFields.all, - SchedulingOverlapDocumentFields.all, - ]; -} diff --git a/packages/scheduler_core/lib/src/persistence_contract/application_operation_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/application_operation_document_fields.dart new file mode 100644 index 0000000..eaf96eb --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/application_operation_document_fields.dart @@ -0,0 +1,21 @@ +part of '../persistence_contract.dart'; + +/// Document field names for idempotent operation records. +abstract final class ApplicationOperationDocumentFields { + static const schemaVersion = DocumentFields.schemaVersion; + static const id = DocumentFields.id; + static const ownerId = DocumentFields.ownerId; + static const revision = DocumentFields.revision; + static const createdAt = DocumentFields.createdAt; + static const updatedAt = DocumentFields.updatedAt; + static const operationId = 'operationId'; + static const operationName = 'operationName'; + static const committedAt = 'committedAt'; + + static const all = { + ...DocumentFields.common, + operationId, + operationName, + committedAt, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/backlog_staleness_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/backlog_staleness_document_fields.dart new file mode 100644 index 0000000..67a4c71 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/backlog_staleness_document_fields.dart @@ -0,0 +1,12 @@ +part of '../persistence_contract.dart'; + +/// Document field names for backlog staleness settings. +abstract final class BacklogStalenessDocumentFields { + static const greenMaxAgeDays = 'greenMaxAgeDays'; + static const blueMaxAgeDays = 'blueMaxAgeDays'; + + static const all = { + greenMaxAgeDays, + blueMaxAgeDays, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/clock_time_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/clock_time_document_fields.dart new file mode 100644 index 0000000..e309815 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/clock_time_document_fields.dart @@ -0,0 +1,10 @@ +part of '../persistence_contract.dart'; + +/// Document field names for [ClockTime] embedded documents. +abstract final class ClockTimeDocumentFields { + static const value = 'value'; + + static const all = { + value, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/document_fields.dart new file mode 100644 index 0000000..8d3ba5c --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/document_fields.dart @@ -0,0 +1,31 @@ +part of '../persistence_contract.dart'; + +/// Shared V1 document field names. +abstract final class DocumentFields { + /// Primary id field. + static const id = '_id'; + + /// Version of the document shape. + static const schemaVersion = 'schemaVersion'; + + /// Authorization-neutral owner scope. + static const ownerId = 'ownerId'; + + /// Optimistic-concurrency revision. + static const revision = 'revision'; + + /// Creation instant. + static const createdAt = 'createdAt'; + + /// Last update instant. + static const updatedAt = 'updatedAt'; + + static const common = { + schemaVersion, + id, + ownerId, + revision, + createdAt, + updatedAt, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/locked_block_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/locked_block_document_fields.dart new file mode 100644 index 0000000..8b3e6b2 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/locked_block_document_fields.dart @@ -0,0 +1,31 @@ +part of '../persistence_contract.dart'; + +/// Document field names for [LockedBlock] documents. +abstract final class LockedBlockDocumentFields { + static const schemaVersion = DocumentFields.schemaVersion; + static const id = DocumentFields.id; + static const ownerId = DocumentFields.ownerId; + static const revision = DocumentFields.revision; + static const name = 'name'; + static const startTime = 'startTime'; + static const endTime = 'endTime'; + static const date = 'date'; + static const recurrence = 'recurrence'; + static const hiddenByDefault = 'hiddenByDefault'; + static const projectId = 'projectId'; + static const createdAt = DocumentFields.createdAt; + static const updatedAt = DocumentFields.updatedAt; + static const archivedAt = 'archivedAt'; + + static const all = { + ...DocumentFields.common, + name, + startTime, + endTime, + date, + recurrence, + hiddenByDefault, + projectId, + archivedAt, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/locked_block_override_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/locked_block_override_document_fields.dart new file mode 100644 index 0000000..2cd83f0 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/locked_block_override_document_fields.dart @@ -0,0 +1,31 @@ +part of '../persistence_contract.dart'; + +/// Document field names for [LockedBlockOverride] documents. +abstract final class LockedBlockOverrideDocumentFields { + static const schemaVersion = DocumentFields.schemaVersion; + static const id = DocumentFields.id; + static const ownerId = DocumentFields.ownerId; + static const revision = DocumentFields.revision; + static const lockedBlockId = 'lockedBlockId'; + static const date = 'date'; + static const type = 'type'; + static const name = 'name'; + static const startTime = 'startTime'; + static const endTime = 'endTime'; + static const hiddenByDefault = 'hiddenByDefault'; + static const projectId = 'projectId'; + static const createdAt = DocumentFields.createdAt; + static const updatedAt = DocumentFields.updatedAt; + + static const all = { + ...DocumentFields.common, + lockedBlockId, + date, + type, + name, + startTime, + endTime, + hiddenByDefault, + projectId, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/locked_block_recurrence_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/locked_block_recurrence_document_fields.dart new file mode 100644 index 0000000..c26ec8b --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/locked_block_recurrence_document_fields.dart @@ -0,0 +1,12 @@ +part of '../persistence_contract.dart'; + +/// Document field names for [LockedBlockRecurrence] embedded documents. +abstract final class LockedBlockRecurrenceDocumentFields { + static const type = 'type'; + static const weekdays = 'weekdays'; + + static const all = { + type, + weekdays, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/notice_acknowledgement_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/notice_acknowledgement_document_fields.dart new file mode 100644 index 0000000..4785fd0 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/notice_acknowledgement_document_fields.dart @@ -0,0 +1,19 @@ +part of '../persistence_contract.dart'; + +/// Document field names for notice acknowledgement documents. +abstract final class NoticeAcknowledgementDocumentFields { + static const schemaVersion = DocumentFields.schemaVersion; + static const id = DocumentFields.id; + static const ownerId = DocumentFields.ownerId; + static const revision = DocumentFields.revision; + static const createdAt = DocumentFields.createdAt; + static const updatedAt = DocumentFields.updatedAt; + static const noticeId = 'noticeId'; + static const acknowledgedAt = 'acknowledgedAt'; + + static const all = { + ...DocumentFields.common, + noticeId, + acknowledgedAt, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/owner_settings_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/owner_settings_document_fields.dart new file mode 100644 index 0000000..49e8783 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/owner_settings_document_fields.dart @@ -0,0 +1,25 @@ +part of '../persistence_contract.dart'; + +/// Document field names for [OwnerSettings] documents. +abstract final class OwnerSettingsDocumentFields { + static const schemaVersion = DocumentFields.schemaVersion; + static const id = DocumentFields.id; + static const ownerId = DocumentFields.ownerId; + static const revision = DocumentFields.revision; + static const createdAt = DocumentFields.createdAt; + static const updatedAt = DocumentFields.updatedAt; + static const timeZoneId = 'timeZoneId'; + static const dayStart = 'dayStart'; + static const dayEnd = 'dayEnd'; + static const compactModeEnabled = 'compactModeEnabled'; + static const backlogStaleness = 'backlogStaleness'; + + static const all = { + ...DocumentFields.common, + timeZoneId, + dayStart, + dayEnd, + compactModeEnabled, + backlogStaleness, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/persistence_civil_date_convention.dart b/packages/scheduler_core/lib/src/persistence_contract/persistence_civil_date_convention.dart new file mode 100644 index 0000000..f110715 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/persistence_civil_date_convention.dart @@ -0,0 +1,18 @@ +part of '../persistence_contract.dart'; + +/// Civil-date storage convention for future document persistence. +abstract final class PersistenceCivilDateConvention { + /// Human-readable convention kept close to the helper for tests and docs. + static const description = + 'Store CivilDate values as YYYY-MM-DD strings without timezone conversion.'; + + /// Convert a CivilDate into the persisted string convention. + static String toStoredString(CivilDate value) { + return value.toIsoString(); + } + + /// Parse a persisted CivilDate string without applying a timezone. + static CivilDate fromStoredString(String value) { + return CivilDate.fromIsoString(value); + } +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/persistence_collections.dart b/packages/scheduler_core/lib/src/persistence_contract/persistence_collections.dart new file mode 100644 index 0000000..10e1bf2 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/persistence_collections.dart @@ -0,0 +1,31 @@ +part of '../persistence_contract.dart'; + +/// Stable V1 logical collection names for persistence adapters. +abstract final class PersistenceCollections { + static const tasks = 'tasks'; + static const projects = 'projects'; + static const projectStatistics = 'project_statistics'; + static const lockedBlocks = 'locked_blocks'; + static const lockedOverrides = 'locked_overrides'; + static const taskActivities = 'task_activities'; + static const ownerSettings = 'owner_settings'; + static const noticeAcknowledgements = 'notice_acknowledgements'; + static const applicationOperations = 'application_operations'; + static const schedulingSnapshots = 'scheduling_snapshots'; + + static const authoritative = { + tasks, + projects, + projectStatistics, + lockedBlocks, + lockedOverrides, + ownerSettings, + }; + + static const internalRetained = { + taskActivities, + noticeAcknowledgements, + applicationOperations, + schedulingSnapshots, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/persistence_date_time_convention.dart b/packages/scheduler_core/lib/src/persistence_contract/persistence_date_time_convention.dart new file mode 100644 index 0000000..8de931a --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/persistence_date_time_convention.dart @@ -0,0 +1,23 @@ +part of '../persistence_contract.dart'; + +/// DateTime storage convention for future document persistence. +abstract final class PersistenceDateTimeConvention { + /// Human-readable convention kept close to the helper for tests and docs. + static const description = + 'Store DateTime values as UTC ISO-8601 strings and compare as UTC instants.'; + + /// Convert a DateTime into the persisted string convention. + static String toStoredString(DateTime value) { + return value.toUtc().toIso8601String(); + } + + /// Parse a persisted DateTime string back into a UTC DateTime. + static DateTime fromStoredString(String value) { + return DateTime.parse(value).toUtc(); + } + + /// Compare two DateTime values by their UTC instant. + static int compare(DateTime left, DateTime right) { + return left.toUtc().compareTo(right.toUtc()); + } +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/persistence_document_field_sets.dart b/packages/scheduler_core/lib/src/persistence_contract/persistence_document_field_sets.dart new file mode 100644 index 0000000..37c66ce --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/persistence_document_field_sets.dart @@ -0,0 +1,27 @@ +part of '../persistence_contract.dart'; + +/// Every field-name set currently committed for future document mapping. +abstract final class PersistenceDocumentFieldSets { + static const all = >[ + DocumentFields.common, + TaskDocumentFields.all, + TaskActivityDocumentFields.all, + TaskStatisticsDocumentFields.all, + ProjectDocumentFields.all, + ProjectStatisticsDocumentFields.all, + ClockTimeDocumentFields.all, + LockedBlockRecurrenceDocumentFields.all, + LockedBlockDocumentFields.all, + LockedBlockOverrideDocumentFields.all, + OwnerSettingsDocumentFields.all, + BacklogStalenessDocumentFields.all, + NoticeAcknowledgementDocumentFields.all, + ApplicationOperationDocumentFields.all, + SchedulingSnapshotDocumentFields.all, + SchedulingWindowDocumentFields.all, + TimeIntervalDocumentFields.all, + SchedulingNoticeDocumentFields.all, + SchedulingChangeDocumentFields.all, + SchedulingOverlapDocumentFields.all, + ]; +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/persistence_enum_name.dart b/packages/scheduler_core/lib/src/persistence_contract/persistence_enum_name.dart new file mode 100644 index 0000000..a31b3f2 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/persistence_enum_name.dart @@ -0,0 +1,10 @@ +part of '../persistence_contract.dart'; + +/// Legacy enum-name extension retained for older callers. +/// +/// V1 codecs use explicit stable code maps in `document_mapping.dart` instead +/// of relying on this extension. +extension PersistenceEnumName on Enum { + /// Legacy persisted name. + String get persistenceName => name; +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/persistence_guard_result.dart b/packages/scheduler_core/lib/src/persistence_contract/persistence_guard_result.dart new file mode 100644 index 0000000..983bc01 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/persistence_guard_result.dart @@ -0,0 +1,33 @@ +part of '../persistence_contract.dart'; + +/// Guard result for bounded document payload checks. +class PersistenceGuardResult { + const PersistenceGuardResult._({ + required this.isValid, + this.fieldName, + this.limit, + this.actual, + }); + + factory PersistenceGuardResult.valid() { + return const PersistenceGuardResult._(isValid: true); + } + + factory PersistenceGuardResult.tooLarge({ + required String fieldName, + required int limit, + required int actual, + }) { + return PersistenceGuardResult._( + isValid: false, + fieldName: fieldName, + limit: limit, + actual: actual, + ); + } + + final bool isValid; + final String? fieldName; + final int? limit; + final int? actual; +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/persistence_index_catalog.dart b/packages/scheduler_core/lib/src/persistence_contract/persistence_index_catalog.dart new file mode 100644 index 0000000..2b4b4b6 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/persistence_index_catalog.dart @@ -0,0 +1,300 @@ +part of '../persistence_contract.dart'; + +/// Required V1 index plan. +abstract final class PersistenceIndexCatalog { + static const all = [ + PersistenceIndexSpec( + name: 'tasks_owner_id_unique', + collection: PersistenceCollections.tasks, + unique: true, + keys: [ + PersistenceIndexField(DocumentFields.ownerId), + PersistenceIndexField(DocumentFields.id), + ], + supportsQueries: { + RepositoryQueryNames.taskById, + RepositoryQueryNames.taskByOwner, + }, + ), + PersistenceIndexSpec( + name: 'tasks_owner_status_backlog_age', + collection: PersistenceCollections.tasks, + keys: [ + PersistenceIndexField(DocumentFields.ownerId), + PersistenceIndexField(TaskDocumentFields.status), + PersistenceIndexField(TaskDocumentFields.backlogEnteredAt), + PersistenceIndexField(TaskDocumentFields.createdAt), + PersistenceIndexField(DocumentFields.id), + ], + supportsQueries: { + RepositoryQueryNames.taskByStatus, + RepositoryQueryNames.taskBacklogCandidates, + }, + ), + PersistenceIndexSpec( + name: 'tasks_owner_scheduled_window', + collection: PersistenceCollections.tasks, + keys: [ + PersistenceIndexField(DocumentFields.ownerId), + PersistenceIndexField(TaskDocumentFields.scheduledStart), + PersistenceIndexField(TaskDocumentFields.scheduledEnd), + PersistenceIndexField(DocumentFields.id), + ], + partialFilterFields: { + TaskDocumentFields.scheduledStart, + TaskDocumentFields.scheduledEnd, + }, + supportsQueries: { + RepositoryQueryNames.taskScheduledWindow, + RepositoryQueryNames.taskLocalDay, + }, + ), + PersistenceIndexSpec( + name: 'tasks_owner_project_status', + collection: PersistenceCollections.tasks, + keys: [ + PersistenceIndexField(DocumentFields.ownerId), + PersistenceIndexField(TaskDocumentFields.projectId), + PersistenceIndexField(TaskDocumentFields.status), + PersistenceIndexField(DocumentFields.id), + ], + supportsQueries: { + RepositoryQueryNames.taskByProject, + RepositoryQueryNames.taskBacklogCandidates, + }, + ), + PersistenceIndexSpec( + name: 'tasks_owner_parent_status', + collection: PersistenceCollections.tasks, + keys: [ + PersistenceIndexField(DocumentFields.ownerId), + PersistenceIndexField(TaskDocumentFields.parentTaskId), + PersistenceIndexField(TaskDocumentFields.status), + PersistenceIndexField(DocumentFields.id), + ], + partialFilterFields: { + TaskDocumentFields.parentTaskId, + }, + supportsQueries: { + RepositoryQueryNames.taskByParent, + }, + ), + PersistenceIndexSpec( + name: 'projects_owner_id_unique', + collection: PersistenceCollections.projects, + unique: true, + keys: [ + PersistenceIndexField(DocumentFields.ownerId), + PersistenceIndexField(DocumentFields.id), + ], + supportsQueries: { + RepositoryQueryNames.projectById, + }, + ), + PersistenceIndexSpec( + name: 'projects_owner_archived_name', + collection: PersistenceCollections.projects, + keys: [ + PersistenceIndexField(DocumentFields.ownerId), + PersistenceIndexField(ProjectDocumentFields.archivedAt), + PersistenceIndexField(ProjectDocumentFields.name), + PersistenceIndexField(DocumentFields.id), + ], + supportsQueries: { + RepositoryQueryNames.projectByOwner, + }, + ), + PersistenceIndexSpec( + name: 'project_stats_owner_project_unique', + collection: PersistenceCollections.projectStatistics, + unique: true, + keys: [ + PersistenceIndexField(DocumentFields.ownerId), + PersistenceIndexField(ProjectStatisticsDocumentFields.projectId), + ], + supportsQueries: { + RepositoryQueryNames.projectStatisticsByProject, + }, + ), + PersistenceIndexSpec( + name: 'locked_blocks_owner_id_unique', + collection: PersistenceCollections.lockedBlocks, + unique: true, + keys: [ + PersistenceIndexField(DocumentFields.ownerId), + PersistenceIndexField(DocumentFields.id), + ], + supportsQueries: { + RepositoryQueryNames.lockedBlockById, + }, + ), + PersistenceIndexSpec( + name: 'locked_blocks_owner_archived_name', + collection: PersistenceCollections.lockedBlocks, + keys: [ + PersistenceIndexField(DocumentFields.ownerId), + PersistenceIndexField(LockedBlockDocumentFields.archivedAt), + PersistenceIndexField(LockedBlockDocumentFields.name), + PersistenceIndexField(DocumentFields.id), + ], + supportsQueries: { + RepositoryQueryNames.lockedBlockByOwner, + }, + ), + PersistenceIndexSpec( + name: 'locked_overrides_owner_id_unique', + collection: PersistenceCollections.lockedOverrides, + unique: true, + keys: [ + PersistenceIndexField(DocumentFields.ownerId), + PersistenceIndexField(DocumentFields.id), + ], + ), + PersistenceIndexSpec( + name: 'locked_overrides_owner_date_block', + collection: PersistenceCollections.lockedOverrides, + keys: [ + PersistenceIndexField(DocumentFields.ownerId), + PersistenceIndexField(LockedBlockOverrideDocumentFields.date), + PersistenceIndexField(LockedBlockOverrideDocumentFields.lockedBlockId), + PersistenceIndexField(DocumentFields.id), + ], + supportsQueries: { + RepositoryQueryNames.lockedOverrideByDate, + }, + ), + PersistenceIndexSpec( + name: 'task_activities_owner_id_unique', + collection: PersistenceCollections.taskActivities, + unique: true, + keys: [ + PersistenceIndexField(DocumentFields.ownerId), + PersistenceIndexField(DocumentFields.id), + ], + ), + PersistenceIndexSpec( + name: 'task_activities_owner_operation', + collection: PersistenceCollections.taskActivities, + keys: [ + PersistenceIndexField(DocumentFields.ownerId), + PersistenceIndexField(TaskActivityDocumentFields.operationId), + PersistenceIndexField(DocumentFields.id), + ], + supportsQueries: { + RepositoryQueryNames.taskActivityByOperation, + }, + ), + PersistenceIndexSpec( + name: 'task_activities_owner_task_time', + collection: PersistenceCollections.taskActivities, + keys: [ + PersistenceIndexField(DocumentFields.ownerId), + PersistenceIndexField(TaskActivityDocumentFields.taskId), + PersistenceIndexField(TaskActivityDocumentFields.occurredAt), + PersistenceIndexField(DocumentFields.id), + ], + supportsQueries: { + RepositoryQueryNames.taskActivityByOwner, + RepositoryQueryNames.taskActivityByTask, + }, + ), + PersistenceIndexSpec( + name: 'task_activities_owner_project_code_time', + collection: PersistenceCollections.taskActivities, + keys: [ + PersistenceIndexField(DocumentFields.ownerId), + PersistenceIndexField(TaskActivityDocumentFields.projectId), + PersistenceIndexField(TaskActivityDocumentFields.code), + PersistenceIndexField(TaskActivityDocumentFields.occurredAt), + ], + supportsQueries: { + RepositoryQueryNames.taskActivityByProject, + }, + ), + PersistenceIndexSpec( + name: 'owner_settings_owner_unique', + collection: PersistenceCollections.ownerSettings, + unique: true, + keys: [ + PersistenceIndexField(DocumentFields.ownerId), + PersistenceIndexField(DocumentFields.id), + ], + supportsQueries: { + RepositoryQueryNames.ownerSettingsByOwner, + }, + ), + PersistenceIndexSpec( + name: 'notice_ack_owner_notice_unique', + collection: PersistenceCollections.noticeAcknowledgements, + unique: true, + keys: [ + PersistenceIndexField(DocumentFields.ownerId), + PersistenceIndexField(NoticeAcknowledgementDocumentFields.noticeId), + ], + supportsQueries: { + RepositoryQueryNames.noticeAcknowledgementById, + }, + ), + PersistenceIndexSpec( + name: 'notice_ack_owner_acknowledged', + collection: PersistenceCollections.noticeAcknowledgements, + keys: [ + PersistenceIndexField(DocumentFields.ownerId), + PersistenceIndexField( + NoticeAcknowledgementDocumentFields.acknowledgedAt, + ), + PersistenceIndexField(NoticeAcknowledgementDocumentFields.noticeId), + ], + supportsQueries: { + RepositoryQueryNames.noticeAcknowledgementByOwner, + }, + ), + PersistenceIndexSpec( + name: 'operations_owner_operation_unique', + collection: PersistenceCollections.applicationOperations, + unique: true, + keys: [ + PersistenceIndexField(DocumentFields.ownerId), + PersistenceIndexField( + ApplicationOperationDocumentFields.operationId, + ), + ], + supportsQueries: { + RepositoryQueryNames.operationByIdempotencyKey, + }, + ), + PersistenceIndexSpec( + name: 'snapshots_owner_id_unique', + collection: PersistenceCollections.schedulingSnapshots, + unique: true, + keys: [ + PersistenceIndexField(DocumentFields.ownerId), + PersistenceIndexField(DocumentFields.id), + ], + supportsQueries: { + RepositoryQueryNames.schedulingSnapshotById, + }, + ), + PersistenceIndexSpec( + name: 'snapshots_owner_dates_retention', + collection: PersistenceCollections.schedulingSnapshots, + keys: [ + PersistenceIndexField(DocumentFields.ownerId), + PersistenceIndexField(SchedulingSnapshotDocumentFields.sourceDate), + PersistenceIndexField(SchedulingSnapshotDocumentFields.targetDate), + PersistenceIndexField( + SchedulingSnapshotDocumentFields.retentionExpiresAt, + ), + ], + supportsQueries: { + RepositoryQueryNames.schedulingSnapshotWindow, + }, + ), + ]; + + static Set get supportedQueries { + return { + for (final spec in all) ...spec.supportsQueries, + }; + } +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/persistence_index_direction.dart b/packages/scheduler_core/lib/src/persistence_contract/persistence_index_direction.dart new file mode 100644 index 0000000..422612a --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/persistence_index_direction.dart @@ -0,0 +1,7 @@ +part of '../persistence_contract.dart'; + +/// Sort direction in an adapter-neutral index specification. +enum PersistenceIndexDirection { + ascending, + descending, +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/persistence_index_field.dart b/packages/scheduler_core/lib/src/persistence_contract/persistence_index_field.dart new file mode 100644 index 0000000..a56897c --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/persistence_index_field.dart @@ -0,0 +1,12 @@ +part of '../persistence_contract.dart'; + +/// One field in a declarative index key. +class PersistenceIndexField { + const PersistenceIndexField( + this.fieldName, { + this.direction = PersistenceIndexDirection.ascending, + }); + + final String fieldName; + final PersistenceIndexDirection direction; +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/persistence_index_spec.dart b/packages/scheduler_core/lib/src/persistence_contract/persistence_index_spec.dart new file mode 100644 index 0000000..1076fe2 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/persistence_index_spec.dart @@ -0,0 +1,20 @@ +part of '../persistence_contract.dart'; + +/// A stable index plan future adapters can translate to driver-specific calls. +class PersistenceIndexSpec { + const PersistenceIndexSpec({ + required this.name, + required this.collection, + required this.keys, + this.unique = false, + this.partialFilterFields = const {}, + this.supportsQueries = const {}, + }); + + final String name; + final String collection; + final List keys; + final bool unique; + final Set partialFilterFields; + final Set supportsQueries; +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/persistence_payload_guard.dart b/packages/scheduler_core/lib/src/persistence_contract/persistence_payload_guard.dart new file mode 100644 index 0000000..f8b5e2d --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/persistence_payload_guard.dart @@ -0,0 +1,35 @@ +part of '../persistence_contract.dart'; + +/// Pure guard helpers for bounded embedded document payloads. +abstract final class PersistencePayloadGuard { + static PersistenceGuardResult mapEntries({ + required String fieldName, + required Map value, + required int limit, + }) { + if (value.length > limit) { + return PersistenceGuardResult.tooLarge( + fieldName: fieldName, + limit: limit, + actual: value.length, + ); + } + return PersistenceGuardResult.valid(); + } + + static PersistenceGuardResult listLength({ + required String fieldName, + required Iterable value, + required int limit, + }) { + final actual = value.length; + if (actual > limit) { + return PersistenceGuardResult.tooLarge( + fieldName: fieldName, + limit: limit, + actual: actual, + ); + } + return PersistenceGuardResult.valid(); + } +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/persistence_payload_limits.dart b/packages/scheduler_core/lib/src/persistence_contract/persistence_payload_limits.dart new file mode 100644 index 0000000..0a3fa51 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/persistence_payload_limits.dart @@ -0,0 +1,13 @@ +part of '../persistence_contract.dart'; + +/// Bounded payload and retention policy for non-authoritative documents. +abstract final class PersistencePayloadLimits { + static const maxTaskActivityMetadataEntries = 50; + static const maxSchedulingSnapshotTasks = 250; + static const maxSchedulingSnapshotNotices = 100; + static const maxSchedulingSnapshotChanges = 250; + static const maxAppliedActivityIds = 1000; + static const taskActivityRetention = Duration(days: 365); + static const applicationOperationRetention = Duration(days: 90); + static const schedulingSnapshotRetention = Duration(days: 30); +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/persistence_wall_time_convention.dart b/packages/scheduler_core/lib/src/persistence_contract/persistence_wall_time_convention.dart new file mode 100644 index 0000000..4fa094c --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/persistence_wall_time_convention.dart @@ -0,0 +1,18 @@ +part of '../persistence_contract.dart'; + +/// Wall-time storage convention for future document persistence. +abstract final class PersistenceWallTimeConvention { + /// Human-readable convention kept close to the helper for tests and docs. + static const description = + 'Store WallTime values as HH:MM strings without date or timezone data.'; + + /// Convert a WallTime into the persisted string convention. + static String toStoredString(WallTime value) { + return value.toIsoString(); + } + + /// Parse a persisted WallTime string without applying a date or timezone. + static WallTime fromStoredString(String value) { + return WallTime.fromIsoString(value); + } +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/project_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/project_document_fields.dart new file mode 100644 index 0000000..53d99e1 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/project_document_fields.dart @@ -0,0 +1,31 @@ +part of '../persistence_contract.dart'; + +/// Document field names for [ProjectProfile] documents. +abstract final class ProjectDocumentFields { + static const schemaVersion = DocumentFields.schemaVersion; + static const id = DocumentFields.id; + static const ownerId = DocumentFields.ownerId; + static const revision = DocumentFields.revision; + static const createdAt = DocumentFields.createdAt; + static const updatedAt = DocumentFields.updatedAt; + static const name = 'name'; + static const colorKey = 'colorKey'; + static const defaultPriority = 'defaultPriority'; + static const defaultReward = 'defaultReward'; + static const defaultDifficulty = 'defaultDifficulty'; + static const defaultReminderProfile = 'defaultReminderProfile'; + static const defaultDurationMinutes = 'defaultDurationMinutes'; + static const archivedAt = 'archivedAt'; + + static const all = { + ...DocumentFields.common, + name, + colorKey, + defaultPriority, + defaultReward, + defaultDifficulty, + defaultReminderProfile, + defaultDurationMinutes, + archivedAt, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/project_statistics_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/project_statistics_document_fields.dart new file mode 100644 index 0000000..ac1dce6 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/project_statistics_document_fields.dart @@ -0,0 +1,35 @@ +part of '../persistence_contract.dart'; + +/// Document field names for [ProjectStatistics] documents. +abstract final class ProjectStatisticsDocumentFields { + static const schemaVersion = DocumentFields.schemaVersion; + static const id = DocumentFields.id; + static const ownerId = DocumentFields.ownerId; + static const revision = DocumentFields.revision; + static const createdAt = DocumentFields.createdAt; + static const updatedAt = DocumentFields.updatedAt; + static const projectId = 'projectId'; + static const completedTaskCount = 'completedTaskCount'; + static const durationMinuteCounts = 'durationMinuteCounts'; + static const completionTimeBucketCounts = 'completionTimeBucketCounts'; + static const totalPushesBeforeCompletion = 'totalPushesBeforeCompletion'; + static const completedAfterPushCount = 'completedAfterPushCount'; + static const rewardCounts = 'rewardCounts'; + static const difficultyCounts = 'difficultyCounts'; + static const reminderProfileCounts = 'reminderProfileCounts'; + static const appliedActivityIds = 'appliedActivityIds'; + + static const all = { + ...DocumentFields.common, + projectId, + completedTaskCount, + durationMinuteCounts, + completionTimeBucketCounts, + totalPushesBeforeCompletion, + completedAfterPushCount, + rewardCounts, + difficultyCounts, + reminderProfileCounts, + appliedActivityIds, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/repository_query_names.dart b/packages/scheduler_core/lib/src/persistence_contract/repository_query_names.dart new file mode 100644 index 0000000..f4a8995 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/repository_query_names.dart @@ -0,0 +1,37 @@ +part of '../persistence_contract.dart'; + +/// Stable repository query identifiers for index coverage tests. +abstract final class RepositoryQueryNames { + static const taskById = 'TaskRepository.findById'; + static const taskByOwner = 'TaskRepository.findByOwner'; + static const taskByStatus = 'TaskRepository.findByStatus'; + static const taskByProject = 'TaskRepository.findByProjectId'; + static const taskByParent = 'TaskRepository.findByParentTaskId'; + static const taskBacklogCandidates = 'TaskRepository.findBacklogCandidates'; + static const taskScheduledWindow = + 'TaskRepository.findScheduledInWindowForOwner'; + static const taskLocalDay = 'TaskRepository.findForLocalDay'; + static const projectByOwner = 'ProjectRepository.findByOwner'; + static const projectById = 'ProjectRepository.findById'; + static const lockedBlockByOwner = 'LockedBlockRepository.findBlocksByOwner'; + static const lockedBlockById = 'LockedBlockRepository.findBlockById'; + static const lockedOverrideByDate = + 'LockedBlockRepository.findOverridesForDate'; + static const taskActivityByOwner = 'TaskActivityRepository.findByOwner'; + static const taskActivityByOperation = + 'TaskActivityRepository.findByOperationId'; + static const taskActivityByTask = 'TaskActivityRepository.findByTaskId'; + static const taskActivityByProject = 'TaskActivityRepository.findByProjectId'; + static const projectStatisticsByProject = + 'ProjectStatisticsRepository.findByProjectId'; + static const ownerSettingsByOwner = 'OwnerSettingsRepository.findByOwnerId'; + static const noticeAcknowledgementByOwner = + 'NoticeAcknowledgementRepository.findByOwner'; + static const noticeAcknowledgementById = + 'NoticeAcknowledgementRepository.findById'; + static const operationByIdempotencyKey = + 'ApplicationOperationRepository.findByIdempotencyKey'; + static const schedulingSnapshotById = 'SchedulingSnapshotRepository.findById'; + static const schedulingSnapshotWindow = + 'SchedulingSnapshotRepository.findInWindow'; +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/scheduling_change_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/scheduling_change_document_fields.dart new file mode 100644 index 0000000..c3ca256 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/scheduling_change_document_fields.dart @@ -0,0 +1,18 @@ +part of '../persistence_contract.dart'; + +/// Document field names for [SchedulingChange] embedded documents. +abstract final class SchedulingChangeDocumentFields { + static const taskId = 'taskId'; + static const previousStart = 'previousStart'; + static const previousEnd = 'previousEnd'; + static const nextStart = 'nextStart'; + static const nextEnd = 'nextEnd'; + + static const all = { + taskId, + previousStart, + previousEnd, + nextStart, + nextEnd, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/scheduling_notice_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/scheduling_notice_document_fields.dart new file mode 100644 index 0000000..c843f55 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/scheduling_notice_document_fields.dart @@ -0,0 +1,22 @@ +part of '../persistence_contract.dart'; + +/// Document field names for [SchedulingNotice] embedded documents. +abstract final class SchedulingNoticeDocumentFields { + static const message = 'message'; + static const type = 'type'; + static const taskId = 'taskId'; + static const issueCode = 'issueCode'; + static const movementCode = 'movementCode'; + static const conflictCode = 'conflictCode'; + static const parameters = 'parameters'; + + static const all = { + message, + type, + taskId, + issueCode, + movementCode, + conflictCode, + parameters, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/scheduling_overlap_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/scheduling_overlap_document_fields.dart new file mode 100644 index 0000000..71718df --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/scheduling_overlap_document_fields.dart @@ -0,0 +1,14 @@ +part of '../persistence_contract.dart'; + +/// Document field names for [SchedulingOverlap] embedded documents. +abstract final class SchedulingOverlapDocumentFields { + static const taskId = 'taskId'; + static const taskInterval = 'taskInterval'; + static const blockedInterval = 'blockedInterval'; + + static const all = { + taskId, + taskInterval, + blockedInterval, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/scheduling_snapshot_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/scheduling_snapshot_document_fields.dart new file mode 100644 index 0000000..3e6573a --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/scheduling_snapshot_document_fields.dart @@ -0,0 +1,41 @@ +part of '../persistence_contract.dart'; + +/// Document field names for [SchedulingStateSnapshot] documents. +abstract final class SchedulingSnapshotDocumentFields { + static const schemaVersion = DocumentFields.schemaVersion; + static const id = DocumentFields.id; + static const ownerId = DocumentFields.ownerId; + static const revision = DocumentFields.revision; + static const createdAt = DocumentFields.createdAt; + static const updatedAt = DocumentFields.updatedAt; + static const capturedAt = 'capturedAt'; + static const sourceDate = 'sourceDate'; + static const targetDate = 'targetDate'; + static const window = 'window'; + static const tasks = 'tasks'; + static const lockedIntervals = 'lockedIntervals'; + static const requiredVisibleIntervals = 'requiredVisibleIntervals'; + static const notices = 'notices'; + static const changes = 'changes'; + static const overlaps = 'overlaps'; + static const operationName = 'operationName'; + static const retentionExpiresAt = 'retentionExpiresAt'; + static const truncated = 'truncated'; + + static const all = { + ...DocumentFields.common, + capturedAt, + sourceDate, + targetDate, + window, + tasks, + lockedIntervals, + requiredVisibleIntervals, + notices, + changes, + overlaps, + operationName, + retentionExpiresAt, + truncated, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/scheduling_window_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/scheduling_window_document_fields.dart new file mode 100644 index 0000000..8e8db94 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/scheduling_window_document_fields.dart @@ -0,0 +1,12 @@ +part of '../persistence_contract.dart'; + +/// Document field names for [SchedulingWindow] embedded documents. +abstract final class SchedulingWindowDocumentFields { + static const start = 'start'; + static const end = 'end'; + + static const all = { + start, + end, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/task_activity_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/task_activity_document_fields.dart new file mode 100644 index 0000000..10ef992 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/task_activity_document_fields.dart @@ -0,0 +1,27 @@ +part of '../persistence_contract.dart'; + +/// Document field names for internal [TaskActivity] documents. +abstract final class TaskActivityDocumentFields { + static const schemaVersion = DocumentFields.schemaVersion; + static const id = DocumentFields.id; + static const ownerId = DocumentFields.ownerId; + static const revision = DocumentFields.revision; + static const createdAt = DocumentFields.createdAt; + static const updatedAt = DocumentFields.updatedAt; + static const operationId = 'operationId'; + static const code = 'code'; + static const taskId = 'taskId'; + static const projectId = 'projectId'; + static const occurredAt = 'occurredAt'; + static const metadata = 'metadata'; + + static const all = { + ...DocumentFields.common, + operationId, + code, + taskId, + projectId, + occurredAt, + metadata, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/task_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/task_document_fields.dart new file mode 100644 index 0000000..921ffc2 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/task_document_fields.dart @@ -0,0 +1,53 @@ +part of '../persistence_contract.dart'; + +/// Document field names for [Task] documents. +abstract final class TaskDocumentFields { + static const schemaVersion = DocumentFields.schemaVersion; + static const id = DocumentFields.id; + static const ownerId = DocumentFields.ownerId; + static const revision = DocumentFields.revision; + static const title = 'title'; + static const projectId = 'projectId'; + static const type = 'type'; + static const status = 'status'; + static const priority = 'priority'; + static const reward = 'reward'; + static const difficulty = 'difficulty'; + static const durationMinutes = 'durationMinutes'; + static const scheduledStart = 'scheduledStart'; + static const scheduledEnd = 'scheduledEnd'; + static const actualStart = 'actualStart'; + static const actualEnd = 'actualEnd'; + static const completedAt = 'completedAt'; + static const parentTaskId = 'parentTaskId'; + static const backlogTags = 'backlogTags'; + static const reminderOverride = 'reminderOverride'; + static const createdAt = DocumentFields.createdAt; + static const updatedAt = DocumentFields.updatedAt; + static const stats = 'stats'; + static const backlogEnteredAt = 'backlogEnteredAt'; + static const backlogEnteredAtProvenance = 'backlogEnteredAtProvenance'; + + static const all = { + ...DocumentFields.common, + title, + projectId, + type, + status, + priority, + reward, + difficulty, + durationMinutes, + scheduledStart, + scheduledEnd, + actualStart, + actualEnd, + completedAt, + parentTaskId, + backlogTags, + reminderOverride, + stats, + backlogEnteredAt, + backlogEnteredAtProvenance, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/task_statistics_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/task_statistics_document_fields.dart new file mode 100644 index 0000000..9f2734f --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/task_statistics_document_fields.dart @@ -0,0 +1,36 @@ +part of '../persistence_contract.dart'; + +/// Document field names for [TaskStatistics] embedded documents. +abstract final class TaskStatisticsDocumentFields { + static const skippedDuringBurnoutCount = 'skippedDuringBurnoutCount'; + static const manuallyPushedCount = 'manuallyPushedCount'; + static const autoPushedCount = 'autoPushedCount'; + static const movedToBacklogCount = 'movedToBacklogCount'; + static const restoredFromBacklogCount = 'restoredFromBacklogCount'; + static const missedCount = 'missedCount'; + static const cancelledCount = 'cancelledCount'; + static const completedLateCount = 'completedLateCount'; + static const completedDuringLockedHoursCount = + 'completedDuringLockedHoursCount'; + static const completedDuringLockedHoursMinutes = + 'completedDuringLockedHoursMinutes'; + static const completedAfterShieldCount = 'completedAfterShieldCount'; + static const completedAfterPushCount = 'completedAfterPushCount'; + static const totalPushesBeforeCompletion = 'totalPushesBeforeCompletion'; + + static const all = { + skippedDuringBurnoutCount, + manuallyPushedCount, + autoPushedCount, + movedToBacklogCount, + restoredFromBacklogCount, + missedCount, + cancelledCount, + completedLateCount, + completedDuringLockedHoursCount, + completedDuringLockedHoursMinutes, + completedAfterShieldCount, + completedAfterPushCount, + totalPushesBeforeCompletion, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/time_interval_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/time_interval_document_fields.dart new file mode 100644 index 0000000..e861bbd --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/time_interval_document_fields.dart @@ -0,0 +1,14 @@ +part of '../persistence_contract.dart'; + +/// Document field names for [TimeInterval] embedded documents. +abstract final class TimeIntervalDocumentFields { + static const start = 'start'; + static const end = 'end'; + static const label = 'label'; + + static const all = { + start, + end, + label, + }; +} diff --git a/packages/scheduler_core/lib/src/project_statistics.dart b/packages/scheduler_core/lib/src/project_statistics.dart index 30fdbdc..44e9c76 100644 --- a/packages/scheduler_core/lib/src/project_statistics.dart +++ b/packages/scheduler_core/lib/src/project_statistics.dart @@ -9,692 +9,14 @@ library; import 'models.dart'; import 'task_lifecycle.dart'; - -/// Coarse completion-time buckets used for learned project suggestions. -enum ProjectCompletionTimeBucket { - overnight, - morning, - afternoon, - evening, - night, -} - -/// Suggestion categories derived from project observations. -enum ProjectSuggestionType { - durationMinutes, - completionTimeBucket, - reward, - difficulty, - reminderProfile, -} - -/// How concentrated the supporting observations are for a suggestion. -enum ProjectSuggestionConfidence { - low, - medium, - high, -} - -/// Immutable project-level observations used for future filtering and hints. -class ProjectStatistics { - ProjectStatistics({ - required String projectId, - this.completedTaskCount = 0, - Map durationMinuteCounts = const {}, - Map completionTimeBucketCounts = - const {}, - this.totalPushesBeforeCompletion = 0, - this.completedAfterPushCount = 0, - Map rewardCounts = const {}, - Map difficultyCounts = const {}, - Map reminderProfileCounts = - const {}, - }) : projectId = _requiredTrimmed(projectId, 'projectId'), - durationMinuteCounts = Map.unmodifiable( - _positiveIntKeyCounts(durationMinuteCounts, 'durationMinuteCounts'), - ), - completionTimeBucketCounts = - Map.unmodifiable( - _enumCounts( - completionTimeBucketCounts, - 'completionTimeBucketCounts', - ), - ), - rewardCounts = Map.unmodifiable( - _enumCounts(rewardCounts, 'rewardCounts'), - ), - difficultyCounts = Map.unmodifiable( - _enumCounts(difficultyCounts, 'difficultyCounts'), - ), - reminderProfileCounts = Map.unmodifiable( - _enumCounts(reminderProfileCounts, 'reminderProfileCounts'), - ) { - _validateNonNegative(completedTaskCount, 'completedTaskCount'); - _validateNonNegative( - totalPushesBeforeCompletion, - 'totalPushesBeforeCompletion', - ); - _validateNonNegative(completedAfterPushCount, 'completedAfterPushCount'); - } - - /// Project these observations belong to. - final String projectId; - - /// Completion activities applied to this project aggregate. - final int completedTaskCount; - - /// Duration samples stored as minute-to-count buckets. - final Map durationMinuteCounts; - - /// Completion timestamp samples stored as coarse bucket counts. - final Map completionTimeBucketCounts; - - /// Sum of push counts present when tasks were completed. - final int totalPushesBeforeCompletion; - - /// Number of completed tasks that had at least one prior push. - final int completedAfterPushCount; - - /// Reward observations from completed tasks. - final Map rewardCounts; - - /// Difficulty observations from completed tasks. - final Map difficultyCounts; - - /// Reminder-profile observations when explicitly known. - final Map reminderProfileCounts; - - /// Known duration sample count derived from the duration distribution. - int get knownDurationSampleCount => _countTotal(durationMinuteCounts); - - /// Sum of known duration samples in minutes. - int get totalKnownDurationMinutes { - var total = 0; - for (final entry in durationMinuteCounts.entries) { - total += entry.key * entry.value; - } - return total; - } - - /// Numerator for average pushes before completion. - int get averagePushesBeforeCompletionNumerator { - return totalPushesBeforeCompletion; - } - - /// Denominator for average pushes before completion. - int get averagePushesBeforeCompletionDenominator { - return completedTaskCount; - } - - /// Derived average push count, without storing floating-point aggregate state. - double? get averagePushesBeforeCompletion { - if (completedTaskCount == 0) { - return null; - } - return totalPushesBeforeCompletion / completedTaskCount; - } - - /// Return a copy with selected aggregate fields changed. - ProjectStatistics copyWith({ - String? projectId, - int? completedTaskCount, - Map? durationMinuteCounts, - Map? completionTimeBucketCounts, - int? totalPushesBeforeCompletion, - int? completedAfterPushCount, - Map? rewardCounts, - Map? difficultyCounts, - Map? reminderProfileCounts, - }) { - return ProjectStatistics( - projectId: projectId ?? this.projectId, - completedTaskCount: completedTaskCount ?? this.completedTaskCount, - durationMinuteCounts: durationMinuteCounts ?? this.durationMinuteCounts, - completionTimeBucketCounts: - completionTimeBucketCounts ?? this.completionTimeBucketCounts, - totalPushesBeforeCompletion: - totalPushesBeforeCompletion ?? this.totalPushesBeforeCompletion, - completedAfterPushCount: - completedAfterPushCount ?? this.completedAfterPushCount, - rewardCounts: rewardCounts ?? this.rewardCounts, - difficultyCounts: difficultyCounts ?? this.difficultyCounts, - reminderProfileCounts: - reminderProfileCounts ?? this.reminderProfileCounts, - ); - } - - /// Record one completed task observation. - ProjectStatistics recordCompletion({ - required DateTime completedAt, - int? durationMinutes, - int pushesBeforeCompletion = 0, - RewardLevel? reward, - DifficultyLevel? difficulty, - ReminderProfile? reminderProfile, - }) { - if (durationMinutes != null && durationMinutes <= 0) { - throw ArgumentError.value( - durationMinutes, - 'durationMinutes', - 'Duration minutes must be positive when present.', - ); - } - if (pushesBeforeCompletion < 0) { - throw ArgumentError.value( - pushesBeforeCompletion, - 'pushesBeforeCompletion', - 'Push count cannot be negative.', - ); - } - - return copyWith( - completedTaskCount: completedTaskCount + 1, - durationMinuteCounts: durationMinutes == null - ? durationMinuteCounts - : _incrementCount(durationMinuteCounts, durationMinutes), - completionTimeBucketCounts: _incrementCount( - completionTimeBucketCounts, - bucketForCompletion(completedAt), - ), - totalPushesBeforeCompletion: - totalPushesBeforeCompletion + pushesBeforeCompletion, - completedAfterPushCount: pushesBeforeCompletion > 0 - ? completedAfterPushCount + 1 - : completedAfterPushCount, - rewardCounts: reward == null - ? rewardCounts - : _incrementCount( - rewardCounts, - reward, - ), - difficultyCounts: difficulty == null - ? difficultyCounts - : _incrementCount( - difficultyCounts, - difficulty, - ), - reminderProfileCounts: reminderProfile == null - ? reminderProfileCounts - : _incrementCount( - reminderProfileCounts, - reminderProfile, - ), - ); - } - - /// Resolve the completion-time bucket for [completedAt]. - static ProjectCompletionTimeBucket bucketForCompletion(DateTime completedAt) { - final hour = completedAt.hour; - if (hour < 5) { - return ProjectCompletionTimeBucket.overnight; - } - if (hour < 12) { - return ProjectCompletionTimeBucket.morning; - } - if (hour < 17) { - return ProjectCompletionTimeBucket.afternoon; - } - if (hour < 21) { - return ProjectCompletionTimeBucket.evening; - } - return ProjectCompletionTimeBucket.night; - } -} - -/// Result of applying project-statistic effects from activities. -class ProjectStatisticsApplicationResult { - ProjectStatisticsApplicationResult({ - required this.statistics, - List appliedActivityIds = const [], - }) : appliedActivityIds = List.unmodifiable(appliedActivityIds); - - /// Aggregate after newly applied activity effects. - final ProjectStatistics statistics; - - /// Completion activity ids that changed the aggregate. - final List appliedActivityIds; -} - -/// Updates project statistics from canonical task activities exactly once. -class ProjectStatisticsAggregationService { - const ProjectStatisticsAggregationService(); - - /// Apply completion [activities] to [statistics]. - /// - /// [alreadyAppliedActivityIds] is supplied by the application/persistence - /// boundary. This service returns the new ids so Block 14 can persist task, - /// activity, and aggregate mutations atomically. - ProjectStatisticsApplicationResult apply({ - required ProjectStatistics statistics, - required Iterable activities, - Iterable tasks = const [], - Iterable alreadyAppliedActivityIds = const [], - }) { - final taskById = { - for (final task in tasks) task.id: task, - }; - final appliedIds = alreadyAppliedActivityIds.toSet(); - final newlyAppliedIds = []; - var current = statistics; - - for (final activity in activities) { - if (activity.projectId != statistics.projectId || - activity.code != TaskActivityCode.completed || - appliedIds.contains(activity.id)) { - continue; - } - - final task = taskById[activity.taskId]; - final completedAt = _dateTimeMetadata(activity.metadata['completedAt']) ?? - activity.occurredAt; - final pushesBeforeCompletion = - _intMetadata(activity.metadata['pushesBeforeCompletion']) ?? - _pushesBeforeCompletion(task); - - current = current.recordCompletion( - completedAt: completedAt, - durationMinutes: _durationMinutes(activity, task), - pushesBeforeCompletion: pushesBeforeCompletion, - reward: _rewardLevel(activity.metadata['reward']) ?? task?.reward, - difficulty: _difficultyLevel(activity.metadata['difficulty']) ?? - task?.difficulty, - reminderProfile: - _reminderProfile(activity.metadata['reminderProfile']) ?? - task?.reminderOverride, - ); - appliedIds.add(activity.id); - newlyAppliedIds.add(activity.id); - } - - return ProjectStatisticsApplicationResult( - statistics: current, - appliedActivityIds: newlyAppliedIds, - ); - } -} - -/// Minimum sample sizes for deterministic project suggestions. -class ProjectSuggestionPolicy { - const ProjectSuggestionPolicy({ - this.minimumDurationSamples = 3, - this.minimumCompletionTimeSamples = 3, - this.minimumRewardSamples = 3, - this.minimumDifficultySamples = 3, - this.minimumReminderSamples = 3, - }); - - final int minimumDurationSamples; - final int minimumCompletionTimeSamples; - final int minimumRewardSamples; - final int minimumDifficultySamples; - final int minimumReminderSamples; -} - -/// One optional learned suggestion with provenance. -class ProjectSuggestion { - const ProjectSuggestion({ - required this.type, - required this.value, - required this.sampleSize, - required this.supportingSampleCount, - required this.minimumSampleSize, - required this.confidence, - }); - - /// Suggestion category. - final ProjectSuggestionType type; - - /// Suggested value. - final T value; - - /// Number of meaningful observations considered. - final int sampleSize; - - /// Observations that support [value]. - final int supportingSampleCount; - - /// Minimum sample threshold used before producing the suggestion. - final int minimumSampleSize; - - /// Concentration of supporting observations. - final ProjectSuggestionConfidence confidence; -} - -/// Optional suggestion set derived from one project's observations. -class ProjectSuggestionSet { - const ProjectSuggestionSet({ - required this.statistics, - this.durationMinutes, - this.completionTimeBucket, - this.reward, - this.difficulty, - this.reminderProfile, - }); - - final ProjectStatistics statistics; - final ProjectSuggestion? durationMinutes; - final ProjectSuggestion? completionTimeBucket; - final ProjectSuggestion? reward; - final ProjectSuggestion? difficulty; - final ProjectSuggestion? reminderProfile; -} - -/// Configured project defaults plus optional suggestions kept separate. -class ProjectDefaultResolution { - const ProjectDefaultResolution({ - required this.project, - required this.suggestions, - }); - - /// Authoritative configured project profile. - final ProjectProfile project; - - /// Optional learned suggestions that UI can present explicitly. - final ProjectSuggestionSet suggestions; - - int? get configuredDurationMinutes => project.defaultDurationMinutes; - RewardLevel get configuredReward => project.defaultReward; - DifficultyLevel get configuredDifficulty => project.defaultDifficulty; - ReminderProfile get configuredReminderProfile => - project.defaultReminderProfile; -} - -/// Derives deterministic project suggestions from aggregate observations. -class ProjectSuggestionService { - const ProjectSuggestionService({ - this.policy = const ProjectSuggestionPolicy(), - }); - - final ProjectSuggestionPolicy policy; - - /// Build optional suggestions for [statistics]. - ProjectSuggestionSet suggestionsFor(ProjectStatistics statistics) { - return ProjectSuggestionSet( - statistics: statistics, - durationMinutes: _durationSuggestion(statistics), - completionTimeBucket: _modeSuggestion( - type: ProjectSuggestionType.completionTimeBucket, - counts: statistics.completionTimeBucketCounts, - minimumSampleSize: policy.minimumCompletionTimeSamples, - ), - reward: _modeSuggestion( - type: ProjectSuggestionType.reward, - counts: _withoutNotSetReward(statistics.rewardCounts), - minimumSampleSize: policy.minimumRewardSamples, - ), - difficulty: _modeSuggestion( - type: ProjectSuggestionType.difficulty, - counts: _withoutNotSetDifficulty(statistics.difficultyCounts), - minimumSampleSize: policy.minimumDifficultySamples, - ), - reminderProfile: _modeSuggestion( - type: ProjectSuggestionType.reminderProfile, - counts: statistics.reminderProfileCounts, - minimumSampleSize: policy.minimumReminderSamples, - ), - ); - } - - /// Return configured defaults and optional suggestions without merging them. - ProjectDefaultResolution resolveDefaults({ - required ProjectProfile project, - required ProjectStatistics statistics, - }) { - return ProjectDefaultResolution( - project: project, - suggestions: suggestionsFor(statistics), - ); - } - - ProjectSuggestion? _durationSuggestion(ProjectStatistics statistics) { - final sampleSize = statistics.knownDurationSampleCount; - if (sampleSize < policy.minimumDurationSamples) { - return null; - } - - final value = _weightedLowerMedian(statistics.durationMinuteCounts); - if (value == null) { - return null; - } - - return ProjectSuggestion( - type: ProjectSuggestionType.durationMinutes, - value: value, - sampleSize: sampleSize, - supportingSampleCount: statistics.durationMinuteCounts[value] ?? 0, - minimumSampleSize: policy.minimumDurationSamples, - confidence: _confidence( - statistics.durationMinuteCounts[value] ?? 0, - sampleSize, - ), - ); - } - - ProjectSuggestion? _modeSuggestion({ - required ProjectSuggestionType type, - required Map counts, - required int minimumSampleSize, - }) { - final sampleSize = _countTotal(counts); - if (sampleSize < minimumSampleSize) { - return null; - } - - final mode = _uniqueMode(counts); - if (mode == null) { - return null; - } - - final support = counts[mode] ?? 0; - return ProjectSuggestion( - type: type, - value: mode, - sampleSize: sampleSize, - supportingSampleCount: support, - minimumSampleSize: minimumSampleSize, - confidence: _confidence(support, sampleSize), - ); - } -} - -Map _incrementCount(Map counts, K key) { - return { - ...counts, - key: (counts[key] ?? 0) + 1, - }; -} - -Map _positiveIntKeyCounts(Map counts, String name) { - final normalized = {}; - for (final entry in counts.entries) { - if (entry.key <= 0) { - throw ArgumentError.value(entry.key, name, 'Key must be positive.'); - } - _validateNonNegative(entry.value, name); - if (entry.value > 0) { - normalized[entry.key] = entry.value; - } - } - return normalized; -} - -Map _enumCounts(Map counts, String name) { - final normalized = {}; - for (final entry in counts.entries) { - _validateNonNegative(entry.value, name); - if (entry.value > 0) { - normalized[entry.key] = entry.value; - } - } - return normalized; -} - -void _validateNonNegative(int value, String name) { - if (value < 0) { - throw ArgumentError.value(value, name, 'Count cannot be negative.'); - } -} - -int _countTotal(Map counts) { - var total = 0; - for (final value in counts.values) { - total += value; - } - return total; -} - -int? _weightedLowerMedian(Map counts) { - if (counts.isEmpty) { - return null; - } - final sampleSize = _countTotal(counts); - final target = (sampleSize + 1) ~/ 2; - var seen = 0; - final sortedMinutes = counts.keys.toList()..sort(); - for (final minutes in sortedMinutes) { - seen += counts[minutes] ?? 0; - if (seen >= target) { - return minutes; - } - } - return null; -} - -T? _uniqueMode(Map counts) { - T? bestKey; - var bestCount = 0; - var tied = false; - - for (final entry in counts.entries) { - if (entry.value > bestCount) { - bestKey = entry.key; - bestCount = entry.value; - tied = false; - continue; - } - if (entry.value == bestCount && bestCount > 0) { - tied = true; - } - } - - return tied ? null : bestKey; -} - -ProjectSuggestionConfidence _confidence(int support, int sampleSize) { - if (sampleSize <= 0) { - return ProjectSuggestionConfidence.low; - } - if (support * 3 >= sampleSize * 2) { - return ProjectSuggestionConfidence.high; - } - if (support * 2 >= sampleSize) { - return ProjectSuggestionConfidence.medium; - } - return ProjectSuggestionConfidence.low; -} - -Map _withoutNotSetReward(Map counts) { - return { - for (final entry in counts.entries) - if (entry.key != RewardLevel.notSet) entry.key: entry.value, - }; -} - -Map _withoutNotSetDifficulty( - Map counts, -) { - return { - for (final entry in counts.entries) - if (entry.key != DifficultyLevel.notSet) entry.key: entry.value, - }; -} - -DateTime? _dateTimeMetadata(Object? value) { - if (value is DateTime) { - return value; - } - if (value is String) { - return DateTime.tryParse(value)?.toUtc(); - } - return null; -} - -int? _intMetadata(Object? value) { - return value is int && value >= 0 ? value : null; -} - -int? _durationMinutes(TaskActivity activity, Task? task) { - final metadataDuration = _intMetadata(activity.metadata['durationMinutes']); - if (metadataDuration != null && metadataDuration > 0) { - return metadataDuration; - } - - final actualStart = - _dateTimeMetadata(activity.metadata['actualStart']) ?? task?.actualStart; - final actualEnd = - _dateTimeMetadata(activity.metadata['actualEnd']) ?? task?.actualEnd; - if (actualStart != null && - actualEnd != null && - actualStart.isBefore(actualEnd)) { - final minutes = actualEnd.difference(actualStart).inMinutes; - if (minutes > 0) { - return minutes; - } - } - - return task?.durationMinutes; -} - -int _pushesBeforeCompletion(Task? task) { - if (task == null) { - return 0; - } - return task.stats.manuallyPushedCount + task.stats.autoPushedCount; -} - -RewardLevel? _rewardLevel(Object? value) { - if (value is RewardLevel) { - return value; - } - if (value is String) { - return _enumByName(RewardLevel.values, value); - } - return null; -} - -DifficultyLevel? _difficultyLevel(Object? value) { - if (value is DifficultyLevel) { - return value; - } - if (value is String) { - return _enumByName(DifficultyLevel.values, value); - } - return null; -} - -ReminderProfile? _reminderProfile(Object? value) { - if (value is ReminderProfile) { - return value; - } - if (value is String) { - return _enumByName(ReminderProfile.values, value); - } - return null; -} - -T? _enumByName(Iterable values, String name) { - for (final value in values) { - if (value.name == name) { - return value; - } - } - return null; -} - -String _requiredTrimmed(String value, String name) { - final trimmed = value.trim(); - if (trimmed.isEmpty) { - throw ArgumentError.value(value, name, 'Value is required.'); - } - return trimmed; -} +part 'project_statistics/project_completion_time_bucket.dart'; +part 'project_statistics/project_suggestion_type.dart'; +part 'project_statistics/project_suggestion_confidence.dart'; +part 'project_statistics/project_statistics.dart'; +part 'project_statistics/project_statistics_application_result.dart'; +part 'project_statistics/project_statistics_aggregation_service.dart'; +part 'project_statistics/project_suggestion_policy.dart'; +part 'project_statistics/project_suggestion.dart'; +part 'project_statistics/project_suggestion_set.dart'; +part 'project_statistics/project_default_resolution.dart'; +part 'project_statistics/project_suggestion_service.dart'; diff --git a/packages/scheduler_core/lib/src/project_statistics/project_completion_time_bucket.dart b/packages/scheduler_core/lib/src/project_statistics/project_completion_time_bucket.dart new file mode 100644 index 0000000..ee998c9 --- /dev/null +++ b/packages/scheduler_core/lib/src/project_statistics/project_completion_time_bucket.dart @@ -0,0 +1,10 @@ +part of '../project_statistics.dart'; + +/// Coarse completion-time buckets used for learned project suggestions. +enum ProjectCompletionTimeBucket { + overnight, + morning, + afternoon, + evening, + night, +} diff --git a/packages/scheduler_core/lib/src/project_statistics/project_default_resolution.dart b/packages/scheduler_core/lib/src/project_statistics/project_default_resolution.dart new file mode 100644 index 0000000..642c57e --- /dev/null +++ b/packages/scheduler_core/lib/src/project_statistics/project_default_resolution.dart @@ -0,0 +1,21 @@ +part of '../project_statistics.dart'; + +/// Configured project defaults plus optional suggestions kept separate. +class ProjectDefaultResolution { + const ProjectDefaultResolution({ + required this.project, + required this.suggestions, + }); + + /// Authoritative configured project profile. + final ProjectProfile project; + + /// Optional learned suggestions that UI can present explicitly. + final ProjectSuggestionSet suggestions; + + int? get configuredDurationMinutes => project.defaultDurationMinutes; + RewardLevel get configuredReward => project.defaultReward; + DifficultyLevel get configuredDifficulty => project.defaultDifficulty; + ReminderProfile get configuredReminderProfile => + project.defaultReminderProfile; +} diff --git a/packages/scheduler_core/lib/src/project_statistics/project_statistics.dart b/packages/scheduler_core/lib/src/project_statistics/project_statistics.dart new file mode 100644 index 0000000..0221848 --- /dev/null +++ b/packages/scheduler_core/lib/src/project_statistics/project_statistics.dart @@ -0,0 +1,207 @@ +part of '../project_statistics.dart'; + +/// Immutable project-level observations used for future filtering and hints. +class ProjectStatistics { + ProjectStatistics({ + required String projectId, + this.completedTaskCount = 0, + Map durationMinuteCounts = const {}, + Map completionTimeBucketCounts = + const {}, + this.totalPushesBeforeCompletion = 0, + this.completedAfterPushCount = 0, + Map rewardCounts = const {}, + Map difficultyCounts = const {}, + Map reminderProfileCounts = + const {}, + }) : projectId = _requiredTrimmed(projectId, 'projectId'), + durationMinuteCounts = Map.unmodifiable( + _positiveIntKeyCounts(durationMinuteCounts, 'durationMinuteCounts'), + ), + completionTimeBucketCounts = + Map.unmodifiable( + _enumCounts( + completionTimeBucketCounts, + 'completionTimeBucketCounts', + ), + ), + rewardCounts = Map.unmodifiable( + _enumCounts(rewardCounts, 'rewardCounts'), + ), + difficultyCounts = Map.unmodifiable( + _enumCounts(difficultyCounts, 'difficultyCounts'), + ), + reminderProfileCounts = Map.unmodifiable( + _enumCounts(reminderProfileCounts, 'reminderProfileCounts'), + ) { + _validateNonNegative(completedTaskCount, 'completedTaskCount'); + _validateNonNegative( + totalPushesBeforeCompletion, + 'totalPushesBeforeCompletion', + ); + _validateNonNegative(completedAfterPushCount, 'completedAfterPushCount'); + } + + /// Project these observations belong to. + final String projectId; + + /// Completion activities applied to this project aggregate. + final int completedTaskCount; + + /// Duration samples stored as minute-to-count buckets. + final Map durationMinuteCounts; + + /// Completion timestamp samples stored as coarse bucket counts. + final Map completionTimeBucketCounts; + + /// Sum of push counts present when tasks were completed. + final int totalPushesBeforeCompletion; + + /// Number of completed tasks that had at least one prior push. + final int completedAfterPushCount; + + /// Reward observations from completed tasks. + final Map rewardCounts; + + /// Difficulty observations from completed tasks. + final Map difficultyCounts; + + /// Reminder-profile observations when explicitly known. + final Map reminderProfileCounts; + + /// Known duration sample count derived from the duration distribution. + int get knownDurationSampleCount => _countTotal(durationMinuteCounts); + + /// Sum of known duration samples in minutes. + int get totalKnownDurationMinutes { + var total = 0; + for (final entry in durationMinuteCounts.entries) { + total += entry.key * entry.value; + } + return total; + } + + /// Numerator for average pushes before completion. + int get averagePushesBeforeCompletionNumerator { + return totalPushesBeforeCompletion; + } + + /// Denominator for average pushes before completion. + int get averagePushesBeforeCompletionDenominator { + return completedTaskCount; + } + + /// Derived average push count, without storing floating-point aggregate state. + double? get averagePushesBeforeCompletion { + if (completedTaskCount == 0) { + return null; + } + return totalPushesBeforeCompletion / completedTaskCount; + } + + /// Return a copy with selected aggregate fields changed. + ProjectStatistics copyWith({ + String? projectId, + int? completedTaskCount, + Map? durationMinuteCounts, + Map? completionTimeBucketCounts, + int? totalPushesBeforeCompletion, + int? completedAfterPushCount, + Map? rewardCounts, + Map? difficultyCounts, + Map? reminderProfileCounts, + }) { + return ProjectStatistics( + projectId: projectId ?? this.projectId, + completedTaskCount: completedTaskCount ?? this.completedTaskCount, + durationMinuteCounts: durationMinuteCounts ?? this.durationMinuteCounts, + completionTimeBucketCounts: + completionTimeBucketCounts ?? this.completionTimeBucketCounts, + totalPushesBeforeCompletion: + totalPushesBeforeCompletion ?? this.totalPushesBeforeCompletion, + completedAfterPushCount: + completedAfterPushCount ?? this.completedAfterPushCount, + rewardCounts: rewardCounts ?? this.rewardCounts, + difficultyCounts: difficultyCounts ?? this.difficultyCounts, + reminderProfileCounts: + reminderProfileCounts ?? this.reminderProfileCounts, + ); + } + + /// Record one completed task observation. + ProjectStatistics recordCompletion({ + required DateTime completedAt, + int? durationMinutes, + int pushesBeforeCompletion = 0, + RewardLevel? reward, + DifficultyLevel? difficulty, + ReminderProfile? reminderProfile, + }) { + if (durationMinutes != null && durationMinutes <= 0) { + throw ArgumentError.value( + durationMinutes, + 'durationMinutes', + 'Duration minutes must be positive when present.', + ); + } + if (pushesBeforeCompletion < 0) { + throw ArgumentError.value( + pushesBeforeCompletion, + 'pushesBeforeCompletion', + 'Push count cannot be negative.', + ); + } + + return copyWith( + completedTaskCount: completedTaskCount + 1, + durationMinuteCounts: durationMinutes == null + ? durationMinuteCounts + : _incrementCount(durationMinuteCounts, durationMinutes), + completionTimeBucketCounts: _incrementCount( + completionTimeBucketCounts, + bucketForCompletion(completedAt), + ), + totalPushesBeforeCompletion: + totalPushesBeforeCompletion + pushesBeforeCompletion, + completedAfterPushCount: pushesBeforeCompletion > 0 + ? completedAfterPushCount + 1 + : completedAfterPushCount, + rewardCounts: reward == null + ? rewardCounts + : _incrementCount( + rewardCounts, + reward, + ), + difficultyCounts: difficulty == null + ? difficultyCounts + : _incrementCount( + difficultyCounts, + difficulty, + ), + reminderProfileCounts: reminderProfile == null + ? reminderProfileCounts + : _incrementCount( + reminderProfileCounts, + reminderProfile, + ), + ); + } + + /// Resolve the completion-time bucket for [completedAt]. + static ProjectCompletionTimeBucket bucketForCompletion(DateTime completedAt) { + final hour = completedAt.hour; + if (hour < 5) { + return ProjectCompletionTimeBucket.overnight; + } + if (hour < 12) { + return ProjectCompletionTimeBucket.morning; + } + if (hour < 17) { + return ProjectCompletionTimeBucket.afternoon; + } + if (hour < 21) { + return ProjectCompletionTimeBucket.evening; + } + return ProjectCompletionTimeBucket.night; + } +} diff --git a/packages/scheduler_core/lib/src/project_statistics/project_statistics_aggregation_service.dart b/packages/scheduler_core/lib/src/project_statistics/project_statistics_aggregation_service.dart new file mode 100644 index 0000000..4c58643 --- /dev/null +++ b/packages/scheduler_core/lib/src/project_statistics/project_statistics_aggregation_service.dart @@ -0,0 +1,59 @@ +part of '../project_statistics.dart'; + +/// Updates project statistics from canonical task activities exactly once. +class ProjectStatisticsAggregationService { + const ProjectStatisticsAggregationService(); + + /// Apply completion [activities] to [statistics]. + /// + /// [alreadyAppliedActivityIds] is supplied by the application/persistence + /// boundary. This service returns the new ids so Block 14 can persist task, + /// activity, and aggregate mutations atomically. + ProjectStatisticsApplicationResult apply({ + required ProjectStatistics statistics, + required Iterable activities, + Iterable tasks = const [], + Iterable alreadyAppliedActivityIds = const [], + }) { + final taskById = { + for (final task in tasks) task.id: task, + }; + final appliedIds = alreadyAppliedActivityIds.toSet(); + final newlyAppliedIds = []; + var current = statistics; + + for (final activity in activities) { + if (activity.projectId != statistics.projectId || + activity.code != TaskActivityCode.completed || + appliedIds.contains(activity.id)) { + continue; + } + + final task = taskById[activity.taskId]; + final completedAt = _dateTimeMetadata(activity.metadata['completedAt']) ?? + activity.occurredAt; + final pushesBeforeCompletion = + _intMetadata(activity.metadata['pushesBeforeCompletion']) ?? + _pushesBeforeCompletion(task); + + current = current.recordCompletion( + completedAt: completedAt, + durationMinutes: _durationMinutes(activity, task), + pushesBeforeCompletion: pushesBeforeCompletion, + reward: _rewardLevel(activity.metadata['reward']) ?? task?.reward, + difficulty: _difficultyLevel(activity.metadata['difficulty']) ?? + task?.difficulty, + reminderProfile: + _reminderProfile(activity.metadata['reminderProfile']) ?? + task?.reminderOverride, + ); + appliedIds.add(activity.id); + newlyAppliedIds.add(activity.id); + } + + return ProjectStatisticsApplicationResult( + statistics: current, + appliedActivityIds: newlyAppliedIds, + ); + } +} diff --git a/packages/scheduler_core/lib/src/project_statistics/project_statistics_application_result.dart b/packages/scheduler_core/lib/src/project_statistics/project_statistics_application_result.dart new file mode 100644 index 0000000..7bb0b3c --- /dev/null +++ b/packages/scheduler_core/lib/src/project_statistics/project_statistics_application_result.dart @@ -0,0 +1,15 @@ +part of '../project_statistics.dart'; + +/// Result of applying project-statistic effects from activities. +class ProjectStatisticsApplicationResult { + ProjectStatisticsApplicationResult({ + required this.statistics, + List appliedActivityIds = const [], + }) : appliedActivityIds = List.unmodifiable(appliedActivityIds); + + /// Aggregate after newly applied activity effects. + final ProjectStatistics statistics; + + /// Completion activity ids that changed the aggregate. + final List appliedActivityIds; +} diff --git a/packages/scheduler_core/lib/src/project_statistics/project_suggestion.dart b/packages/scheduler_core/lib/src/project_statistics/project_suggestion.dart new file mode 100644 index 0000000..1ac0d55 --- /dev/null +++ b/packages/scheduler_core/lib/src/project_statistics/project_suggestion.dart @@ -0,0 +1,31 @@ +part of '../project_statistics.dart'; + +/// One optional learned suggestion with provenance. +class ProjectSuggestion { + const ProjectSuggestion({ + required this.type, + required this.value, + required this.sampleSize, + required this.supportingSampleCount, + required this.minimumSampleSize, + required this.confidence, + }); + + /// Suggestion category. + final ProjectSuggestionType type; + + /// Suggested value. + final T value; + + /// Number of meaningful observations considered. + final int sampleSize; + + /// Observations that support [value]. + final int supportingSampleCount; + + /// Minimum sample threshold used before producing the suggestion. + final int minimumSampleSize; + + /// Concentration of supporting observations. + final ProjectSuggestionConfidence confidence; +} diff --git a/packages/scheduler_core/lib/src/project_statistics/project_suggestion_confidence.dart b/packages/scheduler_core/lib/src/project_statistics/project_suggestion_confidence.dart new file mode 100644 index 0000000..b86c0f1 --- /dev/null +++ b/packages/scheduler_core/lib/src/project_statistics/project_suggestion_confidence.dart @@ -0,0 +1,8 @@ +part of '../project_statistics.dart'; + +/// How concentrated the supporting observations are for a suggestion. +enum ProjectSuggestionConfidence { + low, + medium, + high, +} diff --git a/packages/scheduler_core/lib/src/project_statistics/project_suggestion_policy.dart b/packages/scheduler_core/lib/src/project_statistics/project_suggestion_policy.dart new file mode 100644 index 0000000..7078beb --- /dev/null +++ b/packages/scheduler_core/lib/src/project_statistics/project_suggestion_policy.dart @@ -0,0 +1,18 @@ +part of '../project_statistics.dart'; + +/// Minimum sample sizes for deterministic project suggestions. +class ProjectSuggestionPolicy { + const ProjectSuggestionPolicy({ + this.minimumDurationSamples = 3, + this.minimumCompletionTimeSamples = 3, + this.minimumRewardSamples = 3, + this.minimumDifficultySamples = 3, + this.minimumReminderSamples = 3, + }); + + final int minimumDurationSamples; + final int minimumCompletionTimeSamples; + final int minimumRewardSamples; + final int minimumDifficultySamples; + final int minimumReminderSamples; +} diff --git a/packages/scheduler_core/lib/src/project_statistics/project_suggestion_service.dart b/packages/scheduler_core/lib/src/project_statistics/project_suggestion_service.dart new file mode 100644 index 0000000..afbe56c --- /dev/null +++ b/packages/scheduler_core/lib/src/project_statistics/project_suggestion_service.dart @@ -0,0 +1,301 @@ +part of '../project_statistics.dart'; + +/// Derives deterministic project suggestions from aggregate observations. +class ProjectSuggestionService { + const ProjectSuggestionService({ + this.policy = const ProjectSuggestionPolicy(), + }); + + final ProjectSuggestionPolicy policy; + + /// Build optional suggestions for [statistics]. + ProjectSuggestionSet suggestionsFor(ProjectStatistics statistics) { + return ProjectSuggestionSet( + statistics: statistics, + durationMinutes: _durationSuggestion(statistics), + completionTimeBucket: _modeSuggestion( + type: ProjectSuggestionType.completionTimeBucket, + counts: statistics.completionTimeBucketCounts, + minimumSampleSize: policy.minimumCompletionTimeSamples, + ), + reward: _modeSuggestion( + type: ProjectSuggestionType.reward, + counts: _withoutNotSetReward(statistics.rewardCounts), + minimumSampleSize: policy.minimumRewardSamples, + ), + difficulty: _modeSuggestion( + type: ProjectSuggestionType.difficulty, + counts: _withoutNotSetDifficulty(statistics.difficultyCounts), + minimumSampleSize: policy.minimumDifficultySamples, + ), + reminderProfile: _modeSuggestion( + type: ProjectSuggestionType.reminderProfile, + counts: statistics.reminderProfileCounts, + minimumSampleSize: policy.minimumReminderSamples, + ), + ); + } + + /// Return configured defaults and optional suggestions without merging them. + ProjectDefaultResolution resolveDefaults({ + required ProjectProfile project, + required ProjectStatistics statistics, + }) { + return ProjectDefaultResolution( + project: project, + suggestions: suggestionsFor(statistics), + ); + } + + ProjectSuggestion? _durationSuggestion(ProjectStatistics statistics) { + final sampleSize = statistics.knownDurationSampleCount; + if (sampleSize < policy.minimumDurationSamples) { + return null; + } + + final value = _weightedLowerMedian(statistics.durationMinuteCounts); + if (value == null) { + return null; + } + + return ProjectSuggestion( + type: ProjectSuggestionType.durationMinutes, + value: value, + sampleSize: sampleSize, + supportingSampleCount: statistics.durationMinuteCounts[value] ?? 0, + minimumSampleSize: policy.minimumDurationSamples, + confidence: _confidence( + statistics.durationMinuteCounts[value] ?? 0, + sampleSize, + ), + ); + } + + ProjectSuggestion? _modeSuggestion({ + required ProjectSuggestionType type, + required Map counts, + required int minimumSampleSize, + }) { + final sampleSize = _countTotal(counts); + if (sampleSize < minimumSampleSize) { + return null; + } + + final mode = _uniqueMode(counts); + if (mode == null) { + return null; + } + + final support = counts[mode] ?? 0; + return ProjectSuggestion( + type: type, + value: mode, + sampleSize: sampleSize, + supportingSampleCount: support, + minimumSampleSize: minimumSampleSize, + confidence: _confidence(support, sampleSize), + ); + } +} + +Map _incrementCount(Map counts, K key) { + return { + ...counts, + key: (counts[key] ?? 0) + 1, + }; +} + +Map _positiveIntKeyCounts(Map counts, String name) { + final normalized = {}; + for (final entry in counts.entries) { + if (entry.key <= 0) { + throw ArgumentError.value(entry.key, name, 'Key must be positive.'); + } + _validateNonNegative(entry.value, name); + if (entry.value > 0) { + normalized[entry.key] = entry.value; + } + } + return normalized; +} + +Map _enumCounts(Map counts, String name) { + final normalized = {}; + for (final entry in counts.entries) { + _validateNonNegative(entry.value, name); + if (entry.value > 0) { + normalized[entry.key] = entry.value; + } + } + return normalized; +} + +void _validateNonNegative(int value, String name) { + if (value < 0) { + throw ArgumentError.value(value, name, 'Count cannot be negative.'); + } +} + +int _countTotal(Map counts) { + var total = 0; + for (final value in counts.values) { + total += value; + } + return total; +} + +int? _weightedLowerMedian(Map counts) { + if (counts.isEmpty) { + return null; + } + final sampleSize = _countTotal(counts); + final target = (sampleSize + 1) ~/ 2; + var seen = 0; + final sortedMinutes = counts.keys.toList()..sort(); + for (final minutes in sortedMinutes) { + seen += counts[minutes] ?? 0; + if (seen >= target) { + return minutes; + } + } + return null; +} + +T? _uniqueMode(Map counts) { + T? bestKey; + var bestCount = 0; + var tied = false; + + for (final entry in counts.entries) { + if (entry.value > bestCount) { + bestKey = entry.key; + bestCount = entry.value; + tied = false; + continue; + } + if (entry.value == bestCount && bestCount > 0) { + tied = true; + } + } + + return tied ? null : bestKey; +} + +ProjectSuggestionConfidence _confidence(int support, int sampleSize) { + if (sampleSize <= 0) { + return ProjectSuggestionConfidence.low; + } + if (support * 3 >= sampleSize * 2) { + return ProjectSuggestionConfidence.high; + } + if (support * 2 >= sampleSize) { + return ProjectSuggestionConfidence.medium; + } + return ProjectSuggestionConfidence.low; +} + +Map _withoutNotSetReward(Map counts) { + return { + for (final entry in counts.entries) + if (entry.key != RewardLevel.notSet) entry.key: entry.value, + }; +} + +Map _withoutNotSetDifficulty( + Map counts, +) { + return { + for (final entry in counts.entries) + if (entry.key != DifficultyLevel.notSet) entry.key: entry.value, + }; +} + +DateTime? _dateTimeMetadata(Object? value) { + if (value is DateTime) { + return value; + } + if (value is String) { + return DateTime.tryParse(value)?.toUtc(); + } + return null; +} + +int? _intMetadata(Object? value) { + return value is int && value >= 0 ? value : null; +} + +int? _durationMinutes(TaskActivity activity, Task? task) { + final metadataDuration = _intMetadata(activity.metadata['durationMinutes']); + if (metadataDuration != null && metadataDuration > 0) { + return metadataDuration; + } + + final actualStart = + _dateTimeMetadata(activity.metadata['actualStart']) ?? task?.actualStart; + final actualEnd = + _dateTimeMetadata(activity.metadata['actualEnd']) ?? task?.actualEnd; + if (actualStart != null && + actualEnd != null && + actualStart.isBefore(actualEnd)) { + final minutes = actualEnd.difference(actualStart).inMinutes; + if (minutes > 0) { + return minutes; + } + } + + return task?.durationMinutes; +} + +int _pushesBeforeCompletion(Task? task) { + if (task == null) { + return 0; + } + return task.stats.manuallyPushedCount + task.stats.autoPushedCount; +} + +RewardLevel? _rewardLevel(Object? value) { + if (value is RewardLevel) { + return value; + } + if (value is String) { + return _enumByName(RewardLevel.values, value); + } + return null; +} + +DifficultyLevel? _difficultyLevel(Object? value) { + if (value is DifficultyLevel) { + return value; + } + if (value is String) { + return _enumByName(DifficultyLevel.values, value); + } + return null; +} + +ReminderProfile? _reminderProfile(Object? value) { + if (value is ReminderProfile) { + return value; + } + if (value is String) { + return _enumByName(ReminderProfile.values, value); + } + return null; +} + +T? _enumByName(Iterable values, String name) { + for (final value in values) { + if (value.name == name) { + return value; + } + } + return null; +} + +String _requiredTrimmed(String value, String name) { + final trimmed = value.trim(); + if (trimmed.isEmpty) { + throw ArgumentError.value(value, name, 'Value is required.'); + } + return trimmed; +} diff --git a/packages/scheduler_core/lib/src/project_statistics/project_suggestion_set.dart b/packages/scheduler_core/lib/src/project_statistics/project_suggestion_set.dart new file mode 100644 index 0000000..5b89f92 --- /dev/null +++ b/packages/scheduler_core/lib/src/project_statistics/project_suggestion_set.dart @@ -0,0 +1,20 @@ +part of '../project_statistics.dart'; + +/// Optional suggestion set derived from one project's observations. +class ProjectSuggestionSet { + const ProjectSuggestionSet({ + required this.statistics, + this.durationMinutes, + this.completionTimeBucket, + this.reward, + this.difficulty, + this.reminderProfile, + }); + + final ProjectStatistics statistics; + final ProjectSuggestion? durationMinutes; + final ProjectSuggestion? completionTimeBucket; + final ProjectSuggestion? reward; + final ProjectSuggestion? difficulty; + final ProjectSuggestion? reminderProfile; +} diff --git a/packages/scheduler_core/lib/src/project_statistics/project_suggestion_type.dart b/packages/scheduler_core/lib/src/project_statistics/project_suggestion_type.dart new file mode 100644 index 0000000..7ecaf20 --- /dev/null +++ b/packages/scheduler_core/lib/src/project_statistics/project_suggestion_type.dart @@ -0,0 +1,10 @@ +part of '../project_statistics.dart'; + +/// Suggestion categories derived from project observations. +enum ProjectSuggestionType { + durationMinutes, + completionTimeBucket, + reward, + difficulty, + reminderProfile, +} diff --git a/packages/scheduler_core/lib/src/quick_capture.dart b/packages/scheduler_core/lib/src/quick_capture.dart index c9ccdbc..34a3078 100644 --- a/packages/scheduler_core/lib/src/quick_capture.dart +++ b/packages/scheduler_core/lib/src/quick_capture.dart @@ -10,282 +10,7 @@ library; import 'models.dart'; import 'scheduling_engine.dart'; import 'time_contracts.dart'; - -/// Outcome of a quick-capture request. -/// -/// The UI can use this status to decide whether to show a passive success, draw -/// a scheduled card on the timeline, or display validation messages. It is not -/// an exception-based flow because quick capture should fail gently and keep the -/// user's typed task available. -enum QuickCaptureStatus { - /// Capture succeeded and the task remains unscheduled in backlog. - addedToBacklog, - - /// Capture succeeded and the task was placed on the timeline. - scheduled, - - /// Capture could not complete the requested flow; see result messages. - validationError, -} - -/// Input for low-friction task capture. -/// -/// This object represents what the UI knows at the moment of capture. It mirrors -/// the product goal: adding a thought should require as little structure as -/// possible, but the user can optionally provide enough detail to immediately -/// schedule it into the next open flexible slot. -class QuickCaptureRequest { - QuickCaptureRequest({ - required this.id, - required this.title, - required this.createdAt, - this.addToNextAvailableSlot = false, - this.projectId = 'inbox', - this.priority = PriorityLevel.medium, - this.reward = RewardLevel.notSet, - this.difficulty = DifficultyLevel.notSet, - this.type = TaskType.flexible, - this.durationMinutes, - Set backlogTags = const {}, - }) : backlogTags = Set.unmodifiable(backlogTags); - - /// Caller-generated id. Keeping id generation outside this service makes the - /// domain layer independent from persistence/database choices. - final String id; - - /// Raw user-entered title. The [Task.quickCapture] factory trims it. - final String title; - - /// Capture timestamp supplied by the caller for testability. - final DateTime createdAt; - - /// Whether capture should attempt immediate timeline placement. - final bool addToNextAvailableSlot; - - /// Project id to assign; defaults to the inbox for uncategorized thoughts. - final String projectId; - - /// Initial priority used by backlog/scheduler heuristics. - final PriorityLevel priority; - - /// Initial reward estimate. - final RewardLevel reward; - - /// Initial difficulty estimate. - final DifficultyLevel difficulty; - - /// Captured task type. Immediate scheduling currently requires flexible tasks. - final TaskType type; - - /// Optional duration estimate. Required only when scheduling immediately. - final int? durationMinutes; - - /// Optional backlog flags such as wishlist/someday. - final Set backlogTags; -} - -/// Result of a quick-capture request. -/// -/// The result always carries a [task], even on validation failure, so the UI can -/// preserve the user's input and show what needs to be fixed. When scheduling -/// was attempted, [schedulingResult] exposes the lower-level engine notices and -/// changes for debugging or timeline updates. -class QuickCaptureResult { - QuickCaptureResult({ - required this.task, - required this.status, - this.schedulingResult, - List messages = const [], - }) : messages = List.unmodifiable(messages); - - /// Captured task, scheduled or unscheduled depending on [status]. - final Task task; - - /// High-level outcome of the capture attempt. - final QuickCaptureStatus status; - - /// Detailed scheduling output when immediate placement was attempted. - final SchedulingResult? schedulingResult; - - /// Human-readable validation or scheduling messages. - final List messages; - - /// Convenience check for UI branches that only care whether capture succeeded. - bool get isValid => status != QuickCaptureStatus.validationError; -} - -/// Coordinates quick capture defaults and optional scheduling. -/// -/// This service is intentionally thin: it builds a [Task], validates the extra -/// requirements for immediate scheduling, and delegates placement to -/// [SchedulingEngine]. It keeps quick-capture UI code from needing to understand -/// every scheduler precondition. -class QuickCaptureService { - const QuickCaptureService({ - this.engine = const SchedulingEngine(), - this.clock = const SystemClock(), - this.idGenerator, - }); - - /// Scheduling dependency. Defaults to the starter engine but can be swapped in - /// tests or future implementations. - final SchedulingEngine engine; - - /// Clock boundary used by convenience capture entry points. - final Clock clock; - - /// Optional ID boundary for convenience capture entry points. - final IdGenerator? idGenerator; - - /// Capture a task and optionally place it into the next available slot. - /// - /// Flow: - /// 1. Build the task using lightweight defaults. - /// 2. If immediate scheduling was not requested, return a backlog success. - /// 3. Validate the requirements for immediate scheduling. - /// 4. Call [SchedulingEngine.insertBacklogTaskIntoNextAvailableSlot]. - /// 5. Convert the lower-level scheduling result into a capture result. - QuickCaptureResult capture( - QuickCaptureRequest request, { - SchedulingInput? schedulingInput, - DateTime? updatedAt, - }) { - final capturedTaskWithoutDuration = Task.quickCapture( - id: request.id, - title: request.title, - createdAt: request.createdAt, - projectId: request.projectId, - type: request.type, - priority: request.priority, - reward: request.reward, - difficulty: request.difficulty, - backlogTags: request.backlogTags, - updatedAt: updatedAt, - ); - - if (request.durationMinutes != null && request.durationMinutes! <= 0) { - return QuickCaptureResult( - task: capturedTaskWithoutDuration, - status: QuickCaptureStatus.validationError, - messages: const ['Duration must be positive when provided.'], - ); - } - - final capturedTask = capturedTaskWithoutDuration.copyWith( - durationMinutes: request.durationMinutes, - ); - - if (!request.addToNextAvailableSlot) { - return QuickCaptureResult( - task: capturedTask, - status: QuickCaptureStatus.addedToBacklog, - ); - } - - if (request.durationMinutes == null || request.durationMinutes! <= 0) { - return QuickCaptureResult( - task: capturedTask, - status: QuickCaptureStatus.validationError, - messages: const ['Duration is required to schedule quick capture.'], - ); - } - - if (request.type != TaskType.flexible) { - return QuickCaptureResult( - task: capturedTask, - status: QuickCaptureStatus.validationError, - messages: const ['Scheduled quick capture must be flexible.'], - ); - } - - if (schedulingInput == null) { - return QuickCaptureResult( - task: capturedTask, - status: QuickCaptureStatus.validationError, - messages: const ['Scheduling input is required.'], - ); - } - - final result = engine.insertBacklogTaskIntoNextAvailableSlot( - input: SchedulingInput( - tasks: [capturedTask, ...schedulingInput.tasks], - window: schedulingInput.window, - lockedIntervals: schedulingInput.lockedIntervals, - requiredVisibleIntervals: schedulingInput.requiredVisibleIntervals, - ), - taskId: capturedTask.id, - updatedAt: updatedAt, - ); - final scheduledTask = _taskById(result.tasks, capturedTask.id); - - if (scheduledTask == null || scheduledTask.isBacklog) { - return QuickCaptureResult( - task: capturedTask, - status: QuickCaptureStatus.validationError, - schedulingResult: result, - messages: result.notices.map((notice) => notice.message).toList(), - ); - } - - return QuickCaptureResult( - task: scheduledTask, - status: QuickCaptureStatus.scheduled, - schedulingResult: result, - ); - } - - /// Convenience outer-boundary wrapper that supplies id and creation time. - QuickCaptureResult captureNew({ - required String title, - bool addToNextAvailableSlot = false, - String projectId = 'inbox', - PriorityLevel priority = PriorityLevel.medium, - RewardLevel reward = RewardLevel.notSet, - DifficultyLevel difficulty = DifficultyLevel.notSet, - TaskType type = TaskType.flexible, - int? durationMinutes, - Set backlogTags = const {}, - SchedulingInput? schedulingInput, - DateTime? createdAt, - DateTime? updatedAt, - }) { - final generator = idGenerator; - if (generator == null) { - throw StateError('Quick capture needs an id generator.'); - } - - final now = createdAt ?? updatedAt ?? clock.now(); - - return capture( - QuickCaptureRequest( - id: generator.nextId(), - title: title, - createdAt: now, - addToNextAvailableSlot: addToNextAvailableSlot, - projectId: projectId, - priority: priority, - reward: reward, - difficulty: difficulty, - type: type, - durationMinutes: durationMinutes, - backlogTags: backlogTags, - ), - schedulingInput: schedulingInput, - updatedAt: updatedAt ?? now, - ); - } -} - -/// Find a task in a returned scheduling result. -/// -/// This duplicates a small helper rather than exposing scheduler internals. The -/// capture service only needs to retrieve the newly created task after placement. -Task? _taskById(List tasks, String id) { - for (final task in tasks) { - if (task.id == id) { - return task; - } - } - - return null; -} +part 'quick_capture/quick_capture_status.dart'; +part 'quick_capture/quick_capture_request.dart'; +part 'quick_capture/quick_capture_result.dart'; +part 'quick_capture/quick_capture_service.dart'; diff --git a/packages/scheduler_core/lib/src/quick_capture/quick_capture_request.dart b/packages/scheduler_core/lib/src/quick_capture/quick_capture_request.dart new file mode 100644 index 0000000..f854260 --- /dev/null +++ b/packages/scheduler_core/lib/src/quick_capture/quick_capture_request.dart @@ -0,0 +1,57 @@ +part of '../quick_capture.dart'; + +/// Input for low-friction task capture. +/// +/// This object represents what the UI knows at the moment of capture. It mirrors +/// the product goal: adding a thought should require as little structure as +/// possible, but the user can optionally provide enough detail to immediately +/// schedule it into the next open flexible slot. +class QuickCaptureRequest { + QuickCaptureRequest({ + required this.id, + required this.title, + required this.createdAt, + this.addToNextAvailableSlot = false, + this.projectId = 'inbox', + this.priority = PriorityLevel.medium, + this.reward = RewardLevel.notSet, + this.difficulty = DifficultyLevel.notSet, + this.type = TaskType.flexible, + this.durationMinutes, + Set backlogTags = const {}, + }) : backlogTags = Set.unmodifiable(backlogTags); + + /// Caller-generated id. Keeping id generation outside this service makes the + /// domain layer independent from persistence/database choices. + final String id; + + /// Raw user-entered title. The [Task.quickCapture] factory trims it. + final String title; + + /// Capture timestamp supplied by the caller for testability. + final DateTime createdAt; + + /// Whether capture should attempt immediate timeline placement. + final bool addToNextAvailableSlot; + + /// Project id to assign; defaults to the inbox for uncategorized thoughts. + final String projectId; + + /// Initial priority used by backlog/scheduler heuristics. + final PriorityLevel priority; + + /// Initial reward estimate. + final RewardLevel reward; + + /// Initial difficulty estimate. + final DifficultyLevel difficulty; + + /// Captured task type. Immediate scheduling currently requires flexible tasks. + final TaskType type; + + /// Optional duration estimate. Required only when scheduling immediately. + final int? durationMinutes; + + /// Optional backlog flags such as wishlist/someday. + final Set backlogTags; +} diff --git a/packages/scheduler_core/lib/src/quick_capture/quick_capture_result.dart b/packages/scheduler_core/lib/src/quick_capture/quick_capture_result.dart new file mode 100644 index 0000000..65ea65c --- /dev/null +++ b/packages/scheduler_core/lib/src/quick_capture/quick_capture_result.dart @@ -0,0 +1,31 @@ +part of '../quick_capture.dart'; + +/// Result of a quick-capture request. +/// +/// The result always carries a [task], even on validation failure, so the UI can +/// preserve the user's input and show what needs to be fixed. When scheduling +/// was attempted, [schedulingResult] exposes the lower-level engine notices and +/// changes for debugging or timeline updates. +class QuickCaptureResult { + QuickCaptureResult({ + required this.task, + required this.status, + this.schedulingResult, + List messages = const [], + }) : messages = List.unmodifiable(messages); + + /// Captured task, scheduled or unscheduled depending on [status]. + final Task task; + + /// High-level outcome of the capture attempt. + final QuickCaptureStatus status; + + /// Detailed scheduling output when immediate placement was attempted. + final SchedulingResult? schedulingResult; + + /// Human-readable validation or scheduling messages. + final List messages; + + /// Convenience check for UI branches that only care whether capture succeeded. + bool get isValid => status != QuickCaptureStatus.validationError; +} diff --git a/packages/scheduler_core/lib/src/quick_capture/quick_capture_service.dart b/packages/scheduler_core/lib/src/quick_capture/quick_capture_service.dart new file mode 100644 index 0000000..92f1129 --- /dev/null +++ b/packages/scheduler_core/lib/src/quick_capture/quick_capture_service.dart @@ -0,0 +1,177 @@ +part of '../quick_capture.dart'; + +/// Coordinates quick capture defaults and optional scheduling. +/// +/// This service is intentionally thin: it builds a [Task], validates the extra +/// requirements for immediate scheduling, and delegates placement to +/// [SchedulingEngine]. It keeps quick-capture UI code from needing to understand +/// every scheduler precondition. +class QuickCaptureService { + const QuickCaptureService({ + this.engine = const SchedulingEngine(), + this.clock = const SystemClock(), + this.idGenerator, + }); + + /// Scheduling dependency. Defaults to the starter engine but can be swapped in + /// tests or future implementations. + final SchedulingEngine engine; + + /// Clock boundary used by convenience capture entry points. + final Clock clock; + + /// Optional ID boundary for convenience capture entry points. + final IdGenerator? idGenerator; + + /// Capture a task and optionally place it into the next available slot. + /// + /// Flow: + /// 1. Build the task using lightweight defaults. + /// 2. If immediate scheduling was not requested, return a backlog success. + /// 3. Validate the requirements for immediate scheduling. + /// 4. Call [SchedulingEngine.insertBacklogTaskIntoNextAvailableSlot]. + /// 5. Convert the lower-level scheduling result into a capture result. + QuickCaptureResult capture( + QuickCaptureRequest request, { + SchedulingInput? schedulingInput, + DateTime? updatedAt, + }) { + final capturedTaskWithoutDuration = Task.quickCapture( + id: request.id, + title: request.title, + createdAt: request.createdAt, + projectId: request.projectId, + type: request.type, + priority: request.priority, + reward: request.reward, + difficulty: request.difficulty, + backlogTags: request.backlogTags, + updatedAt: updatedAt, + ); + + if (request.durationMinutes != null && request.durationMinutes! <= 0) { + return QuickCaptureResult( + task: capturedTaskWithoutDuration, + status: QuickCaptureStatus.validationError, + messages: const ['Duration must be positive when provided.'], + ); + } + + final capturedTask = capturedTaskWithoutDuration.copyWith( + durationMinutes: request.durationMinutes, + ); + + if (!request.addToNextAvailableSlot) { + return QuickCaptureResult( + task: capturedTask, + status: QuickCaptureStatus.addedToBacklog, + ); + } + + if (request.durationMinutes == null || request.durationMinutes! <= 0) { + return QuickCaptureResult( + task: capturedTask, + status: QuickCaptureStatus.validationError, + messages: const ['Duration is required to schedule quick capture.'], + ); + } + + if (request.type != TaskType.flexible) { + return QuickCaptureResult( + task: capturedTask, + status: QuickCaptureStatus.validationError, + messages: const ['Scheduled quick capture must be flexible.'], + ); + } + + if (schedulingInput == null) { + return QuickCaptureResult( + task: capturedTask, + status: QuickCaptureStatus.validationError, + messages: const ['Scheduling input is required.'], + ); + } + + final result = engine.insertBacklogTaskIntoNextAvailableSlot( + input: SchedulingInput( + tasks: [capturedTask, ...schedulingInput.tasks], + window: schedulingInput.window, + lockedIntervals: schedulingInput.lockedIntervals, + requiredVisibleIntervals: schedulingInput.requiredVisibleIntervals, + ), + taskId: capturedTask.id, + updatedAt: updatedAt, + ); + final scheduledTask = _taskById(result.tasks, capturedTask.id); + + if (scheduledTask == null || scheduledTask.isBacklog) { + return QuickCaptureResult( + task: capturedTask, + status: QuickCaptureStatus.validationError, + schedulingResult: result, + messages: result.notices.map((notice) => notice.message).toList(), + ); + } + + return QuickCaptureResult( + task: scheduledTask, + status: QuickCaptureStatus.scheduled, + schedulingResult: result, + ); + } + + /// Convenience outer-boundary wrapper that supplies id and creation time. + QuickCaptureResult captureNew({ + required String title, + bool addToNextAvailableSlot = false, + String projectId = 'inbox', + PriorityLevel priority = PriorityLevel.medium, + RewardLevel reward = RewardLevel.notSet, + DifficultyLevel difficulty = DifficultyLevel.notSet, + TaskType type = TaskType.flexible, + int? durationMinutes, + Set backlogTags = const {}, + SchedulingInput? schedulingInput, + DateTime? createdAt, + DateTime? updatedAt, + }) { + final generator = idGenerator; + if (generator == null) { + throw StateError('Quick capture needs an id generator.'); + } + + final now = createdAt ?? updatedAt ?? clock.now(); + + return capture( + QuickCaptureRequest( + id: generator.nextId(), + title: title, + createdAt: now, + addToNextAvailableSlot: addToNextAvailableSlot, + projectId: projectId, + priority: priority, + reward: reward, + difficulty: difficulty, + type: type, + durationMinutes: durationMinutes, + backlogTags: backlogTags, + ), + schedulingInput: schedulingInput, + updatedAt: updatedAt ?? now, + ); + } +} + +/// Find a task in a returned scheduling result. +/// +/// This duplicates a small helper rather than exposing scheduler internals. The +/// capture service only needs to retrieve the newly created task after placement. +Task? _taskById(List tasks, String id) { + for (final task in tasks) { + if (task.id == id) { + return task; + } + } + + return null; +} diff --git a/packages/scheduler_core/lib/src/quick_capture/quick_capture_status.dart b/packages/scheduler_core/lib/src/quick_capture/quick_capture_status.dart new file mode 100644 index 0000000..423d0c7 --- /dev/null +++ b/packages/scheduler_core/lib/src/quick_capture/quick_capture_status.dart @@ -0,0 +1,18 @@ +part of '../quick_capture.dart'; + +/// Outcome of a quick-capture request. +/// +/// The UI can use this status to decide whether to show a passive success, draw +/// a scheduled card on the timeline, or display validation messages. It is not +/// an exception-based flow because quick capture should fail gently and keep the +/// user's typed task available. +enum QuickCaptureStatus { + /// Capture succeeded and the task remains unscheduled in backlog. + addedToBacklog, + + /// Capture succeeded and the task was placed on the timeline. + scheduled, + + /// Capture could not complete the requested flow; see result messages. + validationError, +} diff --git a/packages/scheduler_core/lib/src/reminder_policy.dart b/packages/scheduler_core/lib/src/reminder_policy.dart index 0e9e380..8cbfd1b 100644 --- a/packages/scheduler_core/lib/src/reminder_policy.dart +++ b/packages/scheduler_core/lib/src/reminder_policy.dart @@ -9,329 +9,9 @@ library; import 'models.dart'; import 'occupancy_policy.dart'; - -/// High-level reminder directive for application/platform layers. -enum ReminderDirectiveAction { - deliver, - suppress, - defer, - requireAcknowledgement, -} - -/// Stable reason codes for reminder directives. -enum ReminderDirectiveReason { - deliverNormal, - requireRequiredAcknowledgement, - deferUntilTaskWindow, - deferRequiredDuringProtectedRest, - suppressSilentProfile, - suppressProtectedRest, - suppressNoScheduledWindow, - suppressUnsupportedTaskType, - suppressInactiveStatus, - suppressHiddenLockedTime, - suppressFreeSlotRecord, -} - -/// Result of resolving the effective reminder profile for one task. -class EffectiveReminderProfile { - const EffectiveReminderProfile({ - required this.profile, - required this.source, - }); - - /// Resolved reminder profile. - final ReminderProfile profile; - - /// Where [profile] came from. - final EffectiveReminderProfileSource source; -} - -/// Source for an effective reminder profile. -enum EffectiveReminderProfileSource { - taskOverride, - projectDefault, - fallback, -} - -/// UI/platform-independent directive for one task reminder check. -class ReminderDirective { - const ReminderDirective({ - required this.taskId, - required this.action, - required this.reason, - required this.effectiveProfile, - required this.effectiveProfileSource, - required this.evaluatedAt, - this.scheduledStart, - this.scheduledEnd, - this.protectedFreeSlotTaskId, - this.nextEligibleAt, - }); - - /// Task evaluated for reminder behavior. - final String taskId; - - /// What an application layer may do. - final ReminderDirectiveAction action; - - /// Stable reason code for tests, telemetry, and later UI copy. - final ReminderDirectiveReason reason; - - /// Effective reminder profile used for the decision. - final ReminderProfile effectiveProfile; - - /// Source of [effectiveProfile]. - final EffectiveReminderProfileSource effectiveProfileSource; - - /// Instant used to evaluate the directive. - final DateTime evaluatedAt; - - /// Task scheduled start, when present. - final DateTime? scheduledStart; - - /// Task scheduled end, when present. - final DateTime? scheduledEnd; - - /// Active protected Free Slot that affected the decision, when any. - final String? protectedFreeSlotTaskId; - - /// Next known time the application may re-check, when deterministic. - final DateTime? nextEligibleAt; -} - -/// Resolves reminder profiles and reminder directives. -class ReminderPolicyService { - const ReminderPolicyService({ - this.fallbackProfile = ReminderProfile.gentle, - this.occupancyPolicy = const OccupancyPolicy(), - }); - - /// Fallback used when no task override or project profile is available. - final ReminderProfile fallbackProfile; - - /// Occupancy classifier used to detect active protected Free Slots. - final OccupancyPolicy occupancyPolicy; - - /// Resolve effective profile without considering learned suggestions. - EffectiveReminderProfile resolveEffectiveProfile({ - required Task task, - ProjectProfile? project, - }) { - final override = task.reminderOverride; - if (override != null) { - return EffectiveReminderProfile( - profile: override, - source: EffectiveReminderProfileSource.taskOverride, - ); - } - - if (project != null) { - return EffectiveReminderProfile( - profile: project.defaultReminderProfile, - source: EffectiveReminderProfileSource.projectDefault, - ); - } - - return EffectiveReminderProfile( - profile: fallbackProfile, - source: EffectiveReminderProfileSource.fallback, - ); - } - - /// Resolve one reminder directive at [now]. - ReminderDirective directiveForTask({ - required Task task, - required DateTime now, - ProjectProfile? project, - Iterable contextTasks = const [], - }) { - final effective = resolveEffectiveProfile(task: task, project: project); - final activeFreeSlot = _activeProtectedFreeSlot( - now: now, - contextTasks: contextTasks, - ); - - return _directiveForTask( - task: task, - now: now, - effective: effective, - activeFreeSlot: activeFreeSlot, - ); - } - - ReminderDirective _directiveForTask({ - required Task task, - required DateTime now, - required EffectiveReminderProfile effective, - required OccupancyEntry? activeFreeSlot, - }) { - final start = task.scheduledStart; - final end = task.scheduledEnd; - - ReminderDirective build({ - required ReminderDirectiveAction action, - required ReminderDirectiveReason reason, - DateTime? nextEligibleAt, - }) { - return ReminderDirective( - taskId: task.id, - action: action, - reason: reason, - effectiveProfile: effective.profile, - effectiveProfileSource: effective.source, - evaluatedAt: now, - scheduledStart: start, - scheduledEnd: end, - protectedFreeSlotTaskId: activeFreeSlot?.taskId, - nextEligibleAt: nextEligibleAt, - ); - } - - if (effective.profile == ReminderProfile.silent) { - return build( - action: ReminderDirectiveAction.suppress, - reason: ReminderDirectiveReason.suppressSilentProfile, - ); - } - - if (task.type == TaskType.locked) { - return build( - action: ReminderDirectiveAction.suppress, - reason: ReminderDirectiveReason.suppressHiddenLockedTime, - ); - } - - if (task.type == TaskType.freeSlot) { - return build( - action: ReminderDirectiveAction.suppress, - reason: ReminderDirectiveReason.suppressFreeSlotRecord, - ); - } - - if (!_isReminderEligibleStatus(task.status)) { - return build( - action: ReminderDirectiveAction.suppress, - reason: ReminderDirectiveReason.suppressInactiveStatus, - ); - } - - if (!_isReminderEligibleType(task.type)) { - return build( - action: ReminderDirectiveAction.suppress, - reason: ReminderDirectiveReason.suppressUnsupportedTaskType, - ); - } - - if (start == null || end == null || !start.isBefore(end)) { - return build( - action: ReminderDirectiveAction.suppress, - reason: ReminderDirectiveReason.suppressNoScheduledWindow, - ); - } - - if (now.isBefore(start)) { - return build( - action: ReminderDirectiveAction.defer, - reason: ReminderDirectiveReason.deferUntilTaskWindow, - nextEligibleAt: start, - ); - } - - final protectedRestActive = activeFreeSlot != null; - if (protectedRestActive && task.isFlexible) { - return build( - action: ReminderDirectiveAction.suppress, - reason: ReminderDirectiveReason.suppressProtectedRest, - nextEligibleAt: activeFreeSlot.interval?.end, - ); - } - - if (protectedRestActive && task.isRequiredVisible) { - return _requiredDuringProtectedRest( - task: task, - effective: effective, - build: build, - freeSlotEnd: activeFreeSlot.interval?.end, - ); - } - - if (task.type == TaskType.critical && - effective.profile == ReminderProfile.strict) { - return build( - action: ReminderDirectiveAction.requireAcknowledgement, - reason: ReminderDirectiveReason.requireRequiredAcknowledgement, - ); - } - - return build( - action: ReminderDirectiveAction.deliver, - reason: ReminderDirectiveReason.deliverNormal, - ); - } - - ReminderDirective _requiredDuringProtectedRest({ - required Task task, - required EffectiveReminderProfile effective, - required ReminderDirective Function({ - required ReminderDirectiveAction action, - required ReminderDirectiveReason reason, - DateTime? nextEligibleAt, - }) build, - required DateTime? freeSlotEnd, - }) { - if (task.type == TaskType.critical && - effective.profile == ReminderProfile.strict) { - return build( - action: ReminderDirectiveAction.requireAcknowledgement, - reason: ReminderDirectiveReason.requireRequiredAcknowledgement, - ); - } - - if (task.type == TaskType.inflexible && - effective.profile == ReminderProfile.gentle) { - return build( - action: ReminderDirectiveAction.defer, - reason: ReminderDirectiveReason.deferRequiredDuringProtectedRest, - nextEligibleAt: freeSlotEnd, - ); - } - - return build( - action: ReminderDirectiveAction.deliver, - reason: ReminderDirectiveReason.deliverNormal, - ); - } - - OccupancyEntry? _activeProtectedFreeSlot({ - required DateTime now, - required Iterable contextTasks, - }) { - for (final entry in occupancyPolicy.classify(tasks: contextTasks)) { - final interval = entry.interval; - if (entry.category != OccupancyCategory.protectedFreeSlot || - interval == null) { - continue; - } - - final startsAtOrBeforeNow = - interval.start.isBefore(now) || interval.start.isAtSameMomentAs(now); - final endsAfterNow = interval.end.isAfter(now); - if (startsAtOrBeforeNow && endsAfterNow) { - return entry; - } - } - - return null; - } -} - -bool _isReminderEligibleStatus(TaskStatus status) { - return status == TaskStatus.planned || status == TaskStatus.active; -} - -bool _isReminderEligibleType(TaskType type) { - return type == TaskType.flexible || - type == TaskType.critical || - type == TaskType.inflexible; -} +part 'reminder_policy/reminder_directive_action.dart'; +part 'reminder_policy/reminder_directive_reason.dart'; +part 'reminder_policy/effective_reminder_profile.dart'; +part 'reminder_policy/effective_reminder_profile_source.dart'; +part 'reminder_policy/reminder_directive.dart'; +part 'reminder_policy/reminder_policy_service.dart'; diff --git a/packages/scheduler_core/lib/src/reminder_policy/effective_reminder_profile.dart b/packages/scheduler_core/lib/src/reminder_policy/effective_reminder_profile.dart new file mode 100644 index 0000000..c0eae9a --- /dev/null +++ b/packages/scheduler_core/lib/src/reminder_policy/effective_reminder_profile.dart @@ -0,0 +1,15 @@ +part of '../reminder_policy.dart'; + +/// Result of resolving the effective reminder profile for one task. +class EffectiveReminderProfile { + const EffectiveReminderProfile({ + required this.profile, + required this.source, + }); + + /// Resolved reminder profile. + final ReminderProfile profile; + + /// Where [profile] came from. + final EffectiveReminderProfileSource source; +} diff --git a/packages/scheduler_core/lib/src/reminder_policy/effective_reminder_profile_source.dart b/packages/scheduler_core/lib/src/reminder_policy/effective_reminder_profile_source.dart new file mode 100644 index 0000000..56ad447 --- /dev/null +++ b/packages/scheduler_core/lib/src/reminder_policy/effective_reminder_profile_source.dart @@ -0,0 +1,8 @@ +part of '../reminder_policy.dart'; + +/// Source for an effective reminder profile. +enum EffectiveReminderProfileSource { + taskOverride, + projectDefault, + fallback, +} diff --git a/packages/scheduler_core/lib/src/reminder_policy/reminder_directive.dart b/packages/scheduler_core/lib/src/reminder_policy/reminder_directive.dart new file mode 100644 index 0000000..f350145 --- /dev/null +++ b/packages/scheduler_core/lib/src/reminder_policy/reminder_directive.dart @@ -0,0 +1,47 @@ +part of '../reminder_policy.dart'; + +/// UI/platform-independent directive for one task reminder check. +class ReminderDirective { + const ReminderDirective({ + required this.taskId, + required this.action, + required this.reason, + required this.effectiveProfile, + required this.effectiveProfileSource, + required this.evaluatedAt, + this.scheduledStart, + this.scheduledEnd, + this.protectedFreeSlotTaskId, + this.nextEligibleAt, + }); + + /// Task evaluated for reminder behavior. + final String taskId; + + /// What an application layer may do. + final ReminderDirectiveAction action; + + /// Stable reason code for tests, telemetry, and later UI copy. + final ReminderDirectiveReason reason; + + /// Effective reminder profile used for the decision. + final ReminderProfile effectiveProfile; + + /// Source of [effectiveProfile]. + final EffectiveReminderProfileSource effectiveProfileSource; + + /// Instant used to evaluate the directive. + final DateTime evaluatedAt; + + /// Task scheduled start, when present. + final DateTime? scheduledStart; + + /// Task scheduled end, when present. + final DateTime? scheduledEnd; + + /// Active protected Free Slot that affected the decision, when any. + final String? protectedFreeSlotTaskId; + + /// Next known time the application may re-check, when deterministic. + final DateTime? nextEligibleAt; +} diff --git a/packages/scheduler_core/lib/src/reminder_policy/reminder_directive_action.dart b/packages/scheduler_core/lib/src/reminder_policy/reminder_directive_action.dart new file mode 100644 index 0000000..bffc11b --- /dev/null +++ b/packages/scheduler_core/lib/src/reminder_policy/reminder_directive_action.dart @@ -0,0 +1,9 @@ +part of '../reminder_policy.dart'; + +/// High-level reminder directive for application/platform layers. +enum ReminderDirectiveAction { + deliver, + suppress, + defer, + requireAcknowledgement, +} diff --git a/packages/scheduler_core/lib/src/reminder_policy/reminder_directive_reason.dart b/packages/scheduler_core/lib/src/reminder_policy/reminder_directive_reason.dart new file mode 100644 index 0000000..b7b77c5 --- /dev/null +++ b/packages/scheduler_core/lib/src/reminder_policy/reminder_directive_reason.dart @@ -0,0 +1,16 @@ +part of '../reminder_policy.dart'; + +/// Stable reason codes for reminder directives. +enum ReminderDirectiveReason { + deliverNormal, + requireRequiredAcknowledgement, + deferUntilTaskWindow, + deferRequiredDuringProtectedRest, + suppressSilentProfile, + suppressProtectedRest, + suppressNoScheduledWindow, + suppressUnsupportedTaskType, + suppressInactiveStatus, + suppressHiddenLockedTime, + suppressFreeSlotRecord, +} diff --git a/packages/scheduler_core/lib/src/reminder_policy/reminder_policy_service.dart b/packages/scheduler_core/lib/src/reminder_policy/reminder_policy_service.dart new file mode 100644 index 0000000..9409a2d --- /dev/null +++ b/packages/scheduler_core/lib/src/reminder_policy/reminder_policy_service.dart @@ -0,0 +1,237 @@ +part of '../reminder_policy.dart'; + +/// Resolves reminder profiles and reminder directives. +class ReminderPolicyService { + const ReminderPolicyService({ + this.fallbackProfile = ReminderProfile.gentle, + this.occupancyPolicy = const OccupancyPolicy(), + }); + + /// Fallback used when no task override or project profile is available. + final ReminderProfile fallbackProfile; + + /// Occupancy classifier used to detect active protected Free Slots. + final OccupancyPolicy occupancyPolicy; + + /// Resolve effective profile without considering learned suggestions. + EffectiveReminderProfile resolveEffectiveProfile({ + required Task task, + ProjectProfile? project, + }) { + final override = task.reminderOverride; + if (override != null) { + return EffectiveReminderProfile( + profile: override, + source: EffectiveReminderProfileSource.taskOverride, + ); + } + + if (project != null) { + return EffectiveReminderProfile( + profile: project.defaultReminderProfile, + source: EffectiveReminderProfileSource.projectDefault, + ); + } + + return EffectiveReminderProfile( + profile: fallbackProfile, + source: EffectiveReminderProfileSource.fallback, + ); + } + + /// Resolve one reminder directive at [now]. + ReminderDirective directiveForTask({ + required Task task, + required DateTime now, + ProjectProfile? project, + Iterable contextTasks = const [], + }) { + final effective = resolveEffectiveProfile(task: task, project: project); + final activeFreeSlot = _activeProtectedFreeSlot( + now: now, + contextTasks: contextTasks, + ); + + return _directiveForTask( + task: task, + now: now, + effective: effective, + activeFreeSlot: activeFreeSlot, + ); + } + + ReminderDirective _directiveForTask({ + required Task task, + required DateTime now, + required EffectiveReminderProfile effective, + required OccupancyEntry? activeFreeSlot, + }) { + final start = task.scheduledStart; + final end = task.scheduledEnd; + + ReminderDirective build({ + required ReminderDirectiveAction action, + required ReminderDirectiveReason reason, + DateTime? nextEligibleAt, + }) { + return ReminderDirective( + taskId: task.id, + action: action, + reason: reason, + effectiveProfile: effective.profile, + effectiveProfileSource: effective.source, + evaluatedAt: now, + scheduledStart: start, + scheduledEnd: end, + protectedFreeSlotTaskId: activeFreeSlot?.taskId, + nextEligibleAt: nextEligibleAt, + ); + } + + if (effective.profile == ReminderProfile.silent) { + return build( + action: ReminderDirectiveAction.suppress, + reason: ReminderDirectiveReason.suppressSilentProfile, + ); + } + + if (task.type == TaskType.locked) { + return build( + action: ReminderDirectiveAction.suppress, + reason: ReminderDirectiveReason.suppressHiddenLockedTime, + ); + } + + if (task.type == TaskType.freeSlot) { + return build( + action: ReminderDirectiveAction.suppress, + reason: ReminderDirectiveReason.suppressFreeSlotRecord, + ); + } + + if (!_isReminderEligibleStatus(task.status)) { + return build( + action: ReminderDirectiveAction.suppress, + reason: ReminderDirectiveReason.suppressInactiveStatus, + ); + } + + if (!_isReminderEligibleType(task.type)) { + return build( + action: ReminderDirectiveAction.suppress, + reason: ReminderDirectiveReason.suppressUnsupportedTaskType, + ); + } + + if (start == null || end == null || !start.isBefore(end)) { + return build( + action: ReminderDirectiveAction.suppress, + reason: ReminderDirectiveReason.suppressNoScheduledWindow, + ); + } + + if (now.isBefore(start)) { + return build( + action: ReminderDirectiveAction.defer, + reason: ReminderDirectiveReason.deferUntilTaskWindow, + nextEligibleAt: start, + ); + } + + final protectedRestActive = activeFreeSlot != null; + if (protectedRestActive && task.isFlexible) { + return build( + action: ReminderDirectiveAction.suppress, + reason: ReminderDirectiveReason.suppressProtectedRest, + nextEligibleAt: activeFreeSlot.interval?.end, + ); + } + + if (protectedRestActive && task.isRequiredVisible) { + return _requiredDuringProtectedRest( + task: task, + effective: effective, + build: build, + freeSlotEnd: activeFreeSlot.interval?.end, + ); + } + + if (task.type == TaskType.critical && + effective.profile == ReminderProfile.strict) { + return build( + action: ReminderDirectiveAction.requireAcknowledgement, + reason: ReminderDirectiveReason.requireRequiredAcknowledgement, + ); + } + + return build( + action: ReminderDirectiveAction.deliver, + reason: ReminderDirectiveReason.deliverNormal, + ); + } + + ReminderDirective _requiredDuringProtectedRest({ + required Task task, + required EffectiveReminderProfile effective, + required ReminderDirective Function({ + required ReminderDirectiveAction action, + required ReminderDirectiveReason reason, + DateTime? nextEligibleAt, + }) build, + required DateTime? freeSlotEnd, + }) { + if (task.type == TaskType.critical && + effective.profile == ReminderProfile.strict) { + return build( + action: ReminderDirectiveAction.requireAcknowledgement, + reason: ReminderDirectiveReason.requireRequiredAcknowledgement, + ); + } + + if (task.type == TaskType.inflexible && + effective.profile == ReminderProfile.gentle) { + return build( + action: ReminderDirectiveAction.defer, + reason: ReminderDirectiveReason.deferRequiredDuringProtectedRest, + nextEligibleAt: freeSlotEnd, + ); + } + + return build( + action: ReminderDirectiveAction.deliver, + reason: ReminderDirectiveReason.deliverNormal, + ); + } + + OccupancyEntry? _activeProtectedFreeSlot({ + required DateTime now, + required Iterable contextTasks, + }) { + for (final entry in occupancyPolicy.classify(tasks: contextTasks)) { + final interval = entry.interval; + if (entry.category != OccupancyCategory.protectedFreeSlot || + interval == null) { + continue; + } + + final startsAtOrBeforeNow = + interval.start.isBefore(now) || interval.start.isAtSameMomentAs(now); + final endsAfterNow = interval.end.isAfter(now); + if (startsAtOrBeforeNow && endsAfterNow) { + return entry; + } + } + + return null; + } +} + +bool _isReminderEligibleStatus(TaskStatus status) { + return status == TaskStatus.planned || status == TaskStatus.active; +} + +bool _isReminderEligibleType(TaskType type) { + return type == TaskType.flexible || + type == TaskType.critical || + type == TaskType.inflexible; +} diff --git a/packages/scheduler_core/lib/src/repositories.dart b/packages/scheduler_core/lib/src/repositories.dart index 882a031..a80222f 100644 --- a/packages/scheduler_core/lib/src/repositories.dart +++ b/packages/scheduler_core/lib/src/repositories.dart @@ -10,1202 +10,19 @@ import 'locked_time.dart'; import 'models.dart'; import 'scheduling_engine.dart'; import 'time_contracts.dart'; - -/// Stable repository mutation failure categories. -enum RepositoryFailureCode { - notFound, - staleRevision, - invalidRevision, - ownerMismatch, - duplicateId, - duplicateOperation, -} - -/// Typed repository failure for optimistic writes and uniqueness checks. -class RepositoryFailure { - const RepositoryFailure({ - required this.code, - this.entityId, - this.expectedRevision, - this.actualRevision, - }); - - final RepositoryFailureCode code; - final String? entityId; - final int? expectedRevision; - final int? actualRevision; -} - -/// Mutation result that avoids throwing for expected repository conflicts. -class RepositoryMutationResult { - const RepositoryMutationResult._({ - this.revision, - this.failure, - }); - - factory RepositoryMutationResult.success({required int revision}) { - return RepositoryMutationResult._(revision: revision); - } - - factory RepositoryMutationResult.failure(RepositoryFailure failure) { - return RepositoryMutationResult._(failure: failure); - } - - final int? revision; - final RepositoryFailure? failure; - - bool get isSuccess => failure == null; -} - -/// Repository value plus adapter-neutral metadata. -class RepositoryRecord { - const RepositoryRecord({ - required this.value, - required this.ownerId, - required this.revision, - this.archivedAt, - }); - - final T value; - final String ownerId; - final int revision; - final DateTime? archivedAt; - - bool get isArchived => archivedAt != null; -} - -/// Cursor contract for bounded repository queries. -class RepositoryPageRequest { - const RepositoryPageRequest({ - this.cursor, - this.limit = 100, - }) : assert(limit > 0); - - /// Adapter-neutral cursor. In-memory repositories use an integer offset. - final String? cursor; - - /// Maximum number of records to return. - final int limit; -} - -/// One bounded page of repository query results. -class RepositoryPage { - const RepositoryPage({ - required this.items, - this.nextCursor, - }); - - final List items; - final String? nextCursor; - - bool get hasMore => nextCursor != null; -} - -/// Status/project filters for backlog candidate queries. -class BacklogCandidateQuery { - const BacklogCandidateQuery({ - required this.ownerId, - this.projectId, - this.tags = const {}, - this.page = const RepositoryPageRequest(), - }); - - final String ownerId; - final String? projectId; - final Set tags; - final RepositoryPageRequest page; -} - -/// Repository contract for task documents. -abstract interface class TaskRepository { - /// Return a task by stable id, or null when it does not exist. - Future findById(String id); - - /// Return a task plus owner/revision metadata. - Future?> findRecordById(String id); - - /// Return all tasks currently known to the repository. - Future> findAll(); - - /// Return tasks with a lifecycle status. - Future> findByStatus(TaskStatus status); - - /// Return owner-scoped tasks in deterministic id order. - Future> findByOwner({ - required String ownerId, - RepositoryPageRequest page = const RepositoryPageRequest(), - }); - - /// Return tasks assigned to [projectId]. - Future> findByProjectId({ - required String ownerId, - required String projectId, - RepositoryPageRequest page = const RepositoryPageRequest(), - }); - - /// Return direct children owned by [parentTaskId]. - Future> findByParentTaskId({ - required String ownerId, - required String parentTaskId, - RepositoryPageRequest page = const RepositoryPageRequest(), - }); - - /// Return backlog candidates ordered by backlog age, then id. - Future> findBacklogCandidates( - BacklogCandidateQuery query, - ); - - /// Return tasks that overlap [window] by scheduled start/end. - Future> findScheduledInWindow(SchedulingWindow window); - - /// Return owner-scoped scheduled tasks that overlap [window]. - Future> findScheduledInWindowForOwner({ - required String ownerId, - required SchedulingWindow window, - RepositoryPageRequest page = const RepositoryPageRequest(), - }); - - /// Return tasks in a local-day window. Civil date is supplied for adapters - /// that store date-derived keys; in-memory filtering uses [window]. - Future> findForLocalDay({ - required String ownerId, - required CivilDate localDate, - required SchedulingWindow window, - RepositoryPageRequest page = const RepositoryPageRequest(), - }); - - /// Save or replace [task] by stable id. - Future save(Task task); - - /// Save or replace every task by stable id. - Future saveAll(Iterable tasks); - - /// Insert [task] if its id is unused. - Future insert({ - required Task task, - required String ownerId, - }); - - /// Compare-and-set save by expected repository revision. - Future saveIfRevision({ - required Task task, - required String ownerId, - required int expectedRevision, - }); -} - -/// Repository contract for project profile documents. -abstract interface class ProjectRepository { - /// Return a project by stable id, or null when it does not exist. - Future findById(String id); - - /// Return a project plus owner/revision metadata. - Future?> findRecordById(String id); - - /// Return all projects currently known to the repository. - Future> findAll(); - - /// Return owner-scoped projects, optionally including archived profiles. - Future> findByOwner({ - required String ownerId, - bool includeArchived = false, - RepositoryPageRequest page = const RepositoryPageRequest(), - }); - - /// Save or replace [project] by stable id. - Future save(ProjectProfile project); - - /// Insert [project] if its id is unused. - Future insert({ - required ProjectProfile project, - required String ownerId, - }); - - /// Compare-and-set save by expected repository revision. - Future saveIfRevision({ - required ProjectProfile project, - required String ownerId, - required int expectedRevision, - }); - - /// Archive a project without deleting task history. - Future archive({ - required String projectId, - required String ownerId, - required int expectedRevision, - required DateTime archivedAt, - }); -} - -/// Repository contract for locked-block and one-day override documents. -abstract interface class LockedBlockRepository { - /// Return a locked block by stable id, or null when it does not exist. - Future findBlockById(String id); - - /// Return a locked block plus owner/revision metadata. - Future?> findBlockRecordById(String id); - - /// Return all locked block definitions. - Future> findAllBlocks(); - - /// Return owner-scoped locked blocks, optionally including archived blocks. - Future> findBlocksByOwner({ - required String ownerId, - bool includeArchived = false, - RepositoryPageRequest page = const RepositoryPageRequest(), - }); - - /// Return an override by stable id, or null when it does not exist. - Future findOverrideById(String id); - - /// Return an override plus owner/revision metadata. - Future?> findOverrideRecordById( - String id, - ); - - /// Return all one-day overrides. - Future> findAllOverrides(); - - /// Return owner-scoped one-day overrides for [date]. - Future> findOverridesForDate({ - required String ownerId, - required CivilDate date, - RepositoryPageRequest page = const RepositoryPageRequest(), - }); - - /// Save or replace [block] by stable id. - Future saveBlock(LockedBlock block); - - /// Save or replace [override] by stable id. - Future saveOverride(LockedBlockOverride override); - - Future insertBlock({ - required LockedBlock block, - required String ownerId, - }); - - Future saveBlockIfRevision({ - required LockedBlock block, - required String ownerId, - required int expectedRevision, - }); - - Future archiveBlock({ - required String blockId, - required String ownerId, - required int expectedRevision, - required DateTime archivedAt, - }); - - Future insertOverride({ - required LockedBlockOverride override, - required String ownerId, - }); - - Future saveOverrideIfRevision({ - required LockedBlockOverride override, - required String ownerId, - required int expectedRevision, - }); -} - -/// Snapshot of one scheduling operation or persisted planning state. -class SchedulingStateSnapshot { - SchedulingStateSnapshot({ - required this.id, - required this.capturedAt, - required this.window, - required List tasks, - List lockedIntervals = const [], - List requiredVisibleIntervals = const [], - List notices = const [], - List changes = const [], - List overlaps = const [], - this.operationName, - }) : tasks = List.unmodifiable(tasks), - lockedIntervals = List.unmodifiable(lockedIntervals), - requiredVisibleIntervals = - List.unmodifiable(requiredVisibleIntervals), - notices = List.unmodifiable(notices), - changes = List.unmodifiable(changes), - overlaps = List.unmodifiable(overlaps); - - /// Stable snapshot id. - final String id; - - /// Time this snapshot was created. - final DateTime capturedAt; - - /// Planning window represented by the snapshot. - final SchedulingWindow window; - - /// Task documents visible to the operation. - final List tasks; - - /// Locked intervals supplied to the scheduler. - final List lockedIntervals; - - /// Extra visible required intervals supplied to the scheduler. - final List requiredVisibleIntervals; - - /// Human-readable scheduling notices. - final List notices; - - /// Machine-readable task movements. - final List changes; - - /// Analysis-only overlap findings. - final List overlaps; - - /// Optional operation label, such as `push` or `rollover`. - final String? operationName; - - /// Rebuild scheduler input from the persisted state shape. - SchedulingInput get input { - return SchedulingInput( - tasks: List.unmodifiable(tasks), - window: window, - lockedIntervals: List.unmodifiable(lockedIntervals), - requiredVisibleIntervals: - List.unmodifiable(requiredVisibleIntervals), - ); - } - - /// Rebuild scheduler result details from the persisted state shape. - SchedulingResult get result { - return SchedulingResult( - tasks: List.unmodifiable(tasks), - notices: List.unmodifiable(notices), - changes: List.unmodifiable(changes), - overlaps: List.unmodifiable(overlaps), - ); - } -} - -/// Repository contract for scheduling operation/state snapshot documents. -abstract interface class SchedulingSnapshotRepository { - /// Return a snapshot by stable id, or null when it does not exist. - Future findById(String id); - - /// Return all snapshots overlapping [window]. - Future> findInWindow(SchedulingWindow window); - - /// Save or replace [snapshot] by stable id. - Future save(SchedulingStateSnapshot snapshot); -} - -/// In-memory task repository useful for tests and early application wiring. -class InMemoryTaskRepository implements TaskRepository { - InMemoryTaskRepository([ - Iterable initialTasks = const [], - this.defaultOwnerId = 'owner-1', - ]) : _tasksById = { - for (final task in initialTasks) task.id: task, - }, - _ownersById = { - for (final task in initialTasks) task.id: defaultOwnerId, - }, - _revisionsById = { - for (final task in initialTasks) task.id: 1, - }; - - final Map _tasksById; - final Map _ownersById; - final Map _revisionsById; - final String defaultOwnerId; - - @override - Future findById(String id) async { - return _tasksById[id]; - } - - @override - Future?> findRecordById(String id) async { - final task = _tasksById[id]; - if (task == null) { - return null; - } - return RepositoryRecord( - value: task, - ownerId: _ownerFor(id), - revision: _revisionFor(id), - ); - } - - @override - Future> findAll() async { - return List.unmodifiable(_tasksById.values); - } - - @override - Future> findByStatus(TaskStatus status) async { - return List.unmodifiable( - _tasksById.values.where((task) => task.status == status), - ); - } - - @override - Future> findByOwner({ - required String ownerId, - RepositoryPageRequest page = const RepositoryPageRequest(), - }) async { - final tasks = _tasksById.values - .where((task) => _ownerFor(task.id) == ownerId) - .toList(growable: false) - ..sort(_compareTaskIds); - return _page(tasks, page); - } - - @override - Future> findByProjectId({ - required String ownerId, - required String projectId, - RepositoryPageRequest page = const RepositoryPageRequest(), - }) async { - final tasks = _tasksById.values - .where( - (task) => - _ownerFor(task.id) == ownerId && task.projectId == projectId, - ) - .toList(growable: false) - ..sort(_compareTaskIds); - return _page(tasks, page); - } - - @override - Future> findByParentTaskId({ - required String ownerId, - required String parentTaskId, - RepositoryPageRequest page = const RepositoryPageRequest(), - }) async { - final tasks = _tasksById.values - .where( - (task) => - _ownerFor(task.id) == ownerId && - task.parentTaskId == parentTaskId, - ) - .toList(growable: false) - ..sort(_compareTaskIds); - return _page(tasks, page); - } - - @override - Future> findBacklogCandidates( - BacklogCandidateQuery query, - ) async { - final tasks = _tasksById.values - .where( - (task) => - _ownerFor(task.id) == query.ownerId && - task.status == TaskStatus.backlog && - (query.projectId == null || task.projectId == query.projectId) && - query.tags.every(task.backlogTags.contains), - ) - .toList(growable: false) - ..sort(_compareBacklogCandidates); - return _page(tasks, query.page); - } - - @override - Future> findScheduledInWindow(SchedulingWindow window) async { - return List.unmodifiable( - _tasksById.values.where((task) { - return _taskOverlapsWindow(task, window); - }), - ); - } - - @override - Future> findScheduledInWindowForOwner({ - required String ownerId, - required SchedulingWindow window, - RepositoryPageRequest page = const RepositoryPageRequest(), - }) async { - final tasks = _tasksById.values - .where( - (task) => - _ownerFor(task.id) == ownerId && - _taskOverlapsWindow( - task, - window, - ), - ) - .toList(growable: false) - ..sort(_compareScheduledTasks); - return _page(tasks, page); - } - - @override - Future> findForLocalDay({ - required String ownerId, - required CivilDate localDate, - required SchedulingWindow window, - RepositoryPageRequest page = const RepositoryPageRequest(), - }) { - return findScheduledInWindowForOwner( - ownerId: ownerId, - window: window, - page: page, - ); - } - - @override - Future save(Task task) async { - _tasksById[task.id] = task; - _ownersById.putIfAbsent(task.id, () => defaultOwnerId); - _revisionsById[task.id] = _revisionFor(task.id) + 1; - } - - @override - Future saveAll(Iterable tasks) async { - for (final task in tasks) { - await save(task); - } - } - - @override - Future insert({ - required Task task, - required String ownerId, - }) async { - if (_tasksById.containsKey(task.id)) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.duplicateId, - entityId: task.id, - ), - ); - } - _tasksById[task.id] = task; - _ownersById[task.id] = ownerId; - _revisionsById[task.id] = 1; - return RepositoryMutationResult.success(revision: 1); - } - - @override - Future saveIfRevision({ - required Task task, - required String ownerId, - required int expectedRevision, - }) async { - if (expectedRevision <= 0) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.invalidRevision, - entityId: task.id, - expectedRevision: expectedRevision, - ), - ); - } - final current = _tasksById[task.id]; - if (current == null) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.notFound, - entityId: task.id, - ), - ); - } - if (_ownerFor(task.id) != ownerId) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.ownerMismatch, - entityId: task.id, - ), - ); - } - final actualRevision = _revisionFor(task.id); - if (actualRevision != expectedRevision) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.staleRevision, - entityId: task.id, - expectedRevision: expectedRevision, - actualRevision: actualRevision, - ), - ); - } - final nextRevision = actualRevision + 1; - _tasksById[task.id] = task; - _ownersById[task.id] = ownerId; - _revisionsById[task.id] = nextRevision; - return RepositoryMutationResult.success(revision: nextRevision); - } - - String _ownerFor(String id) => _ownersById[id] ?? defaultOwnerId; - - int _revisionFor(String id) => _revisionsById[id] ?? 1; -} - -/// In-memory project repository useful for tests and early application wiring. -class InMemoryProjectRepository implements ProjectRepository { - InMemoryProjectRepository( - [Iterable initialProjects = const [], - this.defaultOwnerId = 'owner-1']) - : _projectsById = { - for (final project in initialProjects) project.id: project, - }, - _ownersById = { - for (final project in initialProjects) project.id: defaultOwnerId, - }, - _revisionsById = { - for (final project in initialProjects) project.id: 1, - }; - - final Map _projectsById; - final Map _ownersById; - final Map _revisionsById; - final String defaultOwnerId; - - @override - Future findById(String id) async { - return _projectsById[id]; - } - - @override - Future?> findRecordById(String id) async { - final project = _projectsById[id]; - if (project == null) { - return null; - } - return RepositoryRecord( - value: project, - ownerId: _ownerFor(id), - revision: _revisionFor(id), - archivedAt: project.archivedAt, - ); - } - - @override - Future> findAll() async { - return List.unmodifiable(_projectsById.values); - } - - @override - Future> findByOwner({ - required String ownerId, - bool includeArchived = false, - RepositoryPageRequest page = const RepositoryPageRequest(), - }) async { - final projects = _projectsById.values - .where( - (project) => - _ownerFor(project.id) == ownerId && - (includeArchived || !project.isArchived), - ) - .toList(growable: false) - ..sort(_compareProjects); - return _page(projects, page); - } - - @override - Future save(ProjectProfile project) async { - _projectsById[project.id] = project; - _ownersById.putIfAbsent(project.id, () => defaultOwnerId); - _revisionsById[project.id] = _revisionFor(project.id) + 1; - } - - @override - Future insert({ - required ProjectProfile project, - required String ownerId, - }) async { - if (_projectsById.containsKey(project.id)) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.duplicateId, - entityId: project.id, - ), - ); - } - _projectsById[project.id] = project; - _ownersById[project.id] = ownerId; - _revisionsById[project.id] = 1; - return RepositoryMutationResult.success(revision: 1); - } - - @override - Future saveIfRevision({ - required ProjectProfile project, - required String ownerId, - required int expectedRevision, - }) async { - if (expectedRevision <= 0) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.invalidRevision, - entityId: project.id, - expectedRevision: expectedRevision, - ), - ); - } - final current = _projectsById[project.id]; - if (current == null) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.notFound, - entityId: project.id, - ), - ); - } - if (_ownerFor(project.id) != ownerId) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.ownerMismatch, - entityId: project.id, - ), - ); - } - final actualRevision = _revisionFor(project.id); - if (actualRevision != expectedRevision) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.staleRevision, - entityId: project.id, - expectedRevision: expectedRevision, - actualRevision: actualRevision, - ), - ); - } - final nextRevision = actualRevision + 1; - _projectsById[project.id] = project; - _ownersById[project.id] = ownerId; - _revisionsById[project.id] = nextRevision; - return RepositoryMutationResult.success(revision: nextRevision); - } - - @override - Future archive({ - required String projectId, - required String ownerId, - required int expectedRevision, - required DateTime archivedAt, - }) async { - final project = _projectsById[projectId]; - if (project == null) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.notFound, - entityId: projectId, - ), - ); - } - return saveIfRevision( - project: project.copyWith(archivedAt: archivedAt), - ownerId: ownerId, - expectedRevision: expectedRevision, - ); - } - - String _ownerFor(String id) => _ownersById[id] ?? defaultOwnerId; - - int _revisionFor(String id) => _revisionsById[id] ?? 1; -} - -/// In-memory locked-time repository useful for tests and early wiring. -class InMemoryLockedBlockRepository implements LockedBlockRepository { - InMemoryLockedBlockRepository({ - Iterable initialBlocks = const [], - Iterable initialOverrides = const [], - this.defaultOwnerId = 'owner-1', - }) : _blocksById = { - for (final block in initialBlocks) block.id: block, - }, - _blockOwnersById = { - for (final block in initialBlocks) block.id: defaultOwnerId, - }, - _blockRevisionsById = { - for (final block in initialBlocks) block.id: 1, - }, - _overridesById = { - for (final override in initialOverrides) override.id: override, - }, - _overrideOwnersById = { - for (final override in initialOverrides) override.id: defaultOwnerId, - }, - _overrideRevisionsById = { - for (final override in initialOverrides) override.id: 1, - }; - - final Map _blocksById; - final Map _blockOwnersById; - final Map _blockRevisionsById; - final Map _overridesById; - final Map _overrideOwnersById; - final Map _overrideRevisionsById; - final String defaultOwnerId; - - @override - Future findBlockById(String id) async { - return _blocksById[id]; - } - - @override - Future?> findBlockRecordById(String id) async { - final block = _blocksById[id]; - if (block == null) { - return null; - } - return RepositoryRecord( - value: block, - ownerId: _blockOwnerFor(id), - revision: _blockRevisionFor(id), - archivedAt: block.archivedAt, - ); - } - - @override - Future> findAllBlocks() async { - return List.unmodifiable(_blocksById.values); - } - - @override - Future> findBlocksByOwner({ - required String ownerId, - bool includeArchived = false, - RepositoryPageRequest page = const RepositoryPageRequest(), - }) async { - final blocks = _blocksById.values - .where( - (block) => - _blockOwnerFor(block.id) == ownerId && - (includeArchived || !block.isArchived), - ) - .toList(growable: false) - ..sort(_compareLockedBlocks); - return _page(blocks, page); - } - - @override - Future findOverrideById(String id) async { - return _overridesById[id]; - } - - @override - Future?> findOverrideRecordById( - String id, - ) async { - final override = _overridesById[id]; - if (override == null) { - return null; - } - return RepositoryRecord( - value: override, - ownerId: _overrideOwnerFor(id), - revision: _overrideRevisionFor(id), - ); - } - - @override - Future> findAllOverrides() async { - return List.unmodifiable(_overridesById.values); - } - - @override - Future> findOverridesForDate({ - required String ownerId, - required CivilDate date, - RepositoryPageRequest page = const RepositoryPageRequest(), - }) async { - final overrides = _overridesById.values - .where( - (override) => - _overrideOwnerFor(override.id) == ownerId && - override.date == date, - ) - .toList(growable: false) - ..sort(_compareLockedOverrides); - return _page(overrides, page); - } - - @override - Future saveBlock(LockedBlock block) async { - _blocksById[block.id] = block; - _blockOwnersById.putIfAbsent(block.id, () => defaultOwnerId); - _blockRevisionsById[block.id] = _blockRevisionFor(block.id) + 1; - } - - @override - Future saveOverride(LockedBlockOverride override) async { - _overridesById[override.id] = override; - _overrideOwnersById.putIfAbsent(override.id, () => defaultOwnerId); - _overrideRevisionsById[override.id] = _overrideRevisionFor(override.id) + 1; - } - - @override - Future insertBlock({ - required LockedBlock block, - required String ownerId, - }) async { - if (_blocksById.containsKey(block.id)) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.duplicateId, - entityId: block.id, - ), - ); - } - _blocksById[block.id] = block; - _blockOwnersById[block.id] = ownerId; - _blockRevisionsById[block.id] = 1; - return RepositoryMutationResult.success(revision: 1); - } - - @override - Future saveBlockIfRevision({ - required LockedBlock block, - required String ownerId, - required int expectedRevision, - }) async { - if (expectedRevision <= 0) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.invalidRevision, - entityId: block.id, - expectedRevision: expectedRevision, - ), - ); - } - final current = _blocksById[block.id]; - if (current == null) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.notFound, - entityId: block.id, - ), - ); - } - if (_blockOwnerFor(block.id) != ownerId) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.ownerMismatch, - entityId: block.id, - ), - ); - } - final actualRevision = _blockRevisionFor(block.id); - if (actualRevision != expectedRevision) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.staleRevision, - entityId: block.id, - expectedRevision: expectedRevision, - actualRevision: actualRevision, - ), - ); - } - final nextRevision = actualRevision + 1; - _blocksById[block.id] = block; - _blockOwnersById[block.id] = ownerId; - _blockRevisionsById[block.id] = nextRevision; - return RepositoryMutationResult.success(revision: nextRevision); - } - - @override - Future archiveBlock({ - required String blockId, - required String ownerId, - required int expectedRevision, - required DateTime archivedAt, - }) async { - final block = _blocksById[blockId]; - if (block == null) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.notFound, - entityId: blockId, - ), - ); - } - return saveBlockIfRevision( - block: block.copyWith(updatedAt: archivedAt, archivedAt: archivedAt), - ownerId: ownerId, - expectedRevision: expectedRevision, - ); - } - - @override - Future insertOverride({ - required LockedBlockOverride override, - required String ownerId, - }) async { - if (_overridesById.containsKey(override.id)) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.duplicateId, - entityId: override.id, - ), - ); - } - _overridesById[override.id] = override; - _overrideOwnersById[override.id] = ownerId; - _overrideRevisionsById[override.id] = 1; - return RepositoryMutationResult.success(revision: 1); - } - - @override - Future saveOverrideIfRevision({ - required LockedBlockOverride override, - required String ownerId, - required int expectedRevision, - }) async { - if (expectedRevision <= 0) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.invalidRevision, - entityId: override.id, - expectedRevision: expectedRevision, - ), - ); - } - final current = _overridesById[override.id]; - if (current == null) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.notFound, - entityId: override.id, - ), - ); - } - if (_overrideOwnerFor(override.id) != ownerId) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.ownerMismatch, - entityId: override.id, - ), - ); - } - final actualRevision = _overrideRevisionFor(override.id); - if (actualRevision != expectedRevision) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.staleRevision, - entityId: override.id, - expectedRevision: expectedRevision, - actualRevision: actualRevision, - ), - ); - } - final nextRevision = actualRevision + 1; - _overridesById[override.id] = override; - _overrideOwnersById[override.id] = ownerId; - _overrideRevisionsById[override.id] = nextRevision; - return RepositoryMutationResult.success(revision: nextRevision); - } - - String _blockOwnerFor(String id) => _blockOwnersById[id] ?? defaultOwnerId; - - int _blockRevisionFor(String id) => _blockRevisionsById[id] ?? 1; - - String _overrideOwnerFor(String id) => - _overrideOwnersById[id] ?? defaultOwnerId; - - int _overrideRevisionFor(String id) => _overrideRevisionsById[id] ?? 1; -} - -/// In-memory snapshot repository useful for tests and early application wiring. -class InMemorySchedulingSnapshotRepository - implements SchedulingSnapshotRepository { - InMemorySchedulingSnapshotRepository([ - Iterable initialSnapshots = const [], - ]) : _snapshotsById = { - for (final snapshot in initialSnapshots) snapshot.id: snapshot, - }; - - final Map _snapshotsById; - - @override - Future findById(String id) async { - return _snapshotsById[id]; - } - - @override - Future> findInWindow( - SchedulingWindow window, - ) async { - return List.unmodifiable( - _snapshotsById.values.where( - (snapshot) => snapshot.window.interval.overlaps(window.interval), - ), - ); - } - - @override - Future save(SchedulingStateSnapshot snapshot) async { - _snapshotsById[snapshot.id] = snapshot; - } -} - -RepositoryPage _page(List sortedItems, RepositoryPageRequest request) { - final offset = int.tryParse(request.cursor ?? '') ?? 0; - final safeOffset = offset.clamp(0, sortedItems.length); - final end = (safeOffset + request.limit).clamp(0, sortedItems.length); - final items = sortedItems.sublist(safeOffset, end); - final nextCursor = end < sortedItems.length ? end.toString() : null; - return RepositoryPage( - items: List.unmodifiable(items), - nextCursor: nextCursor, - ); -} - -bool _taskOverlapsWindow(Task task, SchedulingWindow window) { - final start = task.scheduledStart; - final end = task.scheduledEnd; - if (start == null || end == null) { - return false; - } - return TimeInterval(start: start, end: end).overlaps(window.interval); -} - -int _compareTaskIds(Task left, Task right) => left.id.compareTo(right.id); - -int _compareScheduledTasks(Task left, Task right) { - final leftStart = left.scheduledStart; - final rightStart = right.scheduledStart; - if (leftStart != null && rightStart != null) { - final startComparison = leftStart.compareTo(rightStart); - if (startComparison != 0) { - return startComparison; - } - } - return left.id.compareTo(right.id); -} - -int _compareBacklogCandidates(Task left, Task right) { - final createdComparison = left.createdAt.compareTo(right.createdAt); - if (createdComparison != 0) { - return createdComparison; - } - return left.id.compareTo(right.id); -} - -int _compareProjects(ProjectProfile left, ProjectProfile right) { - final nameComparison = left.name.compareTo(right.name); - if (nameComparison != 0) { - return nameComparison; - } - return left.id.compareTo(right.id); -} - -int _compareLockedBlocks(LockedBlock left, LockedBlock right) { - final nameComparison = left.name.compareTo(right.name); - if (nameComparison != 0) { - return nameComparison; - } - return left.id.compareTo(right.id); -} - -int _compareLockedOverrides( - LockedBlockOverride left, - LockedBlockOverride right, -) { - final dateComparison = left.date.compareTo(right.date); - if (dateComparison != 0) { - return dateComparison; - } - final createdComparison = left.createdAt.compareTo(right.createdAt); - if (createdComparison != 0) { - return createdComparison; - } - return left.id.compareTo(right.id); -} +part 'repositories/repository_failure_code.dart'; +part 'repositories/repository_failure.dart'; +part 'repositories/repository_mutation_result.dart'; +part 'repositories/repository_record.dart'; +part 'repositories/repository_page_request.dart'; +part 'repositories/repository_page.dart'; +part 'repositories/backlog_candidate_query.dart'; +part 'repositories/task_repository.dart'; +part 'repositories/project_repository.dart'; +part 'repositories/locked_block_repository.dart'; +part 'repositories/scheduling_state_snapshot.dart'; +part 'repositories/scheduling_snapshot_repository.dart'; +part 'repositories/in_memory_task_repository.dart'; +part 'repositories/in_memory_project_repository.dart'; +part 'repositories/in_memory_locked_block_repository.dart'; +part 'repositories/in_memory_scheduling_snapshot_repository.dart'; diff --git a/packages/scheduler_core/lib/src/repositories/backlog_candidate_query.dart b/packages/scheduler_core/lib/src/repositories/backlog_candidate_query.dart new file mode 100644 index 0000000..3387e76 --- /dev/null +++ b/packages/scheduler_core/lib/src/repositories/backlog_candidate_query.dart @@ -0,0 +1,16 @@ +part of '../repositories.dart'; + +/// Status/project filters for backlog candidate queries. +class BacklogCandidateQuery { + const BacklogCandidateQuery({ + required this.ownerId, + this.projectId, + this.tags = const {}, + this.page = const RepositoryPageRequest(), + }); + + final String ownerId; + final String? projectId; + final Set tags; + final RepositoryPageRequest page; +} diff --git a/packages/scheduler_core/lib/src/repositories/in_memory_locked_block_repository.dart b/packages/scheduler_core/lib/src/repositories/in_memory_locked_block_repository.dart new file mode 100644 index 0000000..ce52887 --- /dev/null +++ b/packages/scheduler_core/lib/src/repositories/in_memory_locked_block_repository.dart @@ -0,0 +1,302 @@ +part of '../repositories.dart'; + +/// In-memory locked-time repository useful for tests and early wiring. +class InMemoryLockedBlockRepository implements LockedBlockRepository { + InMemoryLockedBlockRepository({ + Iterable initialBlocks = const [], + Iterable initialOverrides = const [], + this.defaultOwnerId = 'owner-1', + }) : _blocksById = { + for (final block in initialBlocks) block.id: block, + }, + _blockOwnersById = { + for (final block in initialBlocks) block.id: defaultOwnerId, + }, + _blockRevisionsById = { + for (final block in initialBlocks) block.id: 1, + }, + _overridesById = { + for (final override in initialOverrides) override.id: override, + }, + _overrideOwnersById = { + for (final override in initialOverrides) override.id: defaultOwnerId, + }, + _overrideRevisionsById = { + for (final override in initialOverrides) override.id: 1, + }; + + final Map _blocksById; + final Map _blockOwnersById; + final Map _blockRevisionsById; + final Map _overridesById; + final Map _overrideOwnersById; + final Map _overrideRevisionsById; + final String defaultOwnerId; + + @override + Future findBlockById(String id) async { + return _blocksById[id]; + } + + @override + Future?> findBlockRecordById(String id) async { + final block = _blocksById[id]; + if (block == null) { + return null; + } + return RepositoryRecord( + value: block, + ownerId: _blockOwnerFor(id), + revision: _blockRevisionFor(id), + archivedAt: block.archivedAt, + ); + } + + @override + Future> findAllBlocks() async { + return List.unmodifiable(_blocksById.values); + } + + @override + Future> findBlocksByOwner({ + required String ownerId, + bool includeArchived = false, + RepositoryPageRequest page = const RepositoryPageRequest(), + }) async { + final blocks = _blocksById.values + .where( + (block) => + _blockOwnerFor(block.id) == ownerId && + (includeArchived || !block.isArchived), + ) + .toList(growable: false) + ..sort(_compareLockedBlocks); + return _page(blocks, page); + } + + @override + Future findOverrideById(String id) async { + return _overridesById[id]; + } + + @override + Future?> findOverrideRecordById( + String id, + ) async { + final override = _overridesById[id]; + if (override == null) { + return null; + } + return RepositoryRecord( + value: override, + ownerId: _overrideOwnerFor(id), + revision: _overrideRevisionFor(id), + ); + } + + @override + Future> findAllOverrides() async { + return List.unmodifiable(_overridesById.values); + } + + @override + Future> findOverridesForDate({ + required String ownerId, + required CivilDate date, + RepositoryPageRequest page = const RepositoryPageRequest(), + }) async { + final overrides = _overridesById.values + .where( + (override) => + _overrideOwnerFor(override.id) == ownerId && + override.date == date, + ) + .toList(growable: false) + ..sort(_compareLockedOverrides); + return _page(overrides, page); + } + + @override + Future saveBlock(LockedBlock block) async { + _blocksById[block.id] = block; + _blockOwnersById.putIfAbsent(block.id, () => defaultOwnerId); + _blockRevisionsById[block.id] = _blockRevisionFor(block.id) + 1; + } + + @override + Future saveOverride(LockedBlockOverride override) async { + _overridesById[override.id] = override; + _overrideOwnersById.putIfAbsent(override.id, () => defaultOwnerId); + _overrideRevisionsById[override.id] = _overrideRevisionFor(override.id) + 1; + } + + @override + Future insertBlock({ + required LockedBlock block, + required String ownerId, + }) async { + if (_blocksById.containsKey(block.id)) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.duplicateId, + entityId: block.id, + ), + ); + } + _blocksById[block.id] = block; + _blockOwnersById[block.id] = ownerId; + _blockRevisionsById[block.id] = 1; + return RepositoryMutationResult.success(revision: 1); + } + + @override + Future saveBlockIfRevision({ + required LockedBlock block, + required String ownerId, + required int expectedRevision, + }) async { + if (expectedRevision <= 0) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.invalidRevision, + entityId: block.id, + expectedRevision: expectedRevision, + ), + ); + } + final current = _blocksById[block.id]; + if (current == null) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.notFound, + entityId: block.id, + ), + ); + } + if (_blockOwnerFor(block.id) != ownerId) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.ownerMismatch, + entityId: block.id, + ), + ); + } + final actualRevision = _blockRevisionFor(block.id); + if (actualRevision != expectedRevision) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.staleRevision, + entityId: block.id, + expectedRevision: expectedRevision, + actualRevision: actualRevision, + ), + ); + } + final nextRevision = actualRevision + 1; + _blocksById[block.id] = block; + _blockOwnersById[block.id] = ownerId; + _blockRevisionsById[block.id] = nextRevision; + return RepositoryMutationResult.success(revision: nextRevision); + } + + @override + Future archiveBlock({ + required String blockId, + required String ownerId, + required int expectedRevision, + required DateTime archivedAt, + }) async { + final block = _blocksById[blockId]; + if (block == null) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.notFound, + entityId: blockId, + ), + ); + } + return saveBlockIfRevision( + block: block.copyWith(updatedAt: archivedAt, archivedAt: archivedAt), + ownerId: ownerId, + expectedRevision: expectedRevision, + ); + } + + @override + Future insertOverride({ + required LockedBlockOverride override, + required String ownerId, + }) async { + if (_overridesById.containsKey(override.id)) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.duplicateId, + entityId: override.id, + ), + ); + } + _overridesById[override.id] = override; + _overrideOwnersById[override.id] = ownerId; + _overrideRevisionsById[override.id] = 1; + return RepositoryMutationResult.success(revision: 1); + } + + @override + Future saveOverrideIfRevision({ + required LockedBlockOverride override, + required String ownerId, + required int expectedRevision, + }) async { + if (expectedRevision <= 0) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.invalidRevision, + entityId: override.id, + expectedRevision: expectedRevision, + ), + ); + } + final current = _overridesById[override.id]; + if (current == null) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.notFound, + entityId: override.id, + ), + ); + } + if (_overrideOwnerFor(override.id) != ownerId) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.ownerMismatch, + entityId: override.id, + ), + ); + } + final actualRevision = _overrideRevisionFor(override.id); + if (actualRevision != expectedRevision) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.staleRevision, + entityId: override.id, + expectedRevision: expectedRevision, + actualRevision: actualRevision, + ), + ); + } + final nextRevision = actualRevision + 1; + _overridesById[override.id] = override; + _overrideOwnersById[override.id] = ownerId; + _overrideRevisionsById[override.id] = nextRevision; + return RepositoryMutationResult.success(revision: nextRevision); + } + + String _blockOwnerFor(String id) => _blockOwnersById[id] ?? defaultOwnerId; + + int _blockRevisionFor(String id) => _blockRevisionsById[id] ?? 1; + + String _overrideOwnerFor(String id) => + _overrideOwnersById[id] ?? defaultOwnerId; + + int _overrideRevisionFor(String id) => _overrideRevisionsById[id] ?? 1; +} diff --git a/packages/scheduler_core/lib/src/repositories/in_memory_project_repository.dart b/packages/scheduler_core/lib/src/repositories/in_memory_project_repository.dart new file mode 100644 index 0000000..48bad78 --- /dev/null +++ b/packages/scheduler_core/lib/src/repositories/in_memory_project_repository.dart @@ -0,0 +1,166 @@ +part of '../repositories.dart'; + +/// In-memory project repository useful for tests and early application wiring. +class InMemoryProjectRepository implements ProjectRepository { + InMemoryProjectRepository( + [Iterable initialProjects = const [], + this.defaultOwnerId = 'owner-1']) + : _projectsById = { + for (final project in initialProjects) project.id: project, + }, + _ownersById = { + for (final project in initialProjects) project.id: defaultOwnerId, + }, + _revisionsById = { + for (final project in initialProjects) project.id: 1, + }; + + final Map _projectsById; + final Map _ownersById; + final Map _revisionsById; + final String defaultOwnerId; + + @override + Future findById(String id) async { + return _projectsById[id]; + } + + @override + Future?> findRecordById(String id) async { + final project = _projectsById[id]; + if (project == null) { + return null; + } + return RepositoryRecord( + value: project, + ownerId: _ownerFor(id), + revision: _revisionFor(id), + archivedAt: project.archivedAt, + ); + } + + @override + Future> findAll() async { + return List.unmodifiable(_projectsById.values); + } + + @override + Future> findByOwner({ + required String ownerId, + bool includeArchived = false, + RepositoryPageRequest page = const RepositoryPageRequest(), + }) async { + final projects = _projectsById.values + .where( + (project) => + _ownerFor(project.id) == ownerId && + (includeArchived || !project.isArchived), + ) + .toList(growable: false) + ..sort(_compareProjects); + return _page(projects, page); + } + + @override + Future save(ProjectProfile project) async { + _projectsById[project.id] = project; + _ownersById.putIfAbsent(project.id, () => defaultOwnerId); + _revisionsById[project.id] = _revisionFor(project.id) + 1; + } + + @override + Future insert({ + required ProjectProfile project, + required String ownerId, + }) async { + if (_projectsById.containsKey(project.id)) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.duplicateId, + entityId: project.id, + ), + ); + } + _projectsById[project.id] = project; + _ownersById[project.id] = ownerId; + _revisionsById[project.id] = 1; + return RepositoryMutationResult.success(revision: 1); + } + + @override + Future saveIfRevision({ + required ProjectProfile project, + required String ownerId, + required int expectedRevision, + }) async { + if (expectedRevision <= 0) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.invalidRevision, + entityId: project.id, + expectedRevision: expectedRevision, + ), + ); + } + final current = _projectsById[project.id]; + if (current == null) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.notFound, + entityId: project.id, + ), + ); + } + if (_ownerFor(project.id) != ownerId) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.ownerMismatch, + entityId: project.id, + ), + ); + } + final actualRevision = _revisionFor(project.id); + if (actualRevision != expectedRevision) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.staleRevision, + entityId: project.id, + expectedRevision: expectedRevision, + actualRevision: actualRevision, + ), + ); + } + final nextRevision = actualRevision + 1; + _projectsById[project.id] = project; + _ownersById[project.id] = ownerId; + _revisionsById[project.id] = nextRevision; + return RepositoryMutationResult.success(revision: nextRevision); + } + + @override + Future archive({ + required String projectId, + required String ownerId, + required int expectedRevision, + required DateTime archivedAt, + }) async { + final project = _projectsById[projectId]; + if (project == null) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.notFound, + entityId: projectId, + ), + ); + } + return saveIfRevision( + project: project.copyWith(archivedAt: archivedAt), + ownerId: ownerId, + expectedRevision: expectedRevision, + ); + } + + String _ownerFor(String id) => _ownersById[id] ?? defaultOwnerId; + + int _revisionFor(String id) => _revisionsById[id] ?? 1; +} diff --git a/packages/scheduler_core/lib/src/repositories/in_memory_scheduling_snapshot_repository.dart b/packages/scheduler_core/lib/src/repositories/in_memory_scheduling_snapshot_repository.dart new file mode 100644 index 0000000..7b145c0 --- /dev/null +++ b/packages/scheduler_core/lib/src/repositories/in_memory_scheduling_snapshot_repository.dart @@ -0,0 +1,108 @@ +part of '../repositories.dart'; + +/// In-memory snapshot repository useful for tests and early application wiring. +class InMemorySchedulingSnapshotRepository + implements SchedulingSnapshotRepository { + InMemorySchedulingSnapshotRepository([ + Iterable initialSnapshots = const [], + ]) : _snapshotsById = { + for (final snapshot in initialSnapshots) snapshot.id: snapshot, + }; + + final Map _snapshotsById; + + @override + Future findById(String id) async { + return _snapshotsById[id]; + } + + @override + Future> findInWindow( + SchedulingWindow window, + ) async { + return List.unmodifiable( + _snapshotsById.values.where( + (snapshot) => snapshot.window.interval.overlaps(window.interval), + ), + ); + } + + @override + Future save(SchedulingStateSnapshot snapshot) async { + _snapshotsById[snapshot.id] = snapshot; + } +} + +RepositoryPage _page(List sortedItems, RepositoryPageRequest request) { + final offset = int.tryParse(request.cursor ?? '') ?? 0; + final safeOffset = offset.clamp(0, sortedItems.length); + final end = (safeOffset + request.limit).clamp(0, sortedItems.length); + final items = sortedItems.sublist(safeOffset, end); + final nextCursor = end < sortedItems.length ? end.toString() : null; + return RepositoryPage( + items: List.unmodifiable(items), + nextCursor: nextCursor, + ); +} + +bool _taskOverlapsWindow(Task task, SchedulingWindow window) { + final start = task.scheduledStart; + final end = task.scheduledEnd; + if (start == null || end == null) { + return false; + } + return TimeInterval(start: start, end: end).overlaps(window.interval); +} + +int _compareTaskIds(Task left, Task right) => left.id.compareTo(right.id); + +int _compareScheduledTasks(Task left, Task right) { + final leftStart = left.scheduledStart; + final rightStart = right.scheduledStart; + if (leftStart != null && rightStart != null) { + final startComparison = leftStart.compareTo(rightStart); + if (startComparison != 0) { + return startComparison; + } + } + return left.id.compareTo(right.id); +} + +int _compareBacklogCandidates(Task left, Task right) { + final createdComparison = left.createdAt.compareTo(right.createdAt); + if (createdComparison != 0) { + return createdComparison; + } + return left.id.compareTo(right.id); +} + +int _compareProjects(ProjectProfile left, ProjectProfile right) { + final nameComparison = left.name.compareTo(right.name); + if (nameComparison != 0) { + return nameComparison; + } + return left.id.compareTo(right.id); +} + +int _compareLockedBlocks(LockedBlock left, LockedBlock right) { + final nameComparison = left.name.compareTo(right.name); + if (nameComparison != 0) { + return nameComparison; + } + return left.id.compareTo(right.id); +} + +int _compareLockedOverrides( + LockedBlockOverride left, + LockedBlockOverride right, +) { + final dateComparison = left.date.compareTo(right.date); + if (dateComparison != 0) { + return dateComparison; + } + final createdComparison = left.createdAt.compareTo(right.createdAt); + if (createdComparison != 0) { + return createdComparison; + } + return left.id.compareTo(right.id); +} diff --git a/packages/scheduler_core/lib/src/repositories/in_memory_task_repository.dart b/packages/scheduler_core/lib/src/repositories/in_memory_task_repository.dart new file mode 100644 index 0000000..5618f83 --- /dev/null +++ b/packages/scheduler_core/lib/src/repositories/in_memory_task_repository.dart @@ -0,0 +1,244 @@ +part of '../repositories.dart'; + +/// In-memory task repository useful for tests and early application wiring. +class InMemoryTaskRepository implements TaskRepository { + InMemoryTaskRepository([ + Iterable initialTasks = const [], + this.defaultOwnerId = 'owner-1', + ]) : _tasksById = { + for (final task in initialTasks) task.id: task, + }, + _ownersById = { + for (final task in initialTasks) task.id: defaultOwnerId, + }, + _revisionsById = { + for (final task in initialTasks) task.id: 1, + }; + + final Map _tasksById; + final Map _ownersById; + final Map _revisionsById; + final String defaultOwnerId; + + @override + Future findById(String id) async { + return _tasksById[id]; + } + + @override + Future?> findRecordById(String id) async { + final task = _tasksById[id]; + if (task == null) { + return null; + } + return RepositoryRecord( + value: task, + ownerId: _ownerFor(id), + revision: _revisionFor(id), + ); + } + + @override + Future> findAll() async { + return List.unmodifiable(_tasksById.values); + } + + @override + Future> findByStatus(TaskStatus status) async { + return List.unmodifiable( + _tasksById.values.where((task) => task.status == status), + ); + } + + @override + Future> findByOwner({ + required String ownerId, + RepositoryPageRequest page = const RepositoryPageRequest(), + }) async { + final tasks = _tasksById.values + .where((task) => _ownerFor(task.id) == ownerId) + .toList(growable: false) + ..sort(_compareTaskIds); + return _page(tasks, page); + } + + @override + Future> findByProjectId({ + required String ownerId, + required String projectId, + RepositoryPageRequest page = const RepositoryPageRequest(), + }) async { + final tasks = _tasksById.values + .where( + (task) => + _ownerFor(task.id) == ownerId && task.projectId == projectId, + ) + .toList(growable: false) + ..sort(_compareTaskIds); + return _page(tasks, page); + } + + @override + Future> findByParentTaskId({ + required String ownerId, + required String parentTaskId, + RepositoryPageRequest page = const RepositoryPageRequest(), + }) async { + final tasks = _tasksById.values + .where( + (task) => + _ownerFor(task.id) == ownerId && + task.parentTaskId == parentTaskId, + ) + .toList(growable: false) + ..sort(_compareTaskIds); + return _page(tasks, page); + } + + @override + Future> findBacklogCandidates( + BacklogCandidateQuery query, + ) async { + final tasks = _tasksById.values + .where( + (task) => + _ownerFor(task.id) == query.ownerId && + task.status == TaskStatus.backlog && + (query.projectId == null || task.projectId == query.projectId) && + query.tags.every(task.backlogTags.contains), + ) + .toList(growable: false) + ..sort(_compareBacklogCandidates); + return _page(tasks, query.page); + } + + @override + Future> findScheduledInWindow(SchedulingWindow window) async { + return List.unmodifiable( + _tasksById.values.where((task) { + return _taskOverlapsWindow(task, window); + }), + ); + } + + @override + Future> findScheduledInWindowForOwner({ + required String ownerId, + required SchedulingWindow window, + RepositoryPageRequest page = const RepositoryPageRequest(), + }) async { + final tasks = _tasksById.values + .where( + (task) => + _ownerFor(task.id) == ownerId && + _taskOverlapsWindow( + task, + window, + ), + ) + .toList(growable: false) + ..sort(_compareScheduledTasks); + return _page(tasks, page); + } + + @override + Future> findForLocalDay({ + required String ownerId, + required CivilDate localDate, + required SchedulingWindow window, + RepositoryPageRequest page = const RepositoryPageRequest(), + }) { + return findScheduledInWindowForOwner( + ownerId: ownerId, + window: window, + page: page, + ); + } + + @override + Future save(Task task) async { + _tasksById[task.id] = task; + _ownersById.putIfAbsent(task.id, () => defaultOwnerId); + _revisionsById[task.id] = _revisionFor(task.id) + 1; + } + + @override + Future saveAll(Iterable tasks) async { + for (final task in tasks) { + await save(task); + } + } + + @override + Future insert({ + required Task task, + required String ownerId, + }) async { + if (_tasksById.containsKey(task.id)) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.duplicateId, + entityId: task.id, + ), + ); + } + _tasksById[task.id] = task; + _ownersById[task.id] = ownerId; + _revisionsById[task.id] = 1; + return RepositoryMutationResult.success(revision: 1); + } + + @override + Future saveIfRevision({ + required Task task, + required String ownerId, + required int expectedRevision, + }) async { + if (expectedRevision <= 0) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.invalidRevision, + entityId: task.id, + expectedRevision: expectedRevision, + ), + ); + } + final current = _tasksById[task.id]; + if (current == null) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.notFound, + entityId: task.id, + ), + ); + } + if (_ownerFor(task.id) != ownerId) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.ownerMismatch, + entityId: task.id, + ), + ); + } + final actualRevision = _revisionFor(task.id); + if (actualRevision != expectedRevision) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.staleRevision, + entityId: task.id, + expectedRevision: expectedRevision, + actualRevision: actualRevision, + ), + ); + } + final nextRevision = actualRevision + 1; + _tasksById[task.id] = task; + _ownersById[task.id] = ownerId; + _revisionsById[task.id] = nextRevision; + return RepositoryMutationResult.success(revision: nextRevision); + } + + String _ownerFor(String id) => _ownersById[id] ?? defaultOwnerId; + + int _revisionFor(String id) => _revisionsById[id] ?? 1; +} diff --git a/packages/scheduler_core/lib/src/repositories/locked_block_repository.dart b/packages/scheduler_core/lib/src/repositories/locked_block_repository.dart new file mode 100644 index 0000000..fcf0a80 --- /dev/null +++ b/packages/scheduler_core/lib/src/repositories/locked_block_repository.dart @@ -0,0 +1,73 @@ +part of '../repositories.dart'; + +/// Repository contract for locked-block and one-day override documents. +abstract interface class LockedBlockRepository { + /// Return a locked block by stable id, or null when it does not exist. + Future findBlockById(String id); + + /// Return a locked block plus owner/revision metadata. + Future?> findBlockRecordById(String id); + + /// Return all locked block definitions. + Future> findAllBlocks(); + + /// Return owner-scoped locked blocks, optionally including archived blocks. + Future> findBlocksByOwner({ + required String ownerId, + bool includeArchived = false, + RepositoryPageRequest page = const RepositoryPageRequest(), + }); + + /// Return an override by stable id, or null when it does not exist. + Future findOverrideById(String id); + + /// Return an override plus owner/revision metadata. + Future?> findOverrideRecordById( + String id, + ); + + /// Return all one-day overrides. + Future> findAllOverrides(); + + /// Return owner-scoped one-day overrides for [date]. + Future> findOverridesForDate({ + required String ownerId, + required CivilDate date, + RepositoryPageRequest page = const RepositoryPageRequest(), + }); + + /// Save or replace [block] by stable id. + Future saveBlock(LockedBlock block); + + /// Save or replace [override] by stable id. + Future saveOverride(LockedBlockOverride override); + + Future insertBlock({ + required LockedBlock block, + required String ownerId, + }); + + Future saveBlockIfRevision({ + required LockedBlock block, + required String ownerId, + required int expectedRevision, + }); + + Future archiveBlock({ + required String blockId, + required String ownerId, + required int expectedRevision, + required DateTime archivedAt, + }); + + Future insertOverride({ + required LockedBlockOverride override, + required String ownerId, + }); + + Future saveOverrideIfRevision({ + required LockedBlockOverride override, + required String ownerId, + required int expectedRevision, + }); +} diff --git a/packages/scheduler_core/lib/src/repositories/project_repository.dart b/packages/scheduler_core/lib/src/repositories/project_repository.dart new file mode 100644 index 0000000..8f2c9bd --- /dev/null +++ b/packages/scheduler_core/lib/src/repositories/project_repository.dart @@ -0,0 +1,44 @@ +part of '../repositories.dart'; + +/// Repository contract for project profile documents. +abstract interface class ProjectRepository { + /// Return a project by stable id, or null when it does not exist. + Future findById(String id); + + /// Return a project plus owner/revision metadata. + Future?> findRecordById(String id); + + /// Return all projects currently known to the repository. + Future> findAll(); + + /// Return owner-scoped projects, optionally including archived profiles. + Future> findByOwner({ + required String ownerId, + bool includeArchived = false, + RepositoryPageRequest page = const RepositoryPageRequest(), + }); + + /// Save or replace [project] by stable id. + Future save(ProjectProfile project); + + /// Insert [project] if its id is unused. + Future insert({ + required ProjectProfile project, + required String ownerId, + }); + + /// Compare-and-set save by expected repository revision. + Future saveIfRevision({ + required ProjectProfile project, + required String ownerId, + required int expectedRevision, + }); + + /// Archive a project without deleting task history. + Future archive({ + required String projectId, + required String ownerId, + required int expectedRevision, + required DateTime archivedAt, + }); +} diff --git a/packages/scheduler_core/lib/src/repositories/repository_failure.dart b/packages/scheduler_core/lib/src/repositories/repository_failure.dart new file mode 100644 index 0000000..0535908 --- /dev/null +++ b/packages/scheduler_core/lib/src/repositories/repository_failure.dart @@ -0,0 +1,16 @@ +part of '../repositories.dart'; + +/// Typed repository failure for optimistic writes and uniqueness checks. +class RepositoryFailure { + const RepositoryFailure({ + required this.code, + this.entityId, + this.expectedRevision, + this.actualRevision, + }); + + final RepositoryFailureCode code; + final String? entityId; + final int? expectedRevision; + final int? actualRevision; +} diff --git a/packages/scheduler_core/lib/src/repositories/repository_failure_code.dart b/packages/scheduler_core/lib/src/repositories/repository_failure_code.dart new file mode 100644 index 0000000..35a36aa --- /dev/null +++ b/packages/scheduler_core/lib/src/repositories/repository_failure_code.dart @@ -0,0 +1,11 @@ +part of '../repositories.dart'; + +/// Stable repository mutation failure categories. +enum RepositoryFailureCode { + notFound, + staleRevision, + invalidRevision, + ownerMismatch, + duplicateId, + duplicateOperation, +} diff --git a/packages/scheduler_core/lib/src/repositories/repository_mutation_result.dart b/packages/scheduler_core/lib/src/repositories/repository_mutation_result.dart new file mode 100644 index 0000000..661e1b0 --- /dev/null +++ b/packages/scheduler_core/lib/src/repositories/repository_mutation_result.dart @@ -0,0 +1,22 @@ +part of '../repositories.dart'; + +/// Mutation result that avoids throwing for expected repository conflicts. +class RepositoryMutationResult { + const RepositoryMutationResult._({ + this.revision, + this.failure, + }); + + factory RepositoryMutationResult.success({required int revision}) { + return RepositoryMutationResult._(revision: revision); + } + + factory RepositoryMutationResult.failure(RepositoryFailure failure) { + return RepositoryMutationResult._(failure: failure); + } + + final int? revision; + final RepositoryFailure? failure; + + bool get isSuccess => failure == null; +} diff --git a/packages/scheduler_core/lib/src/repositories/repository_page.dart b/packages/scheduler_core/lib/src/repositories/repository_page.dart new file mode 100644 index 0000000..121e373 --- /dev/null +++ b/packages/scheduler_core/lib/src/repositories/repository_page.dart @@ -0,0 +1,14 @@ +part of '../repositories.dart'; + +/// One bounded page of repository query results. +class RepositoryPage { + const RepositoryPage({ + required this.items, + this.nextCursor, + }); + + final List items; + final String? nextCursor; + + bool get hasMore => nextCursor != null; +} diff --git a/packages/scheduler_core/lib/src/repositories/repository_page_request.dart b/packages/scheduler_core/lib/src/repositories/repository_page_request.dart new file mode 100644 index 0000000..6e2e18c --- /dev/null +++ b/packages/scheduler_core/lib/src/repositories/repository_page_request.dart @@ -0,0 +1,15 @@ +part of '../repositories.dart'; + +/// Cursor contract for bounded repository queries. +class RepositoryPageRequest { + const RepositoryPageRequest({ + this.cursor, + this.limit = 100, + }) : assert(limit > 0); + + /// Adapter-neutral cursor. In-memory repositories use an integer offset. + final String? cursor; + + /// Maximum number of records to return. + final int limit; +} diff --git a/packages/scheduler_core/lib/src/repositories/repository_record.dart b/packages/scheduler_core/lib/src/repositories/repository_record.dart new file mode 100644 index 0000000..b89b357 --- /dev/null +++ b/packages/scheduler_core/lib/src/repositories/repository_record.dart @@ -0,0 +1,18 @@ +part of '../repositories.dart'; + +/// Repository value plus adapter-neutral metadata. +class RepositoryRecord { + const RepositoryRecord({ + required this.value, + required this.ownerId, + required this.revision, + this.archivedAt, + }); + + final T value; + final String ownerId; + final int revision; + final DateTime? archivedAt; + + bool get isArchived => archivedAt != null; +} diff --git a/packages/scheduler_core/lib/src/repositories/scheduling_snapshot_repository.dart b/packages/scheduler_core/lib/src/repositories/scheduling_snapshot_repository.dart new file mode 100644 index 0000000..6ad760e --- /dev/null +++ b/packages/scheduler_core/lib/src/repositories/scheduling_snapshot_repository.dart @@ -0,0 +1,13 @@ +part of '../repositories.dart'; + +/// Repository contract for scheduling operation/state snapshot documents. +abstract interface class SchedulingSnapshotRepository { + /// Return a snapshot by stable id, or null when it does not exist. + Future findById(String id); + + /// Return all snapshots overlapping [window]. + Future> findInWindow(SchedulingWindow window); + + /// Save or replace [snapshot] by stable id. + Future save(SchedulingStateSnapshot snapshot); +} diff --git a/packages/scheduler_core/lib/src/repositories/scheduling_state_snapshot.dart b/packages/scheduler_core/lib/src/repositories/scheduling_state_snapshot.dart new file mode 100644 index 0000000..366e6ce --- /dev/null +++ b/packages/scheduler_core/lib/src/repositories/scheduling_state_snapshot.dart @@ -0,0 +1,74 @@ +part of '../repositories.dart'; + +/// Snapshot of one scheduling operation or persisted planning state. +class SchedulingStateSnapshot { + SchedulingStateSnapshot({ + required this.id, + required this.capturedAt, + required this.window, + required List tasks, + List lockedIntervals = const [], + List requiredVisibleIntervals = const [], + List notices = const [], + List changes = const [], + List overlaps = const [], + this.operationName, + }) : tasks = List.unmodifiable(tasks), + lockedIntervals = List.unmodifiable(lockedIntervals), + requiredVisibleIntervals = + List.unmodifiable(requiredVisibleIntervals), + notices = List.unmodifiable(notices), + changes = List.unmodifiable(changes), + overlaps = List.unmodifiable(overlaps); + + /// Stable snapshot id. + final String id; + + /// Time this snapshot was created. + final DateTime capturedAt; + + /// Planning window represented by the snapshot. + final SchedulingWindow window; + + /// Task documents visible to the operation. + final List tasks; + + /// Locked intervals supplied to the scheduler. + final List lockedIntervals; + + /// Extra visible required intervals supplied to the scheduler. + final List requiredVisibleIntervals; + + /// Human-readable scheduling notices. + final List notices; + + /// Machine-readable task movements. + final List changes; + + /// Analysis-only overlap findings. + final List overlaps; + + /// Optional operation label, such as `push` or `rollover`. + final String? operationName; + + /// Rebuild scheduler input from the persisted state shape. + SchedulingInput get input { + return SchedulingInput( + tasks: List.unmodifiable(tasks), + window: window, + lockedIntervals: List.unmodifiable(lockedIntervals), + requiredVisibleIntervals: + List.unmodifiable(requiredVisibleIntervals), + ); + } + + /// Rebuild scheduler result details from the persisted state shape. + SchedulingResult get result { + return SchedulingResult( + tasks: List.unmodifiable(tasks), + notices: List.unmodifiable(notices), + changes: List.unmodifiable(changes), + overlaps: List.unmodifiable(overlaps), + ); + } +} diff --git a/packages/scheduler_core/lib/src/repositories/task_repository.dart b/packages/scheduler_core/lib/src/repositories/task_repository.dart new file mode 100644 index 0000000..b6ce2d9 --- /dev/null +++ b/packages/scheduler_core/lib/src/repositories/task_repository.dart @@ -0,0 +1,79 @@ +part of '../repositories.dart'; + +/// Repository contract for task documents. +abstract interface class TaskRepository { + /// Return a task by stable id, or null when it does not exist. + Future findById(String id); + + /// Return a task plus owner/revision metadata. + Future?> findRecordById(String id); + + /// Return all tasks currently known to the repository. + Future> findAll(); + + /// Return tasks with a lifecycle status. + Future> findByStatus(TaskStatus status); + + /// Return owner-scoped tasks in deterministic id order. + Future> findByOwner({ + required String ownerId, + RepositoryPageRequest page = const RepositoryPageRequest(), + }); + + /// Return tasks assigned to [projectId]. + Future> findByProjectId({ + required String ownerId, + required String projectId, + RepositoryPageRequest page = const RepositoryPageRequest(), + }); + + /// Return direct children owned by [parentTaskId]. + Future> findByParentTaskId({ + required String ownerId, + required String parentTaskId, + RepositoryPageRequest page = const RepositoryPageRequest(), + }); + + /// Return backlog candidates ordered by backlog age, then id. + Future> findBacklogCandidates( + BacklogCandidateQuery query, + ); + + /// Return tasks that overlap [window] by scheduled start/end. + Future> findScheduledInWindow(SchedulingWindow window); + + /// Return owner-scoped scheduled tasks that overlap [window]. + Future> findScheduledInWindowForOwner({ + required String ownerId, + required SchedulingWindow window, + RepositoryPageRequest page = const RepositoryPageRequest(), + }); + + /// Return tasks in a local-day window. Civil date is supplied for adapters + /// that store date-derived keys; in-memory filtering uses [window]. + Future> findForLocalDay({ + required String ownerId, + required CivilDate localDate, + required SchedulingWindow window, + RepositoryPageRequest page = const RepositoryPageRequest(), + }); + + /// Save or replace [task] by stable id. + Future save(Task task); + + /// Save or replace every task by stable id. + Future saveAll(Iterable tasks); + + /// Insert [task] if its id is unused. + Future insert({ + required Task task, + required String ownerId, + }); + + /// Compare-and-set save by expected repository revision. + Future saveIfRevision({ + required Task task, + required String ownerId, + required int expectedRevision, + }); +} diff --git a/packages/scheduler_core/lib/src/repository_values.dart b/packages/scheduler_core/lib/src/repository_values.dart index c86ca60..95a523a 100644 --- a/packages/scheduler_core/lib/src/repository_values.dart +++ b/packages/scheduler_core/lib/src/repository_values.dart @@ -5,88 +5,7 @@ /// and paging contracts without importing a storage implementation. library; -/// Stable owner scope for repository operations. -class OwnerId { - OwnerId(String value) : value = _requiredTrimmed(value, 'ownerId'); - - /// Raw owner identifier. - final String value; - - @override - String toString() => value; - - @override - bool operator ==(Object other) { - return other is OwnerId && other.value == value; - } - - @override - int get hashCode => value.hashCode; -} - -/// Optimistic concurrency revision for mutable repository records. -class Revision implements Comparable { - const Revision(this.value) : assert(value >= 0); - - /// First revision assigned to a newly inserted record. - static const initial = Revision(1); - - /// Numeric revision value. - final int value; - - /// Return the next monotonically increasing revision. - Revision next() => Revision(value + 1); - - @override - int compareTo(Revision other) => value.compareTo(other.value); - - @override - String toString() => value.toString(); - - @override - bool operator ==(Object other) { - return other is Revision && other.value == value; - } - - @override - int get hashCode => value.hashCode; -} - -/// Cursor contract for bounded repository queries. -class PageRequest { - const PageRequest({ - this.cursor, - this.limit = 100, - }) : assert(limit > 0); - - /// Adapter-neutral cursor. Implementations choose the cursor format. - final String? cursor; - - /// Maximum number of records to return. - final int limit; -} - -/// One bounded page of repository query results. -class Page { - Page({ - required Iterable items, - this.nextCursor, - }) : items = List.unmodifiable(items); - - /// Items returned for this page. - final List items; - - /// Cursor for the next page, or null when this is the last page. - final String? nextCursor; - - /// Whether another page is available. - bool get hasMore => nextCursor != null; -} - -String _requiredTrimmed(String value, String name) { - final trimmed = value.trim(); - if (trimmed.isEmpty) { - throw ArgumentError.value(value, name, 'Value must not be blank.'); - } - return trimmed; -} +part 'repository_values/owner_id.dart'; +part 'repository_values/revision.dart'; +part 'repository_values/page_request.dart'; +part 'repository_values/page.dart'; diff --git a/packages/scheduler_core/lib/src/repository_values/owner_id.dart b/packages/scheduler_core/lib/src/repository_values/owner_id.dart new file mode 100644 index 0000000..dddd783 --- /dev/null +++ b/packages/scheduler_core/lib/src/repository_values/owner_id.dart @@ -0,0 +1,20 @@ +part of '../repository_values.dart'; + +/// Stable owner scope for repository operations. +class OwnerId { + OwnerId(String value) : value = _requiredTrimmed(value, 'ownerId'); + + /// Raw owner identifier. + final String value; + + @override + String toString() => value; + + @override + bool operator ==(Object other) { + return other is OwnerId && other.value == value; + } + + @override + int get hashCode => value.hashCode; +} diff --git a/packages/scheduler_core/lib/src/repository_values/page.dart b/packages/scheduler_core/lib/src/repository_values/page.dart new file mode 100644 index 0000000..fa35079 --- /dev/null +++ b/packages/scheduler_core/lib/src/repository_values/page.dart @@ -0,0 +1,26 @@ +part of '../repository_values.dart'; + +/// One bounded page of repository query results. +class Page { + Page({ + required Iterable items, + this.nextCursor, + }) : items = List.unmodifiable(items); + + /// Items returned for this page. + final List items; + + /// Cursor for the next page, or null when this is the last page. + final String? nextCursor; + + /// Whether another page is available. + bool get hasMore => nextCursor != null; +} + +String _requiredTrimmed(String value, String name) { + final trimmed = value.trim(); + if (trimmed.isEmpty) { + throw ArgumentError.value(value, name, 'Value must not be blank.'); + } + return trimmed; +} diff --git a/packages/scheduler_core/lib/src/repository_values/page_request.dart b/packages/scheduler_core/lib/src/repository_values/page_request.dart new file mode 100644 index 0000000..d249de2 --- /dev/null +++ b/packages/scheduler_core/lib/src/repository_values/page_request.dart @@ -0,0 +1,15 @@ +part of '../repository_values.dart'; + +/// Cursor contract for bounded repository queries. +class PageRequest { + const PageRequest({ + this.cursor, + this.limit = 100, + }) : assert(limit > 0); + + /// Adapter-neutral cursor. Implementations choose the cursor format. + final String? cursor; + + /// Maximum number of records to return. + final int limit; +} diff --git a/packages/scheduler_core/lib/src/repository_values/revision.dart b/packages/scheduler_core/lib/src/repository_values/revision.dart new file mode 100644 index 0000000..aebda11 --- /dev/null +++ b/packages/scheduler_core/lib/src/repository_values/revision.dart @@ -0,0 +1,29 @@ +part of '../repository_values.dart'; + +/// Optimistic concurrency revision for mutable repository records. +class Revision implements Comparable { + const Revision(this.value) : assert(value >= 0); + + /// First revision assigned to a newly inserted record. + static const initial = Revision(1); + + /// Numeric revision value. + final int value; + + /// Return the next monotonically increasing revision. + Revision next() => Revision(value + 1); + + @override + int compareTo(Revision other) => value.compareTo(other.value); + + @override + String toString() => value.toString(); + + @override + bool operator ==(Object other) { + return other is Revision && other.value == value; + } + + @override + int get hashCode => value.hashCode; +} diff --git a/packages/scheduler_core/lib/src/scheduling_engine.dart b/packages/scheduler_core/lib/src/scheduling_engine.dart index 287239d..193af5d 100644 --- a/packages/scheduler_core/lib/src/scheduling_engine.dart +++ b/packages/scheduler_core/lib/src/scheduling_engine.dart @@ -19,1745 +19,23 @@ import 'models.dart'; import 'occupancy_policy.dart'; import 'task_lifecycle.dart'; import 'time_contracts.dart'; +part 'scheduling_engine/scheduling_notice_type.dart'; +part 'scheduling_engine/scheduling_operation_code.dart'; +part 'scheduling_engine/scheduling_outcome_code.dart'; +part 'scheduling_engine/scheduling_issue_code.dart'; +part 'scheduling_engine/scheduling_movement_code.dart'; +part 'scheduling_engine/scheduling_conflict_code.dart'; +part 'scheduling_engine/scheduling_window.dart'; +part 'scheduling_engine/scheduling_input.dart'; +part 'scheduling_engine/scheduling_change.dart'; +part 'scheduling_engine/scheduling_overlap.dart'; +part 'scheduling_engine/scheduling_notice.dart'; +part 'scheduling_engine/scheduling_result.dart'; +part 'scheduling_engine/scheduling_engine.dart'; +part 'scheduling_engine/placement_item.dart'; +part 'scheduling_engine/backlog_insertion_plan.dart'; const OccupancyPolicy _occupancyPolicy = OccupancyPolicy(); const TaskTransitionService _transitionService = TaskTransitionService(); const TaskActivityAccountingService _activityAccountingService = TaskActivityAccountingService(); - -/// Category for scheduler notices. -/// -/// Notices are human-readable summaries attached to a [SchedulingResult]. They -/// are not exceptions. The scheduler returns them alongside the task list so UI -/// can explain what happened without losing the successfully computed output. -enum SchedulingNoticeType { - /// General informational notice. - info, - - /// A task was moved by a scheduling operation. - moved, - - /// A scheduled task overlaps blocked time. - overlap, - - /// A task could not fit in the requested window. - noFit, - - /// A task would need to move outside the requested window. - overflow, -} - -/// Stable scheduler operation identifiers. -enum SchedulingOperationCode { - /// Operation was built outside the core scheduler or is not specified. - unspecified, - - /// Insert a backlog task into the next available slot. - insertBacklogTaskIntoNextAvailableSlot, - - /// Push a flexible task later in the current window. - pushFlexibleTaskToNextAvailableSlot, - - /// Move a flexible task to the top of a future/tomorrow queue. - pushFlexibleTaskToTomorrowTopOfQueue, - - /// Roll unfinished flexible tasks into a target window. - rollOverUnfinishedFlexibleTasks, - - /// Analyze overlaps without moving tasks. - analyzeSchedule, - - /// Move a flexible task to backlog through the action service. - moveFlexibleTaskToBacklog, - - /// Log completed surprise work. - logSurpriseTask, - - /// Explicitly schedule a critical or inflexible commitment. - scheduleRequiredCommitment, -} - -/// Stable high-level outcome categories for scheduling operations. -enum SchedulingOutcomeCode { - /// Operation completed without conflicts. - success, - - /// Operation intentionally made no changes. - noOp, - - /// Requested record was not found. - notFound, - - /// Requested operation does not apply to the current state. - invalidState, - - /// No placement slot exists for the requested item. - noSlot, - - /// The requested queue would extend beyond the planning window. - overflow, - - /// Operation completed or analyzed with a conflict. - conflict, -} - -/// Stable issue identifiers for validation, no-slot, and no-op notices. -enum SchedulingIssueCode { - taskNotFound, - invalidTaskState, - missingDuration, - missingScheduledSlot, - nonPositiveDuration, - noAvailableSlot, - unfinishedTasksCouldNotFit, - noUnfinishedFlexibleTasks, - duplicateSurpriseLog, -} - -/// Stable movement identifiers for task placement changes. -enum SchedulingMovementCode { - backlogTaskInserted, - flexibleTaskMovedToMakeRoom, - flexibleTaskPushedToNextAvailableSlot, - flexibleTaskMovedToTomorrow, - unfinishedFlexibleTasksRolledOver, - flexibleTaskMovedToBacklog, - requiredCommitmentScheduled, -} - -/// Stable conflict identifiers for overlap/interrupt notices. -enum SchedulingConflictCode { - flexibleTaskOverlapsBlockedTime, - surpriseTaskOverlapsRequiredVisibleTime, - requiredCommitmentOverlapsProtectedFreeSlot, -} - -/// Window of time available to a scheduling operation. -/// -/// Most engine methods operate on one planning window: "today", "tomorrow", -/// or any other bounded range supplied by the caller. The window constrains where -/// flexible tasks can be placed. Anything outside this range is treated as out of -/// scope for the operation. -class SchedulingWindow { - SchedulingWindow({ - required this.start, - required this.end, - }) { - if (!start.isBefore(end)) { - throw DomainValidationException( - code: DomainValidationCode.invalidSchedulingWindow, - invalidValue: {'start': start, 'end': end}, - name: 'SchedulingWindow', - message: 'Scheduling window end must be after start.', - ); - } - } - - /// Inclusive beginning of the scheduling range. - final DateTime start; - - /// Exclusive ending of the scheduling range. - final DateTime end; - - /// The window as a [TimeInterval], useful for overlap checks. - TimeInterval get interval => TimeInterval(start: start, end: end); - - /// Whether [interval] is completely inside this window. - /// - /// A task that starts before the window or ends after the window is considered - /// outside the operation. The engine may treat such tasks as fixed blocks - /// instead of moving them. - bool contains(TimeInterval interval) { - final startsInWindow = - interval.start.isAfter(start) || interval.start.isAtSameMomentAs(start); - final endsInWindow = - interval.end.isBefore(end) || interval.end.isAtSameMomentAs(end); - - return startsInWindow && endsInWindow; - } -} - -/// In-memory input for scheduling operations. -/// -/// This is the complete snapshot the pure scheduling engine needs. It contains -/// tasks plus the fixed intervals the scheduler must avoid. It deliberately does -/// not know where the data came from: UI state, a database, tests, or generated -/// examples can all build this same object. -class SchedulingInput { - SchedulingInput({ - required List tasks, - required this.window, - List lockedIntervals = const [], - List requiredVisibleIntervals = const [], - }) : tasks = List.unmodifiable(tasks), - lockedIntervals = List.unmodifiable(lockedIntervals), - requiredVisibleIntervals = - List.unmodifiable(requiredVisibleIntervals); - - /// All tasks available to this operation. The scheduler returns a replacement - /// list rather than mutating this one. - final List tasks; - - /// Date/time range that the operation is allowed to plan inside. - final SchedulingWindow window; - - /// External locked time intervals, usually produced by `locked_time.dart`. - final List lockedIntervals; - - /// Extra fixed visible intervals supplied by the caller. This lets UI/backend - /// code reserve required time even when that time is not represented as a - /// [Task] in the current list. - final List requiredVisibleIntervals; - - /// Tasks that the flexible movement algorithms are allowed to consider. - List get flexibleTasks { - return tasks.where((task) => task.isFlexible).toList(growable: false); - } - - /// Central occupancy classification for this scheduling snapshot. - List get occupancyEntries { - return _occupancyPolicy.classify( - tasks: tasks, - lockedIntervals: lockedIntervals, - requiredVisibleIntervals: requiredVisibleIntervals, - ); - } - - /// Planned flexible task records that automatic scheduling may move. - List get movablePlannedFlexibleTasks { - return occupancyEntries - .where((entry) => entry.isMovablePlannedFlexible) - .map((entry) => entry.task) - .whereType() - .toList(growable: false); - } - - /// Locked task records in [tasks], if the caller represents locked time as - /// tasks instead of only passing [lockedIntervals]. - List get lockedTasks { - return tasks.where((task) => task.isLocked).toList(growable: false); - } - - /// Critical and inflexible task records that should block flexible placement. - List get requiredVisibleTasks { - return tasks - .where((task) => task.isRequiredVisible) - .toList(growable: false); - } - - /// Scheduled intervals for flexible tasks only. Useful for analysis/debugging. - List get flexibleIntervals { - return _scheduledIntervalsFor(flexibleTasks); - } - - /// All intervals that flexible scheduling must avoid. - /// - /// This is derived from the central occupancy policy rather than from UI card - /// categories. The result is sorted to make interval scanning deterministic. - List get blockedIntervals { - final intervals = occupancyEntries - .where((entry) => entry.blocksAutomaticFlexiblePlacement) - .map((entry) => entry.interval) - .whereType() - .toList(growable: false) - ..sort((a, b) => a.start.compareTo(b.start)); - - return List.unmodifiable(intervals); - } - - /// Occupancy entries that should be surfaced as conflicts when overlapped. - List get conflictReportingOccupancies { - return occupancyEntries - .where((entry) => entry.reportsConflict) - .toList(growable: false); - } -} - -/// Exact placement change made by a scheduling operation. -/// -/// Changes are machine-readable before/after records. UI can use notices for -/// display text, but persistence, undo, analytics, or tests should inspect these -/// fields to know exactly which task moved and where. -class SchedulingChange { - const SchedulingChange({ - required this.taskId, - required this.previousStart, - required this.previousEnd, - required this.nextStart, - required this.nextEnd, - }); - - /// Task that moved or had its schedule cleared. - final String taskId; - - /// Previous scheduled start, or null if the task was previously unplaced. - final DateTime? previousStart; - - /// Previous scheduled end, or null if the task was previously unplaced. - final DateTime? previousEnd; - - /// New scheduled start, or null if the task was moved out of the timeline. - final DateTime? nextStart; - - /// New scheduled end, or null if the task was moved out of the timeline. - final DateTime? nextEnd; -} - -/// Overlap between a scheduled task and blocked time. -/// -/// Analysis uses this to report problems without moving anything. This is useful -/// when loading persisted data, debugging imports, or validating a day before the -/// UI presents it as clean. -class SchedulingOverlap { - const SchedulingOverlap({ - required this.taskId, - required this.taskInterval, - required this.blockedInterval, - }); - - /// Flexible task that overlaps blocked time. - final String taskId; - - /// The task's scheduled interval. - final TimeInterval taskInterval; - - /// The blocked interval it overlaps. - final TimeInterval blockedInterval; -} - -/// Starter notice type returned by scheduling operations. -/// -/// A notice is presentation-friendly context about an operation. It intentionally -/// carries both text and a structured [type] so the UI can decide whether to show -/// it as neutral info, movement, overlap, or failure. -class SchedulingNotice { - SchedulingNotice( - this.message, { - this.type = SchedulingNoticeType.info, - this.taskId, - this.issueCode, - this.movementCode, - this.conflictCode, - Map parameters = const {}, - }) : parameters = Map.unmodifiable(parameters); - - /// Human-readable message safe to surface in UI or logs. - final String message; - - /// Structured category for UI styling and tests. - final SchedulingNoticeType type; - - /// Optional task related to this notice. Null means the notice applies to the - /// whole operation. - final String? taskId; - - /// Stable issue code for validation/no-slot/no-op outcomes. - final SchedulingIssueCode? issueCode; - - /// Stable movement code for placement changes. - final SchedulingMovementCode? movementCode; - - /// Stable conflict code for overlap outcomes. - final SchedulingConflictCode? conflictCode; - - /// Structured notice parameters for callers that need more context. - final Map parameters; -} - -/// Starter result wrapper for scheduling operations. -/// -/// Every engine operation returns a [SchedulingResult], even when nothing moved. -/// This keeps the call pattern predictable: always inspect `tasks`, then surface -/// any `notices`, `changes`, or `overlaps` relevant to the UI. -class SchedulingResult { - SchedulingResult({ - required List tasks, - this.operationCode = SchedulingOperationCode.unspecified, - this.outcomeCode = SchedulingOutcomeCode.success, - List notices = const [], - List changes = const [], - List overlaps = const [], - }) : tasks = List.unmodifiable(tasks), - assert( - _noticeCodesAreSinglePurpose(notices), - 'Each scheduling notice may have only one issue/movement/conflict code.', - ), - notices = List.unmodifiable(notices), - changes = List.unmodifiable(changes), - overlaps = List.unmodifiable(overlaps); - - /// Replacement task list after the operation. - final List tasks; - - /// Stable operation identifier. - final SchedulingOperationCode operationCode; - - /// Stable high-level outcome category. - final SchedulingOutcomeCode outcomeCode; - - /// Human-readable operation messages. - final List notices; - - /// Machine-readable movements or schedule clears. - final List changes; - - /// Analysis-only overlap findings. - final List overlaps; -} - -bool _noticeCodesAreSinglePurpose(Iterable notices) { - for (final notice in notices) { - final codeCount = [ - notice.issueCode, - notice.movementCode, - notice.conflictCode, - ].where((code) => code != null).length; - if (codeCount > 1) { - return false; - } - } - - return true; -} - -/// Starter scheduling engine. -/// -/// The engine is a pure domain service: it receives immutable-ish input values -/// and returns new values. It does not persist data, render UI, send reminders, -/// or read the clock except where an optional [updatedAt] timestamp is omitted. -/// -/// Current V1 responsibilities: -/// - insert backlog tasks into the earliest available flexible slot; -/// - push flexible tasks later today; -/// - move flexible tasks to tomorrow's queue; -/// - roll unfinished flexible tasks into a new planning window; -/// - analyze overlaps against locked/required time; -/// - perform small state transitions such as missed/backlog handling. -/// -/// Important rule vocabulary: -/// - `fixedBlocks` are intervals the engine will not move. -/// - `queue` is the ordered set of flexible tasks that may be placed or shifted. -/// - `placement` is a map from task id to the interval chosen by the planner. -class SchedulingEngine { - const SchedulingEngine({ - this.clock = const SystemClock(), - }); - - /// Clock boundary used when an operation timestamp is not supplied. - final Clock clock; - - /// Insert a backlog task into the earliest available slot today. - /// - /// Locked, inflexible, and critical time is treated as fixed. Planned - /// flexible tasks at or after the insertion point may shift later, preserving - /// their relative order. - /// - /// The selected task must already exist in `input.tasks`, be flexible, be in - /// backlog status, and have a positive duration. If any precondition fails, the - /// original task list is returned with a no-fit/overflow notice. - SchedulingResult insertBacklogTaskIntoNextAvailableSlot({ - required SchedulingInput input, - required String taskId, - DateTime? updatedAt, - }) { - // Step 1: resolve and validate the task. The engine does not create tasks; - // quick capture or persistence code is responsible for adding it to the list. - final task = _taskById(input.tasks, taskId); - if (task == null) { - return _unchangedResult( - input, - SchedulingNotice( - 'Task was not found.', - type: SchedulingNoticeType.noFit, - taskId: taskId, - issueCode: SchedulingIssueCode.taskNotFound, - ), - operationCode: - SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot, - outcomeCode: SchedulingOutcomeCode.notFound, - ); - } - - if (!task.isFlexible || !task.isBacklog) { - return _unchangedResult( - input, - SchedulingNotice( - 'Only backlog flexible tasks can be inserted.', - type: SchedulingNoticeType.noFit, - taskId: task.id, - issueCode: SchedulingIssueCode.invalidTaskState, - ), - operationCode: - SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot, - outcomeCode: SchedulingOutcomeCode.invalidState, - ); - } - - final taskDuration = _durationFromMinutes(task.durationMinutes); - if (taskDuration == null) { - return _unchangedResult( - input, - SchedulingNotice( - 'Task needs a positive duration before scheduling.', - type: SchedulingNoticeType.noFit, - taskId: task.id, - issueCode: SchedulingIssueCode.missingDuration, - ), - operationCode: - SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot, - outcomeCode: SchedulingOutcomeCode.invalidState, - ); - } - - // Step 2: compute placements without mutating any task. Planning returns null - // if the inserted task and shifted queue cannot fit inside the window. - final placement = _planBacklogInsertion( - input: input, - task: task, - taskDuration: taskDuration, - ); - - if (placement == null) { - return _unchangedResult( - input, - SchedulingNotice( - 'No available flexible slot today.', - type: SchedulingNoticeType.overflow, - taskId: task.id, - issueCode: SchedulingIssueCode.noAvailableSlot, - ), - operationCode: - SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot, - outcomeCode: SchedulingOutcomeCode.noSlot, - ); - } - - return _applyPlacement( - input: input, - insertedTask: task, - placement: placement, - updatedAt: updatedAt ?? clock.now(), - ); - } - - /// Push a planned flexible task to the next available slot today. - /// - /// The selected task moves after its current slot. Planned flexible tasks - /// after it may shift later, preserving their relative order. - /// - /// This is the "not now, later today" action. Anything before the pushed - /// task's current end time becomes fixed for this operation, while later - /// planned flexible tasks may be shifted if necessary. - SchedulingResult pushFlexibleTaskToNextAvailableSlot({ - required SchedulingInput input, - required String taskId, - DateTime? updatedAt, - bool countAsManualPush = true, - }) { - // Resolve the selected task by id so UI code only needs to pass a stable - // identifier, not object references. - final task = _taskById(input.tasks, taskId); - if (task == null) { - return _unchangedResult( - input, - SchedulingNotice( - 'Task was not found.', - type: SchedulingNoticeType.noFit, - taskId: taskId, - issueCode: SchedulingIssueCode.taskNotFound, - ), - operationCode: - SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot, - outcomeCode: SchedulingOutcomeCode.notFound, - ); - } - - if (!task.isFlexible || task.status != TaskStatus.planned) { - return _unchangedResult( - input, - SchedulingNotice( - 'Only planned flexible tasks can be pushed.', - type: SchedulingNoticeType.noFit, - taskId: task.id, - issueCode: SchedulingIssueCode.invalidTaskState, - ), - operationCode: - SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot, - outcomeCode: SchedulingOutcomeCode.invalidState, - ); - } - - final currentInterval = _scheduledIntervalFor(task); - if (currentInterval == null || !input.window.contains(currentInterval)) { - return _unchangedResult( - input, - SchedulingNotice( - 'Task needs a current scheduled slot before pushing.', - type: SchedulingNoticeType.noFit, - taskId: task.id, - issueCode: SchedulingIssueCode.missingScheduledSlot, - ), - operationCode: - SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot, - outcomeCode: SchedulingOutcomeCode.invalidState, - ); - } - - if (currentInterval.duration.inMicroseconds <= 0) { - return _unchangedResult( - input, - SchedulingNotice( - 'Task needs a positive scheduled duration before pushing.', - type: SchedulingNoticeType.noFit, - taskId: task.id, - issueCode: SchedulingIssueCode.nonPositiveDuration, - ), - operationCode: - SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot, - outcomeCode: SchedulingOutcomeCode.invalidState, - ); - } - - final placement = _planFlexiblePush( - input: input, - task: task, - currentInterval: currentInterval, - ); - - if (placement == null) { - return _unchangedResult( - input, - SchedulingNotice( - 'No available flexible slot today.', - type: SchedulingNoticeType.overflow, - taskId: task.id, - issueCode: SchedulingIssueCode.noAvailableSlot, - ), - operationCode: - SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot, - outcomeCode: SchedulingOutcomeCode.noSlot, - ); - } - - return _applyPushPlacement( - input: input, - pushedTask: task, - placement: placement, - pushedTaskMessage: 'Flexible task pushed to next available slot.', - pushedTaskMovementCode: - SchedulingMovementCode.flexibleTaskPushedToNextAvailableSlot, - operationCode: - SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot, - updatedAt: updatedAt ?? clock.now(), - countPushedTaskAsManual: countAsManualPush, - ); - } - - /// Move a planned flexible task to the top of tomorrow's flexible queue. - /// - /// The input window represents tomorrow's scheduling window. Existing planned - /// flexible tasks in that window may shift later, preserving their order. - /// - /// The method name says "tomorrow" because that is the product action, but the - /// engine only trusts `input.window`. Tests can pass any future window. - SchedulingResult pushFlexibleTaskToTomorrowTopOfQueue({ - required SchedulingInput input, - required String taskId, - DateTime? updatedAt, - }) { - final task = _taskById(input.tasks, taskId); - if (task == null) { - return _unchangedResult( - input, - SchedulingNotice( - 'Task was not found.', - type: SchedulingNoticeType.noFit, - taskId: taskId, - issueCode: SchedulingIssueCode.taskNotFound, - ), - operationCode: - SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue, - outcomeCode: SchedulingOutcomeCode.notFound, - ); - } - - if (!task.isFlexible || task.status != TaskStatus.planned) { - return _unchangedResult( - input, - SchedulingNotice( - 'Only planned flexible tasks can be moved to tomorrow.', - type: SchedulingNoticeType.noFit, - taskId: task.id, - issueCode: SchedulingIssueCode.invalidTaskState, - ), - operationCode: - SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue, - outcomeCode: SchedulingOutcomeCode.invalidState, - ); - } - - final taskDuration = _scheduledIntervalFor(task)?.duration ?? - _durationFromMinutes(task.durationMinutes); - if (taskDuration == null || taskDuration.inMicroseconds <= 0) { - return _unchangedResult( - input, - SchedulingNotice( - 'Task needs a positive duration before moving to tomorrow.', - type: SchedulingNoticeType.noFit, - taskId: task.id, - issueCode: SchedulingIssueCode.missingDuration, - ), - operationCode: - SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue, - outcomeCode: SchedulingOutcomeCode.invalidState, - ); - } - - final placement = _planTomorrowQueueInsertion( - input: input, - task: task, - taskDuration: taskDuration, - ); - - if (placement == null) { - return _unchangedResult( - input, - SchedulingNotice( - 'No available flexible slot tomorrow.', - type: SchedulingNoticeType.overflow, - taskId: task.id, - issueCode: SchedulingIssueCode.noAvailableSlot, - ), - operationCode: - SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue, - outcomeCode: SchedulingOutcomeCode.noSlot, - ); - } - - return _applyPushPlacement( - input: input, - pushedTask: task, - placement: placement, - pushedTaskMessage: 'Flexible task moved to tomorrow.', - pushedTaskMovementCode: - SchedulingMovementCode.flexibleTaskMovedToTomorrow, - operationCode: - SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue, - updatedAt: updatedAt ?? clock.now(), - ); - } - - /// Move unfinished flexible tasks to the top of tomorrow's flexible queue. - /// - /// The input window represents tomorrow's scheduling window. Only planned and - /// active flexible tasks are rolled; required, locked, completed, and - /// cancelled tasks remain unchanged. - /// - /// Rollover is bulk push behavior for day-end recovery. It collects unfinished - /// flexible tasks outside the target window, preserves their relative order, - /// then places them at the start of the new window while shifting already - /// planned flexible tasks as needed. When [sourceWindow] is supplied, only - /// unfinished flexible tasks from that source window are rolled over. This - /// prevents future planned tasks from being pulled into tomorrow accidentally. - SchedulingResult rollOverUnfinishedFlexibleTasks({ - required SchedulingInput input, - SchedulingWindow? sourceWindow, - DateTime? updatedAt, - }) { - // Build the explicit queue of tasks to roll before asking the planner to - // place anything. This keeps selection separate from placement. - final rolledItems = <_PlacementItem>[]; - final rolloverTasks = input.flexibleTasks - .where((task) => _shouldRollOver( - task: task, - targetWindow: input.window, - sourceWindow: sourceWindow, - )) - .toList() - ..sort((a, b) { - final aStart = - a.scheduledStart ?? sourceWindow?.start ?? input.window.start; - final bStart = - b.scheduledStart ?? sourceWindow?.start ?? input.window.start; - - return aStart.compareTo(bStart); - }); - - for (final task in rolloverTasks) { - final duration = _scheduledIntervalFor(task)?.duration ?? - _durationFromMinutes(task.durationMinutes); - if (duration == null || duration.inMicroseconds <= 0) { - continue; - } - - rolledItems.add( - _PlacementItem( - task: task, - duration: duration, - earliestStart: input.window.start, - ), - ); - } - - if (rolledItems.isEmpty) { - return SchedulingResult( - tasks: input.tasks, - operationCode: SchedulingOperationCode.rollOverUnfinishedFlexibleTasks, - outcomeCode: SchedulingOutcomeCode.noOp, - notices: [ - SchedulingNotice( - 'No unfinished flexible tasks to roll over.', - issueCode: SchedulingIssueCode.noUnfinishedFlexibleTasks, - ), - ], - ); - } - - final placement = _planQueueAtWindowStart( - input: input, - queue: rolledItems, - excludeTaskIds: rolledItems.map((item) => item.task.id).toSet(), - ); - - if (placement == null) { - return _unchangedResult( - input, - SchedulingNotice( - 'Unfinished flexible tasks could not fit tomorrow.', - type: SchedulingNoticeType.overflow, - issueCode: SchedulingIssueCode.unfinishedTasksCouldNotFit, - ), - operationCode: SchedulingOperationCode.rollOverUnfinishedFlexibleTasks, - outcomeCode: SchedulingOutcomeCode.overflow, - ); - } - - return _applyRolloverPlacement( - input: input, - rolledTaskIds: rolledItems.map((item) => item.task.id).toSet(), - placement: placement, - updatedAt: updatedAt ?? clock.now(), - ); - } - - /// Analyze the current in-memory schedule without moving tasks. - /// - /// This is a validation/debugging helper. It scans scheduled flexible tasks and - /// reports any overlap with blocked intervals. It deliberately returns the - /// original task list unchanged. - SchedulingResult analyzeSchedule(SchedulingInput input) { - final overlaps = []; - final notices = []; - final conflictOccupancies = input.conflictReportingOccupancies; - - for (final task in input.movablePlannedFlexibleTasks) { - final taskInterval = _scheduledIntervalFor(task); - if (taskInterval == null || !input.window.contains(taskInterval)) { - continue; - } - - for (final occupancy in conflictOccupancies) { - final blockedInterval = occupancy.interval; - if (blockedInterval == null) { - continue; - } - - if (!taskInterval.overlaps(blockedInterval)) { - continue; - } - - overlaps.add( - SchedulingOverlap( - taskId: task.id, - taskInterval: taskInterval, - blockedInterval: blockedInterval, - ), - ); - notices.add( - SchedulingNotice( - 'Flexible task overlaps blocked time.', - type: SchedulingNoticeType.overlap, - taskId: task.id, - conflictCode: - SchedulingConflictCode.flexibleTaskOverlapsBlockedTime, - parameters: { - 'blockedStart': blockedInterval.start, - 'blockedEnd': blockedInterval.end, - }, - ), - ); - } - } - - return SchedulingResult( - tasks: input.tasks, - operationCode: SchedulingOperationCode.analyzeSchedule, - outcomeCode: overlaps.isEmpty - ? SchedulingOutcomeCode.success - : SchedulingOutcomeCode.conflict, - notices: List.unmodifiable(notices), - overlaps: List.unmodifiable(overlaps), - ); - } - - /// Move a task to backlog. - /// - /// Backlog does not preserve original schedule/order placement. The task's - /// schedule is cleared and its moved-to-backlog counter is incremented so - /// reports can distinguish this from a task that was never scheduled. - Task moveToBacklog(Task task, {DateTime? updatedAt}) { - final now = updatedAt ?? clock.now(); - final result = _transitionService.apply( - task: task, - transitionCode: TaskTransitionCode.moveToBacklog, - operationId: 'legacy-move-to-backlog:${task.id}:${now.toIso8601String()}', - activityId: - 'legacy-move-to-backlog:${task.id}:${now.toIso8601String()}:activity', - occurredAt: now, - ); - return result.applied ? result.task : task; - } - - /// Mark a flexible task pushed manually. - /// - /// This updates statistics only. Use the push methods above when the task's - /// actual scheduled slot should change. - Task markManuallyPushed(Task task, {DateTime? updatedAt}) { - final now = updatedAt ?? clock.now(); - final result = _transitionService.apply( - task: task, - transitionCode: TaskTransitionCode.manualPush, - operationId: 'legacy-manual-push:${task.id}:${now.toIso8601String()}', - activityId: - 'legacy-manual-push:${task.id}:${now.toIso8601String()}:activity', - occurredAt: now, - ); - return result.applied ? result.task : task; - } - - /// Mark missed according to the current MVP rules. - /// - /// Critical missed tasks go to backlog so they remain actionable. Inflexible - /// missed tasks stay in place as missed because they represented a fixed event - /// or time block that cannot simply be rescheduled automatically. - Task markMissed(Task task, {DateTime? updatedAt}) { - final now = updatedAt ?? clock.now(); - final result = _transitionService.apply( - task: task, - transitionCode: TaskTransitionCode.miss, - operationId: 'legacy-miss:${task.id}:${now.toIso8601String()}', - activityId: 'legacy-miss:${task.id}:${now.toIso8601String()}:activity', - occurredAt: now, - ); - return result.applied ? result.task : task; - } - - /// Finds the first interval that can fit the requested duration while avoiding - /// blocked intervals. - /// - /// This public helper is deliberately simple and does not shift existing - /// flexible tasks. It is useful for UI previews or tests that only need to know - /// the first open gap. Full bump/queue behavior lives in the private planning - /// helpers below. - TimeInterval? findFirstOpenInterval({ - required DateTime windowStart, - required DateTime windowEnd, - required Duration duration, - required List blocked, - }) { - final sortedBlocked = [...blocked] - ..sort((a, b) => a.start.compareTo(b.start)); - var cursor = windowStart; - - for (final interval in sortedBlocked) { - if (cursor.add(duration).isBefore(interval.start) || - cursor.add(duration).isAtSameMomentAs(interval.start)) { - return TimeInterval(start: cursor, end: cursor.add(duration)); - } - - if (interval.end.isAfter(cursor)) { - cursor = interval.end; - } - } - - final candidateEnd = cursor.add(duration); - if (candidateEnd.isBefore(windowEnd) || - candidateEnd.isAtSameMomentAs(windowEnd)) { - return TimeInterval(start: cursor, end: candidateEnd); - } - - return null; - } -} - -/// Convert a scheduled task into an interval, or null if it is unplaced. -/// -/// This helper does not validate positive duration; callers that require a valid -/// duration check that separately. -TimeInterval? _scheduledIntervalFor(Task task) { - final start = task.scheduledStart; - final end = task.scheduledEnd; - - if (start == null || end == null) { - return null; - } - - return TimeInterval(start: start, end: end, label: task.id); -} - -/// Convert all placed tasks in [tasks] into intervals. -List _scheduledIntervalsFor(Iterable tasks) { - final intervals = []; - - for (final task in tasks) { - final interval = _scheduledIntervalFor(task); - if (interval != null) { - intervals.add(interval); - } - } - - return List.unmodifiable(intervals); -} - -/// Return the original task list with one explanatory notice. -/// -/// Most validation failures use this so callers can keep rendering the existing -/// schedule while showing why the requested action did not apply. -SchedulingResult _unchangedResult( - SchedulingInput input, - SchedulingNotice notice, { - SchedulingOperationCode operationCode = SchedulingOperationCode.unspecified, - SchedulingOutcomeCode outcomeCode = SchedulingOutcomeCode.invalidState, -}) { - return SchedulingResult( - tasks: input.tasks, - operationCode: operationCode, - outcomeCode: outcomeCode, - notices: [notice], - ); -} - -/// Find a task by stable id. -Task? _taskById(List tasks, String taskId) { - for (final task in tasks) { - if (task.id == taskId) { - return task; - } - } - - return null; -} - -/// Convert a nullable minute estimate into a positive [Duration]. -Duration? _durationFromMinutes(int? minutes) { - if (minutes == null || minutes <= 0) { - return null; - } - - return Duration(minutes: minutes); -} - -/// Plan insertion of a backlog task plus any flexible tasks that must shift. -/// -/// This function only calculates intervals. It does not update task objects. The -/// returned plan is later applied by [_applyPlacement], which creates notices, -/// changes, and updated task copies. -_BacklogInsertionPlan? _planBacklogInsertion({ - required SchedulingInput input, - required Task task, - required Duration taskDuration, -}) { - // Start with intervals that the algorithm is not allowed to move. - final fixedBlocks = [ - ...input.blockedIntervals, - ]; - final queue = <_PlacementItem>[ - _PlacementItem( - task: task, - duration: taskDuration, - earliestStart: input.window.start, - ), - ]; - - // Existing flexible tasks are inspected in timeline order. Planned tasks inside - // the movable portion become part of the placement queue; everything else is - // treated as fixed. - final scheduledFlexibleTasks = input.movablePlannedFlexibleTasks - .where((flexibleTask) => flexibleTask.id != task.id) - .toList(growable: false) - ..sort((a, b) { - final aStart = a.scheduledStart ?? input.window.end; - final bStart = b.scheduledStart ?? input.window.end; - - return aStart.compareTo(bStart); - }); - - for (final flexibleTask in scheduledFlexibleTasks) { - final interval = _scheduledIntervalFor(flexibleTask); - if (interval == null) { - continue; - } - - final startsBeforeWindow = interval.start.isBefore(input.window.start); - final startsAfterWindow = interval.start.isAfter(input.window.end) || - interval.start.isAtSameMomentAs(input.window.end); - - if (startsBeforeWindow || startsAfterWindow) { - fixedBlocks.add(interval); - continue; - } - - queue.add( - _PlacementItem( - task: flexibleTask, - duration: interval.duration, - earliestStart: interval.start, - ), - ); - } - - fixedBlocks.sort((a, b) => a.start.compareTo(b.start)); - - // The cursor tracks the earliest point after the previously placed queue item. - // Each queued task is placed no earlier than both the cursor and its own - // original earliest start. - var cursor = input.window.start; - final placements = {}; - - for (final item in queue) { - final earliestStart = _laterOf(cursor, item.earliestStart); - final interval = _firstOpenIntervalFrom( - earliestStart: earliestStart, - windowEnd: input.window.end, - duration: item.duration, - blocked: fixedBlocks, - ); - - if (interval == null) { - return null; - } - - placements[item.task.id] = interval; - cursor = interval.end; - } - - return _BacklogInsertionPlan(placements: placements); -} - -/// Plan the "push later today" behavior for one flexible task. -/// -/// Items before the pushed task's current end are fixed. The pushed task starts -/// the queue at its current end, followed by later planned flexible tasks that -/// may need to move to preserve order. -_BacklogInsertionPlan? _planFlexiblePush({ - required SchedulingInput input, - required Task task, - required TimeInterval currentInterval, -}) { - final fixedBlocks = [ - ...input.blockedIntervals, - ]; - final queue = <_PlacementItem>[ - _PlacementItem( - task: task, - duration: currentInterval.duration, - earliestStart: currentInterval.end, - ), - ]; - - final scheduledFlexibleTasks = input.movablePlannedFlexibleTasks - .where((flexibleTask) => flexibleTask.id != task.id) - .toList(growable: false) - ..sort((a, b) { - final aStart = a.scheduledStart ?? input.window.end; - final bStart = b.scheduledStart ?? input.window.end; - - return aStart.compareTo(bStart); - }); - - for (final flexibleTask in scheduledFlexibleTasks) { - final interval = _scheduledIntervalFor(flexibleTask); - if (interval == null) { - continue; - } - - final startsBeforePushPoint = interval.start.isBefore(currentInterval.end); - final startsAfterWindow = interval.start.isAfter(input.window.end) || - interval.start.isAtSameMomentAs(input.window.end); - - if (startsBeforePushPoint || startsAfterWindow) { - fixedBlocks.add(interval); - continue; - } - - queue.add( - _PlacementItem( - task: flexibleTask, - duration: interval.duration, - earliestStart: interval.start, - ), - ); - } - - fixedBlocks.sort((a, b) => a.start.compareTo(b.start)); - - var cursor = currentInterval.end; - final placements = {}; - - for (final item in queue) { - final earliestStart = _laterOf(cursor, item.earliestStart); - final interval = _firstOpenIntervalFrom( - earliestStart: earliestStart, - windowEnd: input.window.end, - duration: item.duration, - blocked: fixedBlocks, - ); - - if (interval == null) { - return null; - } - - placements[item.task.id] = interval; - cursor = interval.end; - } - - return _BacklogInsertionPlan(placements: placements); -} - -/// Plan putting a single task at the start of the supplied future window. -_BacklogInsertionPlan? _planTomorrowQueueInsertion({ - required SchedulingInput input, - required Task task, - required Duration taskDuration, -}) { - final queue = <_PlacementItem>[ - _PlacementItem( - task: task, - duration: taskDuration, - earliestStart: input.window.start, - ), - ]; - - return _planQueueAtWindowStart( - input: input, - queue: queue, - excludeTaskIds: {task.id}, - ); -} - -/// Plan a queue of flexible tasks at the beginning of [input.window]. -/// -/// This is shared by tomorrow push and bulk rollover. [excludeTaskIds] identifies -/// tasks already represented in the incoming [queue] so they are not also pulled -/// from existing scheduled flexible tasks. -_BacklogInsertionPlan? _planQueueAtWindowStart({ - required SchedulingInput input, - required List<_PlacementItem> queue, - required Set excludeTaskIds, -}) { - final fixedBlocks = [ - ...input.blockedIntervals, - ]; - final placementQueue = <_PlacementItem>[ - ...queue, - ]; - - final scheduledFlexibleTasks = input.movablePlannedFlexibleTasks - .where((flexibleTask) => !excludeTaskIds.contains(flexibleTask.id)) - .toList(growable: false) - ..sort((a, b) { - final aStart = a.scheduledStart ?? input.window.end; - final bStart = b.scheduledStart ?? input.window.end; - - return aStart.compareTo(bStart); - }); - - for (final flexibleTask in scheduledFlexibleTasks) { - final interval = _scheduledIntervalFor(flexibleTask); - if (interval == null) { - continue; - } - - final overlapsWindow = interval.overlaps(input.window.interval); - if (!overlapsWindow) { - continue; - } - - if (!input.window.contains(interval)) { - fixedBlocks.add(interval); - continue; - } - - placementQueue.add( - _PlacementItem( - task: flexibleTask, - duration: interval.duration, - earliestStart: interval.start, - ), - ); - } - - fixedBlocks.sort((a, b) => a.start.compareTo(b.start)); - - var cursor = input.window.start; - final placements = {}; - - for (final item in placementQueue) { - final earliestStart = _laterOf(cursor, item.earliestStart); - final interval = _firstOpenIntervalFrom( - earliestStart: earliestStart, - windowEnd: input.window.end, - duration: item.duration, - blocked: fixedBlocks, - ); - - if (interval == null) { - return null; - } - - placements[item.task.id] = interval; - cursor = interval.end; - } - - return _BacklogInsertionPlan(placements: placements); -} - -/// Apply a backlog insertion plan to the task list. -/// -/// The inserted backlog task becomes planned and increments -/// `restoredFromBacklogCount`; any existing flexible tasks moved to make room -/// increment `autoPushedCount`. -SchedulingResult _applyPlacement({ - required SchedulingInput input, - required Task insertedTask, - required _BacklogInsertionPlan placement, - required DateTime updatedAt, -}) { - final changes = []; - final notices = []; - final updatedTasks = []; - - for (final task in input.tasks) { - final interval = placement.placements[task.id]; - if (interval == null) { - updatedTasks.add(task); - continue; - } - - final isInsertedTask = task.id == insertedTask.id; - final moved = isInsertedTask || - !_sameDateTime(task.scheduledStart, interval.start) || - !_sameDateTime(task.scheduledEnd, interval.end); - - if (!moved) { - updatedTasks.add(task); - continue; - } - - final placedTask = task.copyWith( - status: isInsertedTask ? TaskStatus.planned : task.status, - scheduledStart: interval.start, - scheduledEnd: interval.end, - updatedAt: updatedAt, - ); - final updatedTask = _applySchedulingActivity( - originalTask: task, - updatedTask: placedTask, - operationCode: - SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot, - activityCode: isInsertedTask - ? TaskActivityCode.restoredFromBacklog - : TaskActivityCode.automaticallyPushed, - occurredAt: updatedAt, - previousStart: task.scheduledStart, - previousEnd: task.scheduledEnd, - nextStart: interval.start, - nextEnd: interval.end, - ); - - updatedTasks.add(updatedTask); - changes.add( - SchedulingChange( - taskId: task.id, - previousStart: task.scheduledStart, - previousEnd: task.scheduledEnd, - nextStart: interval.start, - nextEnd: interval.end, - ), - ); - notices.add( - SchedulingNotice( - isInsertedTask - ? 'Backlog task inserted into schedule.' - : 'Flexible task moved to make room.', - type: SchedulingNoticeType.moved, - taskId: task.id, - movementCode: isInsertedTask - ? SchedulingMovementCode.backlogTaskInserted - : SchedulingMovementCode.flexibleTaskMovedToMakeRoom, - parameters: { - 'previousStart': task.scheduledStart, - 'previousEnd': task.scheduledEnd, - 'nextStart': interval.start, - 'nextEnd': interval.end, - }, - ), - ); - } - - return SchedulingResult( - tasks: List.unmodifiable(updatedTasks), - operationCode: - SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot, - outcomeCode: SchedulingOutcomeCode.success, - notices: List.unmodifiable(notices), - changes: List.unmodifiable(changes), - ); -} - -/// Apply a push/tomorrow placement plan to the task list. -/// -/// The explicitly pushed task increments `manuallyPushedCount`; other moved -/// flexible tasks increment `autoPushedCount` because the scheduler moved them as -/// a side effect. -SchedulingResult _applyPushPlacement({ - required SchedulingInput input, - required Task pushedTask, - required _BacklogInsertionPlan placement, - required String pushedTaskMessage, - required SchedulingMovementCode pushedTaskMovementCode, - required SchedulingOperationCode operationCode, - required DateTime updatedAt, - bool countPushedTaskAsManual = true, -}) { - final changes = []; - final notices = []; - final updatedTasks = []; - - for (final task in input.tasks) { - final interval = placement.placements[task.id]; - if (interval == null) { - updatedTasks.add(task); - continue; - } - - final moved = !_sameDateTime(task.scheduledStart, interval.start) || - !_sameDateTime(task.scheduledEnd, interval.end); - - if (!moved) { - updatedTasks.add(task); - continue; - } - - final isPushedTask = task.id == pushedTask.id; - final placedTask = task.copyWith( - scheduledStart: interval.start, - scheduledEnd: interval.end, - updatedAt: updatedAt, - ); - final updatedTask = _applySchedulingActivity( - originalTask: task, - updatedTask: placedTask, - operationCode: operationCode, - activityCode: isPushedTask && countPushedTaskAsManual - ? TaskActivityCode.manuallyPushed - : TaskActivityCode.automaticallyPushed, - occurredAt: updatedAt, - previousStart: task.scheduledStart, - previousEnd: task.scheduledEnd, - nextStart: interval.start, - nextEnd: interval.end, - ); - - updatedTasks.add(updatedTask); - changes.add( - SchedulingChange( - taskId: task.id, - previousStart: task.scheduledStart, - previousEnd: task.scheduledEnd, - nextStart: interval.start, - nextEnd: interval.end, - ), - ); - notices.add( - SchedulingNotice( - isPushedTask ? pushedTaskMessage : 'Flexible task moved to make room.', - type: SchedulingNoticeType.moved, - taskId: task.id, - movementCode: isPushedTask - ? pushedTaskMovementCode - : SchedulingMovementCode.flexibleTaskMovedToMakeRoom, - parameters: { - 'previousStart': task.scheduledStart, - 'previousEnd': task.scheduledEnd, - 'nextStart': interval.start, - 'nextEnd': interval.end, - }, - ), - ); - } - - return SchedulingResult( - tasks: List.unmodifiable(updatedTasks), - operationCode: operationCode, - outcomeCode: SchedulingOutcomeCode.success, - notices: List.unmodifiable(notices), - changes: List.unmodifiable(changes), - ); -} - -/// Apply a bulk rollover placement plan. -/// -/// Rolled tasks are set back to planned status in the target window. Existing -/// tasks moved to make room receive normal movement notices. -SchedulingResult _applyRolloverPlacement({ - required SchedulingInput input, - required Set rolledTaskIds, - required _BacklogInsertionPlan placement, - required DateTime updatedAt, -}) { - final changes = []; - final notices = []; - final updatedTasks = []; - var rolledCount = 0; - - for (final task in input.tasks) { - final interval = placement.placements[task.id]; - if (interval == null) { - updatedTasks.add(task); - continue; - } - - final moved = !_sameDateTime(task.scheduledStart, interval.start) || - !_sameDateTime(task.scheduledEnd, interval.end); - - if (!moved) { - updatedTasks.add(task); - continue; - } - - final isRolledTask = rolledTaskIds.contains(task.id); - final placedTask = task.copyWith( - status: isRolledTask ? TaskStatus.planned : task.status, - scheduledStart: interval.start, - scheduledEnd: interval.end, - updatedAt: updatedAt, - ); - final updatedTask = _applySchedulingActivity( - originalTask: task, - updatedTask: placedTask, - operationCode: SchedulingOperationCode.rollOverUnfinishedFlexibleTasks, - activityCode: TaskActivityCode.automaticallyPushed, - occurredAt: updatedAt, - previousStart: task.scheduledStart, - previousEnd: task.scheduledEnd, - nextStart: interval.start, - nextEnd: interval.end, - ); - - updatedTasks.add(updatedTask); - changes.add( - SchedulingChange( - taskId: task.id, - previousStart: task.scheduledStart, - previousEnd: task.scheduledEnd, - nextStart: interval.start, - nextEnd: interval.end, - ), - ); - - if (isRolledTask) { - rolledCount += 1; - } else { - notices.add( - SchedulingNotice( - 'Flexible task moved to make room.', - type: SchedulingNoticeType.moved, - taskId: task.id, - movementCode: SchedulingMovementCode.flexibleTaskMovedToMakeRoom, - parameters: { - 'previousStart': task.scheduledStart, - 'previousEnd': task.scheduledEnd, - 'nextStart': interval.start, - 'nextEnd': interval.end, - }, - ), - ); - } - } - - notices.insert( - 0, - SchedulingNotice( - '$rolledCount unfinished flexible tasks were moved to tomorrow.', - type: SchedulingNoticeType.moved, - movementCode: SchedulingMovementCode.unfinishedFlexibleTasksRolledOver, - parameters: { - 'rolledCount': rolledCount, - }, - ), - ); - - return SchedulingResult( - tasks: List.unmodifiable(updatedTasks), - operationCode: SchedulingOperationCode.rollOverUnfinishedFlexibleTasks, - outcomeCode: SchedulingOutcomeCode.success, - notices: List.unmodifiable(notices), - changes: List.unmodifiable(changes), - ); -} - -/// Whether [task] belongs in the rollover queue. -/// -/// Planned/active flexible tasks already inside the target window are not rolled -/// again; they are handled as existing tasks that may shift to make room. -bool _shouldRollOver({ - required Task task, - required SchedulingWindow targetWindow, - SchedulingWindow? sourceWindow, -}) { - final interval = _scheduledIntervalFor(task); - final isTomorrowTask = - interval != null && interval.overlaps(targetWindow.interval); - - final isInSourceWindow = sourceWindow == null || - (interval != null && sourceWindow.contains(interval)); - - return task.isFlexible && - !isTomorrowTask && - isInSourceWindow && - (task.status == TaskStatus.planned || task.status == TaskStatus.active); -} - -/// Return whichever timestamp is later. -DateTime _laterOf(DateTime first, DateTime second) { - if (first.isAfter(second)) { - return first; - } - - return second; -} - -/// Null-safe exact timestamp comparison. -bool _sameDateTime(DateTime? first, DateTime? second) { - if (first == null || second == null) { - return first == null && second == null; - } - - return first.isAtSameMomentAs(second); -} - -Task _applySchedulingActivity({ - required Task originalTask, - required Task updatedTask, - required SchedulingOperationCode operationCode, - required TaskActivityCode activityCode, - required DateTime occurredAt, - required DateTime? previousStart, - required DateTime? previousEnd, - required DateTime? nextStart, - required DateTime? nextEnd, -}) { - final operationId = - '${operationCode.name}:${originalTask.id}:${occurredAt.toIso8601String()}'; - final activity = TaskActivity( - id: '$operationId:${activityCode.name}', - operationId: operationId, - code: activityCode, - taskId: originalTask.id, - projectId: originalTask.projectId, - occurredAt: occurredAt, - metadata: { - 'previousStatus': originalTask.status.name, - 'nextStatus': updatedTask.status.name, - 'previousStart': previousStart, - 'previousEnd': previousEnd, - 'nextStart': nextStart, - 'nextEnd': nextEnd, - }, - ); - - return _activityAccountingService - .apply(task: updatedTask, activities: [activity]).task; -} - -/// Find the first candidate interval at or after [earliestStart]. -/// -/// The scan assumes [blocked] is sorted by start time. When a candidate overlaps -/// a blocked interval, the cursor jumps to that blocked interval's end and tries -/// again. This makes the algorithm easy to follow and adequate for the starter -/// in-memory engine. -TimeInterval? _firstOpenIntervalFrom({ - required DateTime earliestStart, - required DateTime windowEnd, - required Duration duration, - required List blocked, -}) { - var cursor = earliestStart; - - while (true) { - final candidateEnd = cursor.add(duration); - if (candidateEnd.isAfter(windowEnd)) { - return null; - } - - final candidate = TimeInterval(start: cursor, end: candidateEnd); - TimeInterval? overlappingBlock; - - for (final block in blocked) { - if (block.end.isBefore(cursor) || block.end.isAtSameMomentAs(cursor)) { - continue; - } - - if (block.start.isAfter(candidate.end) || - block.start.isAtSameMomentAs(candidate.end)) { - break; - } - - if (candidate.overlaps(block)) { - overlappingBlock = block; - break; - } - } - - if (overlappingBlock == null) { - return candidate; - } - - cursor = overlappingBlock.end; - } -} - -/// One item in a placement queue. -/// -/// [earliestStart] preserves a task's natural ordering constraint. For existing -/// scheduled tasks, this is usually their current start; for a pushed task, it is -/// the earliest time the push operation allows. -class _PlacementItem { - const _PlacementItem({ - required this.task, - required this.duration, - required this.earliestStart, - }); - - /// Task represented by this queue entry. - final Task task; - - /// Duration the planner must reserve. - final Duration duration; - - /// Earliest allowed start time for this item. - final DateTime earliestStart; -} - -/// Planned task intervals keyed by task id. -/// -/// The name is historical from the first insertion feature; it now also supports -/// push and rollover placement plans. It remains private so it can be renamed or -/// expanded later without affecting callers. -class _BacklogInsertionPlan { - const _BacklogInsertionPlan({required this.placements}); - - /// Chosen interval for each task that should be scheduled or moved. - final Map placements; -} diff --git a/packages/scheduler_core/lib/src/scheduling_engine/backlog_insertion_plan.dart b/packages/scheduler_core/lib/src/scheduling_engine/backlog_insertion_plan.dart new file mode 100644 index 0000000..7f63ed8 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling_engine/backlog_insertion_plan.dart @@ -0,0 +1,13 @@ +part of '../scheduling_engine.dart'; + +/// Planned task intervals keyed by task id. +/// +/// The name is historical from the first insertion feature; it now also supports +/// push and rollover placement plans. It remains private so it can be renamed or +/// expanded later without affecting callers. +class _BacklogInsertionPlan { + const _BacklogInsertionPlan({required this.placements}); + + /// Chosen interval for each task that should be scheduled or moved. + final Map placements; +} diff --git a/packages/scheduler_core/lib/src/scheduling_engine/placement_item.dart b/packages/scheduler_core/lib/src/scheduling_engine/placement_item.dart new file mode 100644 index 0000000..c0e8580 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling_engine/placement_item.dart @@ -0,0 +1,23 @@ +part of '../scheduling_engine.dart'; + +/// One item in a placement queue. +/// +/// [earliestStart] preserves a task's natural ordering constraint. For existing +/// scheduled tasks, this is usually their current start; for a pushed task, it is +/// the earliest time the push operation allows. +class _PlacementItem { + const _PlacementItem({ + required this.task, + required this.duration, + required this.earliestStart, + }); + + /// Task represented by this queue entry. + final Task task; + + /// Duration the planner must reserve. + final Duration duration; + + /// Earliest allowed start time for this item. + final DateTime earliestStart; +} diff --git a/packages/scheduler_core/lib/src/scheduling_engine/scheduling_change.dart b/packages/scheduler_core/lib/src/scheduling_engine/scheduling_change.dart new file mode 100644 index 0000000..04dc40a --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling_engine/scheduling_change.dart @@ -0,0 +1,31 @@ +part of '../scheduling_engine.dart'; + +/// Exact placement change made by a scheduling operation. +/// +/// Changes are machine-readable before/after records. UI can use notices for +/// display text, but persistence, undo, analytics, or tests should inspect these +/// fields to know exactly which task moved and where. +class SchedulingChange { + const SchedulingChange({ + required this.taskId, + required this.previousStart, + required this.previousEnd, + required this.nextStart, + required this.nextEnd, + }); + + /// Task that moved or had its schedule cleared. + final String taskId; + + /// Previous scheduled start, or null if the task was previously unplaced. + final DateTime? previousStart; + + /// Previous scheduled end, or null if the task was previously unplaced. + final DateTime? previousEnd; + + /// New scheduled start, or null if the task was moved out of the timeline. + final DateTime? nextStart; + + /// New scheduled end, or null if the task was moved out of the timeline. + final DateTime? nextEnd; +} diff --git a/packages/scheduler_core/lib/src/scheduling_engine/scheduling_conflict_code.dart b/packages/scheduler_core/lib/src/scheduling_engine/scheduling_conflict_code.dart new file mode 100644 index 0000000..d2a529c --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling_engine/scheduling_conflict_code.dart @@ -0,0 +1,8 @@ +part of '../scheduling_engine.dart'; + +/// Stable conflict identifiers for overlap/interrupt notices. +enum SchedulingConflictCode { + flexibleTaskOverlapsBlockedTime, + surpriseTaskOverlapsRequiredVisibleTime, + requiredCommitmentOverlapsProtectedFreeSlot, +} diff --git a/packages/scheduler_core/lib/src/scheduling_engine/scheduling_engine.dart b/packages/scheduler_core/lib/src/scheduling_engine/scheduling_engine.dart new file mode 100644 index 0000000..9de4ccb --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling_engine/scheduling_engine.dart @@ -0,0 +1,1309 @@ +part of '../scheduling_engine.dart'; + +/// Starter scheduling engine. +/// +/// The engine is a pure domain service: it receives immutable-ish input values +/// and returns new values. It does not persist data, render UI, send reminders, +/// or read the clock except where an optional [updatedAt] timestamp is omitted. +/// +/// Current V1 responsibilities: +/// - insert backlog tasks into the earliest available flexible slot; +/// - push flexible tasks later today; +/// - move flexible tasks to tomorrow's queue; +/// - roll unfinished flexible tasks into a new planning window; +/// - analyze overlaps against locked/required time; +/// - perform small state transitions such as missed/backlog handling. +/// +/// Important rule vocabulary: +/// - `fixedBlocks` are intervals the engine will not move. +/// - `queue` is the ordered set of flexible tasks that may be placed or shifted. +/// - `placement` is a map from task id to the interval chosen by the planner. +class SchedulingEngine { + const SchedulingEngine({ + this.clock = const SystemClock(), + }); + + /// Clock boundary used when an operation timestamp is not supplied. + final Clock clock; + + /// Insert a backlog task into the earliest available slot today. + /// + /// Locked, inflexible, and critical time is treated as fixed. Planned + /// flexible tasks at or after the insertion point may shift later, preserving + /// their relative order. + /// + /// The selected task must already exist in `input.tasks`, be flexible, be in + /// backlog status, and have a positive duration. If any precondition fails, the + /// original task list is returned with a no-fit/overflow notice. + SchedulingResult insertBacklogTaskIntoNextAvailableSlot({ + required SchedulingInput input, + required String taskId, + DateTime? updatedAt, + }) { + // Step 1: resolve and validate the task. The engine does not create tasks; + // quick capture or persistence code is responsible for adding it to the list. + final task = _taskById(input.tasks, taskId); + if (task == null) { + return _unchangedResult( + input, + SchedulingNotice( + 'Task was not found.', + type: SchedulingNoticeType.noFit, + taskId: taskId, + issueCode: SchedulingIssueCode.taskNotFound, + ), + operationCode: + SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot, + outcomeCode: SchedulingOutcomeCode.notFound, + ); + } + + if (!task.isFlexible || !task.isBacklog) { + return _unchangedResult( + input, + SchedulingNotice( + 'Only backlog flexible tasks can be inserted.', + type: SchedulingNoticeType.noFit, + taskId: task.id, + issueCode: SchedulingIssueCode.invalidTaskState, + ), + operationCode: + SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot, + outcomeCode: SchedulingOutcomeCode.invalidState, + ); + } + + final taskDuration = _durationFromMinutes(task.durationMinutes); + if (taskDuration == null) { + return _unchangedResult( + input, + SchedulingNotice( + 'Task needs a positive duration before scheduling.', + type: SchedulingNoticeType.noFit, + taskId: task.id, + issueCode: SchedulingIssueCode.missingDuration, + ), + operationCode: + SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot, + outcomeCode: SchedulingOutcomeCode.invalidState, + ); + } + + // Step 2: compute placements without mutating any task. Planning returns null + // if the inserted task and shifted queue cannot fit inside the window. + final placement = _planBacklogInsertion( + input: input, + task: task, + taskDuration: taskDuration, + ); + + if (placement == null) { + return _unchangedResult( + input, + SchedulingNotice( + 'No available flexible slot today.', + type: SchedulingNoticeType.overflow, + taskId: task.id, + issueCode: SchedulingIssueCode.noAvailableSlot, + ), + operationCode: + SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot, + outcomeCode: SchedulingOutcomeCode.noSlot, + ); + } + + return _applyPlacement( + input: input, + insertedTask: task, + placement: placement, + updatedAt: updatedAt ?? clock.now(), + ); + } + + /// Push a planned flexible task to the next available slot today. + /// + /// The selected task moves after its current slot. Planned flexible tasks + /// after it may shift later, preserving their relative order. + /// + /// This is the "not now, later today" action. Anything before the pushed + /// task's current end time becomes fixed for this operation, while later + /// planned flexible tasks may be shifted if necessary. + SchedulingResult pushFlexibleTaskToNextAvailableSlot({ + required SchedulingInput input, + required String taskId, + DateTime? updatedAt, + bool countAsManualPush = true, + }) { + // Resolve the selected task by id so UI code only needs to pass a stable + // identifier, not object references. + final task = _taskById(input.tasks, taskId); + if (task == null) { + return _unchangedResult( + input, + SchedulingNotice( + 'Task was not found.', + type: SchedulingNoticeType.noFit, + taskId: taskId, + issueCode: SchedulingIssueCode.taskNotFound, + ), + operationCode: + SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot, + outcomeCode: SchedulingOutcomeCode.notFound, + ); + } + + if (!task.isFlexible || task.status != TaskStatus.planned) { + return _unchangedResult( + input, + SchedulingNotice( + 'Only planned flexible tasks can be pushed.', + type: SchedulingNoticeType.noFit, + taskId: task.id, + issueCode: SchedulingIssueCode.invalidTaskState, + ), + operationCode: + SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot, + outcomeCode: SchedulingOutcomeCode.invalidState, + ); + } + + final currentInterval = _scheduledIntervalFor(task); + if (currentInterval == null || !input.window.contains(currentInterval)) { + return _unchangedResult( + input, + SchedulingNotice( + 'Task needs a current scheduled slot before pushing.', + type: SchedulingNoticeType.noFit, + taskId: task.id, + issueCode: SchedulingIssueCode.missingScheduledSlot, + ), + operationCode: + SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot, + outcomeCode: SchedulingOutcomeCode.invalidState, + ); + } + + if (currentInterval.duration.inMicroseconds <= 0) { + return _unchangedResult( + input, + SchedulingNotice( + 'Task needs a positive scheduled duration before pushing.', + type: SchedulingNoticeType.noFit, + taskId: task.id, + issueCode: SchedulingIssueCode.nonPositiveDuration, + ), + operationCode: + SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot, + outcomeCode: SchedulingOutcomeCode.invalidState, + ); + } + + final placement = _planFlexiblePush( + input: input, + task: task, + currentInterval: currentInterval, + ); + + if (placement == null) { + return _unchangedResult( + input, + SchedulingNotice( + 'No available flexible slot today.', + type: SchedulingNoticeType.overflow, + taskId: task.id, + issueCode: SchedulingIssueCode.noAvailableSlot, + ), + operationCode: + SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot, + outcomeCode: SchedulingOutcomeCode.noSlot, + ); + } + + return _applyPushPlacement( + input: input, + pushedTask: task, + placement: placement, + pushedTaskMessage: 'Flexible task pushed to next available slot.', + pushedTaskMovementCode: + SchedulingMovementCode.flexibleTaskPushedToNextAvailableSlot, + operationCode: + SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot, + updatedAt: updatedAt ?? clock.now(), + countPushedTaskAsManual: countAsManualPush, + ); + } + + /// Move a planned flexible task to the top of tomorrow's flexible queue. + /// + /// The input window represents tomorrow's scheduling window. Existing planned + /// flexible tasks in that window may shift later, preserving their order. + /// + /// The method name says "tomorrow" because that is the product action, but the + /// engine only trusts `input.window`. Tests can pass any future window. + SchedulingResult pushFlexibleTaskToTomorrowTopOfQueue({ + required SchedulingInput input, + required String taskId, + DateTime? updatedAt, + }) { + final task = _taskById(input.tasks, taskId); + if (task == null) { + return _unchangedResult( + input, + SchedulingNotice( + 'Task was not found.', + type: SchedulingNoticeType.noFit, + taskId: taskId, + issueCode: SchedulingIssueCode.taskNotFound, + ), + operationCode: + SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue, + outcomeCode: SchedulingOutcomeCode.notFound, + ); + } + + if (!task.isFlexible || task.status != TaskStatus.planned) { + return _unchangedResult( + input, + SchedulingNotice( + 'Only planned flexible tasks can be moved to tomorrow.', + type: SchedulingNoticeType.noFit, + taskId: task.id, + issueCode: SchedulingIssueCode.invalidTaskState, + ), + operationCode: + SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue, + outcomeCode: SchedulingOutcomeCode.invalidState, + ); + } + + final taskDuration = _scheduledIntervalFor(task)?.duration ?? + _durationFromMinutes(task.durationMinutes); + if (taskDuration == null || taskDuration.inMicroseconds <= 0) { + return _unchangedResult( + input, + SchedulingNotice( + 'Task needs a positive duration before moving to tomorrow.', + type: SchedulingNoticeType.noFit, + taskId: task.id, + issueCode: SchedulingIssueCode.missingDuration, + ), + operationCode: + SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue, + outcomeCode: SchedulingOutcomeCode.invalidState, + ); + } + + final placement = _planTomorrowQueueInsertion( + input: input, + task: task, + taskDuration: taskDuration, + ); + + if (placement == null) { + return _unchangedResult( + input, + SchedulingNotice( + 'No available flexible slot tomorrow.', + type: SchedulingNoticeType.overflow, + taskId: task.id, + issueCode: SchedulingIssueCode.noAvailableSlot, + ), + operationCode: + SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue, + outcomeCode: SchedulingOutcomeCode.noSlot, + ); + } + + return _applyPushPlacement( + input: input, + pushedTask: task, + placement: placement, + pushedTaskMessage: 'Flexible task moved to tomorrow.', + pushedTaskMovementCode: + SchedulingMovementCode.flexibleTaskMovedToTomorrow, + operationCode: + SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue, + updatedAt: updatedAt ?? clock.now(), + ); + } + + /// Move unfinished flexible tasks to the top of tomorrow's flexible queue. + /// + /// The input window represents tomorrow's scheduling window. Only planned and + /// active flexible tasks are rolled; required, locked, completed, and + /// cancelled tasks remain unchanged. + /// + /// Rollover is bulk push behavior for day-end recovery. It collects unfinished + /// flexible tasks outside the target window, preserves their relative order, + /// then places them at the start of the new window while shifting already + /// planned flexible tasks as needed. When [sourceWindow] is supplied, only + /// unfinished flexible tasks from that source window are rolled over. This + /// prevents future planned tasks from being pulled into tomorrow accidentally. + SchedulingResult rollOverUnfinishedFlexibleTasks({ + required SchedulingInput input, + SchedulingWindow? sourceWindow, + DateTime? updatedAt, + }) { + // Build the explicit queue of tasks to roll before asking the planner to + // place anything. This keeps selection separate from placement. + final rolledItems = <_PlacementItem>[]; + final rolloverTasks = input.flexibleTasks + .where((task) => _shouldRollOver( + task: task, + targetWindow: input.window, + sourceWindow: sourceWindow, + )) + .toList() + ..sort((a, b) { + final aStart = + a.scheduledStart ?? sourceWindow?.start ?? input.window.start; + final bStart = + b.scheduledStart ?? sourceWindow?.start ?? input.window.start; + + return aStart.compareTo(bStart); + }); + + for (final task in rolloverTasks) { + final duration = _scheduledIntervalFor(task)?.duration ?? + _durationFromMinutes(task.durationMinutes); + if (duration == null || duration.inMicroseconds <= 0) { + continue; + } + + rolledItems.add( + _PlacementItem( + task: task, + duration: duration, + earliestStart: input.window.start, + ), + ); + } + + if (rolledItems.isEmpty) { + return SchedulingResult( + tasks: input.tasks, + operationCode: SchedulingOperationCode.rollOverUnfinishedFlexibleTasks, + outcomeCode: SchedulingOutcomeCode.noOp, + notices: [ + SchedulingNotice( + 'No unfinished flexible tasks to roll over.', + issueCode: SchedulingIssueCode.noUnfinishedFlexibleTasks, + ), + ], + ); + } + + final placement = _planQueueAtWindowStart( + input: input, + queue: rolledItems, + excludeTaskIds: rolledItems.map((item) => item.task.id).toSet(), + ); + + if (placement == null) { + return _unchangedResult( + input, + SchedulingNotice( + 'Unfinished flexible tasks could not fit tomorrow.', + type: SchedulingNoticeType.overflow, + issueCode: SchedulingIssueCode.unfinishedTasksCouldNotFit, + ), + operationCode: SchedulingOperationCode.rollOverUnfinishedFlexibleTasks, + outcomeCode: SchedulingOutcomeCode.overflow, + ); + } + + return _applyRolloverPlacement( + input: input, + rolledTaskIds: rolledItems.map((item) => item.task.id).toSet(), + placement: placement, + updatedAt: updatedAt ?? clock.now(), + ); + } + + /// Analyze the current in-memory schedule without moving tasks. + /// + /// This is a validation/debugging helper. It scans scheduled flexible tasks and + /// reports any overlap with blocked intervals. It deliberately returns the + /// original task list unchanged. + SchedulingResult analyzeSchedule(SchedulingInput input) { + final overlaps = []; + final notices = []; + final conflictOccupancies = input.conflictReportingOccupancies; + + for (final task in input.movablePlannedFlexibleTasks) { + final taskInterval = _scheduledIntervalFor(task); + if (taskInterval == null || !input.window.contains(taskInterval)) { + continue; + } + + for (final occupancy in conflictOccupancies) { + final blockedInterval = occupancy.interval; + if (blockedInterval == null) { + continue; + } + + if (!taskInterval.overlaps(blockedInterval)) { + continue; + } + + overlaps.add( + SchedulingOverlap( + taskId: task.id, + taskInterval: taskInterval, + blockedInterval: blockedInterval, + ), + ); + notices.add( + SchedulingNotice( + 'Flexible task overlaps blocked time.', + type: SchedulingNoticeType.overlap, + taskId: task.id, + conflictCode: + SchedulingConflictCode.flexibleTaskOverlapsBlockedTime, + parameters: { + 'blockedStart': blockedInterval.start, + 'blockedEnd': blockedInterval.end, + }, + ), + ); + } + } + + return SchedulingResult( + tasks: input.tasks, + operationCode: SchedulingOperationCode.analyzeSchedule, + outcomeCode: overlaps.isEmpty + ? SchedulingOutcomeCode.success + : SchedulingOutcomeCode.conflict, + notices: List.unmodifiable(notices), + overlaps: List.unmodifiable(overlaps), + ); + } + + /// Move a task to backlog. + /// + /// Backlog does not preserve original schedule/order placement. The task's + /// schedule is cleared and its moved-to-backlog counter is incremented so + /// reports can distinguish this from a task that was never scheduled. + Task moveToBacklog(Task task, {DateTime? updatedAt}) { + final now = updatedAt ?? clock.now(); + final result = _transitionService.apply( + task: task, + transitionCode: TaskTransitionCode.moveToBacklog, + operationId: 'legacy-move-to-backlog:${task.id}:${now.toIso8601String()}', + activityId: + 'legacy-move-to-backlog:${task.id}:${now.toIso8601String()}:activity', + occurredAt: now, + ); + return result.applied ? result.task : task; + } + + /// Mark a flexible task pushed manually. + /// + /// This updates statistics only. Use the push methods above when the task's + /// actual scheduled slot should change. + Task markManuallyPushed(Task task, {DateTime? updatedAt}) { + final now = updatedAt ?? clock.now(); + final result = _transitionService.apply( + task: task, + transitionCode: TaskTransitionCode.manualPush, + operationId: 'legacy-manual-push:${task.id}:${now.toIso8601String()}', + activityId: + 'legacy-manual-push:${task.id}:${now.toIso8601String()}:activity', + occurredAt: now, + ); + return result.applied ? result.task : task; + } + + /// Mark missed according to the current MVP rules. + /// + /// Critical missed tasks go to backlog so they remain actionable. Inflexible + /// missed tasks stay in place as missed because they represented a fixed event + /// or time block that cannot simply be rescheduled automatically. + Task markMissed(Task task, {DateTime? updatedAt}) { + final now = updatedAt ?? clock.now(); + final result = _transitionService.apply( + task: task, + transitionCode: TaskTransitionCode.miss, + operationId: 'legacy-miss:${task.id}:${now.toIso8601String()}', + activityId: 'legacy-miss:${task.id}:${now.toIso8601String()}:activity', + occurredAt: now, + ); + return result.applied ? result.task : task; + } + + /// Finds the first interval that can fit the requested duration while avoiding + /// blocked intervals. + /// + /// This public helper is deliberately simple and does not shift existing + /// flexible tasks. It is useful for UI previews or tests that only need to know + /// the first open gap. Full bump/queue behavior lives in the private planning + /// helpers below. + TimeInterval? findFirstOpenInterval({ + required DateTime windowStart, + required DateTime windowEnd, + required Duration duration, + required List blocked, + }) { + final sortedBlocked = [...blocked] + ..sort((a, b) => a.start.compareTo(b.start)); + var cursor = windowStart; + + for (final interval in sortedBlocked) { + if (cursor.add(duration).isBefore(interval.start) || + cursor.add(duration).isAtSameMomentAs(interval.start)) { + return TimeInterval(start: cursor, end: cursor.add(duration)); + } + + if (interval.end.isAfter(cursor)) { + cursor = interval.end; + } + } + + final candidateEnd = cursor.add(duration); + if (candidateEnd.isBefore(windowEnd) || + candidateEnd.isAtSameMomentAs(windowEnd)) { + return TimeInterval(start: cursor, end: candidateEnd); + } + + return null; + } +} + +/// Convert a scheduled task into an interval, or null if it is unplaced. +/// +/// This helper does not validate positive duration; callers that require a valid +/// duration check that separately. +TimeInterval? _scheduledIntervalFor(Task task) { + final start = task.scheduledStart; + final end = task.scheduledEnd; + + if (start == null || end == null) { + return null; + } + + return TimeInterval(start: start, end: end, label: task.id); +} + +/// Convert all placed tasks in [tasks] into intervals. +List _scheduledIntervalsFor(Iterable tasks) { + final intervals = []; + + for (final task in tasks) { + final interval = _scheduledIntervalFor(task); + if (interval != null) { + intervals.add(interval); + } + } + + return List.unmodifiable(intervals); +} + +/// Return the original task list with one explanatory notice. +/// +/// Most validation failures use this so callers can keep rendering the existing +/// schedule while showing why the requested action did not apply. +SchedulingResult _unchangedResult( + SchedulingInput input, + SchedulingNotice notice, { + SchedulingOperationCode operationCode = SchedulingOperationCode.unspecified, + SchedulingOutcomeCode outcomeCode = SchedulingOutcomeCode.invalidState, +}) { + return SchedulingResult( + tasks: input.tasks, + operationCode: operationCode, + outcomeCode: outcomeCode, + notices: [notice], + ); +} + +/// Find a task by stable id. +Task? _taskById(List tasks, String taskId) { + for (final task in tasks) { + if (task.id == taskId) { + return task; + } + } + + return null; +} + +/// Convert a nullable minute estimate into a positive [Duration]. +Duration? _durationFromMinutes(int? minutes) { + if (minutes == null || minutes <= 0) { + return null; + } + + return Duration(minutes: minutes); +} + +/// Plan insertion of a backlog task plus any flexible tasks that must shift. +/// +/// This function only calculates intervals. It does not update task objects. The +/// returned plan is later applied by [_applyPlacement], which creates notices, +/// changes, and updated task copies. +_BacklogInsertionPlan? _planBacklogInsertion({ + required SchedulingInput input, + required Task task, + required Duration taskDuration, +}) { + // Start with intervals that the algorithm is not allowed to move. + final fixedBlocks = [ + ...input.blockedIntervals, + ]; + final queue = <_PlacementItem>[ + _PlacementItem( + task: task, + duration: taskDuration, + earliestStart: input.window.start, + ), + ]; + + // Existing flexible tasks are inspected in timeline order. Planned tasks inside + // the movable portion become part of the placement queue; everything else is + // treated as fixed. + final scheduledFlexibleTasks = input.movablePlannedFlexibleTasks + .where((flexibleTask) => flexibleTask.id != task.id) + .toList(growable: false) + ..sort((a, b) { + final aStart = a.scheduledStart ?? input.window.end; + final bStart = b.scheduledStart ?? input.window.end; + + return aStart.compareTo(bStart); + }); + + for (final flexibleTask in scheduledFlexibleTasks) { + final interval = _scheduledIntervalFor(flexibleTask); + if (interval == null) { + continue; + } + + final startsBeforeWindow = interval.start.isBefore(input.window.start); + final startsAfterWindow = interval.start.isAfter(input.window.end) || + interval.start.isAtSameMomentAs(input.window.end); + + if (startsBeforeWindow || startsAfterWindow) { + fixedBlocks.add(interval); + continue; + } + + queue.add( + _PlacementItem( + task: flexibleTask, + duration: interval.duration, + earliestStart: interval.start, + ), + ); + } + + fixedBlocks.sort((a, b) => a.start.compareTo(b.start)); + + // The cursor tracks the earliest point after the previously placed queue item. + // Each queued task is placed no earlier than both the cursor and its own + // original earliest start. + var cursor = input.window.start; + final placements = {}; + + for (final item in queue) { + final earliestStart = _laterOf(cursor, item.earliestStart); + final interval = _firstOpenIntervalFrom( + earliestStart: earliestStart, + windowEnd: input.window.end, + duration: item.duration, + blocked: fixedBlocks, + ); + + if (interval == null) { + return null; + } + + placements[item.task.id] = interval; + cursor = interval.end; + } + + return _BacklogInsertionPlan(placements: placements); +} + +/// Plan the "push later today" behavior for one flexible task. +/// +/// Items before the pushed task's current end are fixed. The pushed task starts +/// the queue at its current end, followed by later planned flexible tasks that +/// may need to move to preserve order. +_BacklogInsertionPlan? _planFlexiblePush({ + required SchedulingInput input, + required Task task, + required TimeInterval currentInterval, +}) { + final fixedBlocks = [ + ...input.blockedIntervals, + ]; + final queue = <_PlacementItem>[ + _PlacementItem( + task: task, + duration: currentInterval.duration, + earliestStart: currentInterval.end, + ), + ]; + + final scheduledFlexibleTasks = input.movablePlannedFlexibleTasks + .where((flexibleTask) => flexibleTask.id != task.id) + .toList(growable: false) + ..sort((a, b) { + final aStart = a.scheduledStart ?? input.window.end; + final bStart = b.scheduledStart ?? input.window.end; + + return aStart.compareTo(bStart); + }); + + for (final flexibleTask in scheduledFlexibleTasks) { + final interval = _scheduledIntervalFor(flexibleTask); + if (interval == null) { + continue; + } + + final startsBeforePushPoint = interval.start.isBefore(currentInterval.end); + final startsAfterWindow = interval.start.isAfter(input.window.end) || + interval.start.isAtSameMomentAs(input.window.end); + + if (startsBeforePushPoint || startsAfterWindow) { + fixedBlocks.add(interval); + continue; + } + + queue.add( + _PlacementItem( + task: flexibleTask, + duration: interval.duration, + earliestStart: interval.start, + ), + ); + } + + fixedBlocks.sort((a, b) => a.start.compareTo(b.start)); + + var cursor = currentInterval.end; + final placements = {}; + + for (final item in queue) { + final earliestStart = _laterOf(cursor, item.earliestStart); + final interval = _firstOpenIntervalFrom( + earliestStart: earliestStart, + windowEnd: input.window.end, + duration: item.duration, + blocked: fixedBlocks, + ); + + if (interval == null) { + return null; + } + + placements[item.task.id] = interval; + cursor = interval.end; + } + + return _BacklogInsertionPlan(placements: placements); +} + +/// Plan putting a single task at the start of the supplied future window. +_BacklogInsertionPlan? _planTomorrowQueueInsertion({ + required SchedulingInput input, + required Task task, + required Duration taskDuration, +}) { + final queue = <_PlacementItem>[ + _PlacementItem( + task: task, + duration: taskDuration, + earliestStart: input.window.start, + ), + ]; + + return _planQueueAtWindowStart( + input: input, + queue: queue, + excludeTaskIds: {task.id}, + ); +} + +/// Plan a queue of flexible tasks at the beginning of [input.window]. +/// +/// This is shared by tomorrow push and bulk rollover. [excludeTaskIds] identifies +/// tasks already represented in the incoming [queue] so they are not also pulled +/// from existing scheduled flexible tasks. +_BacklogInsertionPlan? _planQueueAtWindowStart({ + required SchedulingInput input, + required List<_PlacementItem> queue, + required Set excludeTaskIds, +}) { + final fixedBlocks = [ + ...input.blockedIntervals, + ]; + final placementQueue = <_PlacementItem>[ + ...queue, + ]; + + final scheduledFlexibleTasks = input.movablePlannedFlexibleTasks + .where((flexibleTask) => !excludeTaskIds.contains(flexibleTask.id)) + .toList(growable: false) + ..sort((a, b) { + final aStart = a.scheduledStart ?? input.window.end; + final bStart = b.scheduledStart ?? input.window.end; + + return aStart.compareTo(bStart); + }); + + for (final flexibleTask in scheduledFlexibleTasks) { + final interval = _scheduledIntervalFor(flexibleTask); + if (interval == null) { + continue; + } + + final overlapsWindow = interval.overlaps(input.window.interval); + if (!overlapsWindow) { + continue; + } + + if (!input.window.contains(interval)) { + fixedBlocks.add(interval); + continue; + } + + placementQueue.add( + _PlacementItem( + task: flexibleTask, + duration: interval.duration, + earliestStart: interval.start, + ), + ); + } + + fixedBlocks.sort((a, b) => a.start.compareTo(b.start)); + + var cursor = input.window.start; + final placements = {}; + + for (final item in placementQueue) { + final earliestStart = _laterOf(cursor, item.earliestStart); + final interval = _firstOpenIntervalFrom( + earliestStart: earliestStart, + windowEnd: input.window.end, + duration: item.duration, + blocked: fixedBlocks, + ); + + if (interval == null) { + return null; + } + + placements[item.task.id] = interval; + cursor = interval.end; + } + + return _BacklogInsertionPlan(placements: placements); +} + +/// Apply a backlog insertion plan to the task list. +/// +/// The inserted backlog task becomes planned and increments +/// `restoredFromBacklogCount`; any existing flexible tasks moved to make room +/// increment `autoPushedCount`. +SchedulingResult _applyPlacement({ + required SchedulingInput input, + required Task insertedTask, + required _BacklogInsertionPlan placement, + required DateTime updatedAt, +}) { + final changes = []; + final notices = []; + final updatedTasks = []; + + for (final task in input.tasks) { + final interval = placement.placements[task.id]; + if (interval == null) { + updatedTasks.add(task); + continue; + } + + final isInsertedTask = task.id == insertedTask.id; + final moved = isInsertedTask || + !_sameDateTime(task.scheduledStart, interval.start) || + !_sameDateTime(task.scheduledEnd, interval.end); + + if (!moved) { + updatedTasks.add(task); + continue; + } + + final placedTask = task.copyWith( + status: isInsertedTask ? TaskStatus.planned : task.status, + scheduledStart: interval.start, + scheduledEnd: interval.end, + updatedAt: updatedAt, + ); + final updatedTask = _applySchedulingActivity( + originalTask: task, + updatedTask: placedTask, + operationCode: + SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot, + activityCode: isInsertedTask + ? TaskActivityCode.restoredFromBacklog + : TaskActivityCode.automaticallyPushed, + occurredAt: updatedAt, + previousStart: task.scheduledStart, + previousEnd: task.scheduledEnd, + nextStart: interval.start, + nextEnd: interval.end, + ); + + updatedTasks.add(updatedTask); + changes.add( + SchedulingChange( + taskId: task.id, + previousStart: task.scheduledStart, + previousEnd: task.scheduledEnd, + nextStart: interval.start, + nextEnd: interval.end, + ), + ); + notices.add( + SchedulingNotice( + isInsertedTask + ? 'Backlog task inserted into schedule.' + : 'Flexible task moved to make room.', + type: SchedulingNoticeType.moved, + taskId: task.id, + movementCode: isInsertedTask + ? SchedulingMovementCode.backlogTaskInserted + : SchedulingMovementCode.flexibleTaskMovedToMakeRoom, + parameters: { + 'previousStart': task.scheduledStart, + 'previousEnd': task.scheduledEnd, + 'nextStart': interval.start, + 'nextEnd': interval.end, + }, + ), + ); + } + + return SchedulingResult( + tasks: List.unmodifiable(updatedTasks), + operationCode: + SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot, + outcomeCode: SchedulingOutcomeCode.success, + notices: List.unmodifiable(notices), + changes: List.unmodifiable(changes), + ); +} + +/// Apply a push/tomorrow placement plan to the task list. +/// +/// The explicitly pushed task increments `manuallyPushedCount`; other moved +/// flexible tasks increment `autoPushedCount` because the scheduler moved them as +/// a side effect. +SchedulingResult _applyPushPlacement({ + required SchedulingInput input, + required Task pushedTask, + required _BacklogInsertionPlan placement, + required String pushedTaskMessage, + required SchedulingMovementCode pushedTaskMovementCode, + required SchedulingOperationCode operationCode, + required DateTime updatedAt, + bool countPushedTaskAsManual = true, +}) { + final changes = []; + final notices = []; + final updatedTasks = []; + + for (final task in input.tasks) { + final interval = placement.placements[task.id]; + if (interval == null) { + updatedTasks.add(task); + continue; + } + + final moved = !_sameDateTime(task.scheduledStart, interval.start) || + !_sameDateTime(task.scheduledEnd, interval.end); + + if (!moved) { + updatedTasks.add(task); + continue; + } + + final isPushedTask = task.id == pushedTask.id; + final placedTask = task.copyWith( + scheduledStart: interval.start, + scheduledEnd: interval.end, + updatedAt: updatedAt, + ); + final updatedTask = _applySchedulingActivity( + originalTask: task, + updatedTask: placedTask, + operationCode: operationCode, + activityCode: isPushedTask && countPushedTaskAsManual + ? TaskActivityCode.manuallyPushed + : TaskActivityCode.automaticallyPushed, + occurredAt: updatedAt, + previousStart: task.scheduledStart, + previousEnd: task.scheduledEnd, + nextStart: interval.start, + nextEnd: interval.end, + ); + + updatedTasks.add(updatedTask); + changes.add( + SchedulingChange( + taskId: task.id, + previousStart: task.scheduledStart, + previousEnd: task.scheduledEnd, + nextStart: interval.start, + nextEnd: interval.end, + ), + ); + notices.add( + SchedulingNotice( + isPushedTask ? pushedTaskMessage : 'Flexible task moved to make room.', + type: SchedulingNoticeType.moved, + taskId: task.id, + movementCode: isPushedTask + ? pushedTaskMovementCode + : SchedulingMovementCode.flexibleTaskMovedToMakeRoom, + parameters: { + 'previousStart': task.scheduledStart, + 'previousEnd': task.scheduledEnd, + 'nextStart': interval.start, + 'nextEnd': interval.end, + }, + ), + ); + } + + return SchedulingResult( + tasks: List.unmodifiable(updatedTasks), + operationCode: operationCode, + outcomeCode: SchedulingOutcomeCode.success, + notices: List.unmodifiable(notices), + changes: List.unmodifiable(changes), + ); +} + +/// Apply a bulk rollover placement plan. +/// +/// Rolled tasks are set back to planned status in the target window. Existing +/// tasks moved to make room receive normal movement notices. +SchedulingResult _applyRolloverPlacement({ + required SchedulingInput input, + required Set rolledTaskIds, + required _BacklogInsertionPlan placement, + required DateTime updatedAt, +}) { + final changes = []; + final notices = []; + final updatedTasks = []; + var rolledCount = 0; + + for (final task in input.tasks) { + final interval = placement.placements[task.id]; + if (interval == null) { + updatedTasks.add(task); + continue; + } + + final moved = !_sameDateTime(task.scheduledStart, interval.start) || + !_sameDateTime(task.scheduledEnd, interval.end); + + if (!moved) { + updatedTasks.add(task); + continue; + } + + final isRolledTask = rolledTaskIds.contains(task.id); + final placedTask = task.copyWith( + status: isRolledTask ? TaskStatus.planned : task.status, + scheduledStart: interval.start, + scheduledEnd: interval.end, + updatedAt: updatedAt, + ); + final updatedTask = _applySchedulingActivity( + originalTask: task, + updatedTask: placedTask, + operationCode: SchedulingOperationCode.rollOverUnfinishedFlexibleTasks, + activityCode: TaskActivityCode.automaticallyPushed, + occurredAt: updatedAt, + previousStart: task.scheduledStart, + previousEnd: task.scheduledEnd, + nextStart: interval.start, + nextEnd: interval.end, + ); + + updatedTasks.add(updatedTask); + changes.add( + SchedulingChange( + taskId: task.id, + previousStart: task.scheduledStart, + previousEnd: task.scheduledEnd, + nextStart: interval.start, + nextEnd: interval.end, + ), + ); + + if (isRolledTask) { + rolledCount += 1; + } else { + notices.add( + SchedulingNotice( + 'Flexible task moved to make room.', + type: SchedulingNoticeType.moved, + taskId: task.id, + movementCode: SchedulingMovementCode.flexibleTaskMovedToMakeRoom, + parameters: { + 'previousStart': task.scheduledStart, + 'previousEnd': task.scheduledEnd, + 'nextStart': interval.start, + 'nextEnd': interval.end, + }, + ), + ); + } + } + + notices.insert( + 0, + SchedulingNotice( + '$rolledCount unfinished flexible tasks were moved to tomorrow.', + type: SchedulingNoticeType.moved, + movementCode: SchedulingMovementCode.unfinishedFlexibleTasksRolledOver, + parameters: { + 'rolledCount': rolledCount, + }, + ), + ); + + return SchedulingResult( + tasks: List.unmodifiable(updatedTasks), + operationCode: SchedulingOperationCode.rollOverUnfinishedFlexibleTasks, + outcomeCode: SchedulingOutcomeCode.success, + notices: List.unmodifiable(notices), + changes: List.unmodifiable(changes), + ); +} + +/// Whether [task] belongs in the rollover queue. +/// +/// Planned/active flexible tasks already inside the target window are not rolled +/// again; they are handled as existing tasks that may shift to make room. +bool _shouldRollOver({ + required Task task, + required SchedulingWindow targetWindow, + SchedulingWindow? sourceWindow, +}) { + final interval = _scheduledIntervalFor(task); + final isTomorrowTask = + interval != null && interval.overlaps(targetWindow.interval); + + final isInSourceWindow = sourceWindow == null || + (interval != null && sourceWindow.contains(interval)); + + return task.isFlexible && + !isTomorrowTask && + isInSourceWindow && + (task.status == TaskStatus.planned || task.status == TaskStatus.active); +} + +/// Return whichever timestamp is later. +DateTime _laterOf(DateTime first, DateTime second) { + if (first.isAfter(second)) { + return first; + } + + return second; +} + +/// Null-safe exact timestamp comparison. +bool _sameDateTime(DateTime? first, DateTime? second) { + if (first == null || second == null) { + return first == null && second == null; + } + + return first.isAtSameMomentAs(second); +} + +Task _applySchedulingActivity({ + required Task originalTask, + required Task updatedTask, + required SchedulingOperationCode operationCode, + required TaskActivityCode activityCode, + required DateTime occurredAt, + required DateTime? previousStart, + required DateTime? previousEnd, + required DateTime? nextStart, + required DateTime? nextEnd, +}) { + final operationId = + '${operationCode.name}:${originalTask.id}:${occurredAt.toIso8601String()}'; + final activity = TaskActivity( + id: '$operationId:${activityCode.name}', + operationId: operationId, + code: activityCode, + taskId: originalTask.id, + projectId: originalTask.projectId, + occurredAt: occurredAt, + metadata: { + 'previousStatus': originalTask.status.name, + 'nextStatus': updatedTask.status.name, + 'previousStart': previousStart, + 'previousEnd': previousEnd, + 'nextStart': nextStart, + 'nextEnd': nextEnd, + }, + ); + + return _activityAccountingService + .apply(task: updatedTask, activities: [activity]).task; +} + +/// Find the first candidate interval at or after [earliestStart]. +/// +/// The scan assumes [blocked] is sorted by start time. When a candidate overlaps +/// a blocked interval, the cursor jumps to that blocked interval's end and tries +/// again. This makes the algorithm easy to follow and adequate for the starter +/// in-memory engine. +TimeInterval? _firstOpenIntervalFrom({ + required DateTime earliestStart, + required DateTime windowEnd, + required Duration duration, + required List blocked, +}) { + var cursor = earliestStart; + + while (true) { + final candidateEnd = cursor.add(duration); + if (candidateEnd.isAfter(windowEnd)) { + return null; + } + + final candidate = TimeInterval(start: cursor, end: candidateEnd); + TimeInterval? overlappingBlock; + + for (final block in blocked) { + if (block.end.isBefore(cursor) || block.end.isAtSameMomentAs(cursor)) { + continue; + } + + if (block.start.isAfter(candidate.end) || + block.start.isAtSameMomentAs(candidate.end)) { + break; + } + + if (candidate.overlaps(block)) { + overlappingBlock = block; + break; + } + } + + if (overlappingBlock == null) { + return candidate; + } + + cursor = overlappingBlock.end; + } +} diff --git a/packages/scheduler_core/lib/src/scheduling_engine/scheduling_input.dart b/packages/scheduler_core/lib/src/scheduling_engine/scheduling_input.dart new file mode 100644 index 0000000..f5713f0 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling_engine/scheduling_input.dart @@ -0,0 +1,97 @@ +part of '../scheduling_engine.dart'; + +/// In-memory input for scheduling operations. +/// +/// This is the complete snapshot the pure scheduling engine needs. It contains +/// tasks plus the fixed intervals the scheduler must avoid. It deliberately does +/// not know where the data came from: UI state, a database, tests, or generated +/// examples can all build this same object. +class SchedulingInput { + SchedulingInput({ + required List tasks, + required this.window, + List lockedIntervals = const [], + List requiredVisibleIntervals = const [], + }) : tasks = List.unmodifiable(tasks), + lockedIntervals = List.unmodifiable(lockedIntervals), + requiredVisibleIntervals = + List.unmodifiable(requiredVisibleIntervals); + + /// All tasks available to this operation. The scheduler returns a replacement + /// list rather than mutating this one. + final List tasks; + + /// Date/time range that the operation is allowed to plan inside. + final SchedulingWindow window; + + /// External locked time intervals, usually produced by `locked_time.dart`. + final List lockedIntervals; + + /// Extra fixed visible intervals supplied by the caller. This lets UI/backend + /// code reserve required time even when that time is not represented as a + /// [Task] in the current list. + final List requiredVisibleIntervals; + + /// Tasks that the flexible movement algorithms are allowed to consider. + List get flexibleTasks { + return tasks.where((task) => task.isFlexible).toList(growable: false); + } + + /// Central occupancy classification for this scheduling snapshot. + List get occupancyEntries { + return _occupancyPolicy.classify( + tasks: tasks, + lockedIntervals: lockedIntervals, + requiredVisibleIntervals: requiredVisibleIntervals, + ); + } + + /// Planned flexible task records that automatic scheduling may move. + List get movablePlannedFlexibleTasks { + return occupancyEntries + .where((entry) => entry.isMovablePlannedFlexible) + .map((entry) => entry.task) + .whereType() + .toList(growable: false); + } + + /// Locked task records in [tasks], if the caller represents locked time as + /// tasks instead of only passing [lockedIntervals]. + List get lockedTasks { + return tasks.where((task) => task.isLocked).toList(growable: false); + } + + /// Critical and inflexible task records that should block flexible placement. + List get requiredVisibleTasks { + return tasks + .where((task) => task.isRequiredVisible) + .toList(growable: false); + } + + /// Scheduled intervals for flexible tasks only. Useful for analysis/debugging. + List get flexibleIntervals { + return _scheduledIntervalsFor(flexibleTasks); + } + + /// All intervals that flexible scheduling must avoid. + /// + /// This is derived from the central occupancy policy rather than from UI card + /// categories. The result is sorted to make interval scanning deterministic. + List get blockedIntervals { + final intervals = occupancyEntries + .where((entry) => entry.blocksAutomaticFlexiblePlacement) + .map((entry) => entry.interval) + .whereType() + .toList(growable: false) + ..sort((a, b) => a.start.compareTo(b.start)); + + return List.unmodifiable(intervals); + } + + /// Occupancy entries that should be surfaced as conflicts when overlapped. + List get conflictReportingOccupancies { + return occupancyEntries + .where((entry) => entry.reportsConflict) + .toList(growable: false); + } +} diff --git a/packages/scheduler_core/lib/src/scheduling_engine/scheduling_issue_code.dart b/packages/scheduler_core/lib/src/scheduling_engine/scheduling_issue_code.dart new file mode 100644 index 0000000..ac7b318 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling_engine/scheduling_issue_code.dart @@ -0,0 +1,14 @@ +part of '../scheduling_engine.dart'; + +/// Stable issue identifiers for validation, no-slot, and no-op notices. +enum SchedulingIssueCode { + taskNotFound, + invalidTaskState, + missingDuration, + missingScheduledSlot, + nonPositiveDuration, + noAvailableSlot, + unfinishedTasksCouldNotFit, + noUnfinishedFlexibleTasks, + duplicateSurpriseLog, +} diff --git a/packages/scheduler_core/lib/src/scheduling_engine/scheduling_movement_code.dart b/packages/scheduler_core/lib/src/scheduling_engine/scheduling_movement_code.dart new file mode 100644 index 0000000..d10016d --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling_engine/scheduling_movement_code.dart @@ -0,0 +1,12 @@ +part of '../scheduling_engine.dart'; + +/// Stable movement identifiers for task placement changes. +enum SchedulingMovementCode { + backlogTaskInserted, + flexibleTaskMovedToMakeRoom, + flexibleTaskPushedToNextAvailableSlot, + flexibleTaskMovedToTomorrow, + unfinishedFlexibleTasksRolledOver, + flexibleTaskMovedToBacklog, + requiredCommitmentScheduled, +} diff --git a/packages/scheduler_core/lib/src/scheduling_engine/scheduling_notice.dart b/packages/scheduler_core/lib/src/scheduling_engine/scheduling_notice.dart new file mode 100644 index 0000000..5cabf25 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling_engine/scheduling_notice.dart @@ -0,0 +1,40 @@ +part of '../scheduling_engine.dart'; + +/// Starter notice type returned by scheduling operations. +/// +/// A notice is presentation-friendly context about an operation. It intentionally +/// carries both text and a structured [type] so the UI can decide whether to show +/// it as neutral info, movement, overlap, or failure. +class SchedulingNotice { + SchedulingNotice( + this.message, { + this.type = SchedulingNoticeType.info, + this.taskId, + this.issueCode, + this.movementCode, + this.conflictCode, + Map parameters = const {}, + }) : parameters = Map.unmodifiable(parameters); + + /// Human-readable message safe to surface in UI or logs. + final String message; + + /// Structured category for UI styling and tests. + final SchedulingNoticeType type; + + /// Optional task related to this notice. Null means the notice applies to the + /// whole operation. + final String? taskId; + + /// Stable issue code for validation/no-slot/no-op outcomes. + final SchedulingIssueCode? issueCode; + + /// Stable movement code for placement changes. + final SchedulingMovementCode? movementCode; + + /// Stable conflict code for overlap outcomes. + final SchedulingConflictCode? conflictCode; + + /// Structured notice parameters for callers that need more context. + final Map parameters; +} diff --git a/packages/scheduler_core/lib/src/scheduling_engine/scheduling_notice_type.dart b/packages/scheduler_core/lib/src/scheduling_engine/scheduling_notice_type.dart new file mode 100644 index 0000000..1d303b9 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling_engine/scheduling_notice_type.dart @@ -0,0 +1,23 @@ +part of '../scheduling_engine.dart'; + +/// Category for scheduler notices. +/// +/// Notices are human-readable summaries attached to a [SchedulingResult]. They +/// are not exceptions. The scheduler returns them alongside the task list so UI +/// can explain what happened without losing the successfully computed output. +enum SchedulingNoticeType { + /// General informational notice. + info, + + /// A task was moved by a scheduling operation. + moved, + + /// A scheduled task overlaps blocked time. + overlap, + + /// A task could not fit in the requested window. + noFit, + + /// A task would need to move outside the requested window. + overflow, +} diff --git a/packages/scheduler_core/lib/src/scheduling_engine/scheduling_operation_code.dart b/packages/scheduler_core/lib/src/scheduling_engine/scheduling_operation_code.dart new file mode 100644 index 0000000..1db3a14 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling_engine/scheduling_operation_code.dart @@ -0,0 +1,31 @@ +part of '../scheduling_engine.dart'; + +/// Stable scheduler operation identifiers. +enum SchedulingOperationCode { + /// Operation was built outside the core scheduler or is not specified. + unspecified, + + /// Insert a backlog task into the next available slot. + insertBacklogTaskIntoNextAvailableSlot, + + /// Push a flexible task later in the current window. + pushFlexibleTaskToNextAvailableSlot, + + /// Move a flexible task to the top of a future/tomorrow queue. + pushFlexibleTaskToTomorrowTopOfQueue, + + /// Roll unfinished flexible tasks into a target window. + rollOverUnfinishedFlexibleTasks, + + /// Analyze overlaps without moving tasks. + analyzeSchedule, + + /// Move a flexible task to backlog through the action service. + moveFlexibleTaskToBacklog, + + /// Log completed surprise work. + logSurpriseTask, + + /// Explicitly schedule a critical or inflexible commitment. + scheduleRequiredCommitment, +} diff --git a/packages/scheduler_core/lib/src/scheduling_engine/scheduling_outcome_code.dart b/packages/scheduler_core/lib/src/scheduling_engine/scheduling_outcome_code.dart new file mode 100644 index 0000000..2830814 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling_engine/scheduling_outcome_code.dart @@ -0,0 +1,25 @@ +part of '../scheduling_engine.dart'; + +/// Stable high-level outcome categories for scheduling operations. +enum SchedulingOutcomeCode { + /// Operation completed without conflicts. + success, + + /// Operation intentionally made no changes. + noOp, + + /// Requested record was not found. + notFound, + + /// Requested operation does not apply to the current state. + invalidState, + + /// No placement slot exists for the requested item. + noSlot, + + /// The requested queue would extend beyond the planning window. + overflow, + + /// Operation completed or analyzed with a conflict. + conflict, +} diff --git a/packages/scheduler_core/lib/src/scheduling_engine/scheduling_overlap.dart b/packages/scheduler_core/lib/src/scheduling_engine/scheduling_overlap.dart new file mode 100644 index 0000000..434d634 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling_engine/scheduling_overlap.dart @@ -0,0 +1,23 @@ +part of '../scheduling_engine.dart'; + +/// Overlap between a scheduled task and blocked time. +/// +/// Analysis uses this to report problems without moving anything. This is useful +/// when loading persisted data, debugging imports, or validating a day before the +/// UI presents it as clean. +class SchedulingOverlap { + const SchedulingOverlap({ + required this.taskId, + required this.taskInterval, + required this.blockedInterval, + }); + + /// Flexible task that overlaps blocked time. + final String taskId; + + /// The task's scheduled interval. + final TimeInterval taskInterval; + + /// The blocked interval it overlaps. + final TimeInterval blockedInterval; +} diff --git a/packages/scheduler_core/lib/src/scheduling_engine/scheduling_result.dart b/packages/scheduler_core/lib/src/scheduling_engine/scheduling_result.dart new file mode 100644 index 0000000..eb3c106 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling_engine/scheduling_result.dart @@ -0,0 +1,57 @@ +part of '../scheduling_engine.dart'; + +/// Starter result wrapper for scheduling operations. +/// +/// Every engine operation returns a [SchedulingResult], even when nothing moved. +/// This keeps the call pattern predictable: always inspect `tasks`, then surface +/// any `notices`, `changes`, or `overlaps` relevant to the UI. +class SchedulingResult { + SchedulingResult({ + required List tasks, + this.operationCode = SchedulingOperationCode.unspecified, + this.outcomeCode = SchedulingOutcomeCode.success, + List notices = const [], + List changes = const [], + List overlaps = const [], + }) : tasks = List.unmodifiable(tasks), + assert( + _noticeCodesAreSinglePurpose(notices), + 'Each scheduling notice may have only one issue/movement/conflict code.', + ), + notices = List.unmodifiable(notices), + changes = List.unmodifiable(changes), + overlaps = List.unmodifiable(overlaps); + + /// Replacement task list after the operation. + final List tasks; + + /// Stable operation identifier. + final SchedulingOperationCode operationCode; + + /// Stable high-level outcome category. + final SchedulingOutcomeCode outcomeCode; + + /// Human-readable operation messages. + final List notices; + + /// Machine-readable movements or schedule clears. + final List changes; + + /// Analysis-only overlap findings. + final List overlaps; +} + +bool _noticeCodesAreSinglePurpose(Iterable notices) { + for (final notice in notices) { + final codeCount = [ + notice.issueCode, + notice.movementCode, + notice.conflictCode, + ].where((code) => code != null).length; + if (codeCount > 1) { + return false; + } + } + + return true; +} diff --git a/packages/scheduler_core/lib/src/scheduling_engine/scheduling_window.dart b/packages/scheduler_core/lib/src/scheduling_engine/scheduling_window.dart new file mode 100644 index 0000000..09fa0fa --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling_engine/scheduling_window.dart @@ -0,0 +1,46 @@ +part of '../scheduling_engine.dart'; + +/// Window of time available to a scheduling operation. +/// +/// Most engine methods operate on one planning window: "today", "tomorrow", +/// or any other bounded range supplied by the caller. The window constrains where +/// flexible tasks can be placed. Anything outside this range is treated as out of +/// scope for the operation. +class SchedulingWindow { + SchedulingWindow({ + required this.start, + required this.end, + }) { + if (!start.isBefore(end)) { + throw DomainValidationException( + code: DomainValidationCode.invalidSchedulingWindow, + invalidValue: {'start': start, 'end': end}, + name: 'SchedulingWindow', + message: 'Scheduling window end must be after start.', + ); + } + } + + /// Inclusive beginning of the scheduling range. + final DateTime start; + + /// Exclusive ending of the scheduling range. + final DateTime end; + + /// The window as a [TimeInterval], useful for overlap checks. + TimeInterval get interval => TimeInterval(start: start, end: end); + + /// Whether [interval] is completely inside this window. + /// + /// A task that starts before the window or ends after the window is considered + /// outside the operation. The engine may treat such tasks as fixed blocks + /// instead of moving them. + bool contains(TimeInterval interval) { + final startsInWindow = + interval.start.isAfter(start) || interval.start.isAtSameMomentAs(start); + final endsInWindow = + interval.end.isBefore(end) || interval.end.isAtSameMomentAs(end); + + return startsInWindow && endsInWindow; + } +} diff --git a/packages/scheduler_core/lib/src/task_actions.dart b/packages/scheduler_core/lib/src/task_actions.dart index 2cf0a4f..53676db 100644 --- a/packages/scheduler_core/lib/src/task_actions.dart +++ b/packages/scheduler_core/lib/src/task_actions.dart @@ -13,1089 +13,14 @@ import 'occupancy_policy.dart'; import 'scheduling_engine.dart'; import 'task_lifecycle.dart'; import 'time_contracts.dart'; - -/// Quick actions available from a flexible task card. -/// -/// These are the low-friction card controls the UI can expose directly on a -/// planned flexible task. The service below translates each button into either a -/// direct task update, a scheduling operation, or a follow-up flow. -enum FlexibleTaskQuickAction { - /// Mark the task completed. - done, - - /// Ask the user where the task should be pushed. - push, - - /// Move the task out of today's plan and into backlog. - backlog, - - /// Start a flow that splits the task into child tasks. - breakUp, -} - -/// State transitions available from required visible task cards. -/// -/// Required visible tasks are critical or inflexible items. They are not moved by -/// flexible scheduling, but the user still needs simple lifecycle actions for -/// what happened to the commitment. -enum RequiredTaskAction { - /// Mark the required task completed. - done, - - /// Mark that the required task did not happen. - missed, - - /// Intentionally remove it from the active plan. - cancel, - - /// Dismiss it because it no longer applies, distinct from cancellation. - noLongerRelevant, -} - -/// Explicit push destinations shown after choosing the push quick action. -/// -/// Push starts as a simple quick action, but the actual destination requires one -/// more choice. Keeping destinations as a separate enum prevents the initial card -/// action list from becoming too crowded. -enum PushDestination { - /// Move the task later within the current planning window. - nextAvailableSlot, - - /// Move the task to the beginning of the supplied tomorrow/future window. - tomorrowTopOfQueue, - - /// Remove the task from the active timeline and store it for later. - backlog, -} - -/// Domain result for a flexible task quick action. -/// -/// This result deliberately supports three outcomes: the task changed, the user -/// must choose a push destination, or the UI should start the child-task flow. -/// That keeps card code from guessing how to interpret each action. -class FlexibleTaskActionResult { - FlexibleTaskActionResult({ - required this.action, - required this.task, - this.pushDestinations = const [], - this.startsChildTaskFlow = false, - List activities = const [], - this.transitionOutcomeCode, - }) : activities = List.unmodifiable(activities); - - /// Action the user selected. - final FlexibleTaskQuickAction action; - - /// Current or updated task, depending on the action. - final Task task; - - /// Destination choices to show after `push`; empty for direct actions. - final List pushDestinations; - - /// Whether the UI should open a child-task creation flow. - final bool startsChildTaskFlow; - - /// Internal activities produced by direct lifecycle actions. - final List activities; - - /// Typed transition outcome for direct lifecycle actions. - final TaskTransitionOutcomeCode? transitionOutcomeCode; - - /// True when the action directly produced an updated [task]. - bool get changedTask => !startsChildTaskFlow && pushDestinations.isEmpty; -} - -/// Result from applying a selected push destination. -/// -/// The selected destination is included next to the [SchedulingResult] so UI and -/// tests can distinguish "moved later today" from "moved to tomorrow" even if -/// the low-level scheduling change shape is similar. -class PushDestinationResult { - PushDestinationResult({ - required this.destination, - required this.schedulingResult, - List activities = const [], - }) : activities = List.unmodifiable(activities); - - /// Destination that was applied. - final PushDestination destination; - - /// Full scheduler output: updated tasks, notices, changes, and overlaps. - final SchedulingResult schedulingResult; - - /// Internal activities produced by applying the selected destination. - final List activities; - - /// Convenience flag for UI copy or persistence behavior that cares about the - /// tomorrow queue specifically. - bool get placesAtTomorrowTopOfQueue { - return destination == PushDestination.tomorrowTopOfQueue; - } -} - -/// Result from applying a required task state transition. -class RequiredTaskActionResult { - RequiredTaskActionResult({ - required this.action, - required this.task, - required this.removedFromActivePlan, - List activities = const [], - this.transitionOutcomeCode, - }) : activities = List.unmodifiable(activities); - - /// Action the user selected. - final RequiredTaskAction action; - - /// Updated task after the transition. - final Task task; - - /// Whether the transition removes the task from the active timeline. - /// - /// Critical missed tasks return true because they move to backlog. Cancelled - /// and no-longer-relevant tasks also return true because they are no longer - /// active planned work. - final bool removedFromActivePlan; - - /// Internal activities produced by the transition. - final List activities; - - /// Typed transition outcome. - final TaskTransitionOutcomeCode? transitionOutcomeCode; -} - -/// Input for logging work the user already did outside the plan. -/// -/// The user may know only a title, or may also know when it happened and how -/// long it took. When time data is present, the resulting surprise task occupies -/// that interval and flexible work overlapping it is pushed out of the way. -class SurpriseTaskLogRequest { - const SurpriseTaskLogRequest({ - required this.id, - required this.title, - required this.createdAt, - this.startedAt, - this.timeUsedMinutes, - this.projectId = 'inbox', - this.priority, - this.reward = RewardLevel.notSet, - this.difficulty = DifficultyLevel.notSet, - }); - - /// Caller-generated id for the new surprise task. - /// - /// This id is also the idempotency key for retrying the same log operation. - final String id; - - /// User-entered title. - final String title; - - /// Creation/log timestamp supplied by the caller for testability. - final DateTime createdAt; - - /// When the surprise work started, if known. - final DateTime? startedAt; - - /// Minutes spent on the surprise work, if known. - final int? timeUsedMinutes; - - /// Project id to assign; defaults to inbox when omitted. - final String projectId; - - /// Optional priority. Null means the user did not set one. - final PriorityLevel? priority; - - /// Optional reward estimate. - final RewardLevel reward; - - /// Optional difficulty estimate. - final DifficultyLevel difficulty; -} - -/// Result from logging a surprise task. -class SurpriseTaskLogResult { - const SurpriseTaskLogResult({ - required this.surpriseTask, - required this.schedulingResult, - this.activities = const [], - this.requiredOverlaps = const [], - this.lockedOverlaps = const [], - this.usedNormalPushRules = false, - }); - - /// Newly created completed surprise task. - final Task surpriseTask; - - /// Updated task list, movement notices, changes, and visible overlaps. - final SchedulingResult schedulingResult; - - /// Internal activities produced by logging the surprise task. - final List activities; - - /// Critical/inflexible overlaps with the surprise interval. - final List requiredOverlaps; - - /// Locked overlaps tracked separately so they stay hidden by default. - final List lockedOverlaps; - - /// Whether flexible movement was delegated to normal push behavior. - final bool usedNormalPushRules; -} - -/// Applies low-friction quick actions for flexible task cards. -/// -/// This service is the adapter between small UI button presses and domain logic. -/// It intentionally only accepts flexible tasks; required/locked/surprise items -/// should have their own action rules so the UI cannot accidentally apply a -/// flexible-only behavior to a fixed commitment. -class FlexibleTaskActionService { - const FlexibleTaskActionService({ - this.schedulingEngine = const SchedulingEngine(), - this.transitionService = const TaskTransitionService(), - this.clock = const SystemClock(), - }); - - /// Scheduling dependency used for actions that need timeline changes. - final SchedulingEngine schedulingEngine; - - /// Canonical lifecycle transition dependency. - final TaskTransitionService transitionService; - - /// Clock boundary used when an action timestamp is not supplied. - final Clock clock; - - /// Apply the first-stage quick action. - /// - /// Direct actions (`done`, `backlog`) return a changed task. `push` returns the - /// list of destinations the UI should present. `breakUp` signals that the UI - /// should start a child-task flow rather than changing the task immediately. - FlexibleTaskActionResult apply({ - required Task task, - required FlexibleTaskQuickAction action, - DateTime? updatedAt, - String? operationId, - DateTime? actualStart, - DateTime? actualEnd, - List lockedIntervals = const [], - Iterable existingActivities = const [], - }) { - if (!task.isFlexible) { - throw ArgumentError.value( - task.type, 'task.type', 'Task must be flexible.'); - } - - switch (action) { - case FlexibleTaskQuickAction.done: - final now = updatedAt ?? clock.now(); - final opId = - operationId ?? _defaultOperationId(action.name, task.id, now); - final transition = transitionService.apply( - task: task, - transitionCode: TaskTransitionCode.complete, - operationId: opId, - activityId: _activityId( - opId, - task.id, - TaskActivityCode.completed, - ), - occurredAt: now, - actualStart: actualStart, - actualEnd: actualEnd, - lockedIntervals: lockedIntervals, - existingActivities: existingActivities, - ); - return FlexibleTaskActionResult( - action: action, - task: transition.task, - activities: transition.activities, - transitionOutcomeCode: transition.outcomeCode, - ); - case FlexibleTaskQuickAction.push: - return FlexibleTaskActionResult( - action: action, - task: task, - pushDestinations: const [ - PushDestination.nextAvailableSlot, - PushDestination.tomorrowTopOfQueue, - PushDestination.backlog, - ], - ); - case FlexibleTaskQuickAction.backlog: - final now = updatedAt ?? clock.now(); - final opId = - operationId ?? _defaultOperationId(action.name, task.id, now); - final transition = transitionService.apply( - task: task, - transitionCode: TaskTransitionCode.moveToBacklog, - operationId: opId, - activityId: _activityId( - opId, - task.id, - TaskActivityCode.movedToBacklog, - ), - occurredAt: now, - existingActivities: existingActivities, - ); - return FlexibleTaskActionResult( - action: action, - task: transition.task, - activities: transition.activities, - transitionOutcomeCode: transition.outcomeCode, - ); - case FlexibleTaskQuickAction.breakUp: - return FlexibleTaskActionResult( - action: action, - task: task, - startsChildTaskFlow: true, - ); - } - } - - /// Apply the second-stage destination selected after the `push` action. - /// - /// This needs the full [SchedulingInput] because pushing can shift other - /// flexible tasks and must avoid locked/required intervals. - PushDestinationResult applyPushDestination({ - required PushDestination destination, - required SchedulingInput input, - required String taskId, - DateTime? updatedAt, - String? operationId, - Iterable existingActivities = const [], - }) { - final now = updatedAt ?? clock.now(); - final opId = operationId ?? - _defaultOperationId('push-${destination.name}', taskId, now); - if (_hasDuplicateActivity( - existingActivities: existingActivities, - operationId: opId, - taskId: taskId, - )) { - return PushDestinationResult( - destination: destination, - schedulingResult: SchedulingResult( - tasks: input.tasks, - operationCode: _operationCodeForPushDestination(destination), - outcomeCode: SchedulingOutcomeCode.noOp, - notices: [ - SchedulingNotice('Push operation already applied.'), - ], - ), - ); - } - - final result = switch (destination) { - PushDestination.nextAvailableSlot => - schedulingEngine.pushFlexibleTaskToNextAvailableSlot( - input: input, - taskId: taskId, - updatedAt: now, - ), - PushDestination.tomorrowTopOfQueue => - schedulingEngine.pushFlexibleTaskToTomorrowTopOfQueue( - input: input, - taskId: taskId, - updatedAt: now, - ), - PushDestination.backlog => _moveTaskToBacklog( - input: input, - taskId: taskId, - updatedAt: now, - ), - }; - - return PushDestinationResult( - destination: destination, - schedulingResult: result, - activities: _activitiesForPushDestination( - destination: destination, - input: input, - result: result, - taskId: taskId, - operationId: opId, - occurredAt: now, - existingActivities: existingActivities, - ), - ); - } - - /// Move one planned flexible task to backlog inside a scheduling result. - /// - /// This mirrors the shape of other push destination results so callers can - /// handle every destination through the same `SchedulingResult` interface. - SchedulingResult _moveTaskToBacklog({ - required SchedulingInput input, - required String taskId, - DateTime? updatedAt, - }) { - final task = _taskById(input.tasks, taskId); - if (task == null) { - return SchedulingResult( - tasks: input.tasks, - operationCode: SchedulingOperationCode.moveFlexibleTaskToBacklog, - outcomeCode: SchedulingOutcomeCode.notFound, - notices: [ - SchedulingNotice( - 'Task was not found.', - type: SchedulingNoticeType.noFit, - taskId: taskId, - issueCode: SchedulingIssueCode.taskNotFound, - ), - ], - ); - } - - if (!task.isFlexible || task.status != TaskStatus.planned) { - return SchedulingResult( - tasks: input.tasks, - operationCode: SchedulingOperationCode.moveFlexibleTaskToBacklog, - outcomeCode: SchedulingOutcomeCode.invalidState, - notices: [ - SchedulingNotice( - 'Only planned flexible tasks can be moved to backlog.', - type: SchedulingNoticeType.noFit, - taskId: task.id, - issueCode: SchedulingIssueCode.invalidTaskState, - ), - ], - ); - } - - final movedTask = schedulingEngine.moveToBacklog( - task, - updatedAt: updatedAt, - ); - - return SchedulingResult( - tasks: List.unmodifiable( - input.tasks.map((current) { - if (current.id == task.id) { - return movedTask; - } - - return current; - }), - ), - operationCode: SchedulingOperationCode.moveFlexibleTaskToBacklog, - outcomeCode: SchedulingOutcomeCode.success, - notices: [ - SchedulingNotice( - 'Flexible task moved to backlog.', - type: SchedulingNoticeType.moved, - taskId: task.id, - movementCode: SchedulingMovementCode.flexibleTaskMovedToBacklog, - parameters: { - 'previousStart': task.scheduledStart, - 'previousEnd': task.scheduledEnd, - 'nextStart': null, - 'nextEnd': null, - }, - ), - ], - changes: [ - SchedulingChange( - taskId: task.id, - previousStart: task.scheduledStart, - previousEnd: task.scheduledEnd, - nextStart: null, - nextEnd: null, - ), - ], - ); - } -} - -/// Applies lifecycle actions for required visible task cards. -/// -/// This service only handles critical and inflexible tasks. Locked blocks are -/// scheduling constraints, not normal task cards, and flexible tasks use -/// [FlexibleTaskActionService]. -class RequiredTaskActionService { - const RequiredTaskActionService({ - this.schedulingEngine = const SchedulingEngine(), - this.transitionService = const TaskTransitionService(), - this.clock = const SystemClock(), - }); - - /// Scheduling dependency used for required missed behavior. - final SchedulingEngine schedulingEngine; - - /// Canonical lifecycle transition dependency. - final TaskTransitionService transitionService; - - /// Clock boundary used when an action timestamp is not supplied. - final Clock clock; - - /// Apply one required-card state transition. - RequiredTaskActionResult apply({ - required Task task, - required RequiredTaskAction action, - DateTime? updatedAt, - String? operationId, - DateTime? actualStart, - DateTime? actualEnd, - List lockedIntervals = const [], - Iterable existingActivities = const [], - }) { - if (!task.isRequiredVisible) { - throw ArgumentError.value( - task.type, - 'task.type', - 'Task must be critical or inflexible.', - ); - } - - final now = updatedAt ?? clock.now(); - - final transitionCode = switch (action) { - RequiredTaskAction.done => TaskTransitionCode.complete, - RequiredTaskAction.missed => TaskTransitionCode.miss, - RequiredTaskAction.cancel => TaskTransitionCode.cancel, - RequiredTaskAction.noLongerRelevant => - TaskTransitionCode.noLongerRelevant, - }; - final activityCode = switch (action) { - RequiredTaskAction.done => TaskActivityCode.completed, - RequiredTaskAction.missed => TaskActivityCode.missed, - RequiredTaskAction.cancel => TaskActivityCode.cancelled, - RequiredTaskAction.noLongerRelevant => TaskActivityCode.noLongerRelevant, - }; - final opId = operationId ?? _defaultOperationId(action.name, task.id, now); - final transition = transitionService.apply( - task: task, - transitionCode: transitionCode, - operationId: opId, - activityId: _activityId(opId, task.id, activityCode), - occurredAt: now, - actualStart: actualStart, - actualEnd: actualEnd, - lockedIntervals: lockedIntervals, - existingActivities: existingActivities, - ); - - return RequiredTaskActionResult( - action: action, - task: transition.task, - removedFromActivePlan: transition.task.status == TaskStatus.backlog || - transition.task.status == TaskStatus.cancelled || - transition.task.status == TaskStatus.noLongerRelevant, - activities: transition.activities, - transitionOutcomeCode: transition.outcomeCode, - ); - } -} - -/// Logs surprise completed work and repairs affected flexible tasks. -/// -/// Surprise work is already done, so the new task is created as completed. When -/// it has a concrete interval, overlapping planned flexible tasks are moved by -/// the existing push scheduler with the surprise interval treated as blocked -/// time. Required visible and locked time is never moved. -class SurpriseTaskLogService { - const SurpriseTaskLogService({ - this.schedulingEngine = const SchedulingEngine(), - this.accountingService = const TaskActivityAccountingService(), - this.clock = const SystemClock(), - this.idGenerator, - }); - - /// Scheduling dependency used to move overlapping flexible tasks. - final SchedulingEngine schedulingEngine; - - /// Activity-derived statistics updater. - final TaskActivityAccountingService accountingService; - - /// Clock boundary used when a log timestamp is not supplied. - final Clock clock; - - /// Optional ID boundary for outer convenience entry points. - final IdGenerator? idGenerator; - - /// Log one surprise task against a scheduling snapshot. - SurpriseTaskLogResult log({ - required SurpriseTaskLogRequest request, - required SchedulingInput input, - DateTime? updatedAt, - bool revealHiddenLockedDetails = false, - }) { - final now = updatedAt ?? clock.now(); - final existingTask = _taskById(input.tasks, request.id); - if (existingTask != null) { - if (existingTask.type != TaskType.surprise) { - throw ArgumentError.value( - request.id, - 'request.id', - 'Surprise task id is already used by another task.', - ); - } - - return SurpriseTaskLogResult( - surpriseTask: existingTask, - schedulingResult: SchedulingResult( - tasks: input.tasks, - operationCode: SchedulingOperationCode.logSurpriseTask, - outcomeCode: SchedulingOutcomeCode.noOp, - notices: [ - SchedulingNotice( - 'Surprise task already logged.', - issueCode: SchedulingIssueCode.duplicateSurpriseLog, - taskId: existingTask.id, - ), - ], - ), - ); - } - - final rawSurpriseTask = _createSurpriseTask(request, updatedAt: now); - final completedActivity = _surpriseCompletedActivity( - task: rawSurpriseTask, - operationId: request.id, - occurredAt: now, - ); - final accountedSurprise = accountingService.apply( - task: rawSurpriseTask, - activities: [completedActivity], - lockedIntervals: _lockedIntervalsForAccounting(input), - ); - final surpriseTask = accountedSurprise.task; - final surpriseInterval = _occupyingIntervalForTask(surpriseTask); - var currentTasks = [surpriseTask, ...input.tasks]; - - if (surpriseInterval == null) { - return SurpriseTaskLogResult( - surpriseTask: surpriseTask, - schedulingResult: SchedulingResult( - tasks: List.unmodifiable(currentTasks), - operationCode: SchedulingOperationCode.logSurpriseTask, - outcomeCode: SchedulingOutcomeCode.success, - notices: [ - SchedulingNotice('Surprise task logged.'), - ], - ), - activities: [completedActivity], - ); - } - - final requiredOverlaps = _requiredOverlaps( - input: input, - surpriseInterval: surpriseInterval, - ); - final lockedOverlaps = _lockedOverlaps( - input: input, - surpriseInterval: surpriseInterval, - revealHiddenLockedDetails: revealHiddenLockedDetails, - ); - final flexibleTaskIds = _overlappingFlexibleTaskIds( - input.movablePlannedFlexibleTasks, - surpriseInterval, - ); - final changes = []; - final movementNotices = []; - var usedNormalPushRules = false; - - for (final taskId in flexibleTaskIds) { - final currentTask = _taskById(currentTasks, taskId); - final currentInterval = - currentTask == null ? null : _scheduledIntervalForTask(currentTask); - if (currentTask == null || - currentInterval == null || - !currentInterval.overlaps(surpriseInterval)) { - continue; - } - - final pushResult = schedulingEngine.pushFlexibleTaskToNextAvailableSlot( - input: SchedulingInput( - tasks: currentTasks, - window: input.window, - lockedIntervals: input.lockedIntervals, - requiredVisibleIntervals: input.requiredVisibleIntervals, - ), - taskId: taskId, - updatedAt: now, - countAsManualPush: false, - ); - - usedNormalPushRules = true; - currentTasks = pushResult.tasks; - changes.addAll(pushResult.changes); - movementNotices.addAll(pushResult.notices); - } - - final overlapNotices = requiredOverlaps - .map( - (overlap) => SchedulingNotice( - 'Surprise task overlaps required visible time.', - type: SchedulingNoticeType.overlap, - taskId: overlap.taskId, - conflictCode: - SchedulingConflictCode.surpriseTaskOverlapsRequiredVisibleTime, - parameters: { - 'blockedStart': overlap.blockedInterval.start, - 'blockedEnd': overlap.blockedInterval.end, - }, - ), - ) - .toList(growable: false); - - return SurpriseTaskLogResult( - surpriseTask: _taskById(currentTasks, surpriseTask.id) ?? surpriseTask, - schedulingResult: SchedulingResult( - tasks: List.unmodifiable(currentTasks), - operationCode: SchedulingOperationCode.logSurpriseTask, - outcomeCode: requiredOverlaps.isEmpty - ? SchedulingOutcomeCode.success - : SchedulingOutcomeCode.conflict, - notices: List.unmodifiable([ - SchedulingNotice('Surprise task logged.'), - ...movementNotices, - ...overlapNotices, - ]), - changes: List.unmodifiable(changes), - overlaps: List.unmodifiable(requiredOverlaps), - ), - requiredOverlaps: List.unmodifiable(requiredOverlaps), - lockedOverlaps: List.unmodifiable(lockedOverlaps), - activities: [completedActivity], - usedNormalPushRules: usedNormalPushRules, - ); - } - - /// Convenience outer-boundary wrapper that supplies id and creation time. - SurpriseTaskLogResult logNew({ - required String title, - required SchedulingInput input, - DateTime? startedAt, - int? timeUsedMinutes, - String projectId = 'inbox', - PriorityLevel? priority, - RewardLevel reward = RewardLevel.notSet, - DifficultyLevel difficulty = DifficultyLevel.notSet, - DateTime? createdAt, - DateTime? updatedAt, - bool revealHiddenLockedDetails = false, - }) { - final generator = idGenerator; - if (generator == null) { - throw StateError('Surprise task logging needs an id generator.'); - } - - final now = createdAt ?? updatedAt ?? clock.now(); - - return log( - request: SurpriseTaskLogRequest( - id: generator.nextId(), - title: title, - createdAt: now, - startedAt: startedAt, - timeUsedMinutes: timeUsedMinutes, - projectId: projectId, - priority: priority, - reward: reward, - difficulty: difficulty, - ), - input: input, - updatedAt: updatedAt ?? now, - revealHiddenLockedDetails: revealHiddenLockedDetails, - ); - } - - Task _createSurpriseTask( - SurpriseTaskLogRequest request, { - required DateTime updatedAt, - }) { - final trimmedTitle = request.title.trim(); - if (trimmedTitle.isEmpty) { - throw ArgumentError.value(request.title, 'title', 'Title is required.'); - } - - final duration = request.timeUsedMinutes; - final start = request.startedAt; - final hasScheduledTime = start != null && duration != null && duration > 0; - - return Task( - id: request.id, - title: trimmedTitle, - projectId: request.projectId, - type: TaskType.surprise, - status: TaskStatus.completed, - priority: request.priority, - reward: request.reward, - difficulty: request.difficulty, - durationMinutes: duration != null && duration > 0 ? duration : null, - scheduledStart: hasScheduledTime ? start : null, - scheduledEnd: - hasScheduledTime ? start.add(Duration(minutes: duration)) : null, - actualStart: hasScheduledTime ? start : null, - actualEnd: - hasScheduledTime ? start.add(Duration(minutes: duration)) : null, - completedAt: updatedAt, - createdAt: request.createdAt, - updatedAt: updatedAt, - ); - } -} - -List _activitiesForPushDestination({ - required PushDestination destination, - required SchedulingInput input, - required SchedulingResult result, - required String taskId, - required String operationId, - required DateTime occurredAt, - required Iterable existingActivities, -}) { - if (result.outcomeCode != SchedulingOutcomeCode.success) { - return const []; - } - - final activities = []; - for (final change in result.changes) { - if (_hasDuplicateActivity( - existingActivities: existingActivities, - operationId: operationId, - taskId: change.taskId, - )) { - continue; - } - - final task = _taskById(input.tasks, change.taskId); - if (task == null) { - continue; - } - - final code = _activityCodeForPushChange( - destination: destination, - isSelectedTask: change.taskId == taskId, - ); - activities.add( - TaskActivity( - id: _activityId(operationId, change.taskId, code), - operationId: operationId, - code: code, - taskId: change.taskId, - projectId: task.projectId, - occurredAt: occurredAt, - metadata: { - 'previousStatus': task.status.name, - 'nextStatus': code == TaskActivityCode.movedToBacklog - ? TaskStatus.backlog.name - : task.status.name, - 'previousStart': change.previousStart, - 'previousEnd': change.previousEnd, - 'nextStart': change.nextStart, - 'nextEnd': change.nextEnd, - 'destination': destination.name, - }, - ), - ); - } - - return List.unmodifiable(activities); -} - -TaskActivityCode _activityCodeForPushChange({ - required PushDestination destination, - required bool isSelectedTask, -}) { - if (destination == PushDestination.backlog) { - return TaskActivityCode.movedToBacklog; - } - - return isSelectedTask - ? TaskActivityCode.manuallyPushed - : TaskActivityCode.automaticallyPushed; -} - -SchedulingOperationCode _operationCodeForPushDestination( - PushDestination destination, -) { - return switch (destination) { - PushDestination.nextAvailableSlot => - SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot, - PushDestination.tomorrowTopOfQueue => - SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue, - PushDestination.backlog => - SchedulingOperationCode.moveFlexibleTaskToBacklog, - }; -} - -TaskActivity _surpriseCompletedActivity({ - required Task task, - required String operationId, - required DateTime occurredAt, -}) { - return TaskActivity( - id: _activityId(operationId, task.id, TaskActivityCode.completed), - operationId: operationId, - code: TaskActivityCode.completed, - taskId: task.id, - projectId: task.projectId, - occurredAt: occurredAt, - metadata: { - 'nextStatus': TaskStatus.completed.name, - 'completedAt': occurredAt, - 'actualStart': task.actualStart, - 'actualEnd': task.actualEnd, - 'durationMinutes': task.durationMinutes, - 'reward': task.reward.name, - 'difficulty': task.difficulty.name, - if (task.reminderOverride != null) - 'reminderProfile': task.reminderOverride!.name, - 'pushesBeforeCompletion': 0, - 'source': 'surpriseLog', - }, - ); -} - -List _lockedIntervalsForAccounting(SchedulingInput input) { - return input.occupancyEntries - .where( - (entry) => entry.category == OccupancyCategory.hiddenLockedConstraint, - ) - .map((entry) => entry.interval) - .whereType() - .toList(growable: false); -} - -bool _hasDuplicateActivity({ - required Iterable existingActivities, - required String operationId, - required String taskId, -}) { - for (final activity in existingActivities) { - if (activity.operationId == operationId && activity.taskId == taskId) { - return true; - } - } - - return false; -} - -String _defaultOperationId( - String actionName, String taskId, DateTime occurredAt) { - return '$actionName:$taskId:${occurredAt.toIso8601String()}'; -} - -String _activityId( - String operationId, - String taskId, - TaskActivityCode activityCode, -) { - return '$operationId:$taskId:${activityCode.name}'; -} - -/// Find one task by id in a list. -Task? _taskById(List tasks, String taskId) { - for (final task in tasks) { - if (task.id == taskId) { - return task; - } - } - - return null; -} - -/// Build an interval for a task, or null when it has no usable placement. -TimeInterval? _scheduledIntervalForTask(Task task) { - final start = task.scheduledStart; - final end = task.scheduledEnd; - - if (start == null || end == null || !start.isBefore(end)) { - return null; - } - - return TimeInterval(start: start, end: end, label: task.id); -} - -/// Build an actual-first occupancy interval for completed/active task facts. -TimeInterval? _occupyingIntervalForTask(Task task) { - final actualStart = task.actualStart; - final actualEnd = task.actualEnd; - - if (actualStart != null && - actualEnd != null && - actualStart.isBefore(actualEnd)) { - return TimeInterval(start: actualStart, end: actualEnd, label: task.id); - } - - return _scheduledIntervalForTask(task); -} - -/// Required visible overlaps with a surprise interval. -List _requiredOverlaps({ - required SchedulingInput input, - required TimeInterval surpriseInterval, -}) { - final overlaps = []; - - for (final occupancy in input.occupancyEntries) { - final task = occupancy.task; - final interval = occupancy.interval; - final isRequiredOccupancy = - occupancy.category == OccupancyCategory.requiredVisibleCommitment || - (occupancy.category == OccupancyCategory.activeWork && - task != null && - task.isRequiredVisible); - - if (task == null || - !isRequiredOccupancy || - interval == null || - !interval.overlaps(surpriseInterval)) { - continue; - } - - overlaps.add( - SchedulingOverlap( - taskId: task.id, - taskInterval: interval, - blockedInterval: surpriseInterval, - ), - ); - } - - return overlaps; -} - -/// Locked overlaps with a surprise interval, kept hidden from normal notices. -List _lockedOverlaps({ - required SchedulingInput input, - required TimeInterval surpriseInterval, - required bool revealHiddenLockedDetails, -}) { - return input.occupancyEntries - .where( - (occupancy) => - occupancy.category == OccupancyCategory.hiddenLockedConstraint, - ) - .map((occupancy) => occupancy.interval) - .whereType() - .where((interval) => interval.overlaps(surpriseInterval)) - .map((interval) { - if (revealHiddenLockedDetails) { - return interval; - } - return TimeInterval(start: interval.start, end: interval.end); - }).toList(growable: false); -} - -/// Planned flexible task ids overlapping a surprise interval in timeline order. -List _overlappingFlexibleTaskIds( - List tasks, - TimeInterval surpriseInterval, -) { - final overlappingTasks = tasks.where((task) { - if (task.status != TaskStatus.planned) { - return false; - } - - final interval = _scheduledIntervalForTask(task); - return interval != null && interval.overlaps(surpriseInterval); - }).toList(growable: false) - ..sort((a, b) { - final aStart = a.scheduledStart ?? surpriseInterval.end; - final bStart = b.scheduledStart ?? surpriseInterval.end; - - return aStart.compareTo(bStart); - }); - - return overlappingTasks.map((task) => task.id).toList(growable: false); -} +part 'task_actions/flexible_task_quick_action.dart'; +part 'task_actions/required_task_action.dart'; +part 'task_actions/push_destination.dart'; +part 'task_actions/flexible_task_action_result.dart'; +part 'task_actions/push_destination_result.dart'; +part 'task_actions/required_task_action_result.dart'; +part 'task_actions/surprise_task_log_request.dart'; +part 'task_actions/surprise_task_log_result.dart'; +part 'task_actions/flexible_task_action_service.dart'; +part 'task_actions/required_task_action_service.dart'; +part 'task_actions/surprise_task_log_service.dart'; diff --git a/packages/scheduler_core/lib/src/task_actions/flexible_task_action_result.dart b/packages/scheduler_core/lib/src/task_actions/flexible_task_action_result.dart new file mode 100644 index 0000000..8715749 --- /dev/null +++ b/packages/scheduler_core/lib/src/task_actions/flexible_task_action_result.dart @@ -0,0 +1,38 @@ +part of '../task_actions.dart'; + +/// Domain result for a flexible task quick action. +/// +/// This result deliberately supports three outcomes: the task changed, the user +/// must choose a push destination, or the UI should start the child-task flow. +/// That keeps card code from guessing how to interpret each action. +class FlexibleTaskActionResult { + FlexibleTaskActionResult({ + required this.action, + required this.task, + this.pushDestinations = const [], + this.startsChildTaskFlow = false, + List activities = const [], + this.transitionOutcomeCode, + }) : activities = List.unmodifiable(activities); + + /// Action the user selected. + final FlexibleTaskQuickAction action; + + /// Current or updated task, depending on the action. + final Task task; + + /// Destination choices to show after `push`; empty for direct actions. + final List pushDestinations; + + /// Whether the UI should open a child-task creation flow. + final bool startsChildTaskFlow; + + /// Internal activities produced by direct lifecycle actions. + final List activities; + + /// Typed transition outcome for direct lifecycle actions. + final TaskTransitionOutcomeCode? transitionOutcomeCode; + + /// True when the action directly produced an updated [task]. + bool get changedTask => !startsChildTaskFlow && pushDestinations.isEmpty; +} diff --git a/packages/scheduler_core/lib/src/task_actions/flexible_task_action_service.dart b/packages/scheduler_core/lib/src/task_actions/flexible_task_action_service.dart new file mode 100644 index 0000000..0414933 --- /dev/null +++ b/packages/scheduler_core/lib/src/task_actions/flexible_task_action_service.dart @@ -0,0 +1,264 @@ +part of '../task_actions.dart'; + +/// Applies low-friction quick actions for flexible task cards. +/// +/// This service is the adapter between small UI button presses and domain logic. +/// It intentionally only accepts flexible tasks; required/locked/surprise items +/// should have their own action rules so the UI cannot accidentally apply a +/// flexible-only behavior to a fixed commitment. +class FlexibleTaskActionService { + const FlexibleTaskActionService({ + this.schedulingEngine = const SchedulingEngine(), + this.transitionService = const TaskTransitionService(), + this.clock = const SystemClock(), + }); + + /// Scheduling dependency used for actions that need timeline changes. + final SchedulingEngine schedulingEngine; + + /// Canonical lifecycle transition dependency. + final TaskTransitionService transitionService; + + /// Clock boundary used when an action timestamp is not supplied. + final Clock clock; + + /// Apply the first-stage quick action. + /// + /// Direct actions (`done`, `backlog`) return a changed task. `push` returns the + /// list of destinations the UI should present. `breakUp` signals that the UI + /// should start a child-task flow rather than changing the task immediately. + FlexibleTaskActionResult apply({ + required Task task, + required FlexibleTaskQuickAction action, + DateTime? updatedAt, + String? operationId, + DateTime? actualStart, + DateTime? actualEnd, + List lockedIntervals = const [], + Iterable existingActivities = const [], + }) { + if (!task.isFlexible) { + throw ArgumentError.value( + task.type, 'task.type', 'Task must be flexible.'); + } + + switch (action) { + case FlexibleTaskQuickAction.done: + final now = updatedAt ?? clock.now(); + final opId = + operationId ?? _defaultOperationId(action.name, task.id, now); + final transition = transitionService.apply( + task: task, + transitionCode: TaskTransitionCode.complete, + operationId: opId, + activityId: _activityId( + opId, + task.id, + TaskActivityCode.completed, + ), + occurredAt: now, + actualStart: actualStart, + actualEnd: actualEnd, + lockedIntervals: lockedIntervals, + existingActivities: existingActivities, + ); + return FlexibleTaskActionResult( + action: action, + task: transition.task, + activities: transition.activities, + transitionOutcomeCode: transition.outcomeCode, + ); + case FlexibleTaskQuickAction.push: + return FlexibleTaskActionResult( + action: action, + task: task, + pushDestinations: const [ + PushDestination.nextAvailableSlot, + PushDestination.tomorrowTopOfQueue, + PushDestination.backlog, + ], + ); + case FlexibleTaskQuickAction.backlog: + final now = updatedAt ?? clock.now(); + final opId = + operationId ?? _defaultOperationId(action.name, task.id, now); + final transition = transitionService.apply( + task: task, + transitionCode: TaskTransitionCode.moveToBacklog, + operationId: opId, + activityId: _activityId( + opId, + task.id, + TaskActivityCode.movedToBacklog, + ), + occurredAt: now, + existingActivities: existingActivities, + ); + return FlexibleTaskActionResult( + action: action, + task: transition.task, + activities: transition.activities, + transitionOutcomeCode: transition.outcomeCode, + ); + case FlexibleTaskQuickAction.breakUp: + return FlexibleTaskActionResult( + action: action, + task: task, + startsChildTaskFlow: true, + ); + } + } + + /// Apply the second-stage destination selected after the `push` action. + /// + /// This needs the full [SchedulingInput] because pushing can shift other + /// flexible tasks and must avoid locked/required intervals. + PushDestinationResult applyPushDestination({ + required PushDestination destination, + required SchedulingInput input, + required String taskId, + DateTime? updatedAt, + String? operationId, + Iterable existingActivities = const [], + }) { + final now = updatedAt ?? clock.now(); + final opId = operationId ?? + _defaultOperationId('push-${destination.name}', taskId, now); + if (_hasDuplicateActivity( + existingActivities: existingActivities, + operationId: opId, + taskId: taskId, + )) { + return PushDestinationResult( + destination: destination, + schedulingResult: SchedulingResult( + tasks: input.tasks, + operationCode: _operationCodeForPushDestination(destination), + outcomeCode: SchedulingOutcomeCode.noOp, + notices: [ + SchedulingNotice('Push operation already applied.'), + ], + ), + ); + } + + final result = switch (destination) { + PushDestination.nextAvailableSlot => + schedulingEngine.pushFlexibleTaskToNextAvailableSlot( + input: input, + taskId: taskId, + updatedAt: now, + ), + PushDestination.tomorrowTopOfQueue => + schedulingEngine.pushFlexibleTaskToTomorrowTopOfQueue( + input: input, + taskId: taskId, + updatedAt: now, + ), + PushDestination.backlog => _moveTaskToBacklog( + input: input, + taskId: taskId, + updatedAt: now, + ), + }; + + return PushDestinationResult( + destination: destination, + schedulingResult: result, + activities: _activitiesForPushDestination( + destination: destination, + input: input, + result: result, + taskId: taskId, + operationId: opId, + occurredAt: now, + existingActivities: existingActivities, + ), + ); + } + + /// Move one planned flexible task to backlog inside a scheduling result. + /// + /// This mirrors the shape of other push destination results so callers can + /// handle every destination through the same `SchedulingResult` interface. + SchedulingResult _moveTaskToBacklog({ + required SchedulingInput input, + required String taskId, + DateTime? updatedAt, + }) { + final task = _taskById(input.tasks, taskId); + if (task == null) { + return SchedulingResult( + tasks: input.tasks, + operationCode: SchedulingOperationCode.moveFlexibleTaskToBacklog, + outcomeCode: SchedulingOutcomeCode.notFound, + notices: [ + SchedulingNotice( + 'Task was not found.', + type: SchedulingNoticeType.noFit, + taskId: taskId, + issueCode: SchedulingIssueCode.taskNotFound, + ), + ], + ); + } + + if (!task.isFlexible || task.status != TaskStatus.planned) { + return SchedulingResult( + tasks: input.tasks, + operationCode: SchedulingOperationCode.moveFlexibleTaskToBacklog, + outcomeCode: SchedulingOutcomeCode.invalidState, + notices: [ + SchedulingNotice( + 'Only planned flexible tasks can be moved to backlog.', + type: SchedulingNoticeType.noFit, + taskId: task.id, + issueCode: SchedulingIssueCode.invalidTaskState, + ), + ], + ); + } + + final movedTask = schedulingEngine.moveToBacklog( + task, + updatedAt: updatedAt, + ); + + return SchedulingResult( + tasks: List.unmodifiable( + input.tasks.map((current) { + if (current.id == task.id) { + return movedTask; + } + + return current; + }), + ), + operationCode: SchedulingOperationCode.moveFlexibleTaskToBacklog, + outcomeCode: SchedulingOutcomeCode.success, + notices: [ + SchedulingNotice( + 'Flexible task moved to backlog.', + type: SchedulingNoticeType.moved, + taskId: task.id, + movementCode: SchedulingMovementCode.flexibleTaskMovedToBacklog, + parameters: { + 'previousStart': task.scheduledStart, + 'previousEnd': task.scheduledEnd, + 'nextStart': null, + 'nextEnd': null, + }, + ), + ], + changes: [ + SchedulingChange( + taskId: task.id, + previousStart: task.scheduledStart, + previousEnd: task.scheduledEnd, + nextStart: null, + nextEnd: null, + ), + ], + ); + } +} diff --git a/packages/scheduler_core/lib/src/task_actions/flexible_task_quick_action.dart b/packages/scheduler_core/lib/src/task_actions/flexible_task_quick_action.dart new file mode 100644 index 0000000..1fb03b1 --- /dev/null +++ b/packages/scheduler_core/lib/src/task_actions/flexible_task_quick_action.dart @@ -0,0 +1,20 @@ +part of '../task_actions.dart'; + +/// Quick actions available from a flexible task card. +/// +/// These are the low-friction card controls the UI can expose directly on a +/// planned flexible task. The service below translates each button into either a +/// direct task update, a scheduling operation, or a follow-up flow. +enum FlexibleTaskQuickAction { + /// Mark the task completed. + done, + + /// Ask the user where the task should be pushed. + push, + + /// Move the task out of today's plan and into backlog. + backlog, + + /// Start a flow that splits the task into child tasks. + breakUp, +} diff --git a/packages/scheduler_core/lib/src/task_actions/push_destination.dart b/packages/scheduler_core/lib/src/task_actions/push_destination.dart new file mode 100644 index 0000000..2c6f0c7 --- /dev/null +++ b/packages/scheduler_core/lib/src/task_actions/push_destination.dart @@ -0,0 +1,17 @@ +part of '../task_actions.dart'; + +/// Explicit push destinations shown after choosing the push quick action. +/// +/// Push starts as a simple quick action, but the actual destination requires one +/// more choice. Keeping destinations as a separate enum prevents the initial card +/// action list from becoming too crowded. +enum PushDestination { + /// Move the task later within the current planning window. + nextAvailableSlot, + + /// Move the task to the beginning of the supplied tomorrow/future window. + tomorrowTopOfQueue, + + /// Remove the task from the active timeline and store it for later. + backlog, +} diff --git a/packages/scheduler_core/lib/src/task_actions/push_destination_result.dart b/packages/scheduler_core/lib/src/task_actions/push_destination_result.dart new file mode 100644 index 0000000..d96ad54 --- /dev/null +++ b/packages/scheduler_core/lib/src/task_actions/push_destination_result.dart @@ -0,0 +1,29 @@ +part of '../task_actions.dart'; + +/// Result from applying a selected push destination. +/// +/// The selected destination is included next to the [SchedulingResult] so UI and +/// tests can distinguish "moved later today" from "moved to tomorrow" even if +/// the low-level scheduling change shape is similar. +class PushDestinationResult { + PushDestinationResult({ + required this.destination, + required this.schedulingResult, + List activities = const [], + }) : activities = List.unmodifiable(activities); + + /// Destination that was applied. + final PushDestination destination; + + /// Full scheduler output: updated tasks, notices, changes, and overlaps. + final SchedulingResult schedulingResult; + + /// Internal activities produced by applying the selected destination. + final List activities; + + /// Convenience flag for UI copy or persistence behavior that cares about the + /// tomorrow queue specifically. + bool get placesAtTomorrowTopOfQueue { + return destination == PushDestination.tomorrowTopOfQueue; + } +} diff --git a/packages/scheduler_core/lib/src/task_actions/required_task_action.dart b/packages/scheduler_core/lib/src/task_actions/required_task_action.dart new file mode 100644 index 0000000..a66a2f4 --- /dev/null +++ b/packages/scheduler_core/lib/src/task_actions/required_task_action.dart @@ -0,0 +1,20 @@ +part of '../task_actions.dart'; + +/// State transitions available from required visible task cards. +/// +/// Required visible tasks are critical or inflexible items. They are not moved by +/// flexible scheduling, but the user still needs simple lifecycle actions for +/// what happened to the commitment. +enum RequiredTaskAction { + /// Mark the required task completed. + done, + + /// Mark that the required task did not happen. + missed, + + /// Intentionally remove it from the active plan. + cancel, + + /// Dismiss it because it no longer applies, distinct from cancellation. + noLongerRelevant, +} diff --git a/packages/scheduler_core/lib/src/task_actions/required_task_action_result.dart b/packages/scheduler_core/lib/src/task_actions/required_task_action_result.dart new file mode 100644 index 0000000..c8cdc73 --- /dev/null +++ b/packages/scheduler_core/lib/src/task_actions/required_task_action_result.dart @@ -0,0 +1,31 @@ +part of '../task_actions.dart'; + +/// Result from applying a required task state transition. +class RequiredTaskActionResult { + RequiredTaskActionResult({ + required this.action, + required this.task, + required this.removedFromActivePlan, + List activities = const [], + this.transitionOutcomeCode, + }) : activities = List.unmodifiable(activities); + + /// Action the user selected. + final RequiredTaskAction action; + + /// Updated task after the transition. + final Task task; + + /// Whether the transition removes the task from the active timeline. + /// + /// Critical missed tasks return true because they move to backlog. Cancelled + /// and no-longer-relevant tasks also return true because they are no longer + /// active planned work. + final bool removedFromActivePlan; + + /// Internal activities produced by the transition. + final List activities; + + /// Typed transition outcome. + final TaskTransitionOutcomeCode? transitionOutcomeCode; +} diff --git a/packages/scheduler_core/lib/src/task_actions/required_task_action_service.dart b/packages/scheduler_core/lib/src/task_actions/required_task_action_service.dart new file mode 100644 index 0000000..97ee665 --- /dev/null +++ b/packages/scheduler_core/lib/src/task_actions/required_task_action_service.dart @@ -0,0 +1,81 @@ +part of '../task_actions.dart'; + +/// Applies lifecycle actions for required visible task cards. +/// +/// This service only handles critical and inflexible tasks. Locked blocks are +/// scheduling constraints, not normal task cards, and flexible tasks use +/// [FlexibleTaskActionService]. +class RequiredTaskActionService { + const RequiredTaskActionService({ + this.schedulingEngine = const SchedulingEngine(), + this.transitionService = const TaskTransitionService(), + this.clock = const SystemClock(), + }); + + /// Scheduling dependency used for required missed behavior. + final SchedulingEngine schedulingEngine; + + /// Canonical lifecycle transition dependency. + final TaskTransitionService transitionService; + + /// Clock boundary used when an action timestamp is not supplied. + final Clock clock; + + /// Apply one required-card state transition. + RequiredTaskActionResult apply({ + required Task task, + required RequiredTaskAction action, + DateTime? updatedAt, + String? operationId, + DateTime? actualStart, + DateTime? actualEnd, + List lockedIntervals = const [], + Iterable existingActivities = const [], + }) { + if (!task.isRequiredVisible) { + throw ArgumentError.value( + task.type, + 'task.type', + 'Task must be critical or inflexible.', + ); + } + + final now = updatedAt ?? clock.now(); + + final transitionCode = switch (action) { + RequiredTaskAction.done => TaskTransitionCode.complete, + RequiredTaskAction.missed => TaskTransitionCode.miss, + RequiredTaskAction.cancel => TaskTransitionCode.cancel, + RequiredTaskAction.noLongerRelevant => + TaskTransitionCode.noLongerRelevant, + }; + final activityCode = switch (action) { + RequiredTaskAction.done => TaskActivityCode.completed, + RequiredTaskAction.missed => TaskActivityCode.missed, + RequiredTaskAction.cancel => TaskActivityCode.cancelled, + RequiredTaskAction.noLongerRelevant => TaskActivityCode.noLongerRelevant, + }; + final opId = operationId ?? _defaultOperationId(action.name, task.id, now); + final transition = transitionService.apply( + task: task, + transitionCode: transitionCode, + operationId: opId, + activityId: _activityId(opId, task.id, activityCode), + occurredAt: now, + actualStart: actualStart, + actualEnd: actualEnd, + lockedIntervals: lockedIntervals, + existingActivities: existingActivities, + ); + + return RequiredTaskActionResult( + action: action, + task: transition.task, + removedFromActivePlan: transition.task.status == TaskStatus.backlog || + transition.task.status == TaskStatus.cancelled || + transition.task.status == TaskStatus.noLongerRelevant, + activities: transition.activities, + transitionOutcomeCode: transition.outcomeCode, + ); + } +} diff --git a/packages/scheduler_core/lib/src/task_actions/surprise_task_log_request.dart b/packages/scheduler_core/lib/src/task_actions/surprise_task_log_request.dart new file mode 100644 index 0000000..d45b69b --- /dev/null +++ b/packages/scheduler_core/lib/src/task_actions/surprise_task_log_request.dart @@ -0,0 +1,49 @@ +part of '../task_actions.dart'; + +/// Input for logging work the user already did outside the plan. +/// +/// The user may know only a title, or may also know when it happened and how +/// long it took. When time data is present, the resulting surprise task occupies +/// that interval and flexible work overlapping it is pushed out of the way. +class SurpriseTaskLogRequest { + const SurpriseTaskLogRequest({ + required this.id, + required this.title, + required this.createdAt, + this.startedAt, + this.timeUsedMinutes, + this.projectId = 'inbox', + this.priority, + this.reward = RewardLevel.notSet, + this.difficulty = DifficultyLevel.notSet, + }); + + /// Caller-generated id for the new surprise task. + /// + /// This id is also the idempotency key for retrying the same log operation. + final String id; + + /// User-entered title. + final String title; + + /// Creation/log timestamp supplied by the caller for testability. + final DateTime createdAt; + + /// When the surprise work started, if known. + final DateTime? startedAt; + + /// Minutes spent on the surprise work, if known. + final int? timeUsedMinutes; + + /// Project id to assign; defaults to inbox when omitted. + final String projectId; + + /// Optional priority. Null means the user did not set one. + final PriorityLevel? priority; + + /// Optional reward estimate. + final RewardLevel reward; + + /// Optional difficulty estimate. + final DifficultyLevel difficulty; +} diff --git a/packages/scheduler_core/lib/src/task_actions/surprise_task_log_result.dart b/packages/scheduler_core/lib/src/task_actions/surprise_task_log_result.dart new file mode 100644 index 0000000..8a462b4 --- /dev/null +++ b/packages/scheduler_core/lib/src/task_actions/surprise_task_log_result.dart @@ -0,0 +1,31 @@ +part of '../task_actions.dart'; + +/// Result from logging a surprise task. +class SurpriseTaskLogResult { + const SurpriseTaskLogResult({ + required this.surpriseTask, + required this.schedulingResult, + this.activities = const [], + this.requiredOverlaps = const [], + this.lockedOverlaps = const [], + this.usedNormalPushRules = false, + }); + + /// Newly created completed surprise task. + final Task surpriseTask; + + /// Updated task list, movement notices, changes, and visible overlaps. + final SchedulingResult schedulingResult; + + /// Internal activities produced by logging the surprise task. + final List activities; + + /// Critical/inflexible overlaps with the surprise interval. + final List requiredOverlaps; + + /// Locked overlaps tracked separately so they stay hidden by default. + final List lockedOverlaps; + + /// Whether flexible movement was delegated to normal push behavior. + final bool usedNormalPushRules; +} diff --git a/packages/scheduler_core/lib/src/task_actions/surprise_task_log_service.dart b/packages/scheduler_core/lib/src/task_actions/surprise_task_log_service.dart new file mode 100644 index 0000000..1cdac2f --- /dev/null +++ b/packages/scheduler_core/lib/src/task_actions/surprise_task_log_service.dart @@ -0,0 +1,517 @@ +part of '../task_actions.dart'; + +/// Logs surprise completed work and repairs affected flexible tasks. +/// +/// Surprise work is already done, so the new task is created as completed. When +/// it has a concrete interval, overlapping planned flexible tasks are moved by +/// the existing push scheduler with the surprise interval treated as blocked +/// time. Required visible and locked time is never moved. +class SurpriseTaskLogService { + const SurpriseTaskLogService({ + this.schedulingEngine = const SchedulingEngine(), + this.accountingService = const TaskActivityAccountingService(), + this.clock = const SystemClock(), + this.idGenerator, + }); + + /// Scheduling dependency used to move overlapping flexible tasks. + final SchedulingEngine schedulingEngine; + + /// Activity-derived statistics updater. + final TaskActivityAccountingService accountingService; + + /// Clock boundary used when a log timestamp is not supplied. + final Clock clock; + + /// Optional ID boundary for outer convenience entry points. + final IdGenerator? idGenerator; + + /// Log one surprise task against a scheduling snapshot. + SurpriseTaskLogResult log({ + required SurpriseTaskLogRequest request, + required SchedulingInput input, + DateTime? updatedAt, + bool revealHiddenLockedDetails = false, + }) { + final now = updatedAt ?? clock.now(); + final existingTask = _taskById(input.tasks, request.id); + if (existingTask != null) { + if (existingTask.type != TaskType.surprise) { + throw ArgumentError.value( + request.id, + 'request.id', + 'Surprise task id is already used by another task.', + ); + } + + return SurpriseTaskLogResult( + surpriseTask: existingTask, + schedulingResult: SchedulingResult( + tasks: input.tasks, + operationCode: SchedulingOperationCode.logSurpriseTask, + outcomeCode: SchedulingOutcomeCode.noOp, + notices: [ + SchedulingNotice( + 'Surprise task already logged.', + issueCode: SchedulingIssueCode.duplicateSurpriseLog, + taskId: existingTask.id, + ), + ], + ), + ); + } + + final rawSurpriseTask = _createSurpriseTask(request, updatedAt: now); + final completedActivity = _surpriseCompletedActivity( + task: rawSurpriseTask, + operationId: request.id, + occurredAt: now, + ); + final accountedSurprise = accountingService.apply( + task: rawSurpriseTask, + activities: [completedActivity], + lockedIntervals: _lockedIntervalsForAccounting(input), + ); + final surpriseTask = accountedSurprise.task; + final surpriseInterval = _occupyingIntervalForTask(surpriseTask); + var currentTasks = [surpriseTask, ...input.tasks]; + + if (surpriseInterval == null) { + return SurpriseTaskLogResult( + surpriseTask: surpriseTask, + schedulingResult: SchedulingResult( + tasks: List.unmodifiable(currentTasks), + operationCode: SchedulingOperationCode.logSurpriseTask, + outcomeCode: SchedulingOutcomeCode.success, + notices: [ + SchedulingNotice('Surprise task logged.'), + ], + ), + activities: [completedActivity], + ); + } + + final requiredOverlaps = _requiredOverlaps( + input: input, + surpriseInterval: surpriseInterval, + ); + final lockedOverlaps = _lockedOverlaps( + input: input, + surpriseInterval: surpriseInterval, + revealHiddenLockedDetails: revealHiddenLockedDetails, + ); + final flexibleTaskIds = _overlappingFlexibleTaskIds( + input.movablePlannedFlexibleTasks, + surpriseInterval, + ); + final changes = []; + final movementNotices = []; + var usedNormalPushRules = false; + + for (final taskId in flexibleTaskIds) { + final currentTask = _taskById(currentTasks, taskId); + final currentInterval = + currentTask == null ? null : _scheduledIntervalForTask(currentTask); + if (currentTask == null || + currentInterval == null || + !currentInterval.overlaps(surpriseInterval)) { + continue; + } + + final pushResult = schedulingEngine.pushFlexibleTaskToNextAvailableSlot( + input: SchedulingInput( + tasks: currentTasks, + window: input.window, + lockedIntervals: input.lockedIntervals, + requiredVisibleIntervals: input.requiredVisibleIntervals, + ), + taskId: taskId, + updatedAt: now, + countAsManualPush: false, + ); + + usedNormalPushRules = true; + currentTasks = pushResult.tasks; + changes.addAll(pushResult.changes); + movementNotices.addAll(pushResult.notices); + } + + final overlapNotices = requiredOverlaps + .map( + (overlap) => SchedulingNotice( + 'Surprise task overlaps required visible time.', + type: SchedulingNoticeType.overlap, + taskId: overlap.taskId, + conflictCode: + SchedulingConflictCode.surpriseTaskOverlapsRequiredVisibleTime, + parameters: { + 'blockedStart': overlap.blockedInterval.start, + 'blockedEnd': overlap.blockedInterval.end, + }, + ), + ) + .toList(growable: false); + + return SurpriseTaskLogResult( + surpriseTask: _taskById(currentTasks, surpriseTask.id) ?? surpriseTask, + schedulingResult: SchedulingResult( + tasks: List.unmodifiable(currentTasks), + operationCode: SchedulingOperationCode.logSurpriseTask, + outcomeCode: requiredOverlaps.isEmpty + ? SchedulingOutcomeCode.success + : SchedulingOutcomeCode.conflict, + notices: List.unmodifiable([ + SchedulingNotice('Surprise task logged.'), + ...movementNotices, + ...overlapNotices, + ]), + changes: List.unmodifiable(changes), + overlaps: List.unmodifiable(requiredOverlaps), + ), + requiredOverlaps: List.unmodifiable(requiredOverlaps), + lockedOverlaps: List.unmodifiable(lockedOverlaps), + activities: [completedActivity], + usedNormalPushRules: usedNormalPushRules, + ); + } + + /// Convenience outer-boundary wrapper that supplies id and creation time. + SurpriseTaskLogResult logNew({ + required String title, + required SchedulingInput input, + DateTime? startedAt, + int? timeUsedMinutes, + String projectId = 'inbox', + PriorityLevel? priority, + RewardLevel reward = RewardLevel.notSet, + DifficultyLevel difficulty = DifficultyLevel.notSet, + DateTime? createdAt, + DateTime? updatedAt, + bool revealHiddenLockedDetails = false, + }) { + final generator = idGenerator; + if (generator == null) { + throw StateError('Surprise task logging needs an id generator.'); + } + + final now = createdAt ?? updatedAt ?? clock.now(); + + return log( + request: SurpriseTaskLogRequest( + id: generator.nextId(), + title: title, + createdAt: now, + startedAt: startedAt, + timeUsedMinutes: timeUsedMinutes, + projectId: projectId, + priority: priority, + reward: reward, + difficulty: difficulty, + ), + input: input, + updatedAt: updatedAt ?? now, + revealHiddenLockedDetails: revealHiddenLockedDetails, + ); + } + + Task _createSurpriseTask( + SurpriseTaskLogRequest request, { + required DateTime updatedAt, + }) { + final trimmedTitle = request.title.trim(); + if (trimmedTitle.isEmpty) { + throw ArgumentError.value(request.title, 'title', 'Title is required.'); + } + + final duration = request.timeUsedMinutes; + final start = request.startedAt; + final hasScheduledTime = start != null && duration != null && duration > 0; + + return Task( + id: request.id, + title: trimmedTitle, + projectId: request.projectId, + type: TaskType.surprise, + status: TaskStatus.completed, + priority: request.priority, + reward: request.reward, + difficulty: request.difficulty, + durationMinutes: duration != null && duration > 0 ? duration : null, + scheduledStart: hasScheduledTime ? start : null, + scheduledEnd: + hasScheduledTime ? start.add(Duration(minutes: duration)) : null, + actualStart: hasScheduledTime ? start : null, + actualEnd: + hasScheduledTime ? start.add(Duration(minutes: duration)) : null, + completedAt: updatedAt, + createdAt: request.createdAt, + updatedAt: updatedAt, + ); + } +} + +List _activitiesForPushDestination({ + required PushDestination destination, + required SchedulingInput input, + required SchedulingResult result, + required String taskId, + required String operationId, + required DateTime occurredAt, + required Iterable existingActivities, +}) { + if (result.outcomeCode != SchedulingOutcomeCode.success) { + return const []; + } + + final activities = []; + for (final change in result.changes) { + if (_hasDuplicateActivity( + existingActivities: existingActivities, + operationId: operationId, + taskId: change.taskId, + )) { + continue; + } + + final task = _taskById(input.tasks, change.taskId); + if (task == null) { + continue; + } + + final code = _activityCodeForPushChange( + destination: destination, + isSelectedTask: change.taskId == taskId, + ); + activities.add( + TaskActivity( + id: _activityId(operationId, change.taskId, code), + operationId: operationId, + code: code, + taskId: change.taskId, + projectId: task.projectId, + occurredAt: occurredAt, + metadata: { + 'previousStatus': task.status.name, + 'nextStatus': code == TaskActivityCode.movedToBacklog + ? TaskStatus.backlog.name + : task.status.name, + 'previousStart': change.previousStart, + 'previousEnd': change.previousEnd, + 'nextStart': change.nextStart, + 'nextEnd': change.nextEnd, + 'destination': destination.name, + }, + ), + ); + } + + return List.unmodifiable(activities); +} + +TaskActivityCode _activityCodeForPushChange({ + required PushDestination destination, + required bool isSelectedTask, +}) { + if (destination == PushDestination.backlog) { + return TaskActivityCode.movedToBacklog; + } + + return isSelectedTask + ? TaskActivityCode.manuallyPushed + : TaskActivityCode.automaticallyPushed; +} + +SchedulingOperationCode _operationCodeForPushDestination( + PushDestination destination, +) { + return switch (destination) { + PushDestination.nextAvailableSlot => + SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot, + PushDestination.tomorrowTopOfQueue => + SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue, + PushDestination.backlog => + SchedulingOperationCode.moveFlexibleTaskToBacklog, + }; +} + +TaskActivity _surpriseCompletedActivity({ + required Task task, + required String operationId, + required DateTime occurredAt, +}) { + return TaskActivity( + id: _activityId(operationId, task.id, TaskActivityCode.completed), + operationId: operationId, + code: TaskActivityCode.completed, + taskId: task.id, + projectId: task.projectId, + occurredAt: occurredAt, + metadata: { + 'nextStatus': TaskStatus.completed.name, + 'completedAt': occurredAt, + 'actualStart': task.actualStart, + 'actualEnd': task.actualEnd, + 'durationMinutes': task.durationMinutes, + 'reward': task.reward.name, + 'difficulty': task.difficulty.name, + if (task.reminderOverride != null) + 'reminderProfile': task.reminderOverride!.name, + 'pushesBeforeCompletion': 0, + 'source': 'surpriseLog', + }, + ); +} + +List _lockedIntervalsForAccounting(SchedulingInput input) { + return input.occupancyEntries + .where( + (entry) => entry.category == OccupancyCategory.hiddenLockedConstraint, + ) + .map((entry) => entry.interval) + .whereType() + .toList(growable: false); +} + +bool _hasDuplicateActivity({ + required Iterable existingActivities, + required String operationId, + required String taskId, +}) { + for (final activity in existingActivities) { + if (activity.operationId == operationId && activity.taskId == taskId) { + return true; + } + } + + return false; +} + +String _defaultOperationId( + String actionName, String taskId, DateTime occurredAt) { + return '$actionName:$taskId:${occurredAt.toIso8601String()}'; +} + +String _activityId( + String operationId, + String taskId, + TaskActivityCode activityCode, +) { + return '$operationId:$taskId:${activityCode.name}'; +} + +/// Find one task by id in a list. +Task? _taskById(List tasks, String taskId) { + for (final task in tasks) { + if (task.id == taskId) { + return task; + } + } + + return null; +} + +/// Build an interval for a task, or null when it has no usable placement. +TimeInterval? _scheduledIntervalForTask(Task task) { + final start = task.scheduledStart; + final end = task.scheduledEnd; + + if (start == null || end == null || !start.isBefore(end)) { + return null; + } + + return TimeInterval(start: start, end: end, label: task.id); +} + +/// Build an actual-first occupancy interval for completed/active task facts. +TimeInterval? _occupyingIntervalForTask(Task task) { + final actualStart = task.actualStart; + final actualEnd = task.actualEnd; + + if (actualStart != null && + actualEnd != null && + actualStart.isBefore(actualEnd)) { + return TimeInterval(start: actualStart, end: actualEnd, label: task.id); + } + + return _scheduledIntervalForTask(task); +} + +/// Required visible overlaps with a surprise interval. +List _requiredOverlaps({ + required SchedulingInput input, + required TimeInterval surpriseInterval, +}) { + final overlaps = []; + + for (final occupancy in input.occupancyEntries) { + final task = occupancy.task; + final interval = occupancy.interval; + final isRequiredOccupancy = + occupancy.category == OccupancyCategory.requiredVisibleCommitment || + (occupancy.category == OccupancyCategory.activeWork && + task != null && + task.isRequiredVisible); + + if (task == null || + !isRequiredOccupancy || + interval == null || + !interval.overlaps(surpriseInterval)) { + continue; + } + + overlaps.add( + SchedulingOverlap( + taskId: task.id, + taskInterval: interval, + blockedInterval: surpriseInterval, + ), + ); + } + + return overlaps; +} + +/// Locked overlaps with a surprise interval, kept hidden from normal notices. +List _lockedOverlaps({ + required SchedulingInput input, + required TimeInterval surpriseInterval, + required bool revealHiddenLockedDetails, +}) { + return input.occupancyEntries + .where( + (occupancy) => + occupancy.category == OccupancyCategory.hiddenLockedConstraint, + ) + .map((occupancy) => occupancy.interval) + .whereType() + .where((interval) => interval.overlaps(surpriseInterval)) + .map((interval) { + if (revealHiddenLockedDetails) { + return interval; + } + return TimeInterval(start: interval.start, end: interval.end); + }).toList(growable: false); +} + +/// Planned flexible task ids overlapping a surprise interval in timeline order. +List _overlappingFlexibleTaskIds( + List tasks, + TimeInterval surpriseInterval, +) { + final overlappingTasks = tasks.where((task) { + if (task.status != TaskStatus.planned) { + return false; + } + + final interval = _scheduledIntervalForTask(task); + return interval != null && interval.overlaps(surpriseInterval); + }).toList(growable: false) + ..sort((a, b) { + final aStart = a.scheduledStart ?? surpriseInterval.end; + final bStart = b.scheduledStart ?? surpriseInterval.end; + + return aStart.compareTo(bStart); + }); + + return overlappingTasks.map((task) => task.id).toList(growable: false); +} diff --git a/packages/scheduler_core/lib/src/task_lifecycle.dart b/packages/scheduler_core/lib/src/task_lifecycle.dart index dbd2c6f..1043c8b 100644 --- a/packages/scheduler_core/lib/src/task_lifecycle.dart +++ b/packages/scheduler_core/lib/src/task_lifecycle.dart @@ -10,954 +10,11 @@ library; import 'models.dart'; import 'task_statistics.dart'; import 'time_contracts.dart'; - -/// Canonical task transition commands. -enum TaskTransitionCode { - complete, - miss, - cancel, - noLongerRelevant, - manualPush, - automaticPush, - moveToBacklog, - restoreFromBacklog, - activate, -} - -/// Stable internal activity categories derived from transitions. -enum TaskActivityCode { - completed, - missed, - cancelled, - noLongerRelevant, - manuallyPushed, - automaticallyPushed, - movedToBacklog, - restoredFromBacklog, - activated, -} - -/// Typed result categories for expected transition states. -enum TaskTransitionOutcomeCode { - applied, - noOp, - duplicateOperation, - invalidState, -} - -/// Immutable internal activity fact. -/// -/// Activities are backend/application data, not a visible per-task history UI. -/// They carry enough stable structure for idempotency, statistics, and future -/// persistence adapters. -class TaskActivity { - TaskActivity({ - required String id, - required String operationId, - required this.code, - required String taskId, - required String projectId, - required this.occurredAt, - Map metadata = const {}, - }) : id = _requiredTrimmed(id, 'id'), - operationId = _requiredTrimmed(operationId, 'operationId'), - taskId = _requiredTrimmed(taskId, 'taskId'), - projectId = _requiredTrimmed(projectId, 'projectId'), - metadata = Map.unmodifiable(metadata); - - /// Stable activity id supplied by the application boundary. - final String id; - - /// Idempotency key for the user/application operation that created this fact. - final String operationId; - - /// Stable activity category. - final TaskActivityCode code; - - /// Task this activity belongs to. - final String taskId; - - /// Project the task belonged to when the activity occurred. - final String projectId; - - /// When the activity happened. - final DateTime occurredAt; - - /// Small structured payload for later statistics and application use cases. - final Map metadata; -} - -/// Result of applying one canonical transition. -class TaskTransitionResult { - TaskTransitionResult({ - required this.transitionCode, - required this.outcomeCode, - required this.task, - List activities = const [], - this.duplicateOfActivityId, - }) : activities = List.unmodifiable(activities); - - /// Requested transition. - final TaskTransitionCode transitionCode; - - /// Typed outcome for callers. - final TaskTransitionOutcomeCode outcomeCode; - - /// Original or updated task. - final Task task; - - /// New internal activity records. Empty for no-op, invalid, and duplicates. - final List activities; - - /// Existing activity id matched by a duplicate operation, when available. - final String? duplicateOfActivityId; - - /// Convenience flag for mutation persistence. - bool get applied => outcomeCode == TaskTransitionOutcomeCode.applied; -} - -/// Result of applying activity-derived task statistics. -class TaskActivityApplicationResult { - TaskActivityApplicationResult({ - required this.task, - List appliedActivityIds = const [], - }) : appliedActivityIds = List.unmodifiable(appliedActivityIds); - - /// Task after all newly applied activity effects. - final Task task; - - /// Activity ids that changed statistics in this application pass. - final List appliedActivityIds; -} - -/// Applies task-statistic effects from internal activities exactly once. -class TaskActivityAccountingService { - const TaskActivityAccountingService(); - - /// Apply [activities] to [task], skipping ids already present in - /// [alreadyAppliedActivityIds]. - /// - /// Completion accounting uses explicit completion timestamps and actual - /// intervals. If a completion has a timestamp but no actual interval, locked - /// minutes are left at zero rather than inferred from the planned slot. - TaskActivityApplicationResult apply({ - required Task task, - required Iterable activities, - Iterable alreadyAppliedActivityIds = const [], - List lockedIntervals = const [], - }) { - final appliedIds = alreadyAppliedActivityIds.toSet(); - final newlyAppliedIds = []; - var current = task; - - for (final activity in activities) { - if (activity.taskId != task.id || appliedIds.contains(activity.id)) { - continue; - } - - final nextStats = _applyActivityStats( - task: current, - activity: activity, - lockedIntervals: lockedIntervals, - ); - current = current.copyWith(stats: nextStats); - appliedIds.add(activity.id); - newlyAppliedIds.add(activity.id); - } - - return TaskActivityApplicationResult( - task: current, - appliedActivityIds: newlyAppliedIds, - ); - } -} - -/// Canonical lifecycle transition service. -class TaskTransitionService { - const TaskTransitionService({ - this.clock = const SystemClock(), - this.accountingService = const TaskActivityAccountingService(), - }); - - /// Clock boundary used when no explicit occurrence time is supplied. - final Clock clock; - - /// Activity-derived statistics updater. - final TaskActivityAccountingService accountingService; - - /// Apply one transition to [task]. - /// - /// [operationId] is the idempotency key. If an existing activity for the same - /// operation and task is supplied, the command is treated as an exact duplicate - /// and no additional changes are returned. - TaskTransitionResult apply({ - required Task task, - required TaskTransitionCode transitionCode, - required String operationId, - required String activityId, - DateTime? occurredAt, - DateTime? actualStart, - DateTime? actualEnd, - Iterable existingActivities = const [], - Iterable appliedActivityIds = const [], - List lockedIntervals = const [], - Map metadata = const {}, - }) { - final now = occurredAt ?? clock.now(); - final duplicate = _duplicateActivity( - existingActivities: existingActivities, - taskId: task.id, - operationId: operationId, - ); - if (duplicate != null) { - return TaskTransitionResult( - transitionCode: transitionCode, - outcomeCode: TaskTransitionOutcomeCode.duplicateOperation, - task: task, - duplicateOfActivityId: duplicate.id, - ); - } - - if (!_actualIntervalShapeIsValid(actualStart, actualEnd)) { - return TaskTransitionResult( - transitionCode: transitionCode, - outcomeCode: TaskTransitionOutcomeCode.invalidState, - task: task, - ); - } - - if (_isNoOpTerminalRepeat(task, transitionCode)) { - return TaskTransitionResult( - transitionCode: transitionCode, - outcomeCode: TaskTransitionOutcomeCode.noOp, - task: task, - ); - } - - if (_isBlockedByTerminalState(task, transitionCode)) { - return TaskTransitionResult( - transitionCode: transitionCode, - outcomeCode: TaskTransitionOutcomeCode.invalidState, - task: task, - ); - } - - return switch (transitionCode) { - TaskTransitionCode.complete => _complete( - task: task, - operationId: operationId, - activityId: activityId, - occurredAt: now, - actualStart: actualStart, - actualEnd: actualEnd, - appliedActivityIds: appliedActivityIds, - lockedIntervals: lockedIntervals, - metadata: metadata, - ), - TaskTransitionCode.miss => _miss( - task: task, - operationId: operationId, - activityId: activityId, - occurredAt: now, - appliedActivityIds: appliedActivityIds, - metadata: metadata, - ), - TaskTransitionCode.cancel => _cancel( - task: task, - operationId: operationId, - activityId: activityId, - occurredAt: now, - appliedActivityIds: appliedActivityIds, - metadata: metadata, - ), - TaskTransitionCode.noLongerRelevant => _noLongerRelevant( - task: task, - operationId: operationId, - activityId: activityId, - occurredAt: now, - appliedActivityIds: appliedActivityIds, - metadata: metadata, - ), - TaskTransitionCode.manualPush => _movementOnly( - task: task, - transitionCode: transitionCode, - activityCode: TaskActivityCode.manuallyPushed, - operationId: operationId, - activityId: activityId, - occurredAt: now, - metadata: metadata, - appliedActivityIds: appliedActivityIds, - ), - TaskTransitionCode.automaticPush => _movementOnly( - task: task, - transitionCode: transitionCode, - activityCode: TaskActivityCode.automaticallyPushed, - operationId: operationId, - activityId: activityId, - occurredAt: now, - metadata: metadata, - appliedActivityIds: appliedActivityIds, - ), - TaskTransitionCode.moveToBacklog => _moveToBacklog( - task: task, - operationId: operationId, - activityId: activityId, - occurredAt: now, - appliedActivityIds: appliedActivityIds, - metadata: metadata, - ), - TaskTransitionCode.restoreFromBacklog => _restoreFromBacklog( - task: task, - operationId: operationId, - activityId: activityId, - occurredAt: now, - appliedActivityIds: appliedActivityIds, - metadata: metadata, - ), - TaskTransitionCode.activate => _activate( - task: task, - operationId: operationId, - activityId: activityId, - occurredAt: now, - appliedActivityIds: appliedActivityIds, - metadata: metadata, - ), - }; - } - - TaskTransitionResult _complete({ - required Task task, - required String operationId, - required String activityId, - required DateTime occurredAt, - required DateTime? actualStart, - required DateTime? actualEnd, - required Iterable appliedActivityIds, - required List lockedIntervals, - required Map metadata, - }) { - if (task.status != TaskStatus.planned && task.status != TaskStatus.active) { - return _invalid(task, TaskTransitionCode.complete); - } - - final completed = task.copyWith( - status: TaskStatus.completed, - updatedAt: occurredAt, - completedAt: occurredAt, - actualStart: actualStart, - actualEnd: actualEnd, - ); - final activities = [ - _activity( - id: activityId, - operationId: operationId, - code: TaskActivityCode.completed, - task: task, - occurredAt: occurredAt, - metadata: { - ...metadata, - 'completedAt': occurredAt, - 'scheduledEnd': task.scheduledEnd, - 'actualStart': completed.actualStart, - 'actualEnd': completed.actualEnd, - 'durationMinutes': _knownDurationMinutes( - completed, - completed.actualStart, - completed.actualEnd, - ), - 'reward': task.reward.name, - 'difficulty': task.difficulty.name, - if (task.reminderOverride != null) - 'reminderProfile': task.reminderOverride!.name, - 'pushesBeforeCompletion': _pushesBeforeCompletion(task), - }, - nextStatus: completed.status, - ), - ]; - final accounted = accountingService.apply( - task: completed, - activities: activities, - alreadyAppliedActivityIds: appliedActivityIds, - lockedIntervals: lockedIntervals, - ); - - return _applied( - transitionCode: TaskTransitionCode.complete, - task: accounted.task, - activities: activities, - ); - } - - TaskTransitionResult _miss({ - required Task task, - required String operationId, - required String activityId, - required DateTime occurredAt, - required Iterable appliedActivityIds, - required Map metadata, - }) { - if (task.status != TaskStatus.planned && task.status != TaskStatus.active) { - return _invalid(task, TaskTransitionCode.miss); - } - if (!task.isFlexible && !task.isRequiredVisible) { - return _invalid(task, TaskTransitionCode.miss); - } - - final updatedTask = task.type == TaskType.critical - ? task.copyWith( - status: TaskStatus.backlog, - updatedAt: occurredAt, - clearSchedule: true, - ) - : task.copyWith( - status: TaskStatus.missed, - updatedAt: occurredAt, - ); - final activities = [ - _activity( - id: activityId, - operationId: operationId, - code: TaskActivityCode.missed, - task: task, - occurredAt: occurredAt, - metadata: metadata, - nextStatus: updatedTask.status, - ), - ]; - - if (task.type == TaskType.critical) { - activities.add( - _activity( - id: '$activityId:movedToBacklog', - operationId: operationId, - code: TaskActivityCode.movedToBacklog, - task: task, - occurredAt: occurredAt, - metadata: { - ...metadata, - 'reason': 'criticalMissed', - }, - nextStatus: updatedTask.status, - ), - ); - } - - final accounted = accountingService.apply( - task: updatedTask, - activities: activities, - alreadyAppliedActivityIds: appliedActivityIds, - ); - - return _applied( - transitionCode: TaskTransitionCode.miss, - task: accounted.task, - activities: activities, - ); - } - - TaskTransitionResult _cancel({ - required Task task, - required String operationId, - required String activityId, - required DateTime occurredAt, - required Iterable appliedActivityIds, - required Map metadata, - }) { - if (task.status == TaskStatus.cancelled) { - return _noOp(task, TaskTransitionCode.cancel); - } - if (!_canRemoveFromActivePlan(task)) { - return _invalid(task, TaskTransitionCode.cancel); - } - - final cancelled = task.copyWith( - status: TaskStatus.cancelled, - updatedAt: occurredAt, - clearSchedule: true, - ); - final activities = [ - _activity( - id: activityId, - operationId: operationId, - code: TaskActivityCode.cancelled, - task: task, - occurredAt: occurredAt, - metadata: metadata, - nextStatus: cancelled.status, - ), - ]; - final accounted = accountingService.apply( - task: cancelled, - activities: activities, - alreadyAppliedActivityIds: appliedActivityIds, - ); - - return _applied( - transitionCode: TaskTransitionCode.cancel, - task: accounted.task, - activities: activities, - ); - } - - TaskTransitionResult _noLongerRelevant({ - required Task task, - required String operationId, - required String activityId, - required DateTime occurredAt, - required Iterable appliedActivityIds, - required Map metadata, - }) { - if (task.status == TaskStatus.noLongerRelevant) { - return _noOp(task, TaskTransitionCode.noLongerRelevant); - } - if (!_canRemoveFromActivePlan(task)) { - return _invalid(task, TaskTransitionCode.noLongerRelevant); - } - - final dismissed = task.copyWith( - status: TaskStatus.noLongerRelevant, - updatedAt: occurredAt, - clearSchedule: true, - ); - - final activities = [ - _activity( - id: activityId, - operationId: operationId, - code: TaskActivityCode.noLongerRelevant, - task: task, - occurredAt: occurredAt, - metadata: metadata, - nextStatus: dismissed.status, - ), - ]; - final accounted = accountingService.apply( - task: dismissed, - activities: activities, - alreadyAppliedActivityIds: appliedActivityIds, - ); - - return _applied( - transitionCode: TaskTransitionCode.noLongerRelevant, - task: accounted.task, - activities: activities, - ); - } - - TaskTransitionResult _movementOnly({ - required Task task, - required TaskTransitionCode transitionCode, - required TaskActivityCode activityCode, - required String operationId, - required String activityId, - required DateTime occurredAt, - required Map metadata, - required Iterable appliedActivityIds, - }) { - if (!task.isFlexible || task.status != TaskStatus.planned) { - return _invalid(task, transitionCode); - } - - final updated = task.copyWith(updatedAt: occurredAt); - final activities = [ - _activity( - id: activityId, - operationId: operationId, - code: activityCode, - task: task, - occurredAt: occurredAt, - metadata: metadata, - nextStatus: updated.status, - ), - ]; - final accounted = accountingService.apply( - task: updated, - activities: activities, - alreadyAppliedActivityIds: appliedActivityIds, - ); - - return _applied( - transitionCode: transitionCode, - task: accounted.task, - activities: activities, - ); - } - - TaskTransitionResult _moveToBacklog({ - required Task task, - required String operationId, - required String activityId, - required DateTime occurredAt, - required Iterable appliedActivityIds, - required Map metadata, - }) { - final canMove = (task.isFlexible || task.type == TaskType.critical) && - !_isTerminal(task.status); - if (!canMove) { - return _invalid(task, TaskTransitionCode.moveToBacklog); - } - - final moved = task.copyWith( - status: TaskStatus.backlog, - updatedAt: occurredAt, - clearSchedule: true, - ); - final activities = [ - _activity( - id: activityId, - operationId: operationId, - code: TaskActivityCode.movedToBacklog, - task: task, - occurredAt: occurredAt, - metadata: metadata, - nextStatus: moved.status, - ), - ]; - final accounted = accountingService.apply( - task: moved, - activities: activities, - alreadyAppliedActivityIds: appliedActivityIds, - ); - - return _applied( - transitionCode: TaskTransitionCode.moveToBacklog, - task: accounted.task, - activities: activities, - ); - } - - TaskTransitionResult _restoreFromBacklog({ - required Task task, - required String operationId, - required String activityId, - required DateTime occurredAt, - required Iterable appliedActivityIds, - required Map metadata, - }) { - if (task.status != TaskStatus.backlog || - (!task.isFlexible && task.type != TaskType.critical)) { - return _invalid(task, TaskTransitionCode.restoreFromBacklog); - } - - final restored = task.copyWith( - status: TaskStatus.planned, - updatedAt: occurredAt, - ); - final activities = [ - _activity( - id: activityId, - operationId: operationId, - code: TaskActivityCode.restoredFromBacklog, - task: task, - occurredAt: occurredAt, - metadata: metadata, - nextStatus: restored.status, - ), - ]; - final accounted = accountingService.apply( - task: restored, - activities: activities, - alreadyAppliedActivityIds: appliedActivityIds, - ); - - return _applied( - transitionCode: TaskTransitionCode.restoreFromBacklog, - task: accounted.task, - activities: activities, - ); - } - - TaskTransitionResult _activate({ - required Task task, - required String operationId, - required String activityId, - required DateTime occurredAt, - required Iterable appliedActivityIds, - required Map metadata, - }) { - if (task.status != TaskStatus.planned || - task.isLocked || - task.type == TaskType.freeSlot || - task.type == TaskType.surprise) { - return _invalid(task, TaskTransitionCode.activate); - } - - final activated = task.copyWith( - status: TaskStatus.active, - updatedAt: occurredAt, - ); - - final activities = [ - _activity( - id: activityId, - operationId: operationId, - code: TaskActivityCode.activated, - task: task, - occurredAt: occurredAt, - metadata: metadata, - nextStatus: activated.status, - ), - ]; - final accounted = accountingService.apply( - task: activated, - activities: activities, - alreadyAppliedActivityIds: appliedActivityIds, - ); - - return _applied( - transitionCode: TaskTransitionCode.activate, - task: accounted.task, - activities: activities, - ); - } -} - -TaskStatistics _applyActivityStats({ - required Task task, - required TaskActivity activity, - required List lockedIntervals, -}) { - return switch (activity.code) { - TaskActivityCode.manuallyPushed => task.stats.incrementManualPush(), - TaskActivityCode.automaticallyPushed => task.stats.incrementAutoPush(), - TaskActivityCode.movedToBacklog => task.stats.incrementMovedToBacklog(), - TaskActivityCode.restoredFromBacklog => - task.stats.incrementRestoredFromBacklog(), - TaskActivityCode.missed => task.stats.incrementMissed(), - TaskActivityCode.cancelled => task.stats.incrementCancelled(), - TaskActivityCode.completed => _applyCompletionStats( - task: task, - activity: activity, - lockedIntervals: lockedIntervals, - ), - TaskActivityCode.noLongerRelevant || - TaskActivityCode.activated => - task.stats, - }; -} - -TaskStatistics _applyCompletionStats({ - required Task task, - required TaskActivity activity, - required List lockedIntervals, -}) { - var stats = task.stats; - final scheduledEnd = task.scheduledEnd; - final completedAt = task.completedAt ?? activity.occurredAt; - - if (scheduledEnd != null && completedAt.isAfter(scheduledEnd)) { - stats = stats.incrementCompletedLate(); - } - - final lockedMinutes = _actualLockedOverlapMinutes( - task: task, - lockedIntervals: lockedIntervals, - ); - if (lockedMinutes > 0) { - stats = stats.incrementCompletedDuringLockedHours(lockedMinutes); - } - - final pushesBeforeCompletion = - _intMetadata(activity.metadata['pushesBeforeCompletion']) ?? - _pushesBeforeCompletion(task); - - return stats.recordPushesBeforeCompletion(pushesBeforeCompletion); -} - -int _actualLockedOverlapMinutes({ - required Task task, - required List lockedIntervals, -}) { - final actualStart = task.actualStart; - final actualEnd = task.actualEnd; - if (actualStart == null || - actualEnd == null || - !actualStart.isBefore(actualEnd) || - lockedIntervals.isEmpty) { - return 0; - } - - final actualInterval = TimeInterval(start: actualStart, end: actualEnd); - final intersections = []; - - for (final lockedInterval in lockedIntervals) { - if (!actualInterval.overlaps(lockedInterval)) { - continue; - } - intersections.add( - TimeInterval( - start: _latest(actualInterval.start, lockedInterval.start), - end: _earliest(actualInterval.end, lockedInterval.end), - ), - ); - } - - if (intersections.isEmpty) { - return 0; - } - - intersections.sort((left, right) => left.start.compareTo(right.start)); - var total = Duration.zero; - var current = intersections.first; - for (final interval in intersections.skip(1)) { - if (interval.start.isAfter(current.end)) { - total += current.duration; - current = interval; - continue; - } - - current = TimeInterval( - start: current.start, - end: _latest(current.end, interval.end), - ); - } - total += current.duration; - - return total.inMinutes; -} - -int _pushesBeforeCompletion(Task task) { - return task.stats.manuallyPushedCount + task.stats.autoPushedCount; -} - -int? _knownDurationMinutes( - Task task, - DateTime? actualStart, - DateTime? actualEnd, -) { - if (actualStart != null && - actualEnd != null && - actualStart.isBefore(actualEnd)) { - final actualMinutes = actualEnd.difference(actualStart).inMinutes; - if (actualMinutes > 0) { - return actualMinutes; - } - } - - return task.durationMinutes; -} - -int? _intMetadata(Object? value) { - return value is int ? value : null; -} - -DateTime _earliest(DateTime first, DateTime second) { - return first.isBefore(second) ? first : second; -} - -DateTime _latest(DateTime first, DateTime second) { - return first.isAfter(second) ? first : second; -} - -TaskTransitionResult _applied({ - required TaskTransitionCode transitionCode, - required Task task, - required List activities, -}) { - return TaskTransitionResult( - transitionCode: transitionCode, - outcomeCode: TaskTransitionOutcomeCode.applied, - task: task, - activities: activities, - ); -} - -TaskTransitionResult _invalid(Task task, TaskTransitionCode transitionCode) { - return TaskTransitionResult( - transitionCode: transitionCode, - outcomeCode: TaskTransitionOutcomeCode.invalidState, - task: task, - ); -} - -TaskTransitionResult _noOp(Task task, TaskTransitionCode transitionCode) { - return TaskTransitionResult( - transitionCode: transitionCode, - outcomeCode: TaskTransitionOutcomeCode.noOp, - task: task, - ); -} - -TaskActivity _activity({ - required String id, - required String operationId, - required TaskActivityCode code, - required Task task, - required DateTime occurredAt, - required Map metadata, - required TaskStatus nextStatus, -}) { - return TaskActivity( - id: id, - operationId: operationId, - code: code, - taskId: task.id, - projectId: task.projectId, - occurredAt: occurredAt, - metadata: { - ...metadata, - 'previousStatus': task.status.name, - 'nextStatus': nextStatus.name, - }, - ); -} - -TaskActivity? _duplicateActivity({ - required Iterable existingActivities, - required String taskId, - required String operationId, -}) { - for (final activity in existingActivities) { - if (activity.taskId == taskId && activity.operationId == operationId) { - return activity; - } - } - - return null; -} - -bool _actualIntervalShapeIsValid(DateTime? actualStart, DateTime? actualEnd) { - if (actualStart == null && actualEnd == null) { - return true; - } - return actualStart != null && - actualEnd != null && - actualStart.isBefore(actualEnd); -} - -bool _isNoOpTerminalRepeat(Task task, TaskTransitionCode transitionCode) { - return (task.status == TaskStatus.completed && - transitionCode == TaskTransitionCode.complete) || - (task.status == TaskStatus.cancelled && - transitionCode == TaskTransitionCode.cancel) || - (task.status == TaskStatus.noLongerRelevant && - transitionCode == TaskTransitionCode.noLongerRelevant); -} - -bool _isBlockedByTerminalState(Task task, TaskTransitionCode transitionCode) { - if (!_isTerminal(task.status)) { - return false; - } - return !_isNoOpTerminalRepeat(task, transitionCode); -} - -bool _isTerminal(TaskStatus status) { - return status == TaskStatus.completed || - status == TaskStatus.cancelled || - status == TaskStatus.noLongerRelevant; -} - -bool _canRemoveFromActivePlan(Task task) { - return task.status == TaskStatus.planned || - task.status == TaskStatus.active || - task.status == TaskStatus.backlog || - task.status == TaskStatus.missed; -} - -String _requiredTrimmed(String value, String name) { - final trimmed = value.trim(); - if (trimmed.isEmpty) { - throw ArgumentError.value(value, name, 'Value is required.'); - } - return trimmed; -} +part 'task_lifecycle/task_transition_code.dart'; +part 'task_lifecycle/task_activity_code.dart'; +part 'task_lifecycle/task_transition_outcome_code.dart'; +part 'task_lifecycle/task_activity.dart'; +part 'task_lifecycle/task_transition_result.dart'; +part 'task_lifecycle/task_activity_application_result.dart'; +part 'task_lifecycle/task_activity_accounting_service.dart'; +part 'task_lifecycle/task_transition_service.dart'; diff --git a/packages/scheduler_core/lib/src/task_lifecycle/task_activity.dart b/packages/scheduler_core/lib/src/task_lifecycle/task_activity.dart new file mode 100644 index 0000000..66aefb1 --- /dev/null +++ b/packages/scheduler_core/lib/src/task_lifecycle/task_activity.dart @@ -0,0 +1,43 @@ +part of '../task_lifecycle.dart'; + +/// Immutable internal activity fact. +/// +/// Activities are backend/application data, not a visible per-task history UI. +/// They carry enough stable structure for idempotency, statistics, and future +/// persistence adapters. +class TaskActivity { + TaskActivity({ + required String id, + required String operationId, + required this.code, + required String taskId, + required String projectId, + required this.occurredAt, + Map metadata = const {}, + }) : id = _requiredTrimmed(id, 'id'), + operationId = _requiredTrimmed(operationId, 'operationId'), + taskId = _requiredTrimmed(taskId, 'taskId'), + projectId = _requiredTrimmed(projectId, 'projectId'), + metadata = Map.unmodifiable(metadata); + + /// Stable activity id supplied by the application boundary. + final String id; + + /// Idempotency key for the user/application operation that created this fact. + final String operationId; + + /// Stable activity category. + final TaskActivityCode code; + + /// Task this activity belongs to. + final String taskId; + + /// Project the task belonged to when the activity occurred. + final String projectId; + + /// When the activity happened. + final DateTime occurredAt; + + /// Small structured payload for later statistics and application use cases. + final Map metadata; +} diff --git a/packages/scheduler_core/lib/src/task_lifecycle/task_activity_accounting_service.dart b/packages/scheduler_core/lib/src/task_lifecycle/task_activity_accounting_service.dart new file mode 100644 index 0000000..d9baa38 --- /dev/null +++ b/packages/scheduler_core/lib/src/task_lifecycle/task_activity_accounting_service.dart @@ -0,0 +1,43 @@ +part of '../task_lifecycle.dart'; + +/// Applies task-statistic effects from internal activities exactly once. +class TaskActivityAccountingService { + const TaskActivityAccountingService(); + + /// Apply [activities] to [task], skipping ids already present in + /// [alreadyAppliedActivityIds]. + /// + /// Completion accounting uses explicit completion timestamps and actual + /// intervals. If a completion has a timestamp but no actual interval, locked + /// minutes are left at zero rather than inferred from the planned slot. + TaskActivityApplicationResult apply({ + required Task task, + required Iterable activities, + Iterable alreadyAppliedActivityIds = const [], + List lockedIntervals = const [], + }) { + final appliedIds = alreadyAppliedActivityIds.toSet(); + final newlyAppliedIds = []; + var current = task; + + for (final activity in activities) { + if (activity.taskId != task.id || appliedIds.contains(activity.id)) { + continue; + } + + final nextStats = _applyActivityStats( + task: current, + activity: activity, + lockedIntervals: lockedIntervals, + ); + current = current.copyWith(stats: nextStats); + appliedIds.add(activity.id); + newlyAppliedIds.add(activity.id); + } + + return TaskActivityApplicationResult( + task: current, + appliedActivityIds: newlyAppliedIds, + ); + } +} diff --git a/packages/scheduler_core/lib/src/task_lifecycle/task_activity_application_result.dart b/packages/scheduler_core/lib/src/task_lifecycle/task_activity_application_result.dart new file mode 100644 index 0000000..58069e7 --- /dev/null +++ b/packages/scheduler_core/lib/src/task_lifecycle/task_activity_application_result.dart @@ -0,0 +1,15 @@ +part of '../task_lifecycle.dart'; + +/// Result of applying activity-derived task statistics. +class TaskActivityApplicationResult { + TaskActivityApplicationResult({ + required this.task, + List appliedActivityIds = const [], + }) : appliedActivityIds = List.unmodifiable(appliedActivityIds); + + /// Task after all newly applied activity effects. + final Task task; + + /// Activity ids that changed statistics in this application pass. + final List appliedActivityIds; +} diff --git a/packages/scheduler_core/lib/src/task_lifecycle/task_activity_code.dart b/packages/scheduler_core/lib/src/task_lifecycle/task_activity_code.dart new file mode 100644 index 0000000..484d370 --- /dev/null +++ b/packages/scheduler_core/lib/src/task_lifecycle/task_activity_code.dart @@ -0,0 +1,14 @@ +part of '../task_lifecycle.dart'; + +/// Stable internal activity categories derived from transitions. +enum TaskActivityCode { + completed, + missed, + cancelled, + noLongerRelevant, + manuallyPushed, + automaticallyPushed, + movedToBacklog, + restoredFromBacklog, + activated, +} diff --git a/packages/scheduler_core/lib/src/task_lifecycle/task_transition_code.dart b/packages/scheduler_core/lib/src/task_lifecycle/task_transition_code.dart new file mode 100644 index 0000000..4927f18 --- /dev/null +++ b/packages/scheduler_core/lib/src/task_lifecycle/task_transition_code.dart @@ -0,0 +1,14 @@ +part of '../task_lifecycle.dart'; + +/// Canonical task transition commands. +enum TaskTransitionCode { + complete, + miss, + cancel, + noLongerRelevant, + manualPush, + automaticPush, + moveToBacklog, + restoreFromBacklog, + activate, +} diff --git a/packages/scheduler_core/lib/src/task_lifecycle/task_transition_outcome_code.dart b/packages/scheduler_core/lib/src/task_lifecycle/task_transition_outcome_code.dart new file mode 100644 index 0000000..89e7427 --- /dev/null +++ b/packages/scheduler_core/lib/src/task_lifecycle/task_transition_outcome_code.dart @@ -0,0 +1,9 @@ +part of '../task_lifecycle.dart'; + +/// Typed result categories for expected transition states. +enum TaskTransitionOutcomeCode { + applied, + noOp, + duplicateOperation, + invalidState, +} diff --git a/packages/scheduler_core/lib/src/task_lifecycle/task_transition_result.dart b/packages/scheduler_core/lib/src/task_lifecycle/task_transition_result.dart new file mode 100644 index 0000000..ee58d84 --- /dev/null +++ b/packages/scheduler_core/lib/src/task_lifecycle/task_transition_result.dart @@ -0,0 +1,30 @@ +part of '../task_lifecycle.dart'; + +/// Result of applying one canonical transition. +class TaskTransitionResult { + TaskTransitionResult({ + required this.transitionCode, + required this.outcomeCode, + required this.task, + List activities = const [], + this.duplicateOfActivityId, + }) : activities = List.unmodifiable(activities); + + /// Requested transition. + final TaskTransitionCode transitionCode; + + /// Typed outcome for callers. + final TaskTransitionOutcomeCode outcomeCode; + + /// Original or updated task. + final Task task; + + /// New internal activity records. Empty for no-op, invalid, and duplicates. + final List activities; + + /// Existing activity id matched by a duplicate operation, when available. + final String? duplicateOfActivityId; + + /// Convenience flag for mutation persistence. + bool get applied => outcomeCode == TaskTransitionOutcomeCode.applied; +} diff --git a/packages/scheduler_core/lib/src/task_lifecycle/task_transition_service.dart b/packages/scheduler_core/lib/src/task_lifecycle/task_transition_service.dart new file mode 100644 index 0000000..a84f941 --- /dev/null +++ b/packages/scheduler_core/lib/src/task_lifecycle/task_transition_service.dart @@ -0,0 +1,791 @@ +part of '../task_lifecycle.dart'; + +/// Canonical lifecycle transition service. +class TaskTransitionService { + const TaskTransitionService({ + this.clock = const SystemClock(), + this.accountingService = const TaskActivityAccountingService(), + }); + + /// Clock boundary used when no explicit occurrence time is supplied. + final Clock clock; + + /// Activity-derived statistics updater. + final TaskActivityAccountingService accountingService; + + /// Apply one transition to [task]. + /// + /// [operationId] is the idempotency key. If an existing activity for the same + /// operation and task is supplied, the command is treated as an exact duplicate + /// and no additional changes are returned. + TaskTransitionResult apply({ + required Task task, + required TaskTransitionCode transitionCode, + required String operationId, + required String activityId, + DateTime? occurredAt, + DateTime? actualStart, + DateTime? actualEnd, + Iterable existingActivities = const [], + Iterable appliedActivityIds = const [], + List lockedIntervals = const [], + Map metadata = const {}, + }) { + final now = occurredAt ?? clock.now(); + final duplicate = _duplicateActivity( + existingActivities: existingActivities, + taskId: task.id, + operationId: operationId, + ); + if (duplicate != null) { + return TaskTransitionResult( + transitionCode: transitionCode, + outcomeCode: TaskTransitionOutcomeCode.duplicateOperation, + task: task, + duplicateOfActivityId: duplicate.id, + ); + } + + if (!_actualIntervalShapeIsValid(actualStart, actualEnd)) { + return TaskTransitionResult( + transitionCode: transitionCode, + outcomeCode: TaskTransitionOutcomeCode.invalidState, + task: task, + ); + } + + if (_isNoOpTerminalRepeat(task, transitionCode)) { + return TaskTransitionResult( + transitionCode: transitionCode, + outcomeCode: TaskTransitionOutcomeCode.noOp, + task: task, + ); + } + + if (_isBlockedByTerminalState(task, transitionCode)) { + return TaskTransitionResult( + transitionCode: transitionCode, + outcomeCode: TaskTransitionOutcomeCode.invalidState, + task: task, + ); + } + + return switch (transitionCode) { + TaskTransitionCode.complete => _complete( + task: task, + operationId: operationId, + activityId: activityId, + occurredAt: now, + actualStart: actualStart, + actualEnd: actualEnd, + appliedActivityIds: appliedActivityIds, + lockedIntervals: lockedIntervals, + metadata: metadata, + ), + TaskTransitionCode.miss => _miss( + task: task, + operationId: operationId, + activityId: activityId, + occurredAt: now, + appliedActivityIds: appliedActivityIds, + metadata: metadata, + ), + TaskTransitionCode.cancel => _cancel( + task: task, + operationId: operationId, + activityId: activityId, + occurredAt: now, + appliedActivityIds: appliedActivityIds, + metadata: metadata, + ), + TaskTransitionCode.noLongerRelevant => _noLongerRelevant( + task: task, + operationId: operationId, + activityId: activityId, + occurredAt: now, + appliedActivityIds: appliedActivityIds, + metadata: metadata, + ), + TaskTransitionCode.manualPush => _movementOnly( + task: task, + transitionCode: transitionCode, + activityCode: TaskActivityCode.manuallyPushed, + operationId: operationId, + activityId: activityId, + occurredAt: now, + metadata: metadata, + appliedActivityIds: appliedActivityIds, + ), + TaskTransitionCode.automaticPush => _movementOnly( + task: task, + transitionCode: transitionCode, + activityCode: TaskActivityCode.automaticallyPushed, + operationId: operationId, + activityId: activityId, + occurredAt: now, + metadata: metadata, + appliedActivityIds: appliedActivityIds, + ), + TaskTransitionCode.moveToBacklog => _moveToBacklog( + task: task, + operationId: operationId, + activityId: activityId, + occurredAt: now, + appliedActivityIds: appliedActivityIds, + metadata: metadata, + ), + TaskTransitionCode.restoreFromBacklog => _restoreFromBacklog( + task: task, + operationId: operationId, + activityId: activityId, + occurredAt: now, + appliedActivityIds: appliedActivityIds, + metadata: metadata, + ), + TaskTransitionCode.activate => _activate( + task: task, + operationId: operationId, + activityId: activityId, + occurredAt: now, + appliedActivityIds: appliedActivityIds, + metadata: metadata, + ), + }; + } + + TaskTransitionResult _complete({ + required Task task, + required String operationId, + required String activityId, + required DateTime occurredAt, + required DateTime? actualStart, + required DateTime? actualEnd, + required Iterable appliedActivityIds, + required List lockedIntervals, + required Map metadata, + }) { + if (task.status != TaskStatus.planned && task.status != TaskStatus.active) { + return _invalid(task, TaskTransitionCode.complete); + } + + final completed = task.copyWith( + status: TaskStatus.completed, + updatedAt: occurredAt, + completedAt: occurredAt, + actualStart: actualStart, + actualEnd: actualEnd, + ); + final activities = [ + _activity( + id: activityId, + operationId: operationId, + code: TaskActivityCode.completed, + task: task, + occurredAt: occurredAt, + metadata: { + ...metadata, + 'completedAt': occurredAt, + 'scheduledEnd': task.scheduledEnd, + 'actualStart': completed.actualStart, + 'actualEnd': completed.actualEnd, + 'durationMinutes': _knownDurationMinutes( + completed, + completed.actualStart, + completed.actualEnd, + ), + 'reward': task.reward.name, + 'difficulty': task.difficulty.name, + if (task.reminderOverride != null) + 'reminderProfile': task.reminderOverride!.name, + 'pushesBeforeCompletion': _pushesBeforeCompletion(task), + }, + nextStatus: completed.status, + ), + ]; + final accounted = accountingService.apply( + task: completed, + activities: activities, + alreadyAppliedActivityIds: appliedActivityIds, + lockedIntervals: lockedIntervals, + ); + + return _applied( + transitionCode: TaskTransitionCode.complete, + task: accounted.task, + activities: activities, + ); + } + + TaskTransitionResult _miss({ + required Task task, + required String operationId, + required String activityId, + required DateTime occurredAt, + required Iterable appliedActivityIds, + required Map metadata, + }) { + if (task.status != TaskStatus.planned && task.status != TaskStatus.active) { + return _invalid(task, TaskTransitionCode.miss); + } + if (!task.isFlexible && !task.isRequiredVisible) { + return _invalid(task, TaskTransitionCode.miss); + } + + final updatedTask = task.type == TaskType.critical + ? task.copyWith( + status: TaskStatus.backlog, + updatedAt: occurredAt, + clearSchedule: true, + ) + : task.copyWith( + status: TaskStatus.missed, + updatedAt: occurredAt, + ); + final activities = [ + _activity( + id: activityId, + operationId: operationId, + code: TaskActivityCode.missed, + task: task, + occurredAt: occurredAt, + metadata: metadata, + nextStatus: updatedTask.status, + ), + ]; + + if (task.type == TaskType.critical) { + activities.add( + _activity( + id: '$activityId:movedToBacklog', + operationId: operationId, + code: TaskActivityCode.movedToBacklog, + task: task, + occurredAt: occurredAt, + metadata: { + ...metadata, + 'reason': 'criticalMissed', + }, + nextStatus: updatedTask.status, + ), + ); + } + + final accounted = accountingService.apply( + task: updatedTask, + activities: activities, + alreadyAppliedActivityIds: appliedActivityIds, + ); + + return _applied( + transitionCode: TaskTransitionCode.miss, + task: accounted.task, + activities: activities, + ); + } + + TaskTransitionResult _cancel({ + required Task task, + required String operationId, + required String activityId, + required DateTime occurredAt, + required Iterable appliedActivityIds, + required Map metadata, + }) { + if (task.status == TaskStatus.cancelled) { + return _noOp(task, TaskTransitionCode.cancel); + } + if (!_canRemoveFromActivePlan(task)) { + return _invalid(task, TaskTransitionCode.cancel); + } + + final cancelled = task.copyWith( + status: TaskStatus.cancelled, + updatedAt: occurredAt, + clearSchedule: true, + ); + final activities = [ + _activity( + id: activityId, + operationId: operationId, + code: TaskActivityCode.cancelled, + task: task, + occurredAt: occurredAt, + metadata: metadata, + nextStatus: cancelled.status, + ), + ]; + final accounted = accountingService.apply( + task: cancelled, + activities: activities, + alreadyAppliedActivityIds: appliedActivityIds, + ); + + return _applied( + transitionCode: TaskTransitionCode.cancel, + task: accounted.task, + activities: activities, + ); + } + + TaskTransitionResult _noLongerRelevant({ + required Task task, + required String operationId, + required String activityId, + required DateTime occurredAt, + required Iterable appliedActivityIds, + required Map metadata, + }) { + if (task.status == TaskStatus.noLongerRelevant) { + return _noOp(task, TaskTransitionCode.noLongerRelevant); + } + if (!_canRemoveFromActivePlan(task)) { + return _invalid(task, TaskTransitionCode.noLongerRelevant); + } + + final dismissed = task.copyWith( + status: TaskStatus.noLongerRelevant, + updatedAt: occurredAt, + clearSchedule: true, + ); + + final activities = [ + _activity( + id: activityId, + operationId: operationId, + code: TaskActivityCode.noLongerRelevant, + task: task, + occurredAt: occurredAt, + metadata: metadata, + nextStatus: dismissed.status, + ), + ]; + final accounted = accountingService.apply( + task: dismissed, + activities: activities, + alreadyAppliedActivityIds: appliedActivityIds, + ); + + return _applied( + transitionCode: TaskTransitionCode.noLongerRelevant, + task: accounted.task, + activities: activities, + ); + } + + TaskTransitionResult _movementOnly({ + required Task task, + required TaskTransitionCode transitionCode, + required TaskActivityCode activityCode, + required String operationId, + required String activityId, + required DateTime occurredAt, + required Map metadata, + required Iterable appliedActivityIds, + }) { + if (!task.isFlexible || task.status != TaskStatus.planned) { + return _invalid(task, transitionCode); + } + + final updated = task.copyWith(updatedAt: occurredAt); + final activities = [ + _activity( + id: activityId, + operationId: operationId, + code: activityCode, + task: task, + occurredAt: occurredAt, + metadata: metadata, + nextStatus: updated.status, + ), + ]; + final accounted = accountingService.apply( + task: updated, + activities: activities, + alreadyAppliedActivityIds: appliedActivityIds, + ); + + return _applied( + transitionCode: transitionCode, + task: accounted.task, + activities: activities, + ); + } + + TaskTransitionResult _moveToBacklog({ + required Task task, + required String operationId, + required String activityId, + required DateTime occurredAt, + required Iterable appliedActivityIds, + required Map metadata, + }) { + final canMove = (task.isFlexible || task.type == TaskType.critical) && + !_isTerminal(task.status); + if (!canMove) { + return _invalid(task, TaskTransitionCode.moveToBacklog); + } + + final moved = task.copyWith( + status: TaskStatus.backlog, + updatedAt: occurredAt, + clearSchedule: true, + ); + final activities = [ + _activity( + id: activityId, + operationId: operationId, + code: TaskActivityCode.movedToBacklog, + task: task, + occurredAt: occurredAt, + metadata: metadata, + nextStatus: moved.status, + ), + ]; + final accounted = accountingService.apply( + task: moved, + activities: activities, + alreadyAppliedActivityIds: appliedActivityIds, + ); + + return _applied( + transitionCode: TaskTransitionCode.moveToBacklog, + task: accounted.task, + activities: activities, + ); + } + + TaskTransitionResult _restoreFromBacklog({ + required Task task, + required String operationId, + required String activityId, + required DateTime occurredAt, + required Iterable appliedActivityIds, + required Map metadata, + }) { + if (task.status != TaskStatus.backlog || + (!task.isFlexible && task.type != TaskType.critical)) { + return _invalid(task, TaskTransitionCode.restoreFromBacklog); + } + + final restored = task.copyWith( + status: TaskStatus.planned, + updatedAt: occurredAt, + ); + final activities = [ + _activity( + id: activityId, + operationId: operationId, + code: TaskActivityCode.restoredFromBacklog, + task: task, + occurredAt: occurredAt, + metadata: metadata, + nextStatus: restored.status, + ), + ]; + final accounted = accountingService.apply( + task: restored, + activities: activities, + alreadyAppliedActivityIds: appliedActivityIds, + ); + + return _applied( + transitionCode: TaskTransitionCode.restoreFromBacklog, + task: accounted.task, + activities: activities, + ); + } + + TaskTransitionResult _activate({ + required Task task, + required String operationId, + required String activityId, + required DateTime occurredAt, + required Iterable appliedActivityIds, + required Map metadata, + }) { + if (task.status != TaskStatus.planned || + task.isLocked || + task.type == TaskType.freeSlot || + task.type == TaskType.surprise) { + return _invalid(task, TaskTransitionCode.activate); + } + + final activated = task.copyWith( + status: TaskStatus.active, + updatedAt: occurredAt, + ); + + final activities = [ + _activity( + id: activityId, + operationId: operationId, + code: TaskActivityCode.activated, + task: task, + occurredAt: occurredAt, + metadata: metadata, + nextStatus: activated.status, + ), + ]; + final accounted = accountingService.apply( + task: activated, + activities: activities, + alreadyAppliedActivityIds: appliedActivityIds, + ); + + return _applied( + transitionCode: TaskTransitionCode.activate, + task: accounted.task, + activities: activities, + ); + } +} + +TaskStatistics _applyActivityStats({ + required Task task, + required TaskActivity activity, + required List lockedIntervals, +}) { + return switch (activity.code) { + TaskActivityCode.manuallyPushed => task.stats.incrementManualPush(), + TaskActivityCode.automaticallyPushed => task.stats.incrementAutoPush(), + TaskActivityCode.movedToBacklog => task.stats.incrementMovedToBacklog(), + TaskActivityCode.restoredFromBacklog => + task.stats.incrementRestoredFromBacklog(), + TaskActivityCode.missed => task.stats.incrementMissed(), + TaskActivityCode.cancelled => task.stats.incrementCancelled(), + TaskActivityCode.completed => _applyCompletionStats( + task: task, + activity: activity, + lockedIntervals: lockedIntervals, + ), + TaskActivityCode.noLongerRelevant || + TaskActivityCode.activated => + task.stats, + }; +} + +TaskStatistics _applyCompletionStats({ + required Task task, + required TaskActivity activity, + required List lockedIntervals, +}) { + var stats = task.stats; + final scheduledEnd = task.scheduledEnd; + final completedAt = task.completedAt ?? activity.occurredAt; + + if (scheduledEnd != null && completedAt.isAfter(scheduledEnd)) { + stats = stats.incrementCompletedLate(); + } + + final lockedMinutes = _actualLockedOverlapMinutes( + task: task, + lockedIntervals: lockedIntervals, + ); + if (lockedMinutes > 0) { + stats = stats.incrementCompletedDuringLockedHours(lockedMinutes); + } + + final pushesBeforeCompletion = + _intMetadata(activity.metadata['pushesBeforeCompletion']) ?? + _pushesBeforeCompletion(task); + + return stats.recordPushesBeforeCompletion(pushesBeforeCompletion); +} + +int _actualLockedOverlapMinutes({ + required Task task, + required List lockedIntervals, +}) { + final actualStart = task.actualStart; + final actualEnd = task.actualEnd; + if (actualStart == null || + actualEnd == null || + !actualStart.isBefore(actualEnd) || + lockedIntervals.isEmpty) { + return 0; + } + + final actualInterval = TimeInterval(start: actualStart, end: actualEnd); + final intersections = []; + + for (final lockedInterval in lockedIntervals) { + if (!actualInterval.overlaps(lockedInterval)) { + continue; + } + intersections.add( + TimeInterval( + start: _latest(actualInterval.start, lockedInterval.start), + end: _earliest(actualInterval.end, lockedInterval.end), + ), + ); + } + + if (intersections.isEmpty) { + return 0; + } + + intersections.sort((left, right) => left.start.compareTo(right.start)); + var total = Duration.zero; + var current = intersections.first; + for (final interval in intersections.skip(1)) { + if (interval.start.isAfter(current.end)) { + total += current.duration; + current = interval; + continue; + } + + current = TimeInterval( + start: current.start, + end: _latest(current.end, interval.end), + ); + } + total += current.duration; + + return total.inMinutes; +} + +int _pushesBeforeCompletion(Task task) { + return task.stats.manuallyPushedCount + task.stats.autoPushedCount; +} + +int? _knownDurationMinutes( + Task task, + DateTime? actualStart, + DateTime? actualEnd, +) { + if (actualStart != null && + actualEnd != null && + actualStart.isBefore(actualEnd)) { + final actualMinutes = actualEnd.difference(actualStart).inMinutes; + if (actualMinutes > 0) { + return actualMinutes; + } + } + + return task.durationMinutes; +} + +int? _intMetadata(Object? value) { + return value is int ? value : null; +} + +DateTime _earliest(DateTime first, DateTime second) { + return first.isBefore(second) ? first : second; +} + +DateTime _latest(DateTime first, DateTime second) { + return first.isAfter(second) ? first : second; +} + +TaskTransitionResult _applied({ + required TaskTransitionCode transitionCode, + required Task task, + required List activities, +}) { + return TaskTransitionResult( + transitionCode: transitionCode, + outcomeCode: TaskTransitionOutcomeCode.applied, + task: task, + activities: activities, + ); +} + +TaskTransitionResult _invalid(Task task, TaskTransitionCode transitionCode) { + return TaskTransitionResult( + transitionCode: transitionCode, + outcomeCode: TaskTransitionOutcomeCode.invalidState, + task: task, + ); +} + +TaskTransitionResult _noOp(Task task, TaskTransitionCode transitionCode) { + return TaskTransitionResult( + transitionCode: transitionCode, + outcomeCode: TaskTransitionOutcomeCode.noOp, + task: task, + ); +} + +TaskActivity _activity({ + required String id, + required String operationId, + required TaskActivityCode code, + required Task task, + required DateTime occurredAt, + required Map metadata, + required TaskStatus nextStatus, +}) { + return TaskActivity( + id: id, + operationId: operationId, + code: code, + taskId: task.id, + projectId: task.projectId, + occurredAt: occurredAt, + metadata: { + ...metadata, + 'previousStatus': task.status.name, + 'nextStatus': nextStatus.name, + }, + ); +} + +TaskActivity? _duplicateActivity({ + required Iterable existingActivities, + required String taskId, + required String operationId, +}) { + for (final activity in existingActivities) { + if (activity.taskId == taskId && activity.operationId == operationId) { + return activity; + } + } + + return null; +} + +bool _actualIntervalShapeIsValid(DateTime? actualStart, DateTime? actualEnd) { + if (actualStart == null && actualEnd == null) { + return true; + } + return actualStart != null && + actualEnd != null && + actualStart.isBefore(actualEnd); +} + +bool _isNoOpTerminalRepeat(Task task, TaskTransitionCode transitionCode) { + return (task.status == TaskStatus.completed && + transitionCode == TaskTransitionCode.complete) || + (task.status == TaskStatus.cancelled && + transitionCode == TaskTransitionCode.cancel) || + (task.status == TaskStatus.noLongerRelevant && + transitionCode == TaskTransitionCode.noLongerRelevant); +} + +bool _isBlockedByTerminalState(Task task, TaskTransitionCode transitionCode) { + if (!_isTerminal(task.status)) { + return false; + } + return !_isNoOpTerminalRepeat(task, transitionCode); +} + +bool _isTerminal(TaskStatus status) { + return status == TaskStatus.completed || + status == TaskStatus.cancelled || + status == TaskStatus.noLongerRelevant; +} + +bool _canRemoveFromActivePlan(Task task) { + return task.status == TaskStatus.planned || + task.status == TaskStatus.active || + task.status == TaskStatus.backlog || + task.status == TaskStatus.missed; +} + +String _requiredTrimmed(String value, String name) { + final trimmed = value.trim(); + if (trimmed.isEmpty) { + throw ArgumentError.value(value, name, 'Value is required.'); + } + return trimmed; +} diff --git a/packages/scheduler_core/lib/src/time_contracts.dart b/packages/scheduler_core/lib/src/time_contracts.dart index 4740589..2f318be 100644 --- a/packages/scheduler_core/lib/src/time_contracts.dart +++ b/packages/scheduler_core/lib/src/time_contracts.dart @@ -2,295 +2,17 @@ library; import 'models.dart'; - -/// Deterministic source of the current instant. -abstract interface class Clock { - DateTime now(); -} - -/// Production clock boundary. Domain services should receive this through -/// constructors rather than reading wall time directly. -class SystemClock implements Clock { - const SystemClock(); - - @override - DateTime now() => DateTime.now(); -} - -/// Test/application clock with a fixed instant. -class FixedClock implements Clock { - const FixedClock(this.instant); - - final DateTime instant; - - @override - DateTime now() => instant; -} - -/// Deterministic ID source for application/domain convenience entry points. -abstract interface class IdGenerator { - String nextId(); -} - -/// Simple deterministic ID generator useful for tests and local wiring. -class SequentialIdGenerator implements IdGenerator { - SequentialIdGenerator({ - this.prefix = 'id', - int start = 1, - }) : _next = start; - - final String prefix; - int _next; - - @override - String nextId() { - final id = '$prefix-$_next'; - _next += 1; - return id; - } -} - -/// Calendar date without a time or timezone. -class CivilDate implements Comparable { - CivilDate(this.year, this.month, this.day) { - final normalized = DateTime.utc(year, month, day); - if (normalized.year != year || - normalized.month != month || - normalized.day != day) { - throw DomainValidationException( - code: DomainValidationCode.invalidCivilDate, - invalidValue: {'year': year, 'month': month, 'day': day}, - name: 'CivilDate', - message: 'Civil date must be a valid calendar date.', - ); - } - } - - factory CivilDate.fromDateTime(DateTime value) { - return CivilDate(value.year, value.month, value.day); - } - - factory CivilDate.fromIsoString(String value) { - if (!RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(value)) { - throw DomainValidationException( - code: DomainValidationCode.invalidCivilDate, - invalidValue: value, - name: 'CivilDate', - message: 'Civil date must use YYYY-MM-DD format.', - ); - } - final parts = value.split('-'); - return CivilDate( - int.parse(parts[0]), - int.parse(parts[1]), - int.parse(parts[2]), - ); - } - - final int year; - final int month; - final int day; - - int get weekday => DateTime.utc(year, month, day).weekday; - - CivilDate addDays(int days) { - final shifted = DateTime.utc(year, month, day).add(Duration(days: days)); - return CivilDate(shifted.year, shifted.month, shifted.day); - } - - String toIsoString() { - final monthText = month.toString().padLeft(2, '0'); - final dayText = day.toString().padLeft(2, '0'); - return '$year-$monthText-$dayText'; - } - - @override - int compareTo(CivilDate other) { - final yearComparison = year.compareTo(other.year); - if (yearComparison != 0) { - return yearComparison; - } - final monthComparison = month.compareTo(other.month); - if (monthComparison != 0) { - return monthComparison; - } - return day.compareTo(other.day); - } - - @override - bool operator ==(Object other) { - return other is CivilDate && - other.year == year && - other.month == month && - other.day == day; - } - - @override - int get hashCode => Object.hash(year, month, day); - - @override - String toString() => toIsoString(); -} - -/// Wall-clock time without a date or timezone. -class WallTime implements Comparable { - WallTime({ - required this.hour, - required this.minute, - }) { - if (hour < 0 || hour >= 24 || minute < 0 || minute >= 60) { - throw DomainValidationException( - code: DomainValidationCode.invalidClockTime, - invalidValue: {'hour': hour, 'minute': minute}, - name: 'WallTime', - message: 'Wall time must use hour 0-23 and minute 0-59.', - ); - } - } - - factory WallTime.fromIsoString(String value) { - if (!RegExp(r'^\d{2}:\d{2}$').hasMatch(value)) { - throw DomainValidationException( - code: DomainValidationCode.invalidClockTime, - invalidValue: value, - name: 'WallTime', - message: 'Wall time must use HH:MM format.', - ); - } - final parts = value.split(':'); - return WallTime(hour: int.parse(parts[0]), minute: int.parse(parts[1])); - } - - final int hour; - final int minute; - - int get minutesSinceMidnight => hour * 60 + minute; - - String toIsoString() { - return '${hour.toString().padLeft(2, '0')}:' - '${minute.toString().padLeft(2, '0')}'; - } - - @override - int compareTo(WallTime other) { - return minutesSinceMidnight.compareTo(other.minutesSinceMidnight); - } - - @override - bool operator ==(Object other) { - return other is WallTime && other.hour == hour && other.minute == minute; - } - - @override - int get hashCode => Object.hash(hour, minute); - - @override - String toString() => toIsoString(); -} - -enum LocalTimeResolution { - exact, - shiftedForward, - repeatedEarlier, - repeatedLater, -} - -enum NonexistentLocalTimePolicy { - /// Move through a daylight-saving gap to the first valid local instant after it. - shiftForward, - - /// Reject local times that do not exist in the requested timezone. - reject, -} - -enum RepeatedLocalTimePolicy { - /// Use the first instant for a repeated local time during a fall-back transition. - earlier, - - /// Use the second instant for a repeated local time during a fall-back transition. - later, - - /// Reject local times that occur more than once in the requested timezone. - reject, -} - -class TimeZoneResolutionOptions { - const TimeZoneResolutionOptions({ - this.nonexistentLocalTimePolicy = NonexistentLocalTimePolicy.shiftForward, - this.repeatedLocalTimePolicy = RepeatedLocalTimePolicy.earlier, - }); - - final NonexistentLocalTimePolicy nonexistentLocalTimePolicy; - final RepeatedLocalTimePolicy repeatedLocalTimePolicy; -} - -class ResolvedLocalDateTime { - const ResolvedLocalDateTime({ - required this.date, - required this.wallTime, - required this.timeZoneId, - required this.instant, - required this.resolution, - required this.utcOffset, - }); - - final CivilDate date; - final WallTime wallTime; - final String timeZoneId; - final DateTime instant; - final LocalTimeResolution resolution; - final Duration utcOffset; -} - -/// Boundary for converting local civil date/time values to instants. -abstract interface class TimeZoneResolver { - ResolvedLocalDateTime resolve({ - required CivilDate date, - required WallTime wallTime, - required String timeZoneId, - TimeZoneResolutionOptions options, - }); -} - -/// Resolver for zones whose offset is already known by the application boundary. -class FixedOffsetTimeZoneResolver implements TimeZoneResolver { - const FixedOffsetTimeZoneResolver.utc() : offset = Duration.zero; - - const FixedOffsetTimeZoneResolver({required this.offset}); - - final Duration offset; - - @override - ResolvedLocalDateTime resolve({ - required CivilDate date, - required WallTime wallTime, - required String timeZoneId, - TimeZoneResolutionOptions options = const TimeZoneResolutionOptions(), - }) { - final trimmedZone = timeZoneId.trim(); - if (trimmedZone.isEmpty) { - throw DomainValidationException( - code: DomainValidationCode.blankTimeZoneId, - invalidValue: timeZoneId, - name: 'timeZoneId', - message: 'Time-zone id is required.', - ); - } - final localAsUtc = DateTime.utc( - date.year, - date.month, - date.day, - wallTime.hour, - wallTime.minute, - ); - - return ResolvedLocalDateTime( - date: date, - wallTime: wallTime, - timeZoneId: trimmedZone, - instant: localAsUtc.subtract(offset), - resolution: LocalTimeResolution.exact, - utcOffset: offset, - ); - } -} +part 'time_contracts/clock.dart'; +part 'time_contracts/system_clock.dart'; +part 'time_contracts/fixed_clock.dart'; +part 'time_contracts/id_generator.dart'; +part 'time_contracts/sequential_id_generator.dart'; +part 'time_contracts/civil_date.dart'; +part 'time_contracts/wall_time.dart'; +part 'time_contracts/local_time_resolution.dart'; +part 'time_contracts/nonexistent_local_time_policy.dart'; +part 'time_contracts/repeated_local_time_policy.dart'; +part 'time_contracts/time_zone_resolution_options.dart'; +part 'time_contracts/resolved_local_date_time.dart'; +part 'time_contracts/time_zone_resolver.dart'; +part 'time_contracts/fixed_offset_time_zone_resolver.dart'; diff --git a/packages/scheduler_core/lib/src/time_contracts/civil_date.dart b/packages/scheduler_core/lib/src/time_contracts/civil_date.dart new file mode 100644 index 0000000..43cf73c --- /dev/null +++ b/packages/scheduler_core/lib/src/time_contracts/civil_date.dart @@ -0,0 +1,83 @@ +part of '../time_contracts.dart'; + +/// Calendar date without a time or timezone. +class CivilDate implements Comparable { + CivilDate(this.year, this.month, this.day) { + final normalized = DateTime.utc(year, month, day); + if (normalized.year != year || + normalized.month != month || + normalized.day != day) { + throw DomainValidationException( + code: DomainValidationCode.invalidCivilDate, + invalidValue: {'year': year, 'month': month, 'day': day}, + name: 'CivilDate', + message: 'Civil date must be a valid calendar date.', + ); + } + } + + factory CivilDate.fromDateTime(DateTime value) { + return CivilDate(value.year, value.month, value.day); + } + + factory CivilDate.fromIsoString(String value) { + if (!RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(value)) { + throw DomainValidationException( + code: DomainValidationCode.invalidCivilDate, + invalidValue: value, + name: 'CivilDate', + message: 'Civil date must use YYYY-MM-DD format.', + ); + } + final parts = value.split('-'); + return CivilDate( + int.parse(parts[0]), + int.parse(parts[1]), + int.parse(parts[2]), + ); + } + + final int year; + final int month; + final int day; + + int get weekday => DateTime.utc(year, month, day).weekday; + + CivilDate addDays(int days) { + final shifted = DateTime.utc(year, month, day).add(Duration(days: days)); + return CivilDate(shifted.year, shifted.month, shifted.day); + } + + String toIsoString() { + final monthText = month.toString().padLeft(2, '0'); + final dayText = day.toString().padLeft(2, '0'); + return '$year-$monthText-$dayText'; + } + + @override + int compareTo(CivilDate other) { + final yearComparison = year.compareTo(other.year); + if (yearComparison != 0) { + return yearComparison; + } + final monthComparison = month.compareTo(other.month); + if (monthComparison != 0) { + return monthComparison; + } + return day.compareTo(other.day); + } + + @override + bool operator ==(Object other) { + return other is CivilDate && + other.year == year && + other.month == month && + other.day == day; + } + + @override + int get hashCode => Object.hash(year, month, day); + + @override + String toString() => toIsoString(); +} diff --git a/packages/scheduler_core/lib/src/time_contracts/clock.dart b/packages/scheduler_core/lib/src/time_contracts/clock.dart new file mode 100644 index 0000000..96d7cce --- /dev/null +++ b/packages/scheduler_core/lib/src/time_contracts/clock.dart @@ -0,0 +1,6 @@ +part of '../time_contracts.dart'; + +/// Deterministic source of the current instant. +abstract interface class Clock { + DateTime now(); +} diff --git a/packages/scheduler_core/lib/src/time_contracts/fixed_clock.dart b/packages/scheduler_core/lib/src/time_contracts/fixed_clock.dart new file mode 100644 index 0000000..c883a53 --- /dev/null +++ b/packages/scheduler_core/lib/src/time_contracts/fixed_clock.dart @@ -0,0 +1,11 @@ +part of '../time_contracts.dart'; + +/// Test/application clock with a fixed instant. +class FixedClock implements Clock { + const FixedClock(this.instant); + + final DateTime instant; + + @override + DateTime now() => instant; +} diff --git a/packages/scheduler_core/lib/src/time_contracts/fixed_offset_time_zone_resolver.dart b/packages/scheduler_core/lib/src/time_contracts/fixed_offset_time_zone_resolver.dart new file mode 100644 index 0000000..099a02a --- /dev/null +++ b/packages/scheduler_core/lib/src/time_contracts/fixed_offset_time_zone_resolver.dart @@ -0,0 +1,44 @@ +part of '../time_contracts.dart'; + +/// Resolver for zones whose offset is already known by the application boundary. +class FixedOffsetTimeZoneResolver implements TimeZoneResolver { + const FixedOffsetTimeZoneResolver.utc() : offset = Duration.zero; + + const FixedOffsetTimeZoneResolver({required this.offset}); + + final Duration offset; + + @override + ResolvedLocalDateTime resolve({ + required CivilDate date, + required WallTime wallTime, + required String timeZoneId, + TimeZoneResolutionOptions options = const TimeZoneResolutionOptions(), + }) { + final trimmedZone = timeZoneId.trim(); + if (trimmedZone.isEmpty) { + throw DomainValidationException( + code: DomainValidationCode.blankTimeZoneId, + invalidValue: timeZoneId, + name: 'timeZoneId', + message: 'Time-zone id is required.', + ); + } + final localAsUtc = DateTime.utc( + date.year, + date.month, + date.day, + wallTime.hour, + wallTime.minute, + ); + + return ResolvedLocalDateTime( + date: date, + wallTime: wallTime, + timeZoneId: trimmedZone, + instant: localAsUtc.subtract(offset), + resolution: LocalTimeResolution.exact, + utcOffset: offset, + ); + } +} diff --git a/packages/scheduler_core/lib/src/time_contracts/id_generator.dart b/packages/scheduler_core/lib/src/time_contracts/id_generator.dart new file mode 100644 index 0000000..b7ba5eb --- /dev/null +++ b/packages/scheduler_core/lib/src/time_contracts/id_generator.dart @@ -0,0 +1,6 @@ +part of '../time_contracts.dart'; + +/// Deterministic ID source for application/domain convenience entry points. +abstract interface class IdGenerator { + String nextId(); +} diff --git a/packages/scheduler_core/lib/src/time_contracts/local_time_resolution.dart b/packages/scheduler_core/lib/src/time_contracts/local_time_resolution.dart new file mode 100644 index 0000000..9dbae23 --- /dev/null +++ b/packages/scheduler_core/lib/src/time_contracts/local_time_resolution.dart @@ -0,0 +1,8 @@ +part of '../time_contracts.dart'; + +enum LocalTimeResolution { + exact, + shiftedForward, + repeatedEarlier, + repeatedLater, +} diff --git a/packages/scheduler_core/lib/src/time_contracts/nonexistent_local_time_policy.dart b/packages/scheduler_core/lib/src/time_contracts/nonexistent_local_time_policy.dart new file mode 100644 index 0000000..c2b39f9 --- /dev/null +++ b/packages/scheduler_core/lib/src/time_contracts/nonexistent_local_time_policy.dart @@ -0,0 +1,9 @@ +part of '../time_contracts.dart'; + +enum NonexistentLocalTimePolicy { + /// Move through a daylight-saving gap to the first valid local instant after it. + shiftForward, + + /// Reject local times that do not exist in the requested timezone. + reject, +} diff --git a/packages/scheduler_core/lib/src/time_contracts/repeated_local_time_policy.dart b/packages/scheduler_core/lib/src/time_contracts/repeated_local_time_policy.dart new file mode 100644 index 0000000..42b5c30 --- /dev/null +++ b/packages/scheduler_core/lib/src/time_contracts/repeated_local_time_policy.dart @@ -0,0 +1,12 @@ +part of '../time_contracts.dart'; + +enum RepeatedLocalTimePolicy { + /// Use the first instant for a repeated local time during a fall-back transition. + earlier, + + /// Use the second instant for a repeated local time during a fall-back transition. + later, + + /// Reject local times that occur more than once in the requested timezone. + reject, +} diff --git a/packages/scheduler_core/lib/src/time_contracts/resolved_local_date_time.dart b/packages/scheduler_core/lib/src/time_contracts/resolved_local_date_time.dart new file mode 100644 index 0000000..cc6ba80 --- /dev/null +++ b/packages/scheduler_core/lib/src/time_contracts/resolved_local_date_time.dart @@ -0,0 +1,19 @@ +part of '../time_contracts.dart'; + +class ResolvedLocalDateTime { + const ResolvedLocalDateTime({ + required this.date, + required this.wallTime, + required this.timeZoneId, + required this.instant, + required this.resolution, + required this.utcOffset, + }); + + final CivilDate date; + final WallTime wallTime; + final String timeZoneId; + final DateTime instant; + final LocalTimeResolution resolution; + final Duration utcOffset; +} diff --git a/packages/scheduler_core/lib/src/time_contracts/sequential_id_generator.dart b/packages/scheduler_core/lib/src/time_contracts/sequential_id_generator.dart new file mode 100644 index 0000000..7ddb40d --- /dev/null +++ b/packages/scheduler_core/lib/src/time_contracts/sequential_id_generator.dart @@ -0,0 +1,19 @@ +part of '../time_contracts.dart'; + +/// Simple deterministic ID generator useful for tests and local wiring. +class SequentialIdGenerator implements IdGenerator { + SequentialIdGenerator({ + this.prefix = 'id', + int start = 1, + }) : _next = start; + + final String prefix; + int _next; + + @override + String nextId() { + final id = '$prefix-$_next'; + _next += 1; + return id; + } +} diff --git a/packages/scheduler_core/lib/src/time_contracts/system_clock.dart b/packages/scheduler_core/lib/src/time_contracts/system_clock.dart new file mode 100644 index 0000000..bc40c42 --- /dev/null +++ b/packages/scheduler_core/lib/src/time_contracts/system_clock.dart @@ -0,0 +1,10 @@ +part of '../time_contracts.dart'; + +/// Production clock boundary. Domain services should receive this through +/// constructors rather than reading wall time directly. +class SystemClock implements Clock { + const SystemClock(); + + @override + DateTime now() => DateTime.now(); +} diff --git a/packages/scheduler_core/lib/src/time_contracts/time_zone_resolution_options.dart b/packages/scheduler_core/lib/src/time_contracts/time_zone_resolution_options.dart new file mode 100644 index 0000000..aaf01d9 --- /dev/null +++ b/packages/scheduler_core/lib/src/time_contracts/time_zone_resolution_options.dart @@ -0,0 +1,11 @@ +part of '../time_contracts.dart'; + +class TimeZoneResolutionOptions { + const TimeZoneResolutionOptions({ + this.nonexistentLocalTimePolicy = NonexistentLocalTimePolicy.shiftForward, + this.repeatedLocalTimePolicy = RepeatedLocalTimePolicy.earlier, + }); + + final NonexistentLocalTimePolicy nonexistentLocalTimePolicy; + final RepeatedLocalTimePolicy repeatedLocalTimePolicy; +} diff --git a/packages/scheduler_core/lib/src/time_contracts/time_zone_resolver.dart b/packages/scheduler_core/lib/src/time_contracts/time_zone_resolver.dart new file mode 100644 index 0000000..702e2a8 --- /dev/null +++ b/packages/scheduler_core/lib/src/time_contracts/time_zone_resolver.dart @@ -0,0 +1,11 @@ +part of '../time_contracts.dart'; + +/// Boundary for converting local civil date/time values to instants. +abstract interface class TimeZoneResolver { + ResolvedLocalDateTime resolve({ + required CivilDate date, + required WallTime wallTime, + required String timeZoneId, + TimeZoneResolutionOptions options, + }); +} diff --git a/packages/scheduler_core/lib/src/time_contracts/wall_time.dart b/packages/scheduler_core/lib/src/time_contracts/wall_time.dart new file mode 100644 index 0000000..4c2fa64 --- /dev/null +++ b/packages/scheduler_core/lib/src/time_contracts/wall_time.dart @@ -0,0 +1,57 @@ +part of '../time_contracts.dart'; + +/// Wall-clock time without a date or timezone. +class WallTime implements Comparable { + WallTime({ + required this.hour, + required this.minute, + }) { + if (hour < 0 || hour >= 24 || minute < 0 || minute >= 60) { + throw DomainValidationException( + code: DomainValidationCode.invalidClockTime, + invalidValue: {'hour': hour, 'minute': minute}, + name: 'WallTime', + message: 'Wall time must use hour 0-23 and minute 0-59.', + ); + } + } + + factory WallTime.fromIsoString(String value) { + if (!RegExp(r'^\d{2}:\d{2}$').hasMatch(value)) { + throw DomainValidationException( + code: DomainValidationCode.invalidClockTime, + invalidValue: value, + name: 'WallTime', + message: 'Wall time must use HH:MM format.', + ); + } + final parts = value.split(':'); + return WallTime(hour: int.parse(parts[0]), minute: int.parse(parts[1])); + } + + final int hour; + final int minute; + + int get minutesSinceMidnight => hour * 60 + minute; + + String toIsoString() { + return '${hour.toString().padLeft(2, '0')}:' + '${minute.toString().padLeft(2, '0')}'; + } + + @override + int compareTo(WallTime other) { + return minutesSinceMidnight.compareTo(other.minutesSinceMidnight); + } + + @override + bool operator ==(Object other) { + return other is WallTime && other.hour == hour && other.minute == minute; + } + + @override + int get hashCode => Object.hash(hour, minute); + + @override + String toString() => toIsoString(); +} diff --git a/packages/scheduler_core/lib/src/timeline_state.dart b/packages/scheduler_core/lib/src/timeline_state.dart index 4f463ab..99ce16b 100644 --- a/packages/scheduler_core/lib/src/timeline_state.dart +++ b/packages/scheduler_core/lib/src/timeline_state.dart @@ -10,456 +10,11 @@ library; import 'locked_time.dart'; import 'models.dart'; import 'time_contracts.dart'; - -/// Broad display category for an item placed on the Today timeline. -enum TimelineItemCategory { - /// Normal visible task card. - taskCard, - - /// Non-task timeline layer such as locked time. - overlay, -} - -/// Background style token derived from the scheduling behavior type. -enum TimelineBackgroundToken { - flexible, - inflexible, - critical, - locked, - surprise, - freeSlot, -} - -/// Reward icon token for the first timeline card icon. -enum TimelineRewardIconToken { - notSet, - veryLow, - low, - medium, - high, - veryHigh, -} - -/// Difficulty icon token for the second timeline card icon. -enum TimelineDifficultyIconToken { - notSet, - veryEasy, - easy, - medium, - hard, - veryHard, -} - -/// Quick actions the timeline can expose without depending on a UI framework. -enum TimelineQuickAction { - done, - push, - backlog, - breakUp, - missed, - cancel, - noLongerRelevant, -} - -/// Display-ready timeline item derived from a domain [Task]. -class TimelineItem { - TimelineItem({ - required this.id, - required this.displayTitle, - required this.taskType, - required this.projectColorToken, - required this.backgroundToken, - required this.rewardIconToken, - required this.difficultyIconToken, - required this.showsExplicitTime, - required List quickActions, - required this.category, - this.start, - this.end, - this.completedAt, - this.durationMinutes, - }) : quickActions = List.unmodifiable(quickActions); - - /// Stable id of the source item. - final String id; - - /// Text shown as the item name. - final String displayTitle; - - /// Source scheduling behavior type. - final TaskType taskType; - - /// Border/project color token. This is a key, not a raw color. - final String projectColorToken; - - /// Translucent background token derived from [taskType]. - final TimelineBackgroundToken backgroundToken; - - /// First icon token. - final TimelineRewardIconToken rewardIconToken; - - /// Second icon token. - final TimelineDifficultyIconToken difficultyIconToken; - - /// Whether the item should render explicit start/end time text. - final bool showsExplicitTime; - - /// Timeline placement start, if the source item has one. - final DateTime? start; - - /// Timeline placement end, if the source item has one. - final DateTime? end; - - /// Completion instant, if the source task has been completed. - final DateTime? completedAt; - - /// Duration display value for flexible task cards. - final int? durationMinutes; - - /// Quick actions available from this item. - final List quickActions; - - /// Whether this is a normal task card or a non-task overlay. - final TimelineItemCategory category; -} - -/// Reduced Today timeline state for manual compact mode. -class CompactTimelineState { - const CompactTimelineState({ - required this.manualCompactModeEnabled, - required this.fullTimelineExpanded, - this.currentItem, - this.nextRequiredItem, - this.nextFlexibleItem, - }); - - /// User-controlled compact mode flag. - final bool manualCompactModeEnabled; - - /// Whether the full timeline is expanded while compact mode is active. - final bool fullTimelineExpanded; - - /// Current visible task, if one is active or occupies the query time. - final TimelineItem? currentItem; - - /// Next critical or inflexible task after the current moment. - final TimelineItem? nextRequiredItem; - - /// Optional next flexible task after the current moment. - final TimelineItem? nextFlexibleItem; - - /// Whether callers should show the full timeline. - bool get fullTimelineVisible { - return !manualCompactModeEnabled || fullTimelineExpanded; - } - - /// Items selected for the compact view. - List get compactItems { - if (!manualCompactModeEnabled) { - return const []; - } - return [ - if (currentItem != null) currentItem!, - if (nextRequiredItem != null && nextRequiredItem!.id != currentItem?.id) - nextRequiredItem!, - if (nextFlexibleItem != null && - nextFlexibleItem!.id != currentItem?.id && - nextFlexibleItem!.id != nextRequiredItem?.id) - nextFlexibleItem!, - ]; - } -} - -/// Converts domain tasks into Today timeline items. -class TimelineItemMapper { - TimelineItemMapper({ - Map projectColorTokensById = const {}, - }) : projectColorTokensById = - Map.unmodifiable(projectColorTokensById); - - /// Project id to border color token lookup. - final Map projectColorTokensById; - - /// Build a mapper from project profiles. - factory TimelineItemMapper.fromProjects(Iterable projects) { - return TimelineItemMapper( - projectColorTokensById: { - for (final project in projects) project.id: project.colorKey, - }, - ); - } - - /// Convert [task] into framework-neutral timeline view state. - TimelineItem fromTask(Task task) { - return TimelineItem( - id: task.id, - displayTitle: task.title, - taskType: task.type, - projectColorToken: _projectColorTokenFor(task.projectId), - backgroundToken: _backgroundTokenFor(task.type), - rewardIconToken: _rewardIconTokenFor(task.reward), - difficultyIconToken: _difficultyIconTokenFor(task.difficulty), - showsExplicitTime: _showsExplicitTime(task.type), - start: task.scheduledStart, - end: task.scheduledEnd, - completedAt: task.completedAt, - durationMinutes: _durationMinutesFor(task), - quickActions: _quickActionsFor(task.type), - category: task.isLocked - ? TimelineItemCategory.overlay - : TimelineItemCategory.taskCard, - ); - } - - /// Convert a concrete locked-time occurrence into an overlay item. - /// - /// Hidden locked time returns null unless [revealHiddenLockedBlocks] is true. - /// This keeps locked time available to scheduling while leaving UI visibility - /// controlled by an explicit temporary reveal state. - TimelineItem? fromLockedOccurrence( - LockedBlockOccurrence occurrence, { - required bool revealHiddenLockedBlocks, - CivilDate? occurrenceDate, - }) { - if (occurrence.hiddenByDefault && !revealHiddenLockedBlocks) { - return null; - } - - return TimelineItem( - id: _lockedOccurrenceId(occurrence, occurrenceDate: occurrenceDate), - displayTitle: occurrence.name, - taskType: TaskType.locked, - projectColorToken: _projectColorTokenFor(occurrence.projectId), - backgroundToken: TimelineBackgroundToken.locked, - rewardIconToken: TimelineRewardIconToken.notSet, - difficultyIconToken: TimelineDifficultyIconToken.notSet, - showsExplicitTime: true, - start: occurrence.interval.start, - end: occurrence.interval.end, - quickActions: const [], - category: TimelineItemCategory.overlay, - ); - } - - /// Build manual compact-mode state from scheduled task data. - CompactTimelineState compactStateForTasks({ - required Iterable tasks, - required DateTime now, - required bool manualCompactModeEnabled, - bool includeNextFlexibleTask = false, - bool fullTimelineExpanded = false, - }) { - if (!manualCompactModeEnabled) { - return CompactTimelineState( - manualCompactModeEnabled: false, - fullTimelineExpanded: fullTimelineExpanded, - ); - } - - final timelineTasks = tasks.where(_canAppearInCompactMode).toList() - ..sort(_compareTasksByStart); - final currentTask = timelineTasks - .where((task) { - return task.status == TaskStatus.active || _containsTime(task, now); - }) - .cast() - .firstWhere( - (task) => task != null, - orElse: () => null, - ); - final nextRequiredTask = timelineTasks - .where((task) { - return task.isRequiredVisible && _startsAtOrAfter(task, now); - }) - .cast() - .firstWhere( - (task) => task != null, - orElse: () => null, - ); - final nextFlexibleTask = includeNextFlexibleTask - ? timelineTasks - .where((task) { - return task.isFlexible && - task.id != currentTask?.id && - _startsAtOrAfter(task, now); - }) - .cast() - .firstWhere( - (task) => task != null, - orElse: () => null, - ) - : null; - - return CompactTimelineState( - manualCompactModeEnabled: true, - fullTimelineExpanded: fullTimelineExpanded, - currentItem: currentTask == null ? null : fromTask(currentTask), - nextRequiredItem: - nextRequiredTask == null ? null : fromTask(nextRequiredTask), - nextFlexibleItem: - nextFlexibleTask == null ? null : fromTask(nextFlexibleTask), - ); - } - - bool _canAppearInCompactMode(Task task) { - return !task.isLocked && - task.status != TaskStatus.backlog && - task.status != TaskStatus.completed && - task.status != TaskStatus.cancelled && - task.status != TaskStatus.noLongerRelevant && - task.scheduledStart != null && - task.scheduledEnd != null; - } - - bool _containsTime(Task task, DateTime now) { - final start = task.scheduledStart; - final end = task.scheduledEnd; - if (start == null || end == null) { - return false; - } - return !now.isBefore(start) && now.isBefore(end); - } - - bool _startsAtOrAfter(Task task, DateTime now) { - final start = task.scheduledStart; - return start != null && !start.isBefore(now); - } - - int _compareTasksByStart(Task left, Task right) { - final leftStart = left.scheduledStart; - final rightStart = right.scheduledStart; - if (leftStart == null && rightStart == null) { - return 0; - } - if (leftStart == null) { - return 1; - } - if (rightStart == null) { - return -1; - } - return leftStart.compareTo(rightStart); - } - - String _lockedOccurrenceId( - LockedBlockOccurrence occurrence, { - CivilDate? occurrenceDate, - }) { - final datePart = - occurrenceDate == null ? null : ':${occurrenceDate.toIsoString()}'; - final lockedBlockId = occurrence.lockedBlockId; - if (lockedBlockId != null) { - return 'locked-block:$lockedBlockId${datePart ?? ''}'; - } - final overrideId = occurrence.overrideId; - if (overrideId != null) { - return 'locked-override:$overrideId${datePart ?? ''}'; - } - return 'locked:${occurrence.name}:' - '${occurrence.interval.start.toIso8601String()}'; - } - - String _projectColorTokenFor(String? projectId) { - if (projectId == null) { - return 'locked'; - } - return projectColorTokensById[projectId] ?? projectId; - } - - bool _showsExplicitTime(TaskType type) { - return type == TaskType.inflexible || - type == TaskType.critical || - type == TaskType.locked; - } - - int? _durationMinutesFor(Task task) { - if (!task.isFlexible) { - return null; - } - if (task.durationMinutes != null) { - return task.durationMinutes; - } - final start = task.scheduledStart; - final end = task.scheduledEnd; - if (start == null || end == null) { - return null; - } - return end.difference(start).inMinutes; - } - - List _quickActionsFor(TaskType type) { - switch (type) { - case TaskType.flexible: - return const [ - TimelineQuickAction.done, - TimelineQuickAction.push, - TimelineQuickAction.backlog, - TimelineQuickAction.breakUp, - ]; - case TaskType.inflexible: - case TaskType.critical: - return const [ - TimelineQuickAction.done, - TimelineQuickAction.missed, - TimelineQuickAction.cancel, - TimelineQuickAction.noLongerRelevant, - ]; - case TaskType.locked: - case TaskType.surprise: - case TaskType.freeSlot: - return const []; - } - } - - TimelineBackgroundToken _backgroundTokenFor(TaskType type) { - switch (type) { - case TaskType.flexible: - return TimelineBackgroundToken.flexible; - case TaskType.inflexible: - return TimelineBackgroundToken.inflexible; - case TaskType.critical: - return TimelineBackgroundToken.critical; - case TaskType.locked: - return TimelineBackgroundToken.locked; - case TaskType.surprise: - return TimelineBackgroundToken.surprise; - case TaskType.freeSlot: - return TimelineBackgroundToken.freeSlot; - } - } - - TimelineRewardIconToken _rewardIconTokenFor(RewardLevel reward) { - switch (reward) { - case RewardLevel.notSet: - return TimelineRewardIconToken.notSet; - case RewardLevel.veryLow: - return TimelineRewardIconToken.veryLow; - case RewardLevel.low: - return TimelineRewardIconToken.low; - case RewardLevel.medium: - return TimelineRewardIconToken.medium; - case RewardLevel.high: - return TimelineRewardIconToken.high; - case RewardLevel.veryHigh: - return TimelineRewardIconToken.veryHigh; - } - } - - TimelineDifficultyIconToken _difficultyIconTokenFor( - DifficultyLevel difficulty, - ) { - switch (difficulty) { - case DifficultyLevel.notSet: - return TimelineDifficultyIconToken.notSet; - case DifficultyLevel.veryEasy: - return TimelineDifficultyIconToken.veryEasy; - case DifficultyLevel.easy: - return TimelineDifficultyIconToken.easy; - case DifficultyLevel.medium: - return TimelineDifficultyIconToken.medium; - case DifficultyLevel.hard: - return TimelineDifficultyIconToken.hard; - case DifficultyLevel.veryHard: - return TimelineDifficultyIconToken.veryHard; - } - } -} +part 'timeline_state/timeline_item_category.dart'; +part 'timeline_state/timeline_background_token.dart'; +part 'timeline_state/timeline_reward_icon_token.dart'; +part 'timeline_state/timeline_difficulty_icon_token.dart'; +part 'timeline_state/timeline_quick_action.dart'; +part 'timeline_state/timeline_item.dart'; +part 'timeline_state/compact_timeline_state.dart'; +part 'timeline_state/timeline_item_mapper.dart'; diff --git a/packages/scheduler_core/lib/src/timeline_state/compact_timeline_state.dart b/packages/scheduler_core/lib/src/timeline_state/compact_timeline_state.dart new file mode 100644 index 0000000..16afa31 --- /dev/null +++ b/packages/scheduler_core/lib/src/timeline_state/compact_timeline_state.dart @@ -0,0 +1,48 @@ +part of '../timeline_state.dart'; + +/// Reduced Today timeline state for manual compact mode. +class CompactTimelineState { + const CompactTimelineState({ + required this.manualCompactModeEnabled, + required this.fullTimelineExpanded, + this.currentItem, + this.nextRequiredItem, + this.nextFlexibleItem, + }); + + /// User-controlled compact mode flag. + final bool manualCompactModeEnabled; + + /// Whether the full timeline is expanded while compact mode is active. + final bool fullTimelineExpanded; + + /// Current visible task, if one is active or occupies the query time. + final TimelineItem? currentItem; + + /// Next critical or inflexible task after the current moment. + final TimelineItem? nextRequiredItem; + + /// Optional next flexible task after the current moment. + final TimelineItem? nextFlexibleItem; + + /// Whether callers should show the full timeline. + bool get fullTimelineVisible { + return !manualCompactModeEnabled || fullTimelineExpanded; + } + + /// Items selected for the compact view. + List get compactItems { + if (!manualCompactModeEnabled) { + return const []; + } + return [ + if (currentItem != null) currentItem!, + if (nextRequiredItem != null && nextRequiredItem!.id != currentItem?.id) + nextRequiredItem!, + if (nextFlexibleItem != null && + nextFlexibleItem!.id != currentItem?.id && + nextFlexibleItem!.id != nextRequiredItem?.id) + nextFlexibleItem!, + ]; + } +} diff --git a/packages/scheduler_core/lib/src/timeline_state/timeline_background_token.dart b/packages/scheduler_core/lib/src/timeline_state/timeline_background_token.dart new file mode 100644 index 0000000..4250b84 --- /dev/null +++ b/packages/scheduler_core/lib/src/timeline_state/timeline_background_token.dart @@ -0,0 +1,11 @@ +part of '../timeline_state.dart'; + +/// Background style token derived from the scheduling behavior type. +enum TimelineBackgroundToken { + flexible, + inflexible, + critical, + locked, + surprise, + freeSlot, +} diff --git a/packages/scheduler_core/lib/src/timeline_state/timeline_difficulty_icon_token.dart b/packages/scheduler_core/lib/src/timeline_state/timeline_difficulty_icon_token.dart new file mode 100644 index 0000000..1b12185 --- /dev/null +++ b/packages/scheduler_core/lib/src/timeline_state/timeline_difficulty_icon_token.dart @@ -0,0 +1,11 @@ +part of '../timeline_state.dart'; + +/// Difficulty icon token for the second timeline card icon. +enum TimelineDifficultyIconToken { + notSet, + veryEasy, + easy, + medium, + hard, + veryHard, +} diff --git a/packages/scheduler_core/lib/src/timeline_state/timeline_item.dart b/packages/scheduler_core/lib/src/timeline_state/timeline_item.dart new file mode 100644 index 0000000..db8ce65 --- /dev/null +++ b/packages/scheduler_core/lib/src/timeline_state/timeline_item.dart @@ -0,0 +1,63 @@ +part of '../timeline_state.dart'; + +/// Display-ready timeline item derived from a domain [Task]. +class TimelineItem { + TimelineItem({ + required this.id, + required this.displayTitle, + required this.taskType, + required this.projectColorToken, + required this.backgroundToken, + required this.rewardIconToken, + required this.difficultyIconToken, + required this.showsExplicitTime, + required List quickActions, + required this.category, + this.start, + this.end, + this.completedAt, + this.durationMinutes, + }) : quickActions = List.unmodifiable(quickActions); + + /// Stable id of the source item. + final String id; + + /// Text shown as the item name. + final String displayTitle; + + /// Source scheduling behavior type. + final TaskType taskType; + + /// Border/project color token. This is a key, not a raw color. + final String projectColorToken; + + /// Translucent background token derived from [taskType]. + final TimelineBackgroundToken backgroundToken; + + /// First icon token. + final TimelineRewardIconToken rewardIconToken; + + /// Second icon token. + final TimelineDifficultyIconToken difficultyIconToken; + + /// Whether the item should render explicit start/end time text. + final bool showsExplicitTime; + + /// Timeline placement start, if the source item has one. + final DateTime? start; + + /// Timeline placement end, if the source item has one. + final DateTime? end; + + /// Completion instant, if the source task has been completed. + final DateTime? completedAt; + + /// Duration display value for flexible task cards. + final int? durationMinutes; + + /// Quick actions available from this item. + final List quickActions; + + /// Whether this is a normal task card or a non-task overlay. + final TimelineItemCategory category; +} diff --git a/packages/scheduler_core/lib/src/timeline_state/timeline_item_category.dart b/packages/scheduler_core/lib/src/timeline_state/timeline_item_category.dart new file mode 100644 index 0000000..39e4a49 --- /dev/null +++ b/packages/scheduler_core/lib/src/timeline_state/timeline_item_category.dart @@ -0,0 +1,10 @@ +part of '../timeline_state.dart'; + +/// Broad display category for an item placed on the Today timeline. +enum TimelineItemCategory { + /// Normal visible task card. + taskCard, + + /// Non-task timeline layer such as locked time. + overlay, +} diff --git a/packages/scheduler_core/lib/src/timeline_state/timeline_item_mapper.dart b/packages/scheduler_core/lib/src/timeline_state/timeline_item_mapper.dart new file mode 100644 index 0000000..ffd9ab6 --- /dev/null +++ b/packages/scheduler_core/lib/src/timeline_state/timeline_item_mapper.dart @@ -0,0 +1,295 @@ +part of '../timeline_state.dart'; + +/// Converts domain tasks into Today timeline items. +class TimelineItemMapper { + TimelineItemMapper({ + Map projectColorTokensById = const {}, + }) : projectColorTokensById = + Map.unmodifiable(projectColorTokensById); + + /// Project id to border color token lookup. + final Map projectColorTokensById; + + /// Build a mapper from project profiles. + factory TimelineItemMapper.fromProjects(Iterable projects) { + return TimelineItemMapper( + projectColorTokensById: { + for (final project in projects) project.id: project.colorKey, + }, + ); + } + + /// Convert [task] into framework-neutral timeline view state. + TimelineItem fromTask(Task task) { + return TimelineItem( + id: task.id, + displayTitle: task.title, + taskType: task.type, + projectColorToken: _projectColorTokenFor(task.projectId), + backgroundToken: _backgroundTokenFor(task.type), + rewardIconToken: _rewardIconTokenFor(task.reward), + difficultyIconToken: _difficultyIconTokenFor(task.difficulty), + showsExplicitTime: _showsExplicitTime(task.type), + start: task.scheduledStart, + end: task.scheduledEnd, + completedAt: task.completedAt, + durationMinutes: _durationMinutesFor(task), + quickActions: _quickActionsFor(task.type), + category: task.isLocked + ? TimelineItemCategory.overlay + : TimelineItemCategory.taskCard, + ); + } + + /// Convert a concrete locked-time occurrence into an overlay item. + /// + /// Hidden locked time returns null unless [revealHiddenLockedBlocks] is true. + /// This keeps locked time available to scheduling while leaving UI visibility + /// controlled by an explicit temporary reveal state. + TimelineItem? fromLockedOccurrence( + LockedBlockOccurrence occurrence, { + required bool revealHiddenLockedBlocks, + CivilDate? occurrenceDate, + }) { + if (occurrence.hiddenByDefault && !revealHiddenLockedBlocks) { + return null; + } + + return TimelineItem( + id: _lockedOccurrenceId(occurrence, occurrenceDate: occurrenceDate), + displayTitle: occurrence.name, + taskType: TaskType.locked, + projectColorToken: _projectColorTokenFor(occurrence.projectId), + backgroundToken: TimelineBackgroundToken.locked, + rewardIconToken: TimelineRewardIconToken.notSet, + difficultyIconToken: TimelineDifficultyIconToken.notSet, + showsExplicitTime: true, + start: occurrence.interval.start, + end: occurrence.interval.end, + quickActions: const [], + category: TimelineItemCategory.overlay, + ); + } + + /// Build manual compact-mode state from scheduled task data. + CompactTimelineState compactStateForTasks({ + required Iterable tasks, + required DateTime now, + required bool manualCompactModeEnabled, + bool includeNextFlexibleTask = false, + bool fullTimelineExpanded = false, + }) { + if (!manualCompactModeEnabled) { + return CompactTimelineState( + manualCompactModeEnabled: false, + fullTimelineExpanded: fullTimelineExpanded, + ); + } + + final timelineTasks = tasks.where(_canAppearInCompactMode).toList() + ..sort(_compareTasksByStart); + final currentTask = timelineTasks + .where((task) { + return task.status == TaskStatus.active || _containsTime(task, now); + }) + .cast() + .firstWhere( + (task) => task != null, + orElse: () => null, + ); + final nextRequiredTask = timelineTasks + .where((task) { + return task.isRequiredVisible && _startsAtOrAfter(task, now); + }) + .cast() + .firstWhere( + (task) => task != null, + orElse: () => null, + ); + final nextFlexibleTask = includeNextFlexibleTask + ? timelineTasks + .where((task) { + return task.isFlexible && + task.id != currentTask?.id && + _startsAtOrAfter(task, now); + }) + .cast() + .firstWhere( + (task) => task != null, + orElse: () => null, + ) + : null; + + return CompactTimelineState( + manualCompactModeEnabled: true, + fullTimelineExpanded: fullTimelineExpanded, + currentItem: currentTask == null ? null : fromTask(currentTask), + nextRequiredItem: + nextRequiredTask == null ? null : fromTask(nextRequiredTask), + nextFlexibleItem: + nextFlexibleTask == null ? null : fromTask(nextFlexibleTask), + ); + } + + bool _canAppearInCompactMode(Task task) { + return !task.isLocked && + task.status != TaskStatus.backlog && + task.status != TaskStatus.completed && + task.status != TaskStatus.cancelled && + task.status != TaskStatus.noLongerRelevant && + task.scheduledStart != null && + task.scheduledEnd != null; + } + + bool _containsTime(Task task, DateTime now) { + final start = task.scheduledStart; + final end = task.scheduledEnd; + if (start == null || end == null) { + return false; + } + return !now.isBefore(start) && now.isBefore(end); + } + + bool _startsAtOrAfter(Task task, DateTime now) { + final start = task.scheduledStart; + return start != null && !start.isBefore(now); + } + + int _compareTasksByStart(Task left, Task right) { + final leftStart = left.scheduledStart; + final rightStart = right.scheduledStart; + if (leftStart == null && rightStart == null) { + return 0; + } + if (leftStart == null) { + return 1; + } + if (rightStart == null) { + return -1; + } + return leftStart.compareTo(rightStart); + } + + String _lockedOccurrenceId( + LockedBlockOccurrence occurrence, { + CivilDate? occurrenceDate, + }) { + final datePart = + occurrenceDate == null ? null : ':${occurrenceDate.toIsoString()}'; + final lockedBlockId = occurrence.lockedBlockId; + if (lockedBlockId != null) { + return 'locked-block:$lockedBlockId${datePart ?? ''}'; + } + final overrideId = occurrence.overrideId; + if (overrideId != null) { + return 'locked-override:$overrideId${datePart ?? ''}'; + } + return 'locked:${occurrence.name}:' + '${occurrence.interval.start.toIso8601String()}'; + } + + String _projectColorTokenFor(String? projectId) { + if (projectId == null) { + return 'locked'; + } + return projectColorTokensById[projectId] ?? projectId; + } + + bool _showsExplicitTime(TaskType type) { + return type == TaskType.inflexible || + type == TaskType.critical || + type == TaskType.locked; + } + + int? _durationMinutesFor(Task task) { + if (!task.isFlexible) { + return null; + } + if (task.durationMinutes != null) { + return task.durationMinutes; + } + final start = task.scheduledStart; + final end = task.scheduledEnd; + if (start == null || end == null) { + return null; + } + return end.difference(start).inMinutes; + } + + List _quickActionsFor(TaskType type) { + switch (type) { + case TaskType.flexible: + return const [ + TimelineQuickAction.done, + TimelineQuickAction.push, + TimelineQuickAction.backlog, + TimelineQuickAction.breakUp, + ]; + case TaskType.inflexible: + case TaskType.critical: + return const [ + TimelineQuickAction.done, + TimelineQuickAction.missed, + TimelineQuickAction.cancel, + TimelineQuickAction.noLongerRelevant, + ]; + case TaskType.locked: + case TaskType.surprise: + case TaskType.freeSlot: + return const []; + } + } + + TimelineBackgroundToken _backgroundTokenFor(TaskType type) { + switch (type) { + case TaskType.flexible: + return TimelineBackgroundToken.flexible; + case TaskType.inflexible: + return TimelineBackgroundToken.inflexible; + case TaskType.critical: + return TimelineBackgroundToken.critical; + case TaskType.locked: + return TimelineBackgroundToken.locked; + case TaskType.surprise: + return TimelineBackgroundToken.surprise; + case TaskType.freeSlot: + return TimelineBackgroundToken.freeSlot; + } + } + + TimelineRewardIconToken _rewardIconTokenFor(RewardLevel reward) { + switch (reward) { + case RewardLevel.notSet: + return TimelineRewardIconToken.notSet; + case RewardLevel.veryLow: + return TimelineRewardIconToken.veryLow; + case RewardLevel.low: + return TimelineRewardIconToken.low; + case RewardLevel.medium: + return TimelineRewardIconToken.medium; + case RewardLevel.high: + return TimelineRewardIconToken.high; + case RewardLevel.veryHigh: + return TimelineRewardIconToken.veryHigh; + } + } + + TimelineDifficultyIconToken _difficultyIconTokenFor( + DifficultyLevel difficulty, + ) { + switch (difficulty) { + case DifficultyLevel.notSet: + return TimelineDifficultyIconToken.notSet; + case DifficultyLevel.veryEasy: + return TimelineDifficultyIconToken.veryEasy; + case DifficultyLevel.easy: + return TimelineDifficultyIconToken.easy; + case DifficultyLevel.medium: + return TimelineDifficultyIconToken.medium; + case DifficultyLevel.hard: + return TimelineDifficultyIconToken.hard; + case DifficultyLevel.veryHard: + return TimelineDifficultyIconToken.veryHard; + } + } +} diff --git a/packages/scheduler_core/lib/src/timeline_state/timeline_quick_action.dart b/packages/scheduler_core/lib/src/timeline_state/timeline_quick_action.dart new file mode 100644 index 0000000..f962616 --- /dev/null +++ b/packages/scheduler_core/lib/src/timeline_state/timeline_quick_action.dart @@ -0,0 +1,12 @@ +part of '../timeline_state.dart'; + +/// Quick actions the timeline can expose without depending on a UI framework. +enum TimelineQuickAction { + done, + push, + backlog, + breakUp, + missed, + cancel, + noLongerRelevant, +} diff --git a/packages/scheduler_core/lib/src/timeline_state/timeline_reward_icon_token.dart b/packages/scheduler_core/lib/src/timeline_state/timeline_reward_icon_token.dart new file mode 100644 index 0000000..307a6cc --- /dev/null +++ b/packages/scheduler_core/lib/src/timeline_state/timeline_reward_icon_token.dart @@ -0,0 +1,11 @@ +part of '../timeline_state.dart'; + +/// Reward icon token for the first timeline card icon. +enum TimelineRewardIconToken { + notSet, + veryLow, + low, + medium, + high, + veryHigh, +} diff --git a/packages/scheduler_core/lib/src/today_state.dart b/packages/scheduler_core/lib/src/today_state.dart index 6e25d1e..6a6580b 100644 --- a/packages/scheduler_core/lib/src/today_state.dart +++ b/packages/scheduler_core/lib/src/today_state.dart @@ -14,644 +14,10 @@ import 'repositories.dart'; import 'scheduling_engine.dart'; import 'timeline_state.dart'; import 'time_contracts.dart'; - -/// Source category for one Today timeline item. -enum TodayTimelineItemSource { - task, - lockedOccurrence, -} - -/// Request for the V1 Today read model. -class GetTodayStateRequest { - const GetTodayStateRequest({ - required this.context, - required this.date, - this.revealHiddenLockedBlocks = false, - this.includeNextFlexibleItem = true, - this.fullTimelineExpanded = false, - }); - - /// Operation/read context. [ApplicationOperationContext.now] is the read - /// instant and is never recomputed during the query. - final ApplicationOperationContext context; - - /// Owner-local day to read. - final CivilDate date; - - /// Whether hidden locked-time overlays should be present in the item list. - final bool revealHiddenLockedBlocks; - - /// Whether compact projection should expose a next flexible task. - final bool includeNextFlexibleItem; - - /// Whether compact mode is currently showing the full timeline. - final bool fullTimelineExpanded; -} - -/// One item in the Today read model, with task status/source metadata. -class TodayTimelineItem { - TodayTimelineItem({ - required this.item, - required this.source, - this.taskStatus, - this.taskId, - this.lockedBlockId, - this.lockedOverrideId, - this.hiddenByDefault = false, - this.isCurrent = false, - this.isNextRequired = false, - this.isNextFlexible = false, - }); - - /// Display-ready framework-neutral item. - final TimelineItem item; - - /// Repository/domain source for this item. - final TodayTimelineItemSource source; - - /// Source task status. Null for locked occurrences. - final TaskStatus? taskStatus; - - /// Source task id, when [source] is [TodayTimelineItemSource.task]. - final String? taskId; - - /// Source locked block id, when this is a locked occurrence. - final String? lockedBlockId; - - /// Source override id, when this occurrence was replaced or added. - final String? lockedOverrideId; - - /// Whether the locked source is normally hidden. - final bool hiddenByDefault; - - /// Whether this item is the current selected item for the read instant. - final bool isCurrent; - - /// Whether this item is the next required item. - final bool isNextRequired; - - /// Whether this item is the optional next flexible item. - final bool isNextFlexible; - - /// Stable item id. - String get id => item.id; - - /// Timeline start. - DateTime? get start => item.start; - - /// Timeline end. - DateTime? get end => item.end; - - /// Source task behavior type. - TaskType get taskType => item.taskType; - - /// Project/border color token. - String get projectColorToken => item.projectColorToken; - - /// Structured quick actions available for this item. - List get quickActions => item.quickActions; - - /// Return a copy with selection flags changed. - TodayTimelineItem copyWith({ - bool? isCurrent, - bool? isNextRequired, - bool? isNextFlexible, - }) { - return TodayTimelineItem( - item: item, - source: source, - taskStatus: taskStatus, - taskId: taskId, - lockedBlockId: lockedBlockId, - lockedOverrideId: lockedOverrideId, - hiddenByDefault: hiddenByDefault, - isCurrent: isCurrent ?? this.isCurrent, - isNextRequired: isNextRequired ?? this.isNextRequired, - isNextFlexible: isNextFlexible ?? this.isNextFlexible, - ); - } -} - -/// Compact projection derived from the same Today item list. -class TodayCompactState { - const TodayCompactState({ - required this.manualCompactModeEnabled, - required this.fullTimelineExpanded, - this.currentItem, - this.nextRequiredItem, - this.nextFlexibleItem, - }); - - /// User-controlled compact mode flag. - final bool manualCompactModeEnabled; - - /// Whether the full timeline is expanded while compact mode is active. - final bool fullTimelineExpanded; - - /// Current visible item, if one occupies the read instant. - final TodayTimelineItem? currentItem; - - /// Next critical or inflexible item. - final TodayTimelineItem? nextRequiredItem; - - /// Optional next flexible item. - final TodayTimelineItem? nextFlexibleItem; - - /// Whether callers should show the full timeline. - bool get fullTimelineVisible { - return !manualCompactModeEnabled || fullTimelineExpanded; - } - - /// Items selected for compact rendering. - List get compactItems { - if (!manualCompactModeEnabled) { - return const []; - } - - return [ - if (currentItem != null) currentItem!, - if (nextRequiredItem != null && nextRequiredItem!.id != currentItem?.id) - nextRequiredItem!, - if (nextFlexibleItem != null && - nextFlexibleItem!.id != currentItem?.id && - nextFlexibleItem!.id != nextRequiredItem?.id) - nextFlexibleItem!, - ]; - } -} - -/// Pending notice data surfaced through Today state. -class TodayPendingNotice { - TodayPendingNotice({ - required this.noticeId, - required this.snapshotId, - required this.capturedAt, - required this.message, - required this.type, - required Map parameters, - this.taskId, - this.issueCode, - this.movementCode, - this.conflictCode, - }) : parameters = Map.unmodifiable(parameters); - - /// Stable id for acknowledgement/consume commands. - final String noticeId; - - /// Source snapshot id. - final String snapshotId; - - /// Snapshot capture time. - final DateTime capturedAt; - - /// Existing neutral scheduler message. UI may localize from codes later. - final String message; - - /// Notice category. - final SchedulingNoticeType type; - - /// Related task id, if any. - final String? taskId; - - /// Stable issue code, if any. - final SchedulingIssueCode? issueCode; - - /// Stable movement code, if any. - final SchedulingMovementCode? movementCode; - - /// Stable conflict code, if any. - final SchedulingConflictCode? conflictCode; - - /// Structured notice parameters. - final Map parameters; -} - -/// Complete V1 Today state for one owner-local date. -class TodayState { - TodayState({ - required this.date, - required this.ownerId, - required this.timeZoneId, - required this.readAt, - required this.dayStart, - required this.dayEnd, - required this.ownerSettings, - required List timelineItems, - required this.compactState, - required List pendingNotices, - this.currentItem, - this.nextRequiredItem, - this.nextFlexibleItem, - }) : timelineItems = List.unmodifiable(timelineItems), - pendingNotices = List.unmodifiable(pendingNotices); - - /// Owner-local date represented by this state. - final CivilDate date; - - /// Authorization-neutral owner scope. - final String ownerId; - - /// Time-zone id used for local-day expansion. - final String timeZoneId; - - /// Read instant from the request context. - final DateTime readAt; - - /// Resolved start instant for [date]. - final DateTime dayStart; - - /// Resolved end instant for [date]. - final DateTime dayEnd; - - /// Effective owner settings used by the query. - final OwnerSettings ownerSettings; - - /// Full sorted UI-independent timeline projection. - final List timelineItems; - - /// Current item, if one occupies [readAt]. - final TodayTimelineItem? currentItem; - - /// Next required item after [readAt]. - final TodayTimelineItem? nextRequiredItem; - - /// Optional next flexible item after [readAt]. - final TodayTimelineItem? nextFlexibleItem; - - /// Compact-mode projection from [timelineItems]. - final TodayCompactState compactState; - - /// Pending scheduling notices stored for the day. - final List pendingNotices; -} - -/// Builds Today state from application repositories. -class GetTodayStateQuery { - const GetTodayStateQuery({ - required this.applicationStore, - required this.timeZoneResolver, - this.resolutionOptions = const TimeZoneResolutionOptions(), - }); - - /// Read boundary for repository access. - final ApplicationUnitOfWork applicationStore; - - /// Explicit local-time resolver. - final TimeZoneResolver timeZoneResolver; - - /// DST/nonexistent/repeated local time policy. - final TimeZoneResolutionOptions resolutionOptions; - - /// Execute the read-only Today query. - Future> execute( - GetTodayStateRequest request, - ) { - return applicationStore.read( - action: (repositories) async { - final context = request.context; - final ownerId = context.ownerTimeZone.ownerId; - final storedSettings = - await repositories.ownerSettings.findByOwnerId(ownerId); - final settings = storedSettings ?? - OwnerSettings( - ownerId: ownerId, - timeZoneId: context.ownerTimeZone.timeZoneId, - ); - final timeZoneId = settings.timeZoneId; - final dayStart = _resolve( - date: request.date, - wallTime: settings.dayStart, - timeZoneId: timeZoneId, - ); - final dayEnd = _resolve( - date: request.date, - wallTime: settings.dayEnd, - timeZoneId: timeZoneId, - ); - final window = SchedulingWindow(start: dayStart, end: dayEnd); - - final projects = (await repositories.projects.findByOwner( - ownerId: ownerId, - includeArchived: true, - )) - .items; - final tasks = (await repositories.tasks.findForLocalDay( - ownerId: ownerId, - localDate: request.date, - window: window, - )) - .items; - final blocks = (await repositories.lockedBlocks.findBlocksByOwner( - ownerId: ownerId, - )) - .items; - final overrides = (await repositories.lockedBlocks.findOverridesForDate( - ownerId: ownerId, - date: request.date, - )) - .items; - final snapshots = - await repositories.schedulingSnapshots.findInWindow(window); - final acknowledgedNoticeIds = - (await repositories.noticeAcknowledgements.findByOwnerId(ownerId)) - .map((record) => record.noticeId) - .toSet(); - - final lockedExpansion = expandLockedBlocksForDay( - blocks: blocks, - overrides: overrides, - date: request.date, - timeZoneId: timeZoneId, - timeZoneResolver: timeZoneResolver, - resolutionOptions: resolutionOptions, - ); - final mapper = TimelineItemMapper.fromProjects(projects); - final baseItems = [ - for (final task in tasks) - if (_taskAppearsInToday(task, request.revealHiddenLockedBlocks)) - TodayTimelineItem( - item: mapper.fromTask(task), - source: TodayTimelineItemSource.task, - taskStatus: task.status, - taskId: task.id, - hiddenByDefault: task.isLocked, - ), - for (final occurrence in lockedExpansion.occurrences) - if (mapper.fromLockedOccurrence( - occurrence, - revealHiddenLockedBlocks: request.revealHiddenLockedBlocks, - occurrenceDate: request.date, - ) - case final lockedItem?) - TodayTimelineItem( - item: lockedItem, - source: TodayTimelineItemSource.lockedOccurrence, - lockedBlockId: occurrence.lockedBlockId, - lockedOverrideId: occurrence.overrideId, - hiddenByDefault: occurrence.hiddenByDefault, - ), - ]..sort(_compareTodayItems); - - final current = _currentItem(baseItems, context.now); - final nextRequired = _nextRequiredItem(baseItems, context.now); - final nextFlexible = request.includeNextFlexibleItem - ? _nextFlexibleItem( - baseItems, - context.now, - currentTaskId: current?.taskId, - ) - : null; - final selectedItems = baseItems - .map( - (item) => item.copyWith( - isCurrent: item.id == current?.id, - isNextRequired: item.id == nextRequired?.id, - isNextFlexible: item.id == nextFlexible?.id, - ), - ) - .toList(growable: false); - - final selectedCurrent = _findById(selectedItems, current?.id); - final selectedNextRequired = _findById(selectedItems, nextRequired?.id); - final selectedNextFlexible = _findById(selectedItems, nextFlexible?.id); - final pendingNotices = _pendingNoticesFromSnapshots( - snapshots, - acknowledgedNoticeIds: acknowledgedNoticeIds, - ); - - return ApplicationResult.success( - TodayState( - date: request.date, - ownerId: ownerId, - timeZoneId: timeZoneId, - readAt: context.now, - dayStart: dayStart, - dayEnd: dayEnd, - ownerSettings: settings, - timelineItems: selectedItems, - currentItem: selectedCurrent, - nextRequiredItem: selectedNextRequired, - nextFlexibleItem: selectedNextFlexible, - compactState: TodayCompactState( - manualCompactModeEnabled: settings.compactModeEnabled, - fullTimelineExpanded: request.fullTimelineExpanded, - currentItem: selectedCurrent, - nextRequiredItem: selectedNextRequired, - nextFlexibleItem: selectedNextFlexible, - ), - pendingNotices: pendingNotices, - ), - ); - }, - ); - } - - DateTime _resolve({ - required CivilDate date, - required WallTime wallTime, - required String timeZoneId, - }) { - return timeZoneResolver - .resolve( - date: date, - wallTime: wallTime, - timeZoneId: timeZoneId, - options: resolutionOptions, - ) - .instant; - } - - static bool _taskAppearsInToday(Task task, bool revealHiddenLockedBlocks) { - if (task.status == TaskStatus.backlog || - task.status == TaskStatus.cancelled || - task.status == TaskStatus.noLongerRelevant) { - return false; - } - if (task.isLocked && !revealHiddenLockedBlocks) { - return false; - } - - return task.scheduledStart != null && task.scheduledEnd != null; - } - - static TodayTimelineItem? _currentItem( - List items, - DateTime now, - ) { - final active = _firstOrNull( - items.where((item) { - return item.source == TodayTimelineItemSource.task && - item.taskStatus == TaskStatus.active; - }), - ); - if (active != null) { - return active; - } - - return _firstOrNull( - items.where((item) { - return item.source == TodayTimelineItemSource.task && - item.taskStatus == TaskStatus.planned && - _containsTime(item, now); - }), - ); - } - - static TodayTimelineItem? _nextRequiredItem( - List items, - DateTime now, - ) { - return _firstOrNull( - items.where((item) { - return item.source == TodayTimelineItemSource.task && - _isActionable(item.taskStatus) && - _isRequired(item.taskType) && - _startsAtOrAfter(item, now); - }), - ); - } - - static TodayTimelineItem? _nextFlexibleItem( - List items, - DateTime now, { - required String? currentTaskId, - }) { - return _firstOrNull( - items.where((item) { - return item.source == TodayTimelineItemSource.task && - item.taskId != currentTaskId && - _isActionable(item.taskStatus) && - item.taskType == TaskType.flexible && - _startsAtOrAfter(item, now); - }), - ); - } - - static bool _isActionable(TaskStatus? status) { - return status == TaskStatus.planned || status == TaskStatus.active; - } - - static bool _isRequired(TaskType type) { - return type == TaskType.inflexible || type == TaskType.critical; - } - - static bool _containsTime(TodayTimelineItem item, DateTime now) { - final start = item.start; - final end = item.end; - if (start == null || end == null) { - return false; - } - - return !now.isBefore(start) && now.isBefore(end); - } - - static bool _startsAtOrAfter(TodayTimelineItem item, DateTime now) { - final start = item.start; - return start != null && !start.isBefore(now); - } - - static TodayTimelineItem? _findById( - List items, - String? id, - ) { - if (id == null) { - return null; - } - - return _firstOrNull(items.where((item) => item.id == id)); - } - - static List _pendingNoticesFromSnapshots( - List snapshots, { - Set acknowledgedNoticeIds = const {}, - }) { - final sortedSnapshots = [...snapshots]..sort((a, b) { - final capturedComparison = a.capturedAt.compareTo(b.capturedAt); - if (capturedComparison != 0) { - return capturedComparison; - } - - return a.id.compareTo(b.id); - }); - final notices = []; - - for (final snapshot in sortedSnapshots) { - for (var index = 0; index < snapshot.notices.length; index += 1) { - final noticeId = _noticeId(snapshot.id, index); - if (acknowledgedNoticeIds.contains(noticeId)) { - continue; - } - final notice = snapshot.notices[index]; - notices.add( - TodayPendingNotice( - noticeId: noticeId, - snapshotId: snapshot.id, - capturedAt: snapshot.capturedAt, - message: notice.message, - type: notice.type, - taskId: notice.taskId, - issueCode: notice.issueCode, - movementCode: notice.movementCode, - conflictCode: notice.conflictCode, - parameters: notice.parameters, - ), - ); - } - } - - return List.unmodifiable(notices); - } -} - -String schedulingNoticeId({ - required String snapshotId, - required int noticeIndex, -}) { - return _noticeId(snapshotId, noticeIndex); -} - -String _noticeId(String snapshotId, int noticeIndex) { - return '$snapshotId:$noticeIndex'; -} - -int _compareTodayItems(TodayTimelineItem left, TodayTimelineItem right) { - final startComparison = _compareNullableInstants(left.start, right.start); - if (startComparison != 0) { - return startComparison; - } - - final endComparison = _compareNullableInstants(left.end, right.end); - if (endComparison != 0) { - return endComparison; - } - - final sourceComparison = left.source.index.compareTo(right.source.index); - if (sourceComparison != 0) { - return sourceComparison; - } - - return left.id.compareTo(right.id); -} - -int _compareNullableInstants(DateTime? left, DateTime? right) { - if (left == null && right == null) { - return 0; - } - if (left == null) { - return 1; - } - if (right == null) { - return -1; - } - - return left.compareTo(right); -} - -T? _firstOrNull(Iterable values) { - final iterator = values.iterator; - if (!iterator.moveNext()) { - return null; - } - - return iterator.current; -} +part 'today_state/today_timeline_item_source.dart'; +part 'today_state/get_today_state_request.dart'; +part 'today_state/today_timeline_item.dart'; +part 'today_state/today_compact_state.dart'; +part 'today_state/today_pending_notice.dart'; +part 'today_state/today_state.dart'; +part 'today_state/get_today_state_query.dart'; diff --git a/packages/scheduler_core/lib/src/today_state/get_today_state_query.dart b/packages/scheduler_core/lib/src/today_state/get_today_state_query.dart new file mode 100644 index 0000000..1483abd --- /dev/null +++ b/packages/scheduler_core/lib/src/today_state/get_today_state_query.dart @@ -0,0 +1,372 @@ +part of '../today_state.dart'; + +/// Builds Today state from application repositories. +class GetTodayStateQuery { + const GetTodayStateQuery({ + required this.applicationStore, + required this.timeZoneResolver, + this.resolutionOptions = const TimeZoneResolutionOptions(), + }); + + /// Read boundary for repository access. + final ApplicationUnitOfWork applicationStore; + + /// Explicit local-time resolver. + final TimeZoneResolver timeZoneResolver; + + /// DST/nonexistent/repeated local time policy. + final TimeZoneResolutionOptions resolutionOptions; + + /// Execute the read-only Today query. + Future> execute( + GetTodayStateRequest request, + ) { + return applicationStore.read( + action: (repositories) async { + final context = request.context; + final ownerId = context.ownerTimeZone.ownerId; + final storedSettings = + await repositories.ownerSettings.findByOwnerId(ownerId); + final settings = storedSettings ?? + OwnerSettings( + ownerId: ownerId, + timeZoneId: context.ownerTimeZone.timeZoneId, + ); + final timeZoneId = settings.timeZoneId; + final dayStart = _resolve( + date: request.date, + wallTime: settings.dayStart, + timeZoneId: timeZoneId, + ); + final dayEnd = _resolve( + date: request.date, + wallTime: settings.dayEnd, + timeZoneId: timeZoneId, + ); + final window = SchedulingWindow(start: dayStart, end: dayEnd); + + final projects = (await repositories.projects.findByOwner( + ownerId: ownerId, + includeArchived: true, + )) + .items; + final tasks = (await repositories.tasks.findForLocalDay( + ownerId: ownerId, + localDate: request.date, + window: window, + )) + .items; + final blocks = (await repositories.lockedBlocks.findBlocksByOwner( + ownerId: ownerId, + )) + .items; + final overrides = (await repositories.lockedBlocks.findOverridesForDate( + ownerId: ownerId, + date: request.date, + )) + .items; + final snapshots = + await repositories.schedulingSnapshots.findInWindow(window); + final acknowledgedNoticeIds = + (await repositories.noticeAcknowledgements.findByOwnerId(ownerId)) + .map((record) => record.noticeId) + .toSet(); + + final lockedExpansion = expandLockedBlocksForDay( + blocks: blocks, + overrides: overrides, + date: request.date, + timeZoneId: timeZoneId, + timeZoneResolver: timeZoneResolver, + resolutionOptions: resolutionOptions, + ); + final mapper = TimelineItemMapper.fromProjects(projects); + final baseItems = [ + for (final task in tasks) + if (_taskAppearsInToday(task, request.revealHiddenLockedBlocks)) + TodayTimelineItem( + item: mapper.fromTask(task), + source: TodayTimelineItemSource.task, + taskStatus: task.status, + taskId: task.id, + hiddenByDefault: task.isLocked, + ), + for (final occurrence in lockedExpansion.occurrences) + if (mapper.fromLockedOccurrence( + occurrence, + revealHiddenLockedBlocks: request.revealHiddenLockedBlocks, + occurrenceDate: request.date, + ) + case final lockedItem?) + TodayTimelineItem( + item: lockedItem, + source: TodayTimelineItemSource.lockedOccurrence, + lockedBlockId: occurrence.lockedBlockId, + lockedOverrideId: occurrence.overrideId, + hiddenByDefault: occurrence.hiddenByDefault, + ), + ]..sort(_compareTodayItems); + + final current = _currentItem(baseItems, context.now); + final nextRequired = _nextRequiredItem(baseItems, context.now); + final nextFlexible = request.includeNextFlexibleItem + ? _nextFlexibleItem( + baseItems, + context.now, + currentTaskId: current?.taskId, + ) + : null; + final selectedItems = baseItems + .map( + (item) => item.copyWith( + isCurrent: item.id == current?.id, + isNextRequired: item.id == nextRequired?.id, + isNextFlexible: item.id == nextFlexible?.id, + ), + ) + .toList(growable: false); + + final selectedCurrent = _findById(selectedItems, current?.id); + final selectedNextRequired = _findById(selectedItems, nextRequired?.id); + final selectedNextFlexible = _findById(selectedItems, nextFlexible?.id); + final pendingNotices = _pendingNoticesFromSnapshots( + snapshots, + acknowledgedNoticeIds: acknowledgedNoticeIds, + ); + + return ApplicationResult.success( + TodayState( + date: request.date, + ownerId: ownerId, + timeZoneId: timeZoneId, + readAt: context.now, + dayStart: dayStart, + dayEnd: dayEnd, + ownerSettings: settings, + timelineItems: selectedItems, + currentItem: selectedCurrent, + nextRequiredItem: selectedNextRequired, + nextFlexibleItem: selectedNextFlexible, + compactState: TodayCompactState( + manualCompactModeEnabled: settings.compactModeEnabled, + fullTimelineExpanded: request.fullTimelineExpanded, + currentItem: selectedCurrent, + nextRequiredItem: selectedNextRequired, + nextFlexibleItem: selectedNextFlexible, + ), + pendingNotices: pendingNotices, + ), + ); + }, + ); + } + + DateTime _resolve({ + required CivilDate date, + required WallTime wallTime, + required String timeZoneId, + }) { + return timeZoneResolver + .resolve( + date: date, + wallTime: wallTime, + timeZoneId: timeZoneId, + options: resolutionOptions, + ) + .instant; + } + + static bool _taskAppearsInToday(Task task, bool revealHiddenLockedBlocks) { + if (task.status == TaskStatus.backlog || + task.status == TaskStatus.cancelled || + task.status == TaskStatus.noLongerRelevant) { + return false; + } + if (task.isLocked && !revealHiddenLockedBlocks) { + return false; + } + + return task.scheduledStart != null && task.scheduledEnd != null; + } + + static TodayTimelineItem? _currentItem( + List items, + DateTime now, + ) { + final active = _firstOrNull( + items.where((item) { + return item.source == TodayTimelineItemSource.task && + item.taskStatus == TaskStatus.active; + }), + ); + if (active != null) { + return active; + } + + return _firstOrNull( + items.where((item) { + return item.source == TodayTimelineItemSource.task && + item.taskStatus == TaskStatus.planned && + _containsTime(item, now); + }), + ); + } + + static TodayTimelineItem? _nextRequiredItem( + List items, + DateTime now, + ) { + return _firstOrNull( + items.where((item) { + return item.source == TodayTimelineItemSource.task && + _isActionable(item.taskStatus) && + _isRequired(item.taskType) && + _startsAtOrAfter(item, now); + }), + ); + } + + static TodayTimelineItem? _nextFlexibleItem( + List items, + DateTime now, { + required String? currentTaskId, + }) { + return _firstOrNull( + items.where((item) { + return item.source == TodayTimelineItemSource.task && + item.taskId != currentTaskId && + _isActionable(item.taskStatus) && + item.taskType == TaskType.flexible && + _startsAtOrAfter(item, now); + }), + ); + } + + static bool _isActionable(TaskStatus? status) { + return status == TaskStatus.planned || status == TaskStatus.active; + } + + static bool _isRequired(TaskType type) { + return type == TaskType.inflexible || type == TaskType.critical; + } + + static bool _containsTime(TodayTimelineItem item, DateTime now) { + final start = item.start; + final end = item.end; + if (start == null || end == null) { + return false; + } + + return !now.isBefore(start) && now.isBefore(end); + } + + static bool _startsAtOrAfter(TodayTimelineItem item, DateTime now) { + final start = item.start; + return start != null && !start.isBefore(now); + } + + static TodayTimelineItem? _findById( + List items, + String? id, + ) { + if (id == null) { + return null; + } + + return _firstOrNull(items.where((item) => item.id == id)); + } + + static List _pendingNoticesFromSnapshots( + List snapshots, { + Set acknowledgedNoticeIds = const {}, + }) { + final sortedSnapshots = [...snapshots]..sort((a, b) { + final capturedComparison = a.capturedAt.compareTo(b.capturedAt); + if (capturedComparison != 0) { + return capturedComparison; + } + + return a.id.compareTo(b.id); + }); + final notices = []; + + for (final snapshot in sortedSnapshots) { + for (var index = 0; index < snapshot.notices.length; index += 1) { + final noticeId = _noticeId(snapshot.id, index); + if (acknowledgedNoticeIds.contains(noticeId)) { + continue; + } + final notice = snapshot.notices[index]; + notices.add( + TodayPendingNotice( + noticeId: noticeId, + snapshotId: snapshot.id, + capturedAt: snapshot.capturedAt, + message: notice.message, + type: notice.type, + taskId: notice.taskId, + issueCode: notice.issueCode, + movementCode: notice.movementCode, + conflictCode: notice.conflictCode, + parameters: notice.parameters, + ), + ); + } + } + + return List.unmodifiable(notices); + } +} + +String schedulingNoticeId({ + required String snapshotId, + required int noticeIndex, +}) { + return _noticeId(snapshotId, noticeIndex); +} + +String _noticeId(String snapshotId, int noticeIndex) { + return '$snapshotId:$noticeIndex'; +} + +int _compareTodayItems(TodayTimelineItem left, TodayTimelineItem right) { + final startComparison = _compareNullableInstants(left.start, right.start); + if (startComparison != 0) { + return startComparison; + } + + final endComparison = _compareNullableInstants(left.end, right.end); + if (endComparison != 0) { + return endComparison; + } + + final sourceComparison = left.source.index.compareTo(right.source.index); + if (sourceComparison != 0) { + return sourceComparison; + } + + return left.id.compareTo(right.id); +} + +int _compareNullableInstants(DateTime? left, DateTime? right) { + if (left == null && right == null) { + return 0; + } + if (left == null) { + return 1; + } + if (right == null) { + return -1; + } + + return left.compareTo(right); +} + +T? _firstOrNull(Iterable values) { + final iterator = values.iterator; + if (!iterator.moveNext()) { + return null; + } + + return iterator.current; +} diff --git a/packages/scheduler_core/lib/src/today_state/get_today_state_request.dart b/packages/scheduler_core/lib/src/today_state/get_today_state_request.dart new file mode 100644 index 0000000..21f8b0a --- /dev/null +++ b/packages/scheduler_core/lib/src/today_state/get_today_state_request.dart @@ -0,0 +1,28 @@ +part of '../today_state.dart'; + +/// Request for the V1 Today read model. +class GetTodayStateRequest { + const GetTodayStateRequest({ + required this.context, + required this.date, + this.revealHiddenLockedBlocks = false, + this.includeNextFlexibleItem = true, + this.fullTimelineExpanded = false, + }); + + /// Operation/read context. [ApplicationOperationContext.now] is the read + /// instant and is never recomputed during the query. + final ApplicationOperationContext context; + + /// Owner-local day to read. + final CivilDate date; + + /// Whether hidden locked-time overlays should be present in the item list. + final bool revealHiddenLockedBlocks; + + /// Whether compact projection should expose a next flexible task. + final bool includeNextFlexibleItem; + + /// Whether compact mode is currently showing the full timeline. + final bool fullTimelineExpanded; +} diff --git a/packages/scheduler_core/lib/src/today_state/today_compact_state.dart b/packages/scheduler_core/lib/src/today_state/today_compact_state.dart new file mode 100644 index 0000000..43ecdeb --- /dev/null +++ b/packages/scheduler_core/lib/src/today_state/today_compact_state.dart @@ -0,0 +1,49 @@ +part of '../today_state.dart'; + +/// Compact projection derived from the same Today item list. +class TodayCompactState { + const TodayCompactState({ + required this.manualCompactModeEnabled, + required this.fullTimelineExpanded, + this.currentItem, + this.nextRequiredItem, + this.nextFlexibleItem, + }); + + /// User-controlled compact mode flag. + final bool manualCompactModeEnabled; + + /// Whether the full timeline is expanded while compact mode is active. + final bool fullTimelineExpanded; + + /// Current visible item, if one occupies the read instant. + final TodayTimelineItem? currentItem; + + /// Next critical or inflexible item. + final TodayTimelineItem? nextRequiredItem; + + /// Optional next flexible item. + final TodayTimelineItem? nextFlexibleItem; + + /// Whether callers should show the full timeline. + bool get fullTimelineVisible { + return !manualCompactModeEnabled || fullTimelineExpanded; + } + + /// Items selected for compact rendering. + List get compactItems { + if (!manualCompactModeEnabled) { + return const []; + } + + return [ + if (currentItem != null) currentItem!, + if (nextRequiredItem != null && nextRequiredItem!.id != currentItem?.id) + nextRequiredItem!, + if (nextFlexibleItem != null && + nextFlexibleItem!.id != currentItem?.id && + nextFlexibleItem!.id != nextRequiredItem?.id) + nextFlexibleItem!, + ]; + } +} diff --git a/packages/scheduler_core/lib/src/today_state/today_pending_notice.dart b/packages/scheduler_core/lib/src/today_state/today_pending_notice.dart new file mode 100644 index 0000000..e685ef4 --- /dev/null +++ b/packages/scheduler_core/lib/src/today_state/today_pending_notice.dart @@ -0,0 +1,47 @@ +part of '../today_state.dart'; + +/// Pending notice data surfaced through Today state. +class TodayPendingNotice { + TodayPendingNotice({ + required this.noticeId, + required this.snapshotId, + required this.capturedAt, + required this.message, + required this.type, + required Map parameters, + this.taskId, + this.issueCode, + this.movementCode, + this.conflictCode, + }) : parameters = Map.unmodifiable(parameters); + + /// Stable id for acknowledgement/consume commands. + final String noticeId; + + /// Source snapshot id. + final String snapshotId; + + /// Snapshot capture time. + final DateTime capturedAt; + + /// Existing neutral scheduler message. UI may localize from codes later. + final String message; + + /// Notice category. + final SchedulingNoticeType type; + + /// Related task id, if any. + final String? taskId; + + /// Stable issue code, if any. + final SchedulingIssueCode? issueCode; + + /// Stable movement code, if any. + final SchedulingMovementCode? movementCode; + + /// Stable conflict code, if any. + final SchedulingConflictCode? conflictCode; + + /// Structured notice parameters. + final Map parameters; +} diff --git a/packages/scheduler_core/lib/src/today_state/today_state.dart b/packages/scheduler_core/lib/src/today_state/today_state.dart new file mode 100644 index 0000000..eccd00b --- /dev/null +++ b/packages/scheduler_core/lib/src/today_state/today_state.dart @@ -0,0 +1,60 @@ +part of '../today_state.dart'; + +/// Complete V1 Today state for one owner-local date. +class TodayState { + TodayState({ + required this.date, + required this.ownerId, + required this.timeZoneId, + required this.readAt, + required this.dayStart, + required this.dayEnd, + required this.ownerSettings, + required List timelineItems, + required this.compactState, + required List pendingNotices, + this.currentItem, + this.nextRequiredItem, + this.nextFlexibleItem, + }) : timelineItems = List.unmodifiable(timelineItems), + pendingNotices = List.unmodifiable(pendingNotices); + + /// Owner-local date represented by this state. + final CivilDate date; + + /// Authorization-neutral owner scope. + final String ownerId; + + /// Time-zone id used for local-day expansion. + final String timeZoneId; + + /// Read instant from the request context. + final DateTime readAt; + + /// Resolved start instant for [date]. + final DateTime dayStart; + + /// Resolved end instant for [date]. + final DateTime dayEnd; + + /// Effective owner settings used by the query. + final OwnerSettings ownerSettings; + + /// Full sorted UI-independent timeline projection. + final List timelineItems; + + /// Current item, if one occupies [readAt]. + final TodayTimelineItem? currentItem; + + /// Next required item after [readAt]. + final TodayTimelineItem? nextRequiredItem; + + /// Optional next flexible item after [readAt]. + final TodayTimelineItem? nextFlexibleItem; + + /// Compact-mode projection from [timelineItems]. + final TodayCompactState compactState; + + /// Pending scheduling notices stored for the day. + final List pendingNotices; +} diff --git a/packages/scheduler_core/lib/src/today_state/today_timeline_item.dart b/packages/scheduler_core/lib/src/today_state/today_timeline_item.dart new file mode 100644 index 0000000..adb65cc --- /dev/null +++ b/packages/scheduler_core/lib/src/today_state/today_timeline_item.dart @@ -0,0 +1,85 @@ +part of '../today_state.dart'; + +/// One item in the Today read model, with task status/source metadata. +class TodayTimelineItem { + TodayTimelineItem({ + required this.item, + required this.source, + this.taskStatus, + this.taskId, + this.lockedBlockId, + this.lockedOverrideId, + this.hiddenByDefault = false, + this.isCurrent = false, + this.isNextRequired = false, + this.isNextFlexible = false, + }); + + /// Display-ready framework-neutral item. + final TimelineItem item; + + /// Repository/domain source for this item. + final TodayTimelineItemSource source; + + /// Source task status. Null for locked occurrences. + final TaskStatus? taskStatus; + + /// Source task id, when [source] is [TodayTimelineItemSource.task]. + final String? taskId; + + /// Source locked block id, when this is a locked occurrence. + final String? lockedBlockId; + + /// Source override id, when this occurrence was replaced or added. + final String? lockedOverrideId; + + /// Whether the locked source is normally hidden. + final bool hiddenByDefault; + + /// Whether this item is the current selected item for the read instant. + final bool isCurrent; + + /// Whether this item is the next required item. + final bool isNextRequired; + + /// Whether this item is the optional next flexible item. + final bool isNextFlexible; + + /// Stable item id. + String get id => item.id; + + /// Timeline start. + DateTime? get start => item.start; + + /// Timeline end. + DateTime? get end => item.end; + + /// Source task behavior type. + TaskType get taskType => item.taskType; + + /// Project/border color token. + String get projectColorToken => item.projectColorToken; + + /// Structured quick actions available for this item. + List get quickActions => item.quickActions; + + /// Return a copy with selection flags changed. + TodayTimelineItem copyWith({ + bool? isCurrent, + bool? isNextRequired, + bool? isNextFlexible, + }) { + return TodayTimelineItem( + item: item, + source: source, + taskStatus: taskStatus, + taskId: taskId, + lockedBlockId: lockedBlockId, + lockedOverrideId: lockedOverrideId, + hiddenByDefault: hiddenByDefault, + isCurrent: isCurrent ?? this.isCurrent, + isNextRequired: isNextRequired ?? this.isNextRequired, + isNextFlexible: isNextFlexible ?? this.isNextFlexible, + ); + } +} diff --git a/packages/scheduler_core/lib/src/today_state/today_timeline_item_source.dart b/packages/scheduler_core/lib/src/today_state/today_timeline_item_source.dart new file mode 100644 index 0000000..4c859a7 --- /dev/null +++ b/packages/scheduler_core/lib/src/today_state/today_timeline_item_source.dart @@ -0,0 +1,7 @@ +part of '../today_state.dart'; + +/// Source category for one Today timeline item. +enum TodayTimelineItemSource { + task, + lockedOccurrence, +} diff --git a/packages/scheduler_export/lib/src/export_controller.dart b/packages/scheduler_export/lib/src/export_controller.dart index e6cfb1e..119cfc2 100644 --- a/packages/scheduler_export/lib/src/export_controller.dart +++ b/packages/scheduler_export/lib/src/export_controller.dart @@ -5,302 +5,15 @@ import 'dart:convert'; import 'package:scheduler_core/scheduler_core.dart' as core; import 'package:scheduler_persistence/persistence.dart'; - -/// Export metadata shared with every writer. -final class ExportContext { - /// Creates an export context. - ExportContext({ - required this.ownerId, - required String timezone, - required this.version, - }) : timezone = _requiredTrimmed(timezone, 'timezone'); - - /// Owner scope being exported. - final core.OwnerId ownerId; - - /// Timezone id used to interpret local export presentation. - final String timezone; - - /// Export format/schema version. - final int version; -} - -/// Thrown when an export cannot complete. -final class ExportException implements Exception { - /// Creates an export exception with a diagnostic [message]. - const ExportException(this.message); - - /// Diagnostic text for logs and tests. - final String message; - - @override - String toString() => 'ExportException: $message'; -} - -/// Thrown when repository reads fail during export. -final class ExportRepositoryException extends ExportException { - /// Creates an exception wrapping a repository [failure]. - ExportRepositoryException(this.failure) - : super('Repository export read failed: ${failure.runtimeType}'); - - /// Repository failure returned by an adapter. - final RepositoryFailure failure; -} - -/// Writer interface for readable exports. -abstract interface class ExportWriter { - /// Start writing an export for [context]. - Future begin(ExportContext context); - - /// Write one task record. - Future writeTask(core.Task task); - - /// Finish writing the export. - Future end(); -} - -/// Factory signature for sink-backed export writers. -typedef ExportWriterFactory = ExportWriter Function(StringSink sink); - -/// Registry that resolves export format names to writer factories. -final class ExportWriterRegistry { - /// Creates a registry from [factories]. - ExportWriterRegistry({ - Map factories = - const {}, - }) : _factories = Map.fromEntries( - factories.entries.map( - (entry) => MapEntry(_normalizeFormat(entry.key), entry.value), - ), - ); - - final Map _factories; - - /// Default registry with JSON and CSV stub writers. - factory ExportWriterRegistry.defaults() { - return ExportWriterRegistry( - factories: { - 'json': JsonExportWriter.new, - 'csv': CsvExportWriter.new, - }, - ); - } - - /// Available format names. - Set get formats => Set.unmodifiable(_factories.keys); - - /// Register [factory] for [format]. - void register(String format, ExportWriterFactory factory) { - _factories[_normalizeFormat(format)] = factory; - } - - /// Create a writer for [format] using [sink]. - ExportWriter create(String format, StringSink sink) { - final normalized = _normalizeFormat(format); - final factory = _factories[normalized]; - if (factory == null) { - throw ExportException('Unknown export format: $format'); - } - return factory(sink); - } -} - -/// Coordinates repository reads and export writer calls. -final class ExportController { - /// Creates an export controller using [taskRepository]. - ExportController({ - required TaskRepository taskRepository, - ExportWriterRegistry? writerRegistry, - }) : _taskRepository = taskRepository, - _writerRegistry = writerRegistry ?? ExportWriterRegistry.defaults(); - - final TaskRepository _taskRepository; - final ExportWriterRegistry _writerRegistry; - - /// Create a writer for [format] and export owner-scoped tasks to [sink]. - Future exportTasksToSink({ - required ExportContext context, - required String format, - required StringSink sink, - int pageSize = 100, - }) { - final writer = _writerRegistry.create(format, sink); - return exportTasks( - context: context, - writer: writer, - pageSize: pageSize, - ); - } - - /// Export all owner-scoped tasks through [writer]. - Future exportTasks({ - required ExportContext context, - required ExportWriter writer, - int pageSize = 100, - }) async { - if (pageSize <= 0) { - throw ArgumentError.value(pageSize, 'pageSize', 'Must be positive.'); - } - - await writer.begin(context); - String? cursor; - do { - final result = await _taskRepository.findByOwner( - ownerId: context.ownerId, - page: core.PageRequest(cursor: cursor, limit: pageSize), - ); - if (result.isLeft) { - throw ExportRepositoryException(result.left); - } - final page = result.right; - for (final task in page.items) { - await writer.writeTask(task); - } - cursor = page.nextCursor; - } while (cursor != null); - await writer.end(); - } -} - -/// Minimal JSON export writer stub. -final class JsonExportWriter implements ExportWriter { - /// Creates a JSON writer that writes to the provided string sink. - JsonExportWriter(this._sink); - - final StringSink _sink; - ExportContext? _context; - bool _hasTask = false; - bool _ended = false; - - @override - Future begin(ExportContext context) async { - _assertNotEnded(); - _context = context; - _sink.write( - '{"context":${jsonEncode(_contextToJson(context))},"tasks":[', - ); - } - - @override - Future writeTask(core.Task task) async { - final context = _requireContext(); - if (_hasTask) { - _sink.write(','); - } - _sink.write(jsonEncode(core.TaskDocumentMapping.toDocument( - task, - ownerId: context.ownerId.value, - ))); - _hasTask = true; - } - - @override - Future end() async { - _requireContext(); - _assertNotEnded(); - _sink.write(']}'); - _ended = true; - } - - ExportContext _requireContext() { - final context = _context; - if (context == null) { - throw const ExportException('Writer has not started.'); - } - return context; - } - - void _assertNotEnded() { - if (_ended) { - throw const ExportException('Writer has already ended.'); - } - } -} - -/// Minimal CSV export writer stub. -final class CsvExportWriter implements ExportWriter { - /// Creates a CSV writer that writes to the provided string sink. - CsvExportWriter(this._sink); - - final StringSink _sink; - bool _started = false; - bool _ended = false; - - @override - Future begin(ExportContext context) async { - _assertNotEnded(); - _started = true; - _sink.writeln( - 'id,title,projectId,type,status,durationMinutes,scheduledStartUtc,' - 'scheduledEndUtc', - ); - } - - @override - Future writeTask(core.Task task) async { - if (!_started) { - throw const ExportException('Writer has not started.'); - } - _assertNotEnded(); - _sink.writeln([ - task.id, - task.title, - task.projectId, - core.PersistenceEnumMapping.encodeTaskType(task.type), - core.PersistenceEnumMapping.encodeTaskStatus(task.status), - task.durationMinutes?.toString() ?? '', - task.scheduledStart == null - ? '' - : core.PersistenceDateTimeConvention.toStoredString( - task.scheduledStart!, - ), - task.scheduledEnd == null - ? '' - : core.PersistenceDateTimeConvention.toStoredString( - task.scheduledEnd!, - ), - ].map(_csvCell).join(',')); - } - - @override - Future end() async { - if (!_started) { - throw const ExportException('Writer has not started.'); - } - _assertNotEnded(); - _ended = true; - } - - void _assertNotEnded() { - if (_ended) { - throw const ExportException('Writer has already ended.'); - } - } -} - -Map _contextToJson(ExportContext context) { - return { - 'ownerId': context.ownerId.value, - 'timezone': context.timezone, - 'version': context.version, - }; -} - -String _csvCell(String value) { - final needsQuotes = - value.contains(',') || value.contains('"') || value.contains('\n'); - final escaped = value.replaceAll('"', '""'); - return needsQuotes ? '"$escaped"' : escaped; -} - -String _normalizeFormat(String format) { - return _requiredTrimmed(format, 'format').toLowerCase(); -} - -String _requiredTrimmed(String value, String name) { - final trimmed = value.trim(); - if (trimmed.isEmpty) { - throw ArgumentError.value(value, name, 'Value must not be blank.'); - } - return trimmed; -} +part 'export_controller/export_context.dart'; +part 'export_controller/export_exception.dart'; +part 'export_controller/export_repository_exception.dart'; +part 'export_controller/export_writer.dart'; +part 'export_controller/export_writer_factory.dart'; +part 'export_controller/export_writer_registry.dart'; +part 'export_controller/export_controller.dart'; +part 'export_controller/export_format_normalizer.dart'; +part 'export_controller/export_json_context_encoder.dart'; +part 'export_controller/json_export_writer.dart'; +part 'export_controller/csv_cell_encoder.dart'; +part 'export_controller/csv_export_writer.dart'; diff --git a/packages/scheduler_export/lib/src/export_controller/csv_cell_encoder.dart b/packages/scheduler_export/lib/src/export_controller/csv_cell_encoder.dart new file mode 100644 index 0000000..bc80de2 --- /dev/null +++ b/packages/scheduler_export/lib/src/export_controller/csv_cell_encoder.dart @@ -0,0 +1,8 @@ +part of '../export_controller.dart'; + +String _csvCell(String value) { + final needsQuotes = + value.contains(',') || value.contains('"') || value.contains('\n'); + final escaped = value.replaceAll('"', '""'); + return needsQuotes ? '"$escaped"' : escaped; +} diff --git a/packages/scheduler_export/lib/src/export_controller/csv_export_writer.dart b/packages/scheduler_export/lib/src/export_controller/csv_export_writer.dart new file mode 100644 index 0000000..64e1960 --- /dev/null +++ b/packages/scheduler_export/lib/src/export_controller/csv_export_writer.dart @@ -0,0 +1,62 @@ +part of '../export_controller.dart'; + +/// Minimal CSV export writer stub. +final class CsvExportWriter implements ExportWriter { + /// Creates a CSV writer that writes to the provided string sink. + CsvExportWriter(this._sink); + + final StringSink _sink; + bool _started = false; + bool _ended = false; + + @override + Future begin(ExportContext context) async { + _assertNotEnded(); + _started = true; + _sink.writeln( + 'id,title,projectId,type,status,durationMinutes,scheduledStartUtc,' + 'scheduledEndUtc', + ); + } + + @override + Future writeTask(core.Task task) async { + if (!_started) { + throw const ExportException('Writer has not started.'); + } + _assertNotEnded(); + _sink.writeln([ + task.id, + task.title, + task.projectId, + core.PersistenceEnumMapping.encodeTaskType(task.type), + core.PersistenceEnumMapping.encodeTaskStatus(task.status), + task.durationMinutes?.toString() ?? '', + task.scheduledStart == null + ? '' + : core.PersistenceDateTimeConvention.toStoredString( + task.scheduledStart!, + ), + task.scheduledEnd == null + ? '' + : core.PersistenceDateTimeConvention.toStoredString( + task.scheduledEnd!, + ), + ].map(_csvCell).join(',')); + } + + @override + Future end() async { + if (!_started) { + throw const ExportException('Writer has not started.'); + } + _assertNotEnded(); + _ended = true; + } + + void _assertNotEnded() { + if (_ended) { + throw const ExportException('Writer has already ended.'); + } + } +} diff --git a/packages/scheduler_export/lib/src/export_controller/export_context.dart b/packages/scheduler_export/lib/src/export_controller/export_context.dart new file mode 100644 index 0000000..ae3453f --- /dev/null +++ b/packages/scheduler_export/lib/src/export_controller/export_context.dart @@ -0,0 +1,20 @@ +part of '../export_controller.dart'; + +/// Export metadata shared with every writer. +final class ExportContext { + /// Creates an export context. + ExportContext({ + required this.ownerId, + required String timezone, + required this.version, + }) : timezone = _requiredTrimmed(timezone, 'timezone'); + + /// Owner scope being exported. + final core.OwnerId ownerId; + + /// Timezone id used to interpret local export presentation. + final String timezone; + + /// Export format/schema version. + final int version; +} diff --git a/packages/scheduler_export/lib/src/export_controller/export_controller.dart b/packages/scheduler_export/lib/src/export_controller/export_controller.dart new file mode 100644 index 0000000..eeb5616 --- /dev/null +++ b/packages/scheduler_export/lib/src/export_controller/export_controller.dart @@ -0,0 +1,58 @@ +part of '../export_controller.dart'; + +/// Coordinates repository reads and export writer calls. +final class ExportController { + /// Creates an export controller using [taskRepository]. + ExportController({ + required TaskRepository taskRepository, + ExportWriterRegistry? writerRegistry, + }) : _taskRepository = taskRepository, + _writerRegistry = writerRegistry ?? ExportWriterRegistry.defaults(); + + final TaskRepository _taskRepository; + final ExportWriterRegistry _writerRegistry; + + /// Create a writer for [format] and export owner-scoped tasks to [sink]. + Future exportTasksToSink({ + required ExportContext context, + required String format, + required StringSink sink, + int pageSize = 100, + }) { + final writer = _writerRegistry.create(format, sink); + return exportTasks( + context: context, + writer: writer, + pageSize: pageSize, + ); + } + + /// Export all owner-scoped tasks through [writer]. + Future exportTasks({ + required ExportContext context, + required ExportWriter writer, + int pageSize = 100, + }) async { + if (pageSize <= 0) { + throw ArgumentError.value(pageSize, 'pageSize', 'Must be positive.'); + } + + await writer.begin(context); + String? cursor; + do { + final result = await _taskRepository.findByOwner( + ownerId: context.ownerId, + page: core.PageRequest(cursor: cursor, limit: pageSize), + ); + if (result.isLeft) { + throw ExportRepositoryException(result.left); + } + final page = result.right; + for (final task in page.items) { + await writer.writeTask(task); + } + cursor = page.nextCursor; + } while (cursor != null); + await writer.end(); + } +} diff --git a/packages/scheduler_export/lib/src/export_controller/export_exception.dart b/packages/scheduler_export/lib/src/export_controller/export_exception.dart new file mode 100644 index 0000000..6e27cdf --- /dev/null +++ b/packages/scheduler_export/lib/src/export_controller/export_exception.dart @@ -0,0 +1,13 @@ +part of '../export_controller.dart'; + +/// Thrown when an export cannot complete. +final class ExportException implements Exception { + /// Creates an export exception with a diagnostic [message]. + const ExportException(this.message); + + /// Diagnostic text for logs and tests. + final String message; + + @override + String toString() => 'ExportException: $message'; +} diff --git a/packages/scheduler_export/lib/src/export_controller/export_format_normalizer.dart b/packages/scheduler_export/lib/src/export_controller/export_format_normalizer.dart new file mode 100644 index 0000000..fe42e5e --- /dev/null +++ b/packages/scheduler_export/lib/src/export_controller/export_format_normalizer.dart @@ -0,0 +1,13 @@ +part of '../export_controller.dart'; + +String _normalizeFormat(String format) { + return _requiredTrimmed(format, 'format').toLowerCase(); +} + +String _requiredTrimmed(String value, String name) { + final trimmed = value.trim(); + if (trimmed.isEmpty) { + throw ArgumentError.value(value, name, 'Value must not be blank.'); + } + return trimmed; +} diff --git a/packages/scheduler_export/lib/src/export_controller/export_json_context_encoder.dart b/packages/scheduler_export/lib/src/export_controller/export_json_context_encoder.dart new file mode 100644 index 0000000..bea5baf --- /dev/null +++ b/packages/scheduler_export/lib/src/export_controller/export_json_context_encoder.dart @@ -0,0 +1,9 @@ +part of '../export_controller.dart'; + +Map _contextToJson(ExportContext context) { + return { + 'ownerId': context.ownerId.value, + 'timezone': context.timezone, + 'version': context.version, + }; +} diff --git a/packages/scheduler_export/lib/src/export_controller/export_repository_exception.dart b/packages/scheduler_export/lib/src/export_controller/export_repository_exception.dart new file mode 100644 index 0000000..774a2ab --- /dev/null +++ b/packages/scheduler_export/lib/src/export_controller/export_repository_exception.dart @@ -0,0 +1,11 @@ +part of '../export_controller.dart'; + +/// Thrown when repository reads fail during export. +final class ExportRepositoryException extends ExportException { + /// Creates an exception wrapping a repository [failure]. + ExportRepositoryException(this.failure) + : super('Repository export read failed: ${failure.runtimeType}'); + + /// Repository failure returned by an adapter. + final RepositoryFailure failure; +} diff --git a/packages/scheduler_export/lib/src/export_controller/export_writer.dart b/packages/scheduler_export/lib/src/export_controller/export_writer.dart new file mode 100644 index 0000000..14a332a --- /dev/null +++ b/packages/scheduler_export/lib/src/export_controller/export_writer.dart @@ -0,0 +1,13 @@ +part of '../export_controller.dart'; + +/// Writer interface for readable exports. +abstract interface class ExportWriter { + /// Start writing an export for [context]. + Future begin(ExportContext context); + + /// Write one task record. + Future writeTask(core.Task task); + + /// Finish writing the export. + Future end(); +} diff --git a/packages/scheduler_export/lib/src/export_controller/export_writer_factory.dart b/packages/scheduler_export/lib/src/export_controller/export_writer_factory.dart new file mode 100644 index 0000000..5033de4 --- /dev/null +++ b/packages/scheduler_export/lib/src/export_controller/export_writer_factory.dart @@ -0,0 +1,4 @@ +part of '../export_controller.dart'; + +/// Factory signature for sink-backed export writers. +typedef ExportWriterFactory = ExportWriter Function(StringSink sink); diff --git a/packages/scheduler_export/lib/src/export_controller/export_writer_registry.dart b/packages/scheduler_export/lib/src/export_controller/export_writer_registry.dart new file mode 100644 index 0000000..432c74a --- /dev/null +++ b/packages/scheduler_export/lib/src/export_controller/export_writer_registry.dart @@ -0,0 +1,44 @@ +part of '../export_controller.dart'; + +/// Registry that resolves export format names to writer factories. +final class ExportWriterRegistry { + /// Creates a registry from [factories]. + ExportWriterRegistry({ + Map factories = + const {}, + }) : _factories = Map.fromEntries( + factories.entries.map( + (entry) => MapEntry(_normalizeFormat(entry.key), entry.value), + ), + ); + + final Map _factories; + + /// Default registry with JSON and CSV stub writers. + factory ExportWriterRegistry.defaults() { + return ExportWriterRegistry( + factories: { + 'json': JsonExportWriter.new, + 'csv': CsvExportWriter.new, + }, + ); + } + + /// Available format names. + Set get formats => Set.unmodifiable(_factories.keys); + + /// Register [factory] for [format]. + void register(String format, ExportWriterFactory factory) { + _factories[_normalizeFormat(format)] = factory; + } + + /// Create a writer for [format] using [sink]. + ExportWriter create(String format, StringSink sink) { + final normalized = _normalizeFormat(format); + final factory = _factories[normalized]; + if (factory == null) { + throw ExportException('Unknown export format: $format'); + } + return factory(sink); + } +} diff --git a/packages/scheduler_export/lib/src/export_controller/json_export_writer.dart b/packages/scheduler_export/lib/src/export_controller/json_export_writer.dart new file mode 100644 index 0000000..26caaa3 --- /dev/null +++ b/packages/scheduler_export/lib/src/export_controller/json_export_writer.dart @@ -0,0 +1,56 @@ +part of '../export_controller.dart'; + +/// Minimal JSON export writer stub. +final class JsonExportWriter implements ExportWriter { + /// Creates a JSON writer that writes to the provided string sink. + JsonExportWriter(this._sink); + + final StringSink _sink; + ExportContext? _context; + bool _hasTask = false; + bool _ended = false; + + @override + Future begin(ExportContext context) async { + _assertNotEnded(); + _context = context; + _sink.write( + '{"context":${jsonEncode(_contextToJson(context))},"tasks":[', + ); + } + + @override + Future writeTask(core.Task task) async { + final context = _requireContext(); + if (_hasTask) { + _sink.write(','); + } + _sink.write(jsonEncode(core.TaskDocumentMapping.toDocument( + task, + ownerId: context.ownerId.value, + ))); + _hasTask = true; + } + + @override + Future end() async { + _requireContext(); + _assertNotEnded(); + _sink.write(']}'); + _ended = true; + } + + ExportContext _requireContext() { + final context = _context; + if (context == null) { + throw const ExportException('Writer has not started.'); + } + return context; + } + + void _assertNotEnded() { + if (_ended) { + throw const ExportException('Writer has already ended.'); + } + } +} diff --git a/packages/scheduler_export_json/lib/src/json_csv_writers.dart b/packages/scheduler_export_json/lib/src/json_csv_writers.dart index a23c3b7..3b9c427 100644 --- a/packages/scheduler_export_json/lib/src/json_csv_writers.dart +++ b/packages/scheduler_export_json/lib/src/json_csv_writers.dart @@ -5,6 +5,10 @@ import 'dart:convert'; import 'package:scheduler_core/scheduler_core.dart' as core; import 'package:scheduler_export/export.dart'; +part 'json_csv_writers/export_json_context_encoder.dart'; +part 'json_csv_writers/json_export_writer.dart'; +part 'json_csv_writers/csv_cell_encoder.dart'; +part 'json_csv_writers/csv_export_writer.dart'; /// Create the readable export writer registry used by export commands. ExportWriterRegistry readableExportWriterRegistry() { @@ -15,135 +19,3 @@ ExportWriterRegistry readableExportWriterRegistry() { }, ); } - -/// JSON export writer for scheduler tasks. -final class JsonExportWriter implements ExportWriter { - /// Creates a JSON writer that writes to the provided string sink. - JsonExportWriter(this._sink); - - final StringSink _sink; - ExportContext? _context; - bool _hasTask = false; - bool _ended = false; - - @override - Future begin(ExportContext context) async { - _assertNotEnded(); - _context = context; - _sink.write( - '{"context":${jsonEncode(_contextToJson(context))},"tasks":[', - ); - } - - @override - Future writeTask(core.Task task) async { - final context = _requireContext(); - _assertNotEnded(); - if (_hasTask) { - _sink.write(','); - } - _sink.write(jsonEncode(core.TaskDocumentMapping.toDocument( - task, - ownerId: context.ownerId.value, - ))); - _hasTask = true; - } - - @override - Future end() async { - _requireContext(); - _assertNotEnded(); - _sink.write(']}'); - _ended = true; - } - - ExportContext _requireContext() { - final context = _context; - if (context == null) { - throw const ExportException('Writer has not started.'); - } - return context; - } - - void _assertNotEnded() { - if (_ended) { - throw const ExportException('Writer has already ended.'); - } - } -} - -/// CSV export writer for scheduler tasks. -final class CsvExportWriter implements ExportWriter { - /// Creates a CSV writer that writes to the provided string sink. - CsvExportWriter(this._sink); - - final StringSink _sink; - bool _started = false; - bool _ended = false; - - @override - Future begin(ExportContext context) async { - _assertNotEnded(); - _started = true; - _sink.writeln( - 'id,title,projectId,type,status,durationMinutes,scheduledStartUtc,' - 'scheduledEndUtc', - ); - } - - @override - Future writeTask(core.Task task) async { - if (!_started) { - throw const ExportException('Writer has not started.'); - } - _assertNotEnded(); - _sink.writeln([ - task.id, - task.title, - task.projectId, - core.PersistenceEnumMapping.encodeTaskType(task.type), - core.PersistenceEnumMapping.encodeTaskStatus(task.status), - task.durationMinutes?.toString() ?? '', - task.scheduledStart == null - ? '' - : core.PersistenceDateTimeConvention.toStoredString( - task.scheduledStart!, - ), - task.scheduledEnd == null - ? '' - : core.PersistenceDateTimeConvention.toStoredString( - task.scheduledEnd!, - ), - ].map(_csvCell).join(',')); - } - - @override - Future end() async { - if (!_started) { - throw const ExportException('Writer has not started.'); - } - _assertNotEnded(); - _ended = true; - } - - void _assertNotEnded() { - if (_ended) { - throw const ExportException('Writer has already ended.'); - } - } -} - -Map _contextToJson(ExportContext context) { - return { - 'ownerId': context.ownerId.value, - 'timezone': context.timezone, - 'version': context.version, - }; -} - -String _csvCell(String value) { - final needsQuotes = - value.contains(',') || value.contains('"') || value.contains('\n'); - final escaped = value.replaceAll('"', '""'); - return needsQuotes ? '"$escaped"' : escaped; -} diff --git a/packages/scheduler_export_json/lib/src/json_csv_writers/csv_cell_encoder.dart b/packages/scheduler_export_json/lib/src/json_csv_writers/csv_cell_encoder.dart new file mode 100644 index 0000000..b439dd6 --- /dev/null +++ b/packages/scheduler_export_json/lib/src/json_csv_writers/csv_cell_encoder.dart @@ -0,0 +1,8 @@ +part of '../json_csv_writers.dart'; + +String _csvCell(String value) { + final needsQuotes = + value.contains(',') || value.contains('"') || value.contains('\n'); + final escaped = value.replaceAll('"', '""'); + return needsQuotes ? '"$escaped"' : escaped; +} diff --git a/packages/scheduler_export_json/lib/src/json_csv_writers/csv_export_writer.dart b/packages/scheduler_export_json/lib/src/json_csv_writers/csv_export_writer.dart new file mode 100644 index 0000000..4b25b1d --- /dev/null +++ b/packages/scheduler_export_json/lib/src/json_csv_writers/csv_export_writer.dart @@ -0,0 +1,62 @@ +part of '../json_csv_writers.dart'; + +/// CSV export writer for scheduler tasks. +final class CsvExportWriter implements ExportWriter { + /// Creates a CSV writer that writes to the provided string sink. + CsvExportWriter(this._sink); + + final StringSink _sink; + bool _started = false; + bool _ended = false; + + @override + Future begin(ExportContext context) async { + _assertNotEnded(); + _started = true; + _sink.writeln( + 'id,title,projectId,type,status,durationMinutes,scheduledStartUtc,' + 'scheduledEndUtc', + ); + } + + @override + Future writeTask(core.Task task) async { + if (!_started) { + throw const ExportException('Writer has not started.'); + } + _assertNotEnded(); + _sink.writeln([ + task.id, + task.title, + task.projectId, + core.PersistenceEnumMapping.encodeTaskType(task.type), + core.PersistenceEnumMapping.encodeTaskStatus(task.status), + task.durationMinutes?.toString() ?? '', + task.scheduledStart == null + ? '' + : core.PersistenceDateTimeConvention.toStoredString( + task.scheduledStart!, + ), + task.scheduledEnd == null + ? '' + : core.PersistenceDateTimeConvention.toStoredString( + task.scheduledEnd!, + ), + ].map(_csvCell).join(',')); + } + + @override + Future end() async { + if (!_started) { + throw const ExportException('Writer has not started.'); + } + _assertNotEnded(); + _ended = true; + } + + void _assertNotEnded() { + if (_ended) { + throw const ExportException('Writer has already ended.'); + } + } +} diff --git a/packages/scheduler_export_json/lib/src/json_csv_writers/export_json_context_encoder.dart b/packages/scheduler_export_json/lib/src/json_csv_writers/export_json_context_encoder.dart new file mode 100644 index 0000000..98393a4 --- /dev/null +++ b/packages/scheduler_export_json/lib/src/json_csv_writers/export_json_context_encoder.dart @@ -0,0 +1,9 @@ +part of '../json_csv_writers.dart'; + +Map _contextToJson(ExportContext context) { + return { + 'ownerId': context.ownerId.value, + 'timezone': context.timezone, + 'version': context.version, + }; +} diff --git a/packages/scheduler_export_json/lib/src/json_csv_writers/json_export_writer.dart b/packages/scheduler_export_json/lib/src/json_csv_writers/json_export_writer.dart new file mode 100644 index 0000000..df3a45d --- /dev/null +++ b/packages/scheduler_export_json/lib/src/json_csv_writers/json_export_writer.dart @@ -0,0 +1,57 @@ +part of '../json_csv_writers.dart'; + +/// JSON export writer for scheduler tasks. +final class JsonExportWriter implements ExportWriter { + /// Creates a JSON writer that writes to the provided string sink. + JsonExportWriter(this._sink); + + final StringSink _sink; + ExportContext? _context; + bool _hasTask = false; + bool _ended = false; + + @override + Future begin(ExportContext context) async { + _assertNotEnded(); + _context = context; + _sink.write( + '{"context":${jsonEncode(_contextToJson(context))},"tasks":[', + ); + } + + @override + Future writeTask(core.Task task) async { + final context = _requireContext(); + _assertNotEnded(); + if (_hasTask) { + _sink.write(','); + } + _sink.write(jsonEncode(core.TaskDocumentMapping.toDocument( + task, + ownerId: context.ownerId.value, + ))); + _hasTask = true; + } + + @override + Future end() async { + _requireContext(); + _assertNotEnded(); + _sink.write(']}'); + _ended = true; + } + + ExportContext _requireContext() { + final context = _context; + if (context == null) { + throw const ExportException('Writer has not started.'); + } + return context; + } + + void _assertNotEnded() { + if (_ended) { + throw const ExportException('Writer has already ended.'); + } + } +} diff --git a/packages/scheduler_notifications/lib/src/notification_adapter.dart b/packages/scheduler_notifications/lib/src/notification_adapter.dart index 93b7e68..03efdaa 100644 --- a/packages/scheduler_notifications/lib/src/notification_adapter.dart +++ b/packages/scheduler_notifications/lib/src/notification_adapter.dart @@ -2,152 +2,9 @@ library; import 'dart:async'; - -/// Request to schedule one notification through a platform adapter. -sealed class NotificationRequest { - /// Creates a notification request. - factory NotificationRequest({ - required String id, - required String title, - required String body, - required DateTime scheduledDateTimeUtc, - String payloadJson = '{}', - }) { - return _NotificationRequest( - id: _requiredTrimmed(id, 'id'), - title: _requiredTrimmed(title, 'title'), - body: body, - scheduledDateTimeUtc: scheduledDateTimeUtc.toUtc(), - payloadJson: payloadJson, - ); - } - - const NotificationRequest._({ - required this.id, - required this.title, - required this.body, - required this.scheduledDateTimeUtc, - required this.payloadJson, - }); - - /// Stable notification id used for scheduling and cancellation. - final String id; - - /// User-visible notification title. - final String title; - - /// User-visible notification body. - final String body; - - /// UTC instant when the notification should be delivered. - final DateTime scheduledDateTimeUtc; - - /// Adapter-neutral JSON payload string. - final String payloadJson; -} - -final class _NotificationRequest extends NotificationRequest { - const _NotificationRequest({ - required super.id, - required super.title, - required super.body, - required super.scheduledDateTimeUtc, - required super.payloadJson, - }) : super._(); -} - -/// User feedback category emitted by a notification adapter. -enum NotificationFeedbackType { - /// The notification was opened or clicked. - clicked, - - /// The notification was dismissed. - dismissed, - - /// A notification action was selected. - action, -} - -/// Feedback event emitted by a notification adapter. -final class NotificationFeedback { - /// Creates a notification feedback event. - NotificationFeedback({ - required String id, - required this.type, - this.actionId, - this.payloadJson = '{}', - }) : id = _requiredTrimmed(id, 'id'); - - /// Stable notification id associated with the feedback. - final String id; - - /// Type of user feedback. - final NotificationFeedbackType type; - - /// Optional action id for [NotificationFeedbackType.action]. - final String? actionId; - - /// Adapter-neutral JSON payload string. - final String payloadJson; -} - -/// Platform-neutral notification adapter boundary. -abstract interface class NotificationAdapter { - /// Schedule [request] for future delivery. - Future schedule(NotificationRequest request); - - /// Cancel a pending notification by stable [id]. - Future cancel(String id); - - /// Stream of user feedback events from delivered notifications. - Stream get feedback; -} - -/// Test fake for notification adapter behavior. -final class FakeNotificationAdapter implements NotificationAdapter { - final StreamController _feedbackController = - StreamController.broadcast(); - final Map _scheduled = - {}; - final List _cancelledIds = []; - - /// Snapshot of currently scheduled requests by id. - Map get scheduledRequests => - Map.unmodifiable(_scheduled); - - /// Snapshot of cancellation ids in call order. - List get cancelledIds => List.unmodifiable(_cancelledIds); - - @override - Stream get feedback => _feedbackController.stream; - - @override - Future schedule(NotificationRequest request) async { - _scheduled[request.id] = request; - } - - @override - Future cancel(String id) async { - final trimmed = _requiredTrimmed(id, 'id'); - _scheduled.remove(trimmed); - _cancelledIds.add(trimmed); - } - - /// Emit [event] to feedback listeners. - void emitFeedback(NotificationFeedback event) { - _feedbackController.add(event); - } - - /// Release stream resources held by this fake. - Future close() { - return _feedbackController.close(); - } -} - -String _requiredTrimmed(String value, String name) { - final trimmed = value.trim(); - if (trimmed.isEmpty) { - throw ArgumentError.value(value, name, 'Value must not be blank.'); - } - return trimmed; -} +part 'notification_adapter/notification_request.dart'; +part 'notification_adapter/notification_request_impl.dart'; +part 'notification_adapter/notification_feedback_type.dart'; +part 'notification_adapter/notification_feedback.dart'; +part 'notification_adapter/notification_adapter.dart'; +part 'notification_adapter/fake_notification_adapter.dart'; diff --git a/packages/scheduler_notifications/lib/src/notification_adapter/fake_notification_adapter.dart b/packages/scheduler_notifications/lib/src/notification_adapter/fake_notification_adapter.dart new file mode 100644 index 0000000..3c7d78d --- /dev/null +++ b/packages/scheduler_notifications/lib/src/notification_adapter/fake_notification_adapter.dart @@ -0,0 +1,50 @@ +part of '../notification_adapter.dart'; + +/// Test fake for notification adapter behavior. +final class FakeNotificationAdapter implements NotificationAdapter { + final StreamController _feedbackController = + StreamController.broadcast(); + final Map _scheduled = + {}; + final List _cancelledIds = []; + + /// Snapshot of currently scheduled requests by id. + Map get scheduledRequests => + Map.unmodifiable(_scheduled); + + /// Snapshot of cancellation ids in call order. + List get cancelledIds => List.unmodifiable(_cancelledIds); + + @override + Stream get feedback => _feedbackController.stream; + + @override + Future schedule(NotificationRequest request) async { + _scheduled[request.id] = request; + } + + @override + Future cancel(String id) async { + final trimmed = _requiredTrimmed(id, 'id'); + _scheduled.remove(trimmed); + _cancelledIds.add(trimmed); + } + + /// Emit [event] to feedback listeners. + void emitFeedback(NotificationFeedback event) { + _feedbackController.add(event); + } + + /// Release stream resources held by this fake. + Future close() { + return _feedbackController.close(); + } +} + +String _requiredTrimmed(String value, String name) { + final trimmed = value.trim(); + if (trimmed.isEmpty) { + throw ArgumentError.value(value, name, 'Value must not be blank.'); + } + return trimmed; +} diff --git a/packages/scheduler_notifications/lib/src/notification_adapter/notification_adapter.dart b/packages/scheduler_notifications/lib/src/notification_adapter/notification_adapter.dart new file mode 100644 index 0000000..e1e9573 --- /dev/null +++ b/packages/scheduler_notifications/lib/src/notification_adapter/notification_adapter.dart @@ -0,0 +1,13 @@ +part of '../notification_adapter.dart'; + +/// Platform-neutral notification adapter boundary. +abstract interface class NotificationAdapter { + /// Schedule [request] for future delivery. + Future schedule(NotificationRequest request); + + /// Cancel a pending notification by stable [id]. + Future cancel(String id); + + /// Stream of user feedback events from delivered notifications. + Stream get feedback; +} diff --git a/packages/scheduler_notifications/lib/src/notification_adapter/notification_feedback.dart b/packages/scheduler_notifications/lib/src/notification_adapter/notification_feedback.dart new file mode 100644 index 0000000..acb691c --- /dev/null +++ b/packages/scheduler_notifications/lib/src/notification_adapter/notification_feedback.dart @@ -0,0 +1,24 @@ +part of '../notification_adapter.dart'; + +/// Feedback event emitted by a notification adapter. +final class NotificationFeedback { + /// Creates a notification feedback event. + NotificationFeedback({ + required String id, + required this.type, + this.actionId, + this.payloadJson = '{}', + }) : id = _requiredTrimmed(id, 'id'); + + /// Stable notification id associated with the feedback. + final String id; + + /// Type of user feedback. + final NotificationFeedbackType type; + + /// Optional action id for [NotificationFeedbackType.action]. + final String? actionId; + + /// Adapter-neutral JSON payload string. + final String payloadJson; +} diff --git a/packages/scheduler_notifications/lib/src/notification_adapter/notification_feedback_type.dart b/packages/scheduler_notifications/lib/src/notification_adapter/notification_feedback_type.dart new file mode 100644 index 0000000..188e536 --- /dev/null +++ b/packages/scheduler_notifications/lib/src/notification_adapter/notification_feedback_type.dart @@ -0,0 +1,13 @@ +part of '../notification_adapter.dart'; + +/// User feedback category emitted by a notification adapter. +enum NotificationFeedbackType { + /// The notification was opened or clicked. + clicked, + + /// The notification was dismissed. + dismissed, + + /// A notification action was selected. + action, +} diff --git a/packages/scheduler_notifications/lib/src/notification_adapter/notification_request.dart b/packages/scheduler_notifications/lib/src/notification_adapter/notification_request.dart new file mode 100644 index 0000000..45a3629 --- /dev/null +++ b/packages/scheduler_notifications/lib/src/notification_adapter/notification_request.dart @@ -0,0 +1,44 @@ +part of '../notification_adapter.dart'; + +/// Request to schedule one notification through a platform adapter. +sealed class NotificationRequest { + /// Creates a notification request. + factory NotificationRequest({ + required String id, + required String title, + required String body, + required DateTime scheduledDateTimeUtc, + String payloadJson = '{}', + }) { + return _NotificationRequest( + id: _requiredTrimmed(id, 'id'), + title: _requiredTrimmed(title, 'title'), + body: body, + scheduledDateTimeUtc: scheduledDateTimeUtc.toUtc(), + payloadJson: payloadJson, + ); + } + + const NotificationRequest._({ + required this.id, + required this.title, + required this.body, + required this.scheduledDateTimeUtc, + required this.payloadJson, + }); + + /// Stable notification id used for scheduling and cancellation. + final String id; + + /// User-visible notification title. + final String title; + + /// User-visible notification body. + final String body; + + /// UTC instant when the notification should be delivered. + final DateTime scheduledDateTimeUtc; + + /// Adapter-neutral JSON payload string. + final String payloadJson; +} diff --git a/packages/scheduler_notifications/lib/src/notification_adapter/notification_request_impl.dart b/packages/scheduler_notifications/lib/src/notification_adapter/notification_request_impl.dart new file mode 100644 index 0000000..cc02c40 --- /dev/null +++ b/packages/scheduler_notifications/lib/src/notification_adapter/notification_request_impl.dart @@ -0,0 +1,11 @@ +part of '../notification_adapter.dart'; + +final class _NotificationRequest extends NotificationRequest { + const _NotificationRequest({ + required super.id, + required super.title, + required super.body, + required super.scheduledDateTimeUtc, + required super.payloadJson, + }) : super._(); +} diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io.dart index 676f7c5..62ddffc 100644 --- a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io.dart +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io.dart @@ -6,226 +6,14 @@ import 'dart:convert'; import 'dart:io'; import 'package:scheduler_notifications/notifications.dart'; - -/// Supported desktop platform categories. -enum DesktopNotificationPlatform { - /// Linux desktop. - linux, - - /// macOS desktop. - macos, - - /// Windows desktop. - windows, - - /// Unsupported or unknown platform. - unsupported, -} - -/// Logging callback used by fallback notification backends. -typedef DesktopNotificationLogSink = void Function(String line); - -/// Process runner used by command-backed notification backends. -typedef DesktopProcessRunner = Future Function( - String executable, - List arguments, -); - -/// Platform-neutral backend used by [DesktopNotificationAdapter]. -abstract interface class DesktopNotificationBackend { - /// Whether this backend has native notification support. - bool get isSupported; - - /// Deliver [request]. - Future deliver(NotificationRequest request); - - /// Cancel a pending notification by [id], when supported. - Future cancel(String id); -} - -/// Notification adapter that delegates to a desktop backend. -final class DesktopNotificationAdapter implements NotificationAdapter { - /// Creates an adapter backed by [backend]. - DesktopNotificationAdapter({required DesktopNotificationBackend backend}) - : _backend = backend; - - final DesktopNotificationBackend _backend; - - /// Creates the best available desktop adapter for the current platform. - factory DesktopNotificationAdapter.defaultInstance({ - DesktopNotificationPlatform? platform, - DesktopProcessRunner? processRunner, - DesktopNotificationLogSink? logSink, - }) { - return DesktopNotificationAdapter( - backend: createDefaultDesktopNotificationBackend( - platform: platform, - processRunner: processRunner, - logSink: logSink, - ), - ); - } - - @override - Stream get feedback => const Stream.empty(); - - @override - Future schedule(NotificationRequest request) { - return _backend.deliver(request); - } - - @override - Future cancel(String id) { - return _backend.cancel(id.trim()); - } -} - -/// Create the best default desktop notification backend. -DesktopNotificationBackend createDefaultDesktopNotificationBackend({ - DesktopNotificationPlatform? platform, - DesktopProcessRunner? processRunner, - DesktopNotificationLogSink? logSink, -}) { - final resolvedPlatform = platform ?? _currentPlatform(); - final runner = processRunner ?? Process.run; - final fallback = StdoutNotificationBackend( - logSink: logSink ?? stdout.writeln, - platform: resolvedPlatform, - ); - - return switch (resolvedPlatform) { - DesktopNotificationPlatform.linux => LinuxNotifySendBackend( - processRunner: runner, - fallback: fallback, - ), - DesktopNotificationPlatform.macos => MacOsNotificationBackend( - processRunner: runner, - fallback: fallback, - ), - DesktopNotificationPlatform.windows => fallback, - DesktopNotificationPlatform.unsupported => fallback, - }; -} - -/// Linux backend using `notify-send`. -final class LinuxNotifySendBackend implements DesktopNotificationBackend { - /// Creates a Linux command-backed backend. - LinuxNotifySendBackend({ - required DesktopProcessRunner processRunner, - required DesktopNotificationBackend fallback, - }) : _processRunner = processRunner, - _fallback = fallback; - - final DesktopProcessRunner _processRunner; - final DesktopNotificationBackend _fallback; - - @override - bool get isSupported => true; - - @override - Future deliver(NotificationRequest request) async { - await _runOrFallback( - request, - () => _processRunner('notify-send', [ - '--app-name=ADHD Scheduler', - request.title, - request.body, - ]), - ); - } - - @override - Future cancel(String id) async { - await _fallback.cancel(id); - } - - Future _runOrFallback( - NotificationRequest request, - Future Function() run, - ) async { - try { - final result = await run(); - if (result.exitCode != 0) { - await _fallback.deliver(request); - } - } on ProcessException { - await _fallback.deliver(request); - } - } -} - -/// macOS backend using `osascript`. -final class MacOsNotificationBackend implements DesktopNotificationBackend { - /// Creates a macOS command-backed backend. - MacOsNotificationBackend({ - required DesktopProcessRunner processRunner, - required DesktopNotificationBackend fallback, - }) : _processRunner = processRunner, - _fallback = fallback; - - final DesktopProcessRunner _processRunner; - final DesktopNotificationBackend _fallback; - - @override - bool get isSupported => true; - - @override - Future deliver(NotificationRequest request) async { - final script = 'display notification ${_appleScriptString(request.body)} ' - 'with title ${_appleScriptString(request.title)}'; - try { - final result = await _processRunner('osascript', ['-e', script]); - if (result.exitCode != 0) { - await _fallback.deliver(request); - } - } on ProcessException { - await _fallback.deliver(request); - } - } - - @override - Future cancel(String id) async { - await _fallback.cancel(id); - } -} - -/// Fallback backend that writes notification requests to stdout/logs. -final class StdoutNotificationBackend implements DesktopNotificationBackend { - /// Creates a stdout fallback backend. - StdoutNotificationBackend({ - required DesktopNotificationLogSink logSink, - required this.platform, - }) : _logSink = logSink; - - final DesktopNotificationLogSink _logSink; - - /// Platform this fallback represents. - final DesktopNotificationPlatform platform; - - @override - bool get isSupported => false; - - @override - Future deliver(NotificationRequest request) async { - _logSink( - 'notification[$platform] ${request.id}: ${request.title} - ' - '${request.body}', - ); - } - - @override - Future cancel(String id) async { - _logSink('notification[$platform] cancel: ${id.trim()}'); - } -} - -DesktopNotificationPlatform _currentPlatform() { - if (Platform.isLinux) return DesktopNotificationPlatform.linux; - if (Platform.isMacOS) return DesktopNotificationPlatform.macos; - if (Platform.isWindows) return DesktopNotificationPlatform.windows; - return DesktopNotificationPlatform.unsupported; -} - -String _appleScriptString(String value) { - return jsonEncode(value); -} +part 'desktop_notification_adapter_io/desktop_notification_platform.dart'; +part 'desktop_notification_adapter_io/desktop_notification_log_sink.dart'; +part 'desktop_notification_adapter_io/desktop_process_runner.dart'; +part 'desktop_notification_adapter_io/desktop_notification_backend.dart'; +part 'desktop_notification_adapter_io/desktop_notification_adapter.dart'; +part 'desktop_notification_adapter_io/desktop_notification_backend_factory.dart'; +part 'desktop_notification_adapter_io/desktop_notification_platform_detector.dart'; +part 'desktop_notification_adapter_io/linux_notify_send_backend.dart'; +part 'desktop_notification_adapter_io/apple_script_string_encoder.dart'; +part 'desktop_notification_adapter_io/mac_os_notification_backend.dart'; +part 'desktop_notification_adapter_io/stdout_notification_backend.dart'; diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/apple_script_string_encoder.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/apple_script_string_encoder.dart new file mode 100644 index 0000000..f2f98d1 --- /dev/null +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/apple_script_string_encoder.dart @@ -0,0 +1,5 @@ +part of '../desktop_notification_adapter_io.dart'; + +String _appleScriptString(String value) { + return jsonEncode(value); +} diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_adapter.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_adapter.dart new file mode 100644 index 0000000..4b0ef01 --- /dev/null +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_adapter.dart @@ -0,0 +1,38 @@ +part of '../desktop_notification_adapter_io.dart'; + +/// Notification adapter that delegates to a desktop backend. +final class DesktopNotificationAdapter implements NotificationAdapter { + /// Creates an adapter backed by [backend]. + DesktopNotificationAdapter({required DesktopNotificationBackend backend}) + : _backend = backend; + + final DesktopNotificationBackend _backend; + + /// Creates the best available desktop adapter for the current platform. + factory DesktopNotificationAdapter.defaultInstance({ + DesktopNotificationPlatform? platform, + DesktopProcessRunner? processRunner, + DesktopNotificationLogSink? logSink, + }) { + return DesktopNotificationAdapter( + backend: createDefaultDesktopNotificationBackend( + platform: platform, + processRunner: processRunner, + logSink: logSink, + ), + ); + } + + @override + Stream get feedback => const Stream.empty(); + + @override + Future schedule(NotificationRequest request) { + return _backend.deliver(request); + } + + @override + Future cancel(String id) { + return _backend.cancel(id.trim()); + } +} diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_backend.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_backend.dart new file mode 100644 index 0000000..d14f54c --- /dev/null +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_backend.dart @@ -0,0 +1,13 @@ +part of '../desktop_notification_adapter_io.dart'; + +/// Platform-neutral backend used by [DesktopNotificationAdapter]. +abstract interface class DesktopNotificationBackend { + /// Whether this backend has native notification support. + bool get isSupported; + + /// Deliver [request]. + Future deliver(NotificationRequest request); + + /// Cancel a pending notification by [id], when supported. + Future cancel(String id); +} diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_backend_factory.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_backend_factory.dart new file mode 100644 index 0000000..1d8c0e8 --- /dev/null +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_backend_factory.dart @@ -0,0 +1,28 @@ +part of '../desktop_notification_adapter_io.dart'; + +/// Create the best default desktop notification backend. +DesktopNotificationBackend createDefaultDesktopNotificationBackend({ + DesktopNotificationPlatform? platform, + DesktopProcessRunner? processRunner, + DesktopNotificationLogSink? logSink, +}) { + final resolvedPlatform = platform ?? _currentPlatform(); + final runner = processRunner ?? Process.run; + final fallback = StdoutNotificationBackend( + logSink: logSink ?? stdout.writeln, + platform: resolvedPlatform, + ); + + return switch (resolvedPlatform) { + DesktopNotificationPlatform.linux => LinuxNotifySendBackend( + processRunner: runner, + fallback: fallback, + ), + DesktopNotificationPlatform.macos => MacOsNotificationBackend( + processRunner: runner, + fallback: fallback, + ), + DesktopNotificationPlatform.windows => fallback, + DesktopNotificationPlatform.unsupported => fallback, + }; +} diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_log_sink.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_log_sink.dart new file mode 100644 index 0000000..2ddde83 --- /dev/null +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_log_sink.dart @@ -0,0 +1,4 @@ +part of '../desktop_notification_adapter_io.dart'; + +/// Logging callback used by fallback notification backends. +typedef DesktopNotificationLogSink = void Function(String line); diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_platform.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_platform.dart new file mode 100644 index 0000000..4ff6301 --- /dev/null +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_platform.dart @@ -0,0 +1,16 @@ +part of '../desktop_notification_adapter_io.dart'; + +/// Supported desktop platform categories. +enum DesktopNotificationPlatform { + /// Linux desktop. + linux, + + /// macOS desktop. + macos, + + /// Windows desktop. + windows, + + /// Unsupported or unknown platform. + unsupported, +} diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_platform_detector.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_platform_detector.dart new file mode 100644 index 0000000..62e5db8 --- /dev/null +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_platform_detector.dart @@ -0,0 +1,8 @@ +part of '../desktop_notification_adapter_io.dart'; + +DesktopNotificationPlatform _currentPlatform() { + if (Platform.isLinux) return DesktopNotificationPlatform.linux; + if (Platform.isMacOS) return DesktopNotificationPlatform.macos; + if (Platform.isWindows) return DesktopNotificationPlatform.windows; + return DesktopNotificationPlatform.unsupported; +} diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_process_runner.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_process_runner.dart new file mode 100644 index 0000000..c6cf5e6 --- /dev/null +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_process_runner.dart @@ -0,0 +1,7 @@ +part of '../desktop_notification_adapter_io.dart'; + +/// Process runner used by command-backed notification backends. +typedef DesktopProcessRunner = Future Function( + String executable, + List arguments, +); diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/linux_notify_send_backend.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/linux_notify_send_backend.dart new file mode 100644 index 0000000..98d1270 --- /dev/null +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/linux_notify_send_backend.dart @@ -0,0 +1,48 @@ +part of '../desktop_notification_adapter_io.dart'; + +/// Linux backend using `notify-send`. +final class LinuxNotifySendBackend implements DesktopNotificationBackend { + /// Creates a Linux command-backed backend. + LinuxNotifySendBackend({ + required DesktopProcessRunner processRunner, + required DesktopNotificationBackend fallback, + }) : _processRunner = processRunner, + _fallback = fallback; + + final DesktopProcessRunner _processRunner; + final DesktopNotificationBackend _fallback; + + @override + bool get isSupported => true; + + @override + Future deliver(NotificationRequest request) async { + await _runOrFallback( + request, + () => _processRunner('notify-send', [ + '--app-name=ADHD Scheduler', + request.title, + request.body, + ]), + ); + } + + @override + Future cancel(String id) async { + await _fallback.cancel(id); + } + + Future _runOrFallback( + NotificationRequest request, + Future Function() run, + ) async { + try { + final result = await run(); + if (result.exitCode != 0) { + await _fallback.deliver(request); + } + } on ProcessException { + await _fallback.deliver(request); + } + } +} diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/mac_os_notification_backend.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/mac_os_notification_backend.dart new file mode 100644 index 0000000..15bc7ca --- /dev/null +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/mac_os_notification_backend.dart @@ -0,0 +1,36 @@ +part of '../desktop_notification_adapter_io.dart'; + +/// macOS backend using `osascript`. +final class MacOsNotificationBackend implements DesktopNotificationBackend { + /// Creates a macOS command-backed backend. + MacOsNotificationBackend({ + required DesktopProcessRunner processRunner, + required DesktopNotificationBackend fallback, + }) : _processRunner = processRunner, + _fallback = fallback; + + final DesktopProcessRunner _processRunner; + final DesktopNotificationBackend _fallback; + + @override + bool get isSupported => true; + + @override + Future deliver(NotificationRequest request) async { + final script = 'display notification ${_appleScriptString(request.body)} ' + 'with title ${_appleScriptString(request.title)}'; + try { + final result = await _processRunner('osascript', ['-e', script]); + if (result.exitCode != 0) { + await _fallback.deliver(request); + } + } on ProcessException { + await _fallback.deliver(request); + } + } + + @override + Future cancel(String id) async { + await _fallback.cancel(id); + } +} diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/stdout_notification_backend.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/stdout_notification_backend.dart new file mode 100644 index 0000000..b589ef7 --- /dev/null +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/stdout_notification_backend.dart @@ -0,0 +1,31 @@ +part of '../desktop_notification_adapter_io.dart'; + +/// Fallback backend that writes notification requests to stdout/logs. +final class StdoutNotificationBackend implements DesktopNotificationBackend { + /// Creates a stdout fallback backend. + StdoutNotificationBackend({ + required DesktopNotificationLogSink logSink, + required this.platform, + }) : _logSink = logSink; + + final DesktopNotificationLogSink _logSink; + + /// Platform this fallback represents. + final DesktopNotificationPlatform platform; + + @override + bool get isSupported => false; + + @override + Future deliver(NotificationRequest request) async { + _logSink( + 'notification[$platform] ${request.id}: ${request.title} - ' + '${request.body}', + ); + } + + @override + Future cancel(String id) async { + _logSink('notification[$platform] cancel: ${id.trim()}'); + } +} diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub.dart index 5346375..d55f233 100644 --- a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub.dart +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub.dart @@ -4,117 +4,10 @@ library; import 'dart:async'; import 'package:scheduler_notifications/notifications.dart'; - -/// Supported desktop platform categories. -enum DesktopNotificationPlatform { - /// Linux desktop. - linux, - - /// macOS desktop. - macos, - - /// Windows desktop. - windows, - - /// Unsupported or unknown platform. - unsupported, -} - -/// Logging callback used by fallback notification backends. -typedef DesktopNotificationLogSink = void Function(String line); - -/// Platform-neutral backend used by [DesktopNotificationAdapter]. -abstract interface class DesktopNotificationBackend { - /// Whether this backend has native notification support. - bool get isSupported; - - /// Deliver [request]. - Future deliver(NotificationRequest request); - - /// Cancel a pending notification by [id], when supported. - Future cancel(String id); -} - -/// Notification adapter that delegates to a desktop backend. -final class DesktopNotificationAdapter implements NotificationAdapter { - /// Creates an adapter backed by [backend]. - DesktopNotificationAdapter({required DesktopNotificationBackend backend}) - : _backend = backend; - - final DesktopNotificationBackend _backend; - - /// Creates the best available desktop adapter for the current platform. - factory DesktopNotificationAdapter.defaultInstance({ - DesktopNotificationPlatform? platform, - Object? processRunner, - DesktopNotificationLogSink? logSink, - }) { - return DesktopNotificationAdapter( - backend: createDefaultDesktopNotificationBackend( - platform: platform, - processRunner: processRunner, - logSink: logSink, - ), - ); - } - - @override - Stream get feedback => const Stream.empty(); - - @override - Future schedule(NotificationRequest request) { - return _backend.deliver(request); - } - - @override - Future cancel(String id) { - return _backend.cancel(id.trim()); - } -} - -/// Create the best default desktop notification backend. -DesktopNotificationBackend createDefaultDesktopNotificationBackend({ - DesktopNotificationPlatform? platform, - Object? processRunner, - DesktopNotificationLogSink? logSink, -}) { - return StdoutNotificationBackend( - logSink: logSink ?? _defaultLogSink, - platform: platform ?? DesktopNotificationPlatform.unsupported, - ); -} - -/// Fallback backend that writes notification requests to stdout/logs. -final class StdoutNotificationBackend implements DesktopNotificationBackend { - /// Creates a stdout fallback backend. - StdoutNotificationBackend({ - required DesktopNotificationLogSink logSink, - required this.platform, - }) : _logSink = logSink; - - final DesktopNotificationLogSink _logSink; - - /// Platform this fallback represents. - final DesktopNotificationPlatform platform; - - @override - bool get isSupported => false; - - @override - Future deliver(NotificationRequest request) async { - _logSink( - 'notification[$platform] ${request.id}: ${request.title} - ' - '${request.body}', - ); - } - - @override - Future cancel(String id) async { - _logSink('notification[$platform] cancel: ${id.trim()}'); - } -} - -void _defaultLogSink(String line) { - // ignore: avoid_print - print(line); -} +part 'desktop_notification_adapter_stub/desktop_notification_platform.dart'; +part 'desktop_notification_adapter_stub/desktop_notification_log_sink.dart'; +part 'desktop_notification_adapter_stub/desktop_notification_backend.dart'; +part 'desktop_notification_adapter_stub/desktop_notification_backend_factory.dart'; +part 'desktop_notification_adapter_stub/desktop_notification_adapter.dart'; +part 'desktop_notification_adapter_stub/default_log_sink.dart'; +part 'desktop_notification_adapter_stub/stdout_notification_backend.dart'; diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/default_log_sink.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/default_log_sink.dart new file mode 100644 index 0000000..c976700 --- /dev/null +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/default_log_sink.dart @@ -0,0 +1,6 @@ +part of '../desktop_notification_adapter_stub.dart'; + +void _defaultLogSink(String line) { + // ignore: avoid_print + print(line); +} diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/desktop_notification_adapter.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/desktop_notification_adapter.dart new file mode 100644 index 0000000..2212ef2 --- /dev/null +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/desktop_notification_adapter.dart @@ -0,0 +1,38 @@ +part of '../desktop_notification_adapter_stub.dart'; + +/// Notification adapter that delegates to a desktop backend. +final class DesktopNotificationAdapter implements NotificationAdapter { + /// Creates an adapter backed by [backend]. + DesktopNotificationAdapter({required DesktopNotificationBackend backend}) + : _backend = backend; + + final DesktopNotificationBackend _backend; + + /// Creates the best available desktop adapter for the current platform. + factory DesktopNotificationAdapter.defaultInstance({ + DesktopNotificationPlatform? platform, + Object? processRunner, + DesktopNotificationLogSink? logSink, + }) { + return DesktopNotificationAdapter( + backend: createDefaultDesktopNotificationBackend( + platform: platform, + processRunner: processRunner, + logSink: logSink, + ), + ); + } + + @override + Stream get feedback => const Stream.empty(); + + @override + Future schedule(NotificationRequest request) { + return _backend.deliver(request); + } + + @override + Future cancel(String id) { + return _backend.cancel(id.trim()); + } +} diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/desktop_notification_backend.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/desktop_notification_backend.dart new file mode 100644 index 0000000..29d13b7 --- /dev/null +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/desktop_notification_backend.dart @@ -0,0 +1,13 @@ +part of '../desktop_notification_adapter_stub.dart'; + +/// Platform-neutral backend used by [DesktopNotificationAdapter]. +abstract interface class DesktopNotificationBackend { + /// Whether this backend has native notification support. + bool get isSupported; + + /// Deliver [request]. + Future deliver(NotificationRequest request); + + /// Cancel a pending notification by [id], when supported. + Future cancel(String id); +} diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/desktop_notification_backend_factory.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/desktop_notification_backend_factory.dart new file mode 100644 index 0000000..a40b4cd --- /dev/null +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/desktop_notification_backend_factory.dart @@ -0,0 +1,13 @@ +part of '../desktop_notification_adapter_stub.dart'; + +/// Create the best default desktop notification backend. +DesktopNotificationBackend createDefaultDesktopNotificationBackend({ + DesktopNotificationPlatform? platform, + Object? processRunner, + DesktopNotificationLogSink? logSink, +}) { + return StdoutNotificationBackend( + logSink: logSink ?? _defaultLogSink, + platform: platform ?? DesktopNotificationPlatform.unsupported, + ); +} diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/desktop_notification_log_sink.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/desktop_notification_log_sink.dart new file mode 100644 index 0000000..6fc66d4 --- /dev/null +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/desktop_notification_log_sink.dart @@ -0,0 +1,4 @@ +part of '../desktop_notification_adapter_stub.dart'; + +/// Logging callback used by fallback notification backends. +typedef DesktopNotificationLogSink = void Function(String line); diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/desktop_notification_platform.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/desktop_notification_platform.dart new file mode 100644 index 0000000..e18fe86 --- /dev/null +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/desktop_notification_platform.dart @@ -0,0 +1,16 @@ +part of '../desktop_notification_adapter_stub.dart'; + +/// Supported desktop platform categories. +enum DesktopNotificationPlatform { + /// Linux desktop. + linux, + + /// macOS desktop. + macos, + + /// Windows desktop. + windows, + + /// Unsupported or unknown platform. + unsupported, +} diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/stdout_notification_backend.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/stdout_notification_backend.dart new file mode 100644 index 0000000..0bd2d9a --- /dev/null +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/stdout_notification_backend.dart @@ -0,0 +1,31 @@ +part of '../desktop_notification_adapter_stub.dart'; + +/// Fallback backend that writes notification requests to stdout/logs. +final class StdoutNotificationBackend implements DesktopNotificationBackend { + /// Creates a stdout fallback backend. + StdoutNotificationBackend({ + required DesktopNotificationLogSink logSink, + required this.platform, + }) : _logSink = logSink; + + final DesktopNotificationLogSink _logSink; + + /// Platform this fallback represents. + final DesktopNotificationPlatform platform; + + @override + bool get isSupported => false; + + @override + Future deliver(NotificationRequest request) async { + _logSink( + 'notification[$platform] ${request.id}: ${request.title} - ' + '${request.body}', + ); + } + + @override + Future cancel(String id) async { + _logSink('notification[$platform] cancel: ${id.trim()}'); + } +} diff --git a/packages/scheduler_persistence/lib/persistence.dart b/packages/scheduler_persistence/lib/persistence.dart index 55ebd67..47b3b22 100644 --- a/packages/scheduler_persistence/lib/persistence.dart +++ b/packages/scheduler_persistence/lib/persistence.dart @@ -7,319 +7,19 @@ library; import 'package:scheduler_core/scheduler_core.dart' as core; - -/// Adapter-neutral repository failure. -sealed class RepositoryFailure { - const RepositoryFailure({ - this.entityId, - this.expectedRevision, - this.actualRevision, - this.message, - }); - - /// Stable id of the entity related to the failure, when available. - final String? entityId; - - /// Revision the caller expected for a compare-and-set operation. - final core.Revision? expectedRevision; - - /// Revision currently stored by the adapter. - final core.Revision? actualRevision; - - /// Optional non-localized diagnostic text for logs and tests. - final String? message; -} - -/// The requested entity was not found. -final class RepositoryNotFound extends RepositoryFailure { - /// Creates a not-found failure for [entityId]. - const RepositoryNotFound({super.entityId, super.message}); -} - -/// A record with the requested id already exists. -final class RepositoryDuplicateId extends RepositoryFailure { - /// Creates a duplicate-id failure for [entityId]. - const RepositoryDuplicateId({super.entityId, super.message}); -} - -/// The caller supplied an invalid revision value. -final class RepositoryInvalidRevision extends RepositoryFailure { - /// Creates an invalid-revision failure. - const RepositoryInvalidRevision({ - super.entityId, - super.expectedRevision, - super.message, - }); -} - -/// The caller attempted to write using a stale revision. -final class RepositoryStaleRevision extends RepositoryFailure { - /// Creates a stale-revision failure. - const RepositoryStaleRevision({ - super.entityId, - required super.expectedRevision, - required super.actualRevision, - super.message, - }); -} - -/// The requested record exists under a different owner scope. -final class RepositoryOwnerMismatch extends RepositoryFailure { - /// Creates an owner-mismatch failure for [entityId]. - const RepositoryOwnerMismatch({super.entityId, super.message}); -} - -/// The adapter could not complete the operation due to storage failure. -final class RepositoryStorageFailure extends RepositoryFailure { - /// Creates a storage failure with optional [message]. - const RepositoryStorageFailure({super.entityId, super.message}); -} - -/// Minimal Either type for expected repository success/failure paths. -sealed class Either { - const Either(); - - /// Whether this value is a left/failure value. - bool get isLeft => this is Left; - - /// Whether this value is a right/success value. - bool get isRight => this is Right; - - /// Return the left value or throw when this is right. - L get left => switch (this) { - Left(:final value) => value, - Right() => throw StateError('Either does not contain left.'), - }; - - /// Return the right value or throw when this is left. - R get right => switch (this) { - Left() => throw StateError('Either does not contain right.'), - Right(:final value) => value, - }; -} - -/// Failure side of [Either]. -final class Left extends Either { - /// Creates a left/failure value. - const Left(this.value); - - /// Wrapped failure value. - final L value; -} - -/// Success side of [Either]. -final class Right extends Either { - /// Creates a right/success value. - const Right(this.value); - - /// Wrapped success value. - final R value; -} - -/// Repository result wrapper for expected adapter outcomes. -typedef RepoResult = Either; - -/// Repository contract for task records. -abstract interface class TaskRepository { - /// Return the task with [taskId] in [ownerId], or null when it is absent. - Future> findById({ - required core.OwnerId ownerId, - required String taskId, - }); - - /// Return owner-scoped tasks in deterministic adapter order. - Future>> findByOwner({ - required core.OwnerId ownerId, - core.PageRequest page = const core.PageRequest(), - }); - - /// Return owner-scoped tasks assigned to [projectId]. - Future>> findByProjectId({ - required core.OwnerId ownerId, - required String projectId, - core.PageRequest page = const core.PageRequest(), - }); - - /// Return owner-scoped direct child tasks for [parentTaskId]. - Future>> findByParentTaskId({ - required core.OwnerId ownerId, - required String parentTaskId, - core.PageRequest page = const core.PageRequest(), - }); - - /// Return owner-scoped tasks with [status]. - Future>> findByStatus({ - required core.OwnerId ownerId, - required core.TaskStatus status, - core.PageRequest page = const core.PageRequest(), - }); - - /// Insert [task] for [ownerId] and return the assigned revision. - Future> insert({ - required core.OwnerId ownerId, - required core.Task task, - }); - - /// Save [task] if [expectedRevision] still matches the stored record. - Future> save({ - required core.OwnerId ownerId, - required core.Task task, - required core.Revision expectedRevision, - }); - - /// Remove [taskId] if [expectedRevision] still matches the stored record. - Future> delete({ - required core.OwnerId ownerId, - required String taskId, - required core.Revision expectedRevision, - }); -} - -/// Repository contract for project profile records. -abstract interface class ProjectRepository { - /// Return the project with [projectId] in [ownerId], or null when absent. - Future> findById({ - required core.OwnerId ownerId, - required String projectId, - }); - - /// Return owner-scoped projects, optionally including archived profiles. - Future>> findByOwner({ - required core.OwnerId ownerId, - bool includeArchived = false, - core.PageRequest page = const core.PageRequest(), - }); - - /// Insert [project] for [ownerId] and return the assigned revision. - Future> insert({ - required core.OwnerId ownerId, - required core.ProjectProfile project, - }); - - /// Save [project] if [expectedRevision] still matches the stored record. - Future> save({ - required core.OwnerId ownerId, - required core.ProjectProfile project, - required core.Revision expectedRevision, - }); - - /// Archive [projectId] without deleting task history. - Future> archive({ - required core.OwnerId ownerId, - required String projectId, - required core.Revision expectedRevision, - required DateTime archivedAtUtc, - }); -} - -/// Repository contract for locked blocks and date-scoped overrides. -abstract interface class LockedBlockRepository { - /// Return the locked block with [blockId] in [ownerId], or null when absent. - Future> findBlockById({ - required core.OwnerId ownerId, - required String blockId, - }); - - /// Return owner-scoped locked blocks. - Future>> findBlocksByOwner({ - required core.OwnerId ownerId, - bool includeArchived = false, - core.PageRequest page = const core.PageRequest(), - }); - - /// Return the override with [overrideId] in [ownerId], or null when absent. - Future> findOverrideById({ - required core.OwnerId ownerId, - required String overrideId, - }); - - /// Return owner-scoped overrides for [date]. - Future>> findOverridesForDate({ - required core.OwnerId ownerId, - required core.CivilDate date, - core.PageRequest page = const core.PageRequest(), - }); - - /// Insert [block] for [ownerId] and return the assigned revision. - Future> insertBlock({ - required core.OwnerId ownerId, - required core.LockedBlock block, - }); - - /// Save [block] if [expectedRevision] still matches the stored record. - Future> saveBlock({ - required core.OwnerId ownerId, - required core.LockedBlock block, - required core.Revision expectedRevision, - }); - - /// Archive [blockId] without deleting historical schedule context. - Future> archiveBlock({ - required core.OwnerId ownerId, - required String blockId, - required core.Revision expectedRevision, - required DateTime archivedAtUtc, - }); - - /// Insert [override] for [ownerId] and return the assigned revision. - Future> insertOverride({ - required core.OwnerId ownerId, - required core.LockedBlockOverride override, - }); - - /// Save [override] if [expectedRevision] still matches the stored record. - Future> saveOverride({ - required core.OwnerId ownerId, - required core.LockedBlockOverride override, - required core.Revision expectedRevision, - }); -} - -/// Repository contract for owner settings. -abstract interface class SettingsRepository { - /// Return settings for [ownerId], or null when no settings have been saved. - Future> findByOwner({ - required core.OwnerId ownerId, - }); - - /// Insert [settings] for [ownerId] and return the assigned revision. - Future> insert({ - required core.OwnerId ownerId, - required core.OwnerSettings settings, - }); - - /// Save [settings] if [expectedRevision] still matches the stored record. - Future> save({ - required core.OwnerId ownerId, - required core.OwnerSettings settings, - required core.Revision expectedRevision, - }); -} - -/// Repository contract for diagnostic schedule snapshots. -abstract interface class ScheduleSnapshotRepository { - /// Return the snapshot with [snapshotId] in [ownerId], or null when absent. - Future> findById({ - required core.OwnerId ownerId, - required String snapshotId, - }); - - /// Return snapshots captured for [ownerId] that overlap [window]. - Future>> findInWindow({ - required core.OwnerId ownerId, - required core.SchedulingWindow window, - core.PageRequest page = const core.PageRequest(), - }); - - /// Insert [snapshot] for [ownerId] and return the assigned revision. - Future> insert({ - required core.OwnerId ownerId, - required core.SchedulingStateSnapshot snapshot, - }); - - /// Delete snapshots whose retention expiry is before [nowUtc]. - Future> deleteExpired({ - required core.OwnerId ownerId, - required DateTime nowUtc, - }); -} +part 'persistence/repository_failure.dart'; +part 'persistence/repository_not_found.dart'; +part 'persistence/repository_duplicate_id.dart'; +part 'persistence/repository_invalid_revision.dart'; +part 'persistence/repository_stale_revision.dart'; +part 'persistence/repository_owner_mismatch.dart'; +part 'persistence/repository_storage_failure.dart'; +part 'persistence/either.dart'; +part 'persistence/left.dart'; +part 'persistence/right.dart'; +part 'persistence/repo_result.dart'; +part 'persistence/task_repository.dart'; +part 'persistence/project_repository.dart'; +part 'persistence/locked_block_repository.dart'; +part 'persistence/settings_repository.dart'; +part 'persistence/schedule_snapshot_repository.dart'; diff --git a/packages/scheduler_persistence/lib/persistence/either.dart b/packages/scheduler_persistence/lib/persistence/either.dart new file mode 100644 index 0000000..dca6ede --- /dev/null +++ b/packages/scheduler_persistence/lib/persistence/either.dart @@ -0,0 +1,24 @@ +part of '../persistence.dart'; + +/// Minimal Either type for expected repository success/failure paths. +sealed class Either { + const Either(); + + /// Whether this value is a left/failure value. + bool get isLeft => this is Left; + + /// Whether this value is a right/success value. + bool get isRight => this is Right; + + /// Return the left value or throw when this is right. + L get left => switch (this) { + Left(:final value) => value, + Right() => throw StateError('Either does not contain left.'), + }; + + /// Return the right value or throw when this is left. + R get right => switch (this) { + Left() => throw StateError('Either does not contain right.'), + Right(:final value) => value, + }; +} diff --git a/packages/scheduler_persistence/lib/persistence/left.dart b/packages/scheduler_persistence/lib/persistence/left.dart new file mode 100644 index 0000000..e045e6f --- /dev/null +++ b/packages/scheduler_persistence/lib/persistence/left.dart @@ -0,0 +1,10 @@ +part of '../persistence.dart'; + +/// Failure side of [Either]. +final class Left extends Either { + /// Creates a left/failure value. + const Left(this.value); + + /// Wrapped failure value. + final L value; +} diff --git a/packages/scheduler_persistence/lib/persistence/locked_block_repository.dart b/packages/scheduler_persistence/lib/persistence/locked_block_repository.dart new file mode 100644 index 0000000..6f958c3 --- /dev/null +++ b/packages/scheduler_persistence/lib/persistence/locked_block_repository.dart @@ -0,0 +1,64 @@ +part of '../persistence.dart'; + +/// Repository contract for locked blocks and date-scoped overrides. +abstract interface class LockedBlockRepository { + /// Return the locked block with [blockId] in [ownerId], or null when absent. + Future> findBlockById({ + required core.OwnerId ownerId, + required String blockId, + }); + + /// Return owner-scoped locked blocks. + Future>> findBlocksByOwner({ + required core.OwnerId ownerId, + bool includeArchived = false, + core.PageRequest page = const core.PageRequest(), + }); + + /// Return the override with [overrideId] in [ownerId], or null when absent. + Future> findOverrideById({ + required core.OwnerId ownerId, + required String overrideId, + }); + + /// Return owner-scoped overrides for [date]. + Future>> findOverridesForDate({ + required core.OwnerId ownerId, + required core.CivilDate date, + core.PageRequest page = const core.PageRequest(), + }); + + /// Insert [block] for [ownerId] and return the assigned revision. + Future> insertBlock({ + required core.OwnerId ownerId, + required core.LockedBlock block, + }); + + /// Save [block] if [expectedRevision] still matches the stored record. + Future> saveBlock({ + required core.OwnerId ownerId, + required core.LockedBlock block, + required core.Revision expectedRevision, + }); + + /// Archive [blockId] without deleting historical schedule context. + Future> archiveBlock({ + required core.OwnerId ownerId, + required String blockId, + required core.Revision expectedRevision, + required DateTime archivedAtUtc, + }); + + /// Insert [override] for [ownerId] and return the assigned revision. + Future> insertOverride({ + required core.OwnerId ownerId, + required core.LockedBlockOverride override, + }); + + /// Save [override] if [expectedRevision] still matches the stored record. + Future> saveOverride({ + required core.OwnerId ownerId, + required core.LockedBlockOverride override, + required core.Revision expectedRevision, + }); +} diff --git a/packages/scheduler_persistence/lib/persistence/project_repository.dart b/packages/scheduler_persistence/lib/persistence/project_repository.dart new file mode 100644 index 0000000..36c6f09 --- /dev/null +++ b/packages/scheduler_persistence/lib/persistence/project_repository.dart @@ -0,0 +1,38 @@ +part of '../persistence.dart'; + +/// Repository contract for project profile records. +abstract interface class ProjectRepository { + /// Return the project with [projectId] in [ownerId], or null when absent. + Future> findById({ + required core.OwnerId ownerId, + required String projectId, + }); + + /// Return owner-scoped projects, optionally including archived profiles. + Future>> findByOwner({ + required core.OwnerId ownerId, + bool includeArchived = false, + core.PageRequest page = const core.PageRequest(), + }); + + /// Insert [project] for [ownerId] and return the assigned revision. + Future> insert({ + required core.OwnerId ownerId, + required core.ProjectProfile project, + }); + + /// Save [project] if [expectedRevision] still matches the stored record. + Future> save({ + required core.OwnerId ownerId, + required core.ProjectProfile project, + required core.Revision expectedRevision, + }); + + /// Archive [projectId] without deleting task history. + Future> archive({ + required core.OwnerId ownerId, + required String projectId, + required core.Revision expectedRevision, + required DateTime archivedAtUtc, + }); +} diff --git a/packages/scheduler_persistence/lib/persistence/repo_result.dart b/packages/scheduler_persistence/lib/persistence/repo_result.dart new file mode 100644 index 0000000..62d9528 --- /dev/null +++ b/packages/scheduler_persistence/lib/persistence/repo_result.dart @@ -0,0 +1,4 @@ +part of '../persistence.dart'; + +/// Repository result wrapper for expected adapter outcomes. +typedef RepoResult = Either; diff --git a/packages/scheduler_persistence/lib/persistence/repository_duplicate_id.dart b/packages/scheduler_persistence/lib/persistence/repository_duplicate_id.dart new file mode 100644 index 0000000..2f48537 --- /dev/null +++ b/packages/scheduler_persistence/lib/persistence/repository_duplicate_id.dart @@ -0,0 +1,7 @@ +part of '../persistence.dart'; + +/// A record with the requested id already exists. +final class RepositoryDuplicateId extends RepositoryFailure { + /// Creates a duplicate-id failure for [entityId]. + const RepositoryDuplicateId({super.entityId, super.message}); +} diff --git a/packages/scheduler_persistence/lib/persistence/repository_failure.dart b/packages/scheduler_persistence/lib/persistence/repository_failure.dart new file mode 100644 index 0000000..77e7e23 --- /dev/null +++ b/packages/scheduler_persistence/lib/persistence/repository_failure.dart @@ -0,0 +1,23 @@ +part of '../persistence.dart'; + +/// Adapter-neutral repository failure. +sealed class RepositoryFailure { + const RepositoryFailure({ + this.entityId, + this.expectedRevision, + this.actualRevision, + this.message, + }); + + /// Stable id of the entity related to the failure, when available. + final String? entityId; + + /// Revision the caller expected for a compare-and-set operation. + final core.Revision? expectedRevision; + + /// Revision currently stored by the adapter. + final core.Revision? actualRevision; + + /// Optional non-localized diagnostic text for logs and tests. + final String? message; +} diff --git a/packages/scheduler_persistence/lib/persistence/repository_invalid_revision.dart b/packages/scheduler_persistence/lib/persistence/repository_invalid_revision.dart new file mode 100644 index 0000000..35f0286 --- /dev/null +++ b/packages/scheduler_persistence/lib/persistence/repository_invalid_revision.dart @@ -0,0 +1,11 @@ +part of '../persistence.dart'; + +/// The caller supplied an invalid revision value. +final class RepositoryInvalidRevision extends RepositoryFailure { + /// Creates an invalid-revision failure. + const RepositoryInvalidRevision({ + super.entityId, + super.expectedRevision, + super.message, + }); +} diff --git a/packages/scheduler_persistence/lib/persistence/repository_not_found.dart b/packages/scheduler_persistence/lib/persistence/repository_not_found.dart new file mode 100644 index 0000000..feab57d --- /dev/null +++ b/packages/scheduler_persistence/lib/persistence/repository_not_found.dart @@ -0,0 +1,7 @@ +part of '../persistence.dart'; + +/// The requested entity was not found. +final class RepositoryNotFound extends RepositoryFailure { + /// Creates a not-found failure for [entityId]. + const RepositoryNotFound({super.entityId, super.message}); +} diff --git a/packages/scheduler_persistence/lib/persistence/repository_owner_mismatch.dart b/packages/scheduler_persistence/lib/persistence/repository_owner_mismatch.dart new file mode 100644 index 0000000..a57ee20 --- /dev/null +++ b/packages/scheduler_persistence/lib/persistence/repository_owner_mismatch.dart @@ -0,0 +1,7 @@ +part of '../persistence.dart'; + +/// The requested record exists under a different owner scope. +final class RepositoryOwnerMismatch extends RepositoryFailure { + /// Creates an owner-mismatch failure for [entityId]. + const RepositoryOwnerMismatch({super.entityId, super.message}); +} diff --git a/packages/scheduler_persistence/lib/persistence/repository_stale_revision.dart b/packages/scheduler_persistence/lib/persistence/repository_stale_revision.dart new file mode 100644 index 0000000..6a532e2 --- /dev/null +++ b/packages/scheduler_persistence/lib/persistence/repository_stale_revision.dart @@ -0,0 +1,12 @@ +part of '../persistence.dart'; + +/// The caller attempted to write using a stale revision. +final class RepositoryStaleRevision extends RepositoryFailure { + /// Creates a stale-revision failure. + const RepositoryStaleRevision({ + super.entityId, + required super.expectedRevision, + required super.actualRevision, + super.message, + }); +} diff --git a/packages/scheduler_persistence/lib/persistence/repository_storage_failure.dart b/packages/scheduler_persistence/lib/persistence/repository_storage_failure.dart new file mode 100644 index 0000000..0cd1ca7 --- /dev/null +++ b/packages/scheduler_persistence/lib/persistence/repository_storage_failure.dart @@ -0,0 +1,7 @@ +part of '../persistence.dart'; + +/// The adapter could not complete the operation due to storage failure. +final class RepositoryStorageFailure extends RepositoryFailure { + /// Creates a storage failure with optional [message]. + const RepositoryStorageFailure({super.entityId, super.message}); +} diff --git a/packages/scheduler_persistence/lib/persistence/right.dart b/packages/scheduler_persistence/lib/persistence/right.dart new file mode 100644 index 0000000..8557424 --- /dev/null +++ b/packages/scheduler_persistence/lib/persistence/right.dart @@ -0,0 +1,10 @@ +part of '../persistence.dart'; + +/// Success side of [Either]. +final class Right extends Either { + /// Creates a right/success value. + const Right(this.value); + + /// Wrapped success value. + final R value; +} diff --git a/packages/scheduler_persistence/lib/persistence/schedule_snapshot_repository.dart b/packages/scheduler_persistence/lib/persistence/schedule_snapshot_repository.dart new file mode 100644 index 0000000..429d7b1 --- /dev/null +++ b/packages/scheduler_persistence/lib/persistence/schedule_snapshot_repository.dart @@ -0,0 +1,29 @@ +part of '../persistence.dart'; + +/// Repository contract for diagnostic schedule snapshots. +abstract interface class ScheduleSnapshotRepository { + /// Return the snapshot with [snapshotId] in [ownerId], or null when absent. + Future> findById({ + required core.OwnerId ownerId, + required String snapshotId, + }); + + /// Return snapshots captured for [ownerId] that overlap [window]. + Future>> findInWindow({ + required core.OwnerId ownerId, + required core.SchedulingWindow window, + core.PageRequest page = const core.PageRequest(), + }); + + /// Insert [snapshot] for [ownerId] and return the assigned revision. + Future> insert({ + required core.OwnerId ownerId, + required core.SchedulingStateSnapshot snapshot, + }); + + /// Delete snapshots whose retention expiry is before [nowUtc]. + Future> deleteExpired({ + required core.OwnerId ownerId, + required DateTime nowUtc, + }); +} diff --git a/packages/scheduler_persistence/lib/persistence/settings_repository.dart b/packages/scheduler_persistence/lib/persistence/settings_repository.dart new file mode 100644 index 0000000..a8e480a --- /dev/null +++ b/packages/scheduler_persistence/lib/persistence/settings_repository.dart @@ -0,0 +1,22 @@ +part of '../persistence.dart'; + +/// Repository contract for owner settings. +abstract interface class SettingsRepository { + /// Return settings for [ownerId], or null when no settings have been saved. + Future> findByOwner({ + required core.OwnerId ownerId, + }); + + /// Insert [settings] for [ownerId] and return the assigned revision. + Future> insert({ + required core.OwnerId ownerId, + required core.OwnerSettings settings, + }); + + /// Save [settings] if [expectedRevision] still matches the stored record. + Future> save({ + required core.OwnerId ownerId, + required core.OwnerSettings settings, + required core.Revision expectedRevision, + }); +} diff --git a/packages/scheduler_persistence/lib/persistence/task_repository.dart b/packages/scheduler_persistence/lib/persistence/task_repository.dart new file mode 100644 index 0000000..dd41bbb --- /dev/null +++ b/packages/scheduler_persistence/lib/persistence/task_repository.dart @@ -0,0 +1,57 @@ +part of '../persistence.dart'; + +/// Repository contract for task records. +abstract interface class TaskRepository { + /// Return the task with [taskId] in [ownerId], or null when it is absent. + Future> findById({ + required core.OwnerId ownerId, + required String taskId, + }); + + /// Return owner-scoped tasks in deterministic adapter order. + Future>> findByOwner({ + required core.OwnerId ownerId, + core.PageRequest page = const core.PageRequest(), + }); + + /// Return owner-scoped tasks assigned to [projectId]. + Future>> findByProjectId({ + required core.OwnerId ownerId, + required String projectId, + core.PageRequest page = const core.PageRequest(), + }); + + /// Return owner-scoped direct child tasks for [parentTaskId]. + Future>> findByParentTaskId({ + required core.OwnerId ownerId, + required String parentTaskId, + core.PageRequest page = const core.PageRequest(), + }); + + /// Return owner-scoped tasks with [status]. + Future>> findByStatus({ + required core.OwnerId ownerId, + required core.TaskStatus status, + core.PageRequest page = const core.PageRequest(), + }); + + /// Insert [task] for [ownerId] and return the assigned revision. + Future> insert({ + required core.OwnerId ownerId, + required core.Task task, + }); + + /// Save [task] if [expectedRevision] still matches the stored record. + Future> save({ + required core.OwnerId ownerId, + required core.Task task, + required core.Revision expectedRevision, + }); + + /// Remove [taskId] if [expectedRevision] still matches the stored record. + Future> delete({ + required core.OwnerId ownerId, + required String taskId, + required core.Revision expectedRevision, + }); +} diff --git a/packages/scheduler_persistence_memory/lib/persistence_memory.dart b/packages/scheduler_persistence_memory/lib/persistence_memory.dart index 53ecd8e..7ed1daa 100644 --- a/packages/scheduler_persistence_memory/lib/persistence_memory.dart +++ b/packages/scheduler_persistence_memory/lib/persistence_memory.dart @@ -9,844 +9,15 @@ import 'dart:convert'; import 'package:scheduler_core/scheduler_core.dart' as core; import 'package:scheduler_persistence/persistence.dart'; +part 'persistence_memory/in_memory_task_repository.dart'; +part 'persistence_memory/in_memory_project_repository.dart'; +part 'persistence_memory/in_memory_locked_block_repository.dart'; +part 'persistence_memory/in_memory_settings_repository.dart'; +part 'persistence_memory/in_memory_schedule_snapshot_repository.dart'; +part 'persistence_memory/identified_record.dart'; +part 'persistence_memory/record.dart'; +part 'persistence_memory/snapshot_record.dart'; const _snapshotRetention = Duration(days: 30); final _epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); - -/// In-memory task repository with owner scoping and revision checks. -final class InMemoryTaskRepository implements TaskRepository { - final Map> _records = - >{}; - - @override - Future> findById({ - required core.OwnerId ownerId, - required String taskId, - }) async { - final record = _records[taskId]; - if (record == null || record.ownerId != ownerId) { - return const Right(null); - } - return Right(_cloneTask(record.value, record.ownerId, record.revision)); - } - - @override - Future>> findByOwner({ - required core.OwnerId ownerId, - core.PageRequest page = const core.PageRequest(), - }) async { - return Right(_pageRecords( - _records.values.where((record) => record.ownerId == ownerId), - page, - (record) => _cloneTask(record.value, record.ownerId, record.revision), - )); - } - - @override - Future>> findByParentTaskId({ - required core.OwnerId ownerId, - required String parentTaskId, - core.PageRequest page = const core.PageRequest(), - }) async { - return Right(_pageRecords( - _records.values.where( - (record) => - record.ownerId == ownerId && - record.value.parentTaskId == parentTaskId, - ), - page, - (record) => _cloneTask(record.value, record.ownerId, record.revision), - )); - } - - @override - Future>> findByProjectId({ - required core.OwnerId ownerId, - required String projectId, - core.PageRequest page = const core.PageRequest(), - }) async { - return Right(_pageRecords( - _records.values.where( - (record) => - record.ownerId == ownerId && record.value.projectId == projectId, - ), - page, - (record) => _cloneTask(record.value, record.ownerId, record.revision), - )); - } - - @override - Future>> findByStatus({ - required core.OwnerId ownerId, - required core.TaskStatus status, - core.PageRequest page = const core.PageRequest(), - }) async { - return Right(_pageRecords( - _records.values.where( - (record) => record.ownerId == ownerId && record.value.status == status, - ), - page, - (record) => _cloneTask(record.value, record.ownerId, record.revision), - )); - } - - @override - Future> insert({ - required core.OwnerId ownerId, - required core.Task task, - }) async { - if (_records.containsKey(task.id)) { - return Left(RepositoryDuplicateId(entityId: task.id)); - } - _records[task.id] = _Record( - id: task.id, - ownerId: ownerId, - value: _cloneTask(task, ownerId, core.Revision.initial), - revision: core.Revision.initial, - ); - return const Right(core.Revision.initial); - } - - @override - Future> save({ - required core.OwnerId ownerId, - required core.Task task, - required core.Revision expectedRevision, - }) async { - final checked = _checkWrite( - records: _records, - id: task.id, - ownerId: ownerId, - expectedRevision: expectedRevision, - ); - if (checked is Left>) { - return Left(checked.value); - } - final record = checked.right; - final nextRevision = record.revision.next(); - _records[task.id] = record.copyWith( - value: _cloneTask(task, ownerId, nextRevision), - revision: nextRevision, - updatedAt: task.updatedAt, - ); - return Right(nextRevision); - } - - @override - Future> delete({ - required core.OwnerId ownerId, - required String taskId, - required core.Revision expectedRevision, - }) async { - final checked = _checkWrite( - records: _records, - id: taskId, - ownerId: ownerId, - expectedRevision: expectedRevision, - ); - if (checked is Left>) { - return Left(checked.value); - } - final nextRevision = checked.right.revision.next(); - _records.remove(taskId); - return Right(nextRevision); - } -} - -/// In-memory project repository with owner scoping and revision checks. -final class InMemoryProjectRepository implements ProjectRepository { - final Map> _records = - >{}; - - @override - Future> findById({ - required core.OwnerId ownerId, - required String projectId, - }) async { - final record = _records[projectId]; - if (record == null || record.ownerId != ownerId) { - return const Right(null); - } - return Right(_cloneProject(record)); - } - - @override - Future>> findByOwner({ - required core.OwnerId ownerId, - bool includeArchived = false, - core.PageRequest page = const core.PageRequest(), - }) async { - return Right(_pageRecords( - _records.values.where( - (record) => - record.ownerId == ownerId && - (includeArchived || !record.value.isArchived), - ), - page, - _cloneProject, - )); - } - - @override - Future> insert({ - required core.OwnerId ownerId, - required core.ProjectProfile project, - }) async { - if (_records.containsKey(project.id)) { - return Left(RepositoryDuplicateId(entityId: project.id)); - } - _records[project.id] = _Record( - id: project.id, - ownerId: ownerId, - value: _cloneProjectValue( - project, - ownerId: ownerId, - revision: core.Revision.initial, - createdAt: _epoch, - updatedAt: _epoch, - ), - revision: core.Revision.initial, - createdAt: _epoch, - updatedAt: _epoch, - ); - return const Right(core.Revision.initial); - } - - @override - Future> save({ - required core.OwnerId ownerId, - required core.ProjectProfile project, - required core.Revision expectedRevision, - }) async { - final checked = _checkWrite( - records: _records, - id: project.id, - ownerId: ownerId, - expectedRevision: expectedRevision, - ); - if (checked is Left>) { - return Left(checked.value); - } - final record = checked.right; - final nextRevision = record.revision.next(); - _records[project.id] = record.copyWith( - value: _cloneProjectValue( - project, - ownerId: ownerId, - revision: nextRevision, - createdAt: record.createdAt, - updatedAt: _epoch, - ), - revision: nextRevision, - updatedAt: _epoch, - ); - return Right(nextRevision); - } - - @override - Future> archive({ - required core.OwnerId ownerId, - required String projectId, - required core.Revision expectedRevision, - required DateTime archivedAtUtc, - }) async { - final checked = _checkWrite( - records: _records, - id: projectId, - ownerId: ownerId, - expectedRevision: expectedRevision, - ); - if (checked is Left>) { - return Left(checked.value); - } - final record = checked.right; - final nextRevision = record.revision.next(); - final archived = record.value.copyWith(archivedAt: archivedAtUtc); - _records[projectId] = record.copyWith( - value: _cloneProjectValue( - archived, - ownerId: ownerId, - revision: nextRevision, - createdAt: record.createdAt, - updatedAt: _epoch, - ), - revision: nextRevision, - updatedAt: _epoch, - ); - return Right(nextRevision); - } -} - -/// In-memory locked-block repository with owner scoping and revision checks. -final class InMemoryLockedBlockRepository implements LockedBlockRepository { - final Map> _blocks = - >{}; - final Map> _overrides = - >{}; - - @override - Future> findBlockById({ - required core.OwnerId ownerId, - required String blockId, - }) async { - final record = _blocks[blockId]; - if (record == null || record.ownerId != ownerId) { - return const Right(null); - } - return Right( - _cloneLockedBlock(record.value, record.ownerId, record.revision)); - } - - @override - Future>> findBlocksByOwner({ - required core.OwnerId ownerId, - bool includeArchived = false, - core.PageRequest page = const core.PageRequest(), - }) async { - return Right(_pageRecords( - _blocks.values.where( - (record) => - record.ownerId == ownerId && - (includeArchived || !record.value.isArchived), - ), - page, - (record) => _cloneLockedBlock( - record.value, - record.ownerId, - record.revision, - ), - )); - } - - @override - Future> findOverrideById({ - required core.OwnerId ownerId, - required String overrideId, - }) async { - final record = _overrides[overrideId]; - if (record == null || record.ownerId != ownerId) { - return const Right(null); - } - return Right( - _cloneLockedBlockOverride(record.value, record.ownerId, record.revision), - ); - } - - @override - Future>> findOverridesForDate({ - required core.OwnerId ownerId, - required core.CivilDate date, - core.PageRequest page = const core.PageRequest(), - }) async { - return Right(_pageRecords( - _overrides.values.where( - (record) => record.ownerId == ownerId && record.value.date == date, - ), - page, - (record) => _cloneLockedBlockOverride( - record.value, - record.ownerId, - record.revision, - ), - )); - } - - @override - Future> insertBlock({ - required core.OwnerId ownerId, - required core.LockedBlock block, - }) async { - if (_blocks.containsKey(block.id)) { - return Left(RepositoryDuplicateId(entityId: block.id)); - } - _blocks[block.id] = _Record( - id: block.id, - ownerId: ownerId, - value: _cloneLockedBlock(block, ownerId, core.Revision.initial), - revision: core.Revision.initial, - ); - return const Right(core.Revision.initial); - } - - @override - Future> saveBlock({ - required core.OwnerId ownerId, - required core.LockedBlock block, - required core.Revision expectedRevision, - }) async { - final checked = _checkWrite( - records: _blocks, - id: block.id, - ownerId: ownerId, - expectedRevision: expectedRevision, - ); - if (checked is Left>) { - return Left(checked.value); - } - final record = checked.right; - final nextRevision = record.revision.next(); - _blocks[block.id] = record.copyWith( - value: _cloneLockedBlock(block, ownerId, nextRevision), - revision: nextRevision, - updatedAt: block.updatedAt, - ); - return Right(nextRevision); - } - - @override - Future> archiveBlock({ - required core.OwnerId ownerId, - required String blockId, - required core.Revision expectedRevision, - required DateTime archivedAtUtc, - }) async { - final checked = _checkWrite( - records: _blocks, - id: blockId, - ownerId: ownerId, - expectedRevision: expectedRevision, - ); - if (checked is Left>) { - return Left(checked.value); - } - final record = checked.right; - final nextRevision = record.revision.next(); - final archived = record.value.copyWith(archivedAt: archivedAtUtc); - _blocks[blockId] = record.copyWith( - value: _cloneLockedBlock(archived, ownerId, nextRevision), - revision: nextRevision, - updatedAt: archived.updatedAt, - ); - return Right(nextRevision); - } - - @override - Future> insertOverride({ - required core.OwnerId ownerId, - required core.LockedBlockOverride override, - }) async { - if (_overrides.containsKey(override.id)) { - return Left(RepositoryDuplicateId(entityId: override.id)); - } - _overrides[override.id] = _Record( - id: override.id, - ownerId: ownerId, - value: _cloneLockedBlockOverride( - override, - ownerId, - core.Revision.initial, - ), - revision: core.Revision.initial, - ); - return const Right(core.Revision.initial); - } - - @override - Future> saveOverride({ - required core.OwnerId ownerId, - required core.LockedBlockOverride override, - required core.Revision expectedRevision, - }) async { - final checked = _checkWrite( - records: _overrides, - id: override.id, - ownerId: ownerId, - expectedRevision: expectedRevision, - ); - if (checked is Left>) { - return Left(checked.value); - } - final record = checked.right; - final nextRevision = record.revision.next(); - _overrides[override.id] = record.copyWith( - value: _cloneLockedBlockOverride(override, ownerId, nextRevision), - revision: nextRevision, - updatedAt: override.updatedAt, - ); - return Right(nextRevision); - } -} - -/// In-memory owner settings repository with owner scoping and revision checks. -final class InMemorySettingsRepository implements SettingsRepository { - final Map> _records = - >{}; - - @override - Future> findByOwner({ - required core.OwnerId ownerId, - }) async { - final record = _records[ownerId.value]; - if (record == null || record.ownerId != ownerId) { - return const Right(null); - } - return Right(_cloneSettings(record)); - } - - @override - Future> insert({ - required core.OwnerId ownerId, - required core.OwnerSettings settings, - }) async { - if (_records.containsKey(ownerId.value)) { - return Left(RepositoryDuplicateId(entityId: ownerId.value)); - } - final ownedSettings = settings.ownerId == ownerId.value - ? settings - : settings.copyWith(ownerId: ownerId.value); - _records[ownerId.value] = _Record( - id: ownerId.value, - ownerId: ownerId, - value: _cloneSettingsValue( - ownedSettings, - revision: core.Revision.initial, - createdAt: _epoch, - updatedAt: _epoch, - ), - revision: core.Revision.initial, - createdAt: _epoch, - updatedAt: _epoch, - ); - return const Right(core.Revision.initial); - } - - @override - Future> save({ - required core.OwnerId ownerId, - required core.OwnerSettings settings, - required core.Revision expectedRevision, - }) async { - final checked = _checkWrite( - records: _records, - id: ownerId.value, - ownerId: ownerId, - expectedRevision: expectedRevision, - ); - if (checked is Left>) { - return Left(checked.value); - } - final record = checked.right; - final nextRevision = record.revision.next(); - final ownedSettings = settings.ownerId == ownerId.value - ? settings - : settings.copyWith(ownerId: ownerId.value); - _records[ownerId.value] = record.copyWith( - value: _cloneSettingsValue( - ownedSettings, - revision: nextRevision, - createdAt: record.createdAt, - updatedAt: _epoch, - ), - revision: nextRevision, - updatedAt: _epoch, - ); - return Right(nextRevision); - } -} - -/// In-memory diagnostic schedule snapshot repository. -final class InMemoryScheduleSnapshotRepository - implements ScheduleSnapshotRepository { - final Map _records = {}; - - @override - Future> findById({ - required core.OwnerId ownerId, - required String snapshotId, - }) async { - final record = _records[snapshotId]; - if (record == null || record.ownerId != ownerId) { - return const Right(null); - } - return Right(_cloneSnapshot(record.value, record.ownerId, record.revision)); - } - - @override - Future>> findInWindow({ - required core.OwnerId ownerId, - required core.SchedulingWindow window, - core.PageRequest page = const core.PageRequest(), - }) async { - return Right(_pageRecords( - _records.values.where( - (record) => - record.ownerId == ownerId && - _windowsOverlap(record.value.window, window), - ), - page, - (record) => _cloneSnapshot(record.value, record.ownerId, record.revision), - )); - } - - @override - Future> insert({ - required core.OwnerId ownerId, - required core.SchedulingStateSnapshot snapshot, - }) async { - if (_records.containsKey(snapshot.id)) { - return Left(RepositoryDuplicateId(entityId: snapshot.id)); - } - _records[snapshot.id] = _SnapshotRecord( - id: snapshot.id, - ownerId: ownerId, - value: _cloneSnapshot(snapshot, ownerId, core.Revision.initial), - revision: core.Revision.initial, - retentionExpiresAt: snapshot.capturedAt.add(_snapshotRetention), - ); - return const Right(core.Revision.initial); - } - - @override - Future> deleteExpired({ - required core.OwnerId ownerId, - required DateTime nowUtc, - }) async { - final expiredIds = _records.values - .where( - (record) => - record.ownerId == ownerId && - record.retentionExpiresAt.isBefore(nowUtc), - ) - .map((record) => record.id) - .toList(growable: false); - for (final id in expiredIds) { - _records.remove(id); - } - return Right(expiredIds.length); - } -} - -Either> _checkWrite({ - required Map> records, - required String id, - required core.OwnerId ownerId, - required core.Revision expectedRevision, -}) { - if (expectedRevision.value <= 0) { - return Left( - RepositoryInvalidRevision( - entityId: id, - expectedRevision: expectedRevision, - ), - ); - } - final record = records[id]; - if (record == null) { - return Left(RepositoryNotFound(entityId: id)); - } - if (record.ownerId != ownerId) { - return Left(RepositoryOwnerMismatch(entityId: id)); - } - if (record.revision != expectedRevision) { - return Left( - RepositoryStaleRevision( - entityId: id, - expectedRevision: expectedRevision, - actualRevision: record.revision, - ), - ); - } - return Right(record); -} - -core.Page _pageRecords( - Iterable records, - core.PageRequest page, - R Function(T record) clone, -) { - final ordered = records.toList(growable: false) - ..sort((left, right) => left.id.compareTo(right.id)); - final start = _cursorOffset(page.cursor); - final end = (start + page.limit).clamp(0, ordered.length); - final items = ordered.sublist(start, end).map(clone).toList(growable: false); - final nextCursor = end < ordered.length ? end.toString() : null; - return core.Page(items: items, nextCursor: nextCursor); -} - -int _cursorOffset(String? cursor) { - if (cursor == null) { - return 0; - } - final parsed = int.tryParse(cursor); - if (parsed == null || parsed < 0) { - return 0; - } - return parsed; -} - -bool _windowsOverlap(core.SchedulingWindow left, core.SchedulingWindow right) { - return left.start.isBefore(right.end) && right.start.isBefore(left.end); -} - -core.Task _cloneTask( - core.Task task, - core.OwnerId ownerId, - core.Revision revision, -) { - return core.TaskDocumentMapping.fromDocument(_roundTrip( - core.TaskDocumentMapping.toDocument( - task, - ownerId: ownerId.value, - revision: revision.value, - ), - )); -} - -core.ProjectProfile _cloneProject(_Record record) { - return _cloneProjectValue( - record.value, - ownerId: record.ownerId, - revision: record.revision, - createdAt: record.createdAt, - updatedAt: record.updatedAt, - ); -} - -core.ProjectProfile _cloneProjectValue( - core.ProjectProfile project, { - required core.OwnerId ownerId, - required core.Revision revision, - required DateTime createdAt, - required DateTime updatedAt, -}) { - return core.ProjectDocumentMapping.fromDocument(_roundTrip( - core.ProjectDocumentMapping.toDocument( - project, - ownerId: ownerId.value, - revision: revision.value, - createdAt: createdAt, - updatedAt: updatedAt, - ), - )); -} - -core.LockedBlock _cloneLockedBlock( - core.LockedBlock block, - core.OwnerId ownerId, - core.Revision revision, -) { - return core.LockedBlockDocumentMapping.fromDocument(_roundTrip( - core.LockedBlockDocumentMapping.toDocument( - block, - ownerId: ownerId.value, - revision: revision.value, - ), - )); -} - -core.LockedBlockOverride _cloneLockedBlockOverride( - core.LockedBlockOverride override, - core.OwnerId ownerId, - core.Revision revision, -) { - return core.LockedBlockOverrideDocumentMapping.fromDocument(_roundTrip( - core.LockedBlockOverrideDocumentMapping.toDocument( - override, - ownerId: ownerId.value, - revision: revision.value, - ), - )); -} - -core.OwnerSettings _cloneSettings(_Record record) { - return _cloneSettingsValue( - record.value, - revision: record.revision, - createdAt: record.createdAt, - updatedAt: record.updatedAt, - ); -} - -core.OwnerSettings _cloneSettingsValue( - core.OwnerSettings settings, { - required core.Revision revision, - required DateTime createdAt, - required DateTime updatedAt, -}) { - return core.OwnerSettingsDocumentMapping.fromDocument(_roundTrip( - core.OwnerSettingsDocumentMapping.toDocument( - settings, - revision: revision.value, - createdAt: createdAt, - updatedAt: updatedAt, - ), - )); -} - -core.SchedulingStateSnapshot _cloneSnapshot( - core.SchedulingStateSnapshot snapshot, - core.OwnerId ownerId, - core.Revision revision, -) { - return core.SchedulingSnapshotDocumentMapping.fromDocument(_roundTrip( - core.SchedulingSnapshotDocumentMapping.toDocument( - snapshot, - ownerId: ownerId.value, - revision: revision.value, - retentionExpiresAt: snapshot.capturedAt.add(_snapshotRetention), - ), - )); -} - -Map _roundTrip(Map document) { - return Map.from( - jsonDecode(jsonEncode(document)) as Map, - ); -} - -abstract interface class _IdentifiedRecord { - String get id; -} - -final class _Record implements _IdentifiedRecord { - _Record({ - required this.id, - required this.ownerId, - required this.value, - required this.revision, - DateTime? createdAt, - DateTime? updatedAt, - }) : createdAt = createdAt ?? _epoch, - updatedAt = updatedAt ?? _epoch; - - @override - final String id; - final core.OwnerId ownerId; - final T value; - final core.Revision revision; - final DateTime createdAt; - final DateTime updatedAt; - - _Record copyWith({ - T? value, - core.Revision? revision, - DateTime? updatedAt, - }) { - return _Record( - id: id, - ownerId: ownerId, - value: value ?? this.value, - revision: revision ?? this.revision, - createdAt: createdAt, - updatedAt: updatedAt ?? this.updatedAt, - ); - } -} - -final class _SnapshotRecord implements _IdentifiedRecord { - _SnapshotRecord({ - required this.id, - required this.ownerId, - required this.value, - required this.revision, - required this.retentionExpiresAt, - }); - - @override - final String id; - final core.OwnerId ownerId; - final core.SchedulingStateSnapshot value; - final core.Revision revision; - final DateTime retentionExpiresAt; -} diff --git a/packages/scheduler_persistence_memory/lib/persistence_memory/identified_record.dart b/packages/scheduler_persistence_memory/lib/persistence_memory/identified_record.dart new file mode 100644 index 0000000..8894632 --- /dev/null +++ b/packages/scheduler_persistence_memory/lib/persistence_memory/identified_record.dart @@ -0,0 +1,5 @@ +part of '../persistence_memory.dart'; + +abstract interface class _IdentifiedRecord { + String get id; +} diff --git a/packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_locked_block_repository.dart b/packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_locked_block_repository.dart new file mode 100644 index 0000000..799c647 --- /dev/null +++ b/packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_locked_block_repository.dart @@ -0,0 +1,191 @@ +part of '../persistence_memory.dart'; + +/// In-memory locked-block repository with owner scoping and revision checks. +final class InMemoryLockedBlockRepository implements LockedBlockRepository { + final Map> _blocks = + >{}; + final Map> _overrides = + >{}; + + @override + Future> findBlockById({ + required core.OwnerId ownerId, + required String blockId, + }) async { + final record = _blocks[blockId]; + if (record == null || record.ownerId != ownerId) { + return const Right(null); + } + return Right( + _cloneLockedBlock(record.value, record.ownerId, record.revision)); + } + + @override + Future>> findBlocksByOwner({ + required core.OwnerId ownerId, + bool includeArchived = false, + core.PageRequest page = const core.PageRequest(), + }) async { + return Right(_pageRecords( + _blocks.values.where( + (record) => + record.ownerId == ownerId && + (includeArchived || !record.value.isArchived), + ), + page, + (record) => _cloneLockedBlock( + record.value, + record.ownerId, + record.revision, + ), + )); + } + + @override + Future> findOverrideById({ + required core.OwnerId ownerId, + required String overrideId, + }) async { + final record = _overrides[overrideId]; + if (record == null || record.ownerId != ownerId) { + return const Right(null); + } + return Right( + _cloneLockedBlockOverride(record.value, record.ownerId, record.revision), + ); + } + + @override + Future>> findOverridesForDate({ + required core.OwnerId ownerId, + required core.CivilDate date, + core.PageRequest page = const core.PageRequest(), + }) async { + return Right(_pageRecords( + _overrides.values.where( + (record) => record.ownerId == ownerId && record.value.date == date, + ), + page, + (record) => _cloneLockedBlockOverride( + record.value, + record.ownerId, + record.revision, + ), + )); + } + + @override + Future> insertBlock({ + required core.OwnerId ownerId, + required core.LockedBlock block, + }) async { + if (_blocks.containsKey(block.id)) { + return Left(RepositoryDuplicateId(entityId: block.id)); + } + _blocks[block.id] = _Record( + id: block.id, + ownerId: ownerId, + value: _cloneLockedBlock(block, ownerId, core.Revision.initial), + revision: core.Revision.initial, + ); + return const Right(core.Revision.initial); + } + + @override + Future> saveBlock({ + required core.OwnerId ownerId, + required core.LockedBlock block, + required core.Revision expectedRevision, + }) async { + final checked = _checkWrite( + records: _blocks, + id: block.id, + ownerId: ownerId, + expectedRevision: expectedRevision, + ); + if (checked is Left>) { + return Left(checked.value); + } + final record = checked.right; + final nextRevision = record.revision.next(); + _blocks[block.id] = record.copyWith( + value: _cloneLockedBlock(block, ownerId, nextRevision), + revision: nextRevision, + updatedAt: block.updatedAt, + ); + return Right(nextRevision); + } + + @override + Future> archiveBlock({ + required core.OwnerId ownerId, + required String blockId, + required core.Revision expectedRevision, + required DateTime archivedAtUtc, + }) async { + final checked = _checkWrite( + records: _blocks, + id: blockId, + ownerId: ownerId, + expectedRevision: expectedRevision, + ); + if (checked is Left>) { + return Left(checked.value); + } + final record = checked.right; + final nextRevision = record.revision.next(); + final archived = record.value.copyWith(archivedAt: archivedAtUtc); + _blocks[blockId] = record.copyWith( + value: _cloneLockedBlock(archived, ownerId, nextRevision), + revision: nextRevision, + updatedAt: archived.updatedAt, + ); + return Right(nextRevision); + } + + @override + Future> insertOverride({ + required core.OwnerId ownerId, + required core.LockedBlockOverride override, + }) async { + if (_overrides.containsKey(override.id)) { + return Left(RepositoryDuplicateId(entityId: override.id)); + } + _overrides[override.id] = _Record( + id: override.id, + ownerId: ownerId, + value: _cloneLockedBlockOverride( + override, + ownerId, + core.Revision.initial, + ), + revision: core.Revision.initial, + ); + return const Right(core.Revision.initial); + } + + @override + Future> saveOverride({ + required core.OwnerId ownerId, + required core.LockedBlockOverride override, + required core.Revision expectedRevision, + }) async { + final checked = _checkWrite( + records: _overrides, + id: override.id, + ownerId: ownerId, + expectedRevision: expectedRevision, + ); + if (checked is Left>) { + return Left(checked.value); + } + final record = checked.right; + final nextRevision = record.revision.next(); + _overrides[override.id] = record.copyWith( + value: _cloneLockedBlockOverride(override, ownerId, nextRevision), + revision: nextRevision, + updatedAt: override.updatedAt, + ); + return Right(nextRevision); + } +} diff --git a/packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_project_repository.dart b/packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_project_repository.dart new file mode 100644 index 0000000..c31dfea --- /dev/null +++ b/packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_project_repository.dart @@ -0,0 +1,125 @@ +part of '../persistence_memory.dart'; + +/// In-memory project repository with owner scoping and revision checks. +final class InMemoryProjectRepository implements ProjectRepository { + final Map> _records = + >{}; + + @override + Future> findById({ + required core.OwnerId ownerId, + required String projectId, + }) async { + final record = _records[projectId]; + if (record == null || record.ownerId != ownerId) { + return const Right(null); + } + return Right(_cloneProject(record)); + } + + @override + Future>> findByOwner({ + required core.OwnerId ownerId, + bool includeArchived = false, + core.PageRequest page = const core.PageRequest(), + }) async { + return Right(_pageRecords( + _records.values.where( + (record) => + record.ownerId == ownerId && + (includeArchived || !record.value.isArchived), + ), + page, + _cloneProject, + )); + } + + @override + Future> insert({ + required core.OwnerId ownerId, + required core.ProjectProfile project, + }) async { + if (_records.containsKey(project.id)) { + return Left(RepositoryDuplicateId(entityId: project.id)); + } + _records[project.id] = _Record( + id: project.id, + ownerId: ownerId, + value: _cloneProjectValue( + project, + ownerId: ownerId, + revision: core.Revision.initial, + createdAt: _epoch, + updatedAt: _epoch, + ), + revision: core.Revision.initial, + createdAt: _epoch, + updatedAt: _epoch, + ); + return const Right(core.Revision.initial); + } + + @override + Future> save({ + required core.OwnerId ownerId, + required core.ProjectProfile project, + required core.Revision expectedRevision, + }) async { + final checked = _checkWrite( + records: _records, + id: project.id, + ownerId: ownerId, + expectedRevision: expectedRevision, + ); + if (checked is Left>) { + return Left(checked.value); + } + final record = checked.right; + final nextRevision = record.revision.next(); + _records[project.id] = record.copyWith( + value: _cloneProjectValue( + project, + ownerId: ownerId, + revision: nextRevision, + createdAt: record.createdAt, + updatedAt: _epoch, + ), + revision: nextRevision, + updatedAt: _epoch, + ); + return Right(nextRevision); + } + + @override + Future> archive({ + required core.OwnerId ownerId, + required String projectId, + required core.Revision expectedRevision, + required DateTime archivedAtUtc, + }) async { + final checked = _checkWrite( + records: _records, + id: projectId, + ownerId: ownerId, + expectedRevision: expectedRevision, + ); + if (checked is Left>) { + return Left(checked.value); + } + final record = checked.right; + final nextRevision = record.revision.next(); + final archived = record.value.copyWith(archivedAt: archivedAtUtc); + _records[projectId] = record.copyWith( + value: _cloneProjectValue( + archived, + ownerId: ownerId, + revision: nextRevision, + createdAt: record.createdAt, + updatedAt: _epoch, + ), + revision: nextRevision, + updatedAt: _epoch, + ); + return Right(nextRevision); + } +} diff --git a/packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_schedule_snapshot_repository.dart b/packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_schedule_snapshot_repository.dart new file mode 100644 index 0000000..f7ce9b7 --- /dev/null +++ b/packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_schedule_snapshot_repository.dart @@ -0,0 +1,251 @@ +part of '../persistence_memory.dart'; + +/// In-memory diagnostic schedule snapshot repository. +final class InMemoryScheduleSnapshotRepository + implements ScheduleSnapshotRepository { + final Map _records = {}; + + @override + Future> findById({ + required core.OwnerId ownerId, + required String snapshotId, + }) async { + final record = _records[snapshotId]; + if (record == null || record.ownerId != ownerId) { + return const Right(null); + } + return Right(_cloneSnapshot(record.value, record.ownerId, record.revision)); + } + + @override + Future>> findInWindow({ + required core.OwnerId ownerId, + required core.SchedulingWindow window, + core.PageRequest page = const core.PageRequest(), + }) async { + return Right(_pageRecords( + _records.values.where( + (record) => + record.ownerId == ownerId && + _windowsOverlap(record.value.window, window), + ), + page, + (record) => _cloneSnapshot(record.value, record.ownerId, record.revision), + )); + } + + @override + Future> insert({ + required core.OwnerId ownerId, + required core.SchedulingStateSnapshot snapshot, + }) async { + if (_records.containsKey(snapshot.id)) { + return Left(RepositoryDuplicateId(entityId: snapshot.id)); + } + _records[snapshot.id] = _SnapshotRecord( + id: snapshot.id, + ownerId: ownerId, + value: _cloneSnapshot(snapshot, ownerId, core.Revision.initial), + revision: core.Revision.initial, + retentionExpiresAt: snapshot.capturedAt.add(_snapshotRetention), + ); + return const Right(core.Revision.initial); + } + + @override + Future> deleteExpired({ + required core.OwnerId ownerId, + required DateTime nowUtc, + }) async { + final expiredIds = _records.values + .where( + (record) => + record.ownerId == ownerId && + record.retentionExpiresAt.isBefore(nowUtc), + ) + .map((record) => record.id) + .toList(growable: false); + for (final id in expiredIds) { + _records.remove(id); + } + return Right(expiredIds.length); + } +} + +Either> _checkWrite({ + required Map> records, + required String id, + required core.OwnerId ownerId, + required core.Revision expectedRevision, +}) { + if (expectedRevision.value <= 0) { + return Left( + RepositoryInvalidRevision( + entityId: id, + expectedRevision: expectedRevision, + ), + ); + } + final record = records[id]; + if (record == null) { + return Left(RepositoryNotFound(entityId: id)); + } + if (record.ownerId != ownerId) { + return Left(RepositoryOwnerMismatch(entityId: id)); + } + if (record.revision != expectedRevision) { + return Left( + RepositoryStaleRevision( + entityId: id, + expectedRevision: expectedRevision, + actualRevision: record.revision, + ), + ); + } + return Right(record); +} + +core.Page _pageRecords( + Iterable records, + core.PageRequest page, + R Function(T record) clone, +) { + final ordered = records.toList(growable: false) + ..sort((left, right) => left.id.compareTo(right.id)); + final start = _cursorOffset(page.cursor); + final end = (start + page.limit).clamp(0, ordered.length); + final items = ordered.sublist(start, end).map(clone).toList(growable: false); + final nextCursor = end < ordered.length ? end.toString() : null; + return core.Page(items: items, nextCursor: nextCursor); +} + +int _cursorOffset(String? cursor) { + if (cursor == null) { + return 0; + } + final parsed = int.tryParse(cursor); + if (parsed == null || parsed < 0) { + return 0; + } + return parsed; +} + +bool _windowsOverlap(core.SchedulingWindow left, core.SchedulingWindow right) { + return left.start.isBefore(right.end) && right.start.isBefore(left.end); +} + +core.Task _cloneTask( + core.Task task, + core.OwnerId ownerId, + core.Revision revision, +) { + return core.TaskDocumentMapping.fromDocument(_roundTrip( + core.TaskDocumentMapping.toDocument( + task, + ownerId: ownerId.value, + revision: revision.value, + ), + )); +} + +core.ProjectProfile _cloneProject(_Record record) { + return _cloneProjectValue( + record.value, + ownerId: record.ownerId, + revision: record.revision, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + ); +} + +core.ProjectProfile _cloneProjectValue( + core.ProjectProfile project, { + required core.OwnerId ownerId, + required core.Revision revision, + required DateTime createdAt, + required DateTime updatedAt, +}) { + return core.ProjectDocumentMapping.fromDocument(_roundTrip( + core.ProjectDocumentMapping.toDocument( + project, + ownerId: ownerId.value, + revision: revision.value, + createdAt: createdAt, + updatedAt: updatedAt, + ), + )); +} + +core.LockedBlock _cloneLockedBlock( + core.LockedBlock block, + core.OwnerId ownerId, + core.Revision revision, +) { + return core.LockedBlockDocumentMapping.fromDocument(_roundTrip( + core.LockedBlockDocumentMapping.toDocument( + block, + ownerId: ownerId.value, + revision: revision.value, + ), + )); +} + +core.LockedBlockOverride _cloneLockedBlockOverride( + core.LockedBlockOverride override, + core.OwnerId ownerId, + core.Revision revision, +) { + return core.LockedBlockOverrideDocumentMapping.fromDocument(_roundTrip( + core.LockedBlockOverrideDocumentMapping.toDocument( + override, + ownerId: ownerId.value, + revision: revision.value, + ), + )); +} + +core.OwnerSettings _cloneSettings(_Record record) { + return _cloneSettingsValue( + record.value, + revision: record.revision, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + ); +} + +core.OwnerSettings _cloneSettingsValue( + core.OwnerSettings settings, { + required core.Revision revision, + required DateTime createdAt, + required DateTime updatedAt, +}) { + return core.OwnerSettingsDocumentMapping.fromDocument(_roundTrip( + core.OwnerSettingsDocumentMapping.toDocument( + settings, + revision: revision.value, + createdAt: createdAt, + updatedAt: updatedAt, + ), + )); +} + +core.SchedulingStateSnapshot _cloneSnapshot( + core.SchedulingStateSnapshot snapshot, + core.OwnerId ownerId, + core.Revision revision, +) { + return core.SchedulingSnapshotDocumentMapping.fromDocument(_roundTrip( + core.SchedulingSnapshotDocumentMapping.toDocument( + snapshot, + ownerId: ownerId.value, + revision: revision.value, + retentionExpiresAt: snapshot.capturedAt.add(_snapshotRetention), + ), + )); +} + +Map _roundTrip(Map document) { + return Map.from( + jsonDecode(jsonEncode(document)) as Map, + ); +} diff --git a/packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_settings_repository.dart b/packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_settings_repository.dart new file mode 100644 index 0000000..9369259 --- /dev/null +++ b/packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_settings_repository.dart @@ -0,0 +1,78 @@ +part of '../persistence_memory.dart'; + +/// In-memory owner settings repository with owner scoping and revision checks. +final class InMemorySettingsRepository implements SettingsRepository { + final Map> _records = + >{}; + + @override + Future> findByOwner({ + required core.OwnerId ownerId, + }) async { + final record = _records[ownerId.value]; + if (record == null || record.ownerId != ownerId) { + return const Right(null); + } + return Right(_cloneSettings(record)); + } + + @override + Future> insert({ + required core.OwnerId ownerId, + required core.OwnerSettings settings, + }) async { + if (_records.containsKey(ownerId.value)) { + return Left(RepositoryDuplicateId(entityId: ownerId.value)); + } + final ownedSettings = settings.ownerId == ownerId.value + ? settings + : settings.copyWith(ownerId: ownerId.value); + _records[ownerId.value] = _Record( + id: ownerId.value, + ownerId: ownerId, + value: _cloneSettingsValue( + ownedSettings, + revision: core.Revision.initial, + createdAt: _epoch, + updatedAt: _epoch, + ), + revision: core.Revision.initial, + createdAt: _epoch, + updatedAt: _epoch, + ); + return const Right(core.Revision.initial); + } + + @override + Future> save({ + required core.OwnerId ownerId, + required core.OwnerSettings settings, + required core.Revision expectedRevision, + }) async { + final checked = _checkWrite( + records: _records, + id: ownerId.value, + ownerId: ownerId, + expectedRevision: expectedRevision, + ); + if (checked is Left>) { + return Left(checked.value); + } + final record = checked.right; + final nextRevision = record.revision.next(); + final ownedSettings = settings.ownerId == ownerId.value + ? settings + : settings.copyWith(ownerId: ownerId.value); + _records[ownerId.value] = record.copyWith( + value: _cloneSettingsValue( + ownedSettings, + revision: nextRevision, + createdAt: record.createdAt, + updatedAt: _epoch, + ), + revision: nextRevision, + updatedAt: _epoch, + ); + return Right(nextRevision); + } +} diff --git a/packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_task_repository.dart b/packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_task_repository.dart new file mode 100644 index 0000000..29cec50 --- /dev/null +++ b/packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_task_repository.dart @@ -0,0 +1,141 @@ +part of '../persistence_memory.dart'; + +/// In-memory task repository with owner scoping and revision checks. +final class InMemoryTaskRepository implements TaskRepository { + final Map> _records = + >{}; + + @override + Future> findById({ + required core.OwnerId ownerId, + required String taskId, + }) async { + final record = _records[taskId]; + if (record == null || record.ownerId != ownerId) { + return const Right(null); + } + return Right(_cloneTask(record.value, record.ownerId, record.revision)); + } + + @override + Future>> findByOwner({ + required core.OwnerId ownerId, + core.PageRequest page = const core.PageRequest(), + }) async { + return Right(_pageRecords( + _records.values.where((record) => record.ownerId == ownerId), + page, + (record) => _cloneTask(record.value, record.ownerId, record.revision), + )); + } + + @override + Future>> findByParentTaskId({ + required core.OwnerId ownerId, + required String parentTaskId, + core.PageRequest page = const core.PageRequest(), + }) async { + return Right(_pageRecords( + _records.values.where( + (record) => + record.ownerId == ownerId && + record.value.parentTaskId == parentTaskId, + ), + page, + (record) => _cloneTask(record.value, record.ownerId, record.revision), + )); + } + + @override + Future>> findByProjectId({ + required core.OwnerId ownerId, + required String projectId, + core.PageRequest page = const core.PageRequest(), + }) async { + return Right(_pageRecords( + _records.values.where( + (record) => + record.ownerId == ownerId && record.value.projectId == projectId, + ), + page, + (record) => _cloneTask(record.value, record.ownerId, record.revision), + )); + } + + @override + Future>> findByStatus({ + required core.OwnerId ownerId, + required core.TaskStatus status, + core.PageRequest page = const core.PageRequest(), + }) async { + return Right(_pageRecords( + _records.values.where( + (record) => record.ownerId == ownerId && record.value.status == status, + ), + page, + (record) => _cloneTask(record.value, record.ownerId, record.revision), + )); + } + + @override + Future> insert({ + required core.OwnerId ownerId, + required core.Task task, + }) async { + if (_records.containsKey(task.id)) { + return Left(RepositoryDuplicateId(entityId: task.id)); + } + _records[task.id] = _Record( + id: task.id, + ownerId: ownerId, + value: _cloneTask(task, ownerId, core.Revision.initial), + revision: core.Revision.initial, + ); + return const Right(core.Revision.initial); + } + + @override + Future> save({ + required core.OwnerId ownerId, + required core.Task task, + required core.Revision expectedRevision, + }) async { + final checked = _checkWrite( + records: _records, + id: task.id, + ownerId: ownerId, + expectedRevision: expectedRevision, + ); + if (checked is Left>) { + return Left(checked.value); + } + final record = checked.right; + final nextRevision = record.revision.next(); + _records[task.id] = record.copyWith( + value: _cloneTask(task, ownerId, nextRevision), + revision: nextRevision, + updatedAt: task.updatedAt, + ); + return Right(nextRevision); + } + + @override + Future> delete({ + required core.OwnerId ownerId, + required String taskId, + required core.Revision expectedRevision, + }) async { + final checked = _checkWrite( + records: _records, + id: taskId, + ownerId: ownerId, + expectedRevision: expectedRevision, + ); + if (checked is Left>) { + return Left(checked.value); + } + final nextRevision = checked.right.revision.next(); + _records.remove(taskId); + return Right(nextRevision); + } +} diff --git a/packages/scheduler_persistence_memory/lib/persistence_memory/record.dart b/packages/scheduler_persistence_memory/lib/persistence_memory/record.dart new file mode 100644 index 0000000..25b0b53 --- /dev/null +++ b/packages/scheduler_persistence_memory/lib/persistence_memory/record.dart @@ -0,0 +1,36 @@ +part of '../persistence_memory.dart'; + +final class _Record implements _IdentifiedRecord { + _Record({ + required this.id, + required this.ownerId, + required this.value, + required this.revision, + DateTime? createdAt, + DateTime? updatedAt, + }) : createdAt = createdAt ?? _epoch, + updatedAt = updatedAt ?? _epoch; + + @override + final String id; + final core.OwnerId ownerId; + final T value; + final core.Revision revision; + final DateTime createdAt; + final DateTime updatedAt; + + _Record copyWith({ + T? value, + core.Revision? revision, + DateTime? updatedAt, + }) { + return _Record( + id: id, + ownerId: ownerId, + value: value ?? this.value, + revision: revision ?? this.revision, + createdAt: createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } +} diff --git a/packages/scheduler_persistence_memory/lib/persistence_memory/snapshot_record.dart b/packages/scheduler_persistence_memory/lib/persistence_memory/snapshot_record.dart new file mode 100644 index 0000000..6e2d5a7 --- /dev/null +++ b/packages/scheduler_persistence_memory/lib/persistence_memory/snapshot_record.dart @@ -0,0 +1,18 @@ +part of '../persistence_memory.dart'; + +final class _SnapshotRecord implements _IdentifiedRecord { + _SnapshotRecord({ + required this.id, + required this.ownerId, + required this.value, + required this.revision, + required this.retentionExpiresAt, + }); + + @override + final String id; + final core.OwnerId ownerId; + final core.SchedulingStateSnapshot value; + final core.Revision revision; + final DateTime retentionExpiresAt; +} diff --git a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db.dart b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db.dart index 1e063c6..5dfb3a8 100644 --- a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db.dart +++ b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db.dart @@ -7,225 +7,15 @@ library; import 'package:drift/drift.dart'; part 'scheduler_db.g.dart'; - -/// Authoritative task rows. -@DataClassName('TaskRow') -class Tasks extends Table { - TextColumn get id => text()(); - TextColumn get ownerId => text().named('owner_id')(); - TextColumn get title => text()(); - TextColumn get projectId => text().named('project_id')(); - TextColumn get parentId => text().named('parent_id').nullable()(); - TextColumn get type => text()(); - TextColumn get status => text()(); - TextColumn get priority => text().nullable()(); - TextColumn get reward => text()(); - TextColumn get difficulty => text()(); - IntColumn get durationMinutes => - integer().named('duration_minutes').nullable()(); - DateTimeColumn get scheduledStartUtc => - dateTime().named('scheduled_start_utc').nullable()(); - DateTimeColumn get scheduledEndUtc => - dateTime().named('scheduled_end_utc').nullable()(); - DateTimeColumn get actualStartUtc => - dateTime().named('actual_start_utc').nullable()(); - DateTimeColumn get actualEndUtc => - dateTime().named('actual_end_utc').nullable()(); - DateTimeColumn get completedAtUtc => - dateTime().named('completed_at_utc').nullable()(); - TextColumn get backlogTagsJson => text().named('backlog_tags_json')(); - TextColumn get reminderOverride => - text().named('reminder_override').nullable()(); - TextColumn get statsJson => text().named('stats_json')(); - DateTimeColumn get backlogEnteredAtUtc => - dateTime().named('backlog_entered_at_utc').nullable()(); - TextColumn get backlogEnteredProvenance => - text().named('backlog_entered_provenance').nullable()(); - IntColumn get revision => integer()(); - DateTimeColumn get createdAtUtc => dateTime().named('created_at_utc')(); - DateTimeColumn get updatedAtUtc => dateTime().named('updated_at_utc')(); - - @override - Set> get primaryKey => {id}; -} - -/// Project configuration rows. -@DataClassName('ProjectRow') -class Projects extends Table { - TextColumn get id => text()(); - TextColumn get ownerId => text().named('owner_id')(); - TextColumn get name => text()(); - TextColumn get colorKey => text().named('color_key')(); - TextColumn get defaultPriority => text().named('default_priority')(); - TextColumn get defaultReward => text().named('default_reward')(); - TextColumn get defaultDifficulty => text().named('default_difficulty')(); - TextColumn get defaultReminderProfile => - text().named('default_reminder_profile')(); - IntColumn get defaultDurationMinutes => - integer().named('default_duration_minutes').nullable()(); - DateTimeColumn get archivedAtUtc => - dateTime().named('archived_at_utc').nullable()(); - IntColumn get revision => integer()(); - DateTimeColumn get createdAtUtc => dateTime().named('created_at_utc')(); - DateTimeColumn get updatedAtUtc => dateTime().named('updated_at_utc')(); - - @override - Set> get primaryKey => {id}; -} - -/// Recurring and one-off locked-time rows. -@DataClassName('LockedBlockRow') -class LockedBlocks extends Table { - TextColumn get id => text()(); - TextColumn get ownerId => text().named('owner_id')(); - TextColumn get name => text()(); - TextColumn get date => text().nullable()(); - TextColumn get startTime => text().named('start_time')(); - TextColumn get endTime => text().named('end_time')(); - TextColumn get recurrenceJson => text().named('recurrence_json').nullable()(); - BoolColumn get hiddenByDefault => boolean().named('hidden_by_default')(); - TextColumn get projectId => text().named('project_id').nullable()(); - DateTimeColumn get archivedAtUtc => - dateTime().named('archived_at_utc').nullable()(); - IntColumn get revision => integer()(); - DateTimeColumn get createdAtUtc => dateTime().named('created_at_utc')(); - DateTimeColumn get updatedAtUtc => dateTime().named('updated_at_utc')(); - - @override - Set> get primaryKey => {id}; -} - -/// Date-scoped overrides for recurring locked blocks. -@DataClassName('LockedOverrideRow') -class LockedOverrides extends Table { - TextColumn get id => text()(); - TextColumn get ownerId => text().named('owner_id')(); - TextColumn get lockedBlockId => text().named('locked_block_id').nullable()(); - TextColumn get date => text()(); - TextColumn get type => text()(); - TextColumn get name => text().nullable()(); - TextColumn get startTime => text().named('start_time').nullable()(); - TextColumn get endTime => text().named('end_time').nullable()(); - BoolColumn get hiddenByDefault => boolean().named('hidden_by_default')(); - TextColumn get projectId => text().named('project_id').nullable()(); - IntColumn get revision => integer()(); - DateTimeColumn get createdAtUtc => dateTime().named('created_at_utc')(); - DateTimeColumn get updatedAtUtc => dateTime().named('updated_at_utc')(); - - @override - Set> get primaryKey => {id}; -} - -/// Owner-level settings rows. -@DataClassName('SettingsRow') -class SettingsTable extends Table { - @override - String get tableName => 'settings'; - - TextColumn get ownerId => text().named('owner_id')(); - TextColumn get timeZoneId => text().named('timezone_id')(); - IntColumn get dayStartMinutes => integer().named('day_start_minutes')(); - IntColumn get dayEndMinutes => integer().named('day_end_minutes')(); - BoolColumn get compactMode => boolean().named('compact_mode')(); - TextColumn get backlogStalenessJson => - text().named('backlog_staleness_json')(); - IntColumn get revision => integer()(); - DateTimeColumn get createdAtUtc => dateTime().named('created_at_utc')(); - DateTimeColumn get updatedAtUtc => dateTime().named('updated_at_utc')(); - - @override - Set> get primaryKey => {ownerId}; -} - -/// Bounded diagnostic schedule snapshots. -@DataClassName('SnapshotRow') -class Snapshots extends Table { - TextColumn get id => text()(); - TextColumn get ownerId => text().named('owner_id')(); - DateTimeColumn get capturedAtUtc => dateTime().named('captured_at_utc')(); - TextColumn get operationName => text().named('operation_name').nullable()(); - TextColumn get sourceDate => text().named('source_date').nullable()(); - TextColumn get targetDate => text().named('target_date').nullable()(); - TextColumn get windowJson => text().named('window_json')(); - TextColumn get tasksJson => text().named('tasks_json')(); - TextColumn get lockedIntervalsJson => text().named('locked_intervals_json')(); - TextColumn get requiredVisibleIntervalsJson => - text().named('required_visible_intervals_json')(); - TextColumn get noticesJson => text().named('notices_json')(); - TextColumn get changesJson => text().named('changes_json')(); - TextColumn get overlapsJson => text().named('overlaps_json')(); - DateTimeColumn get retentionExpiresUtc => - dateTime().named('retention_expires_utc')(); - BoolColumn get truncated => boolean()(); - IntColumn get revision => integer()(); - DateTimeColumn get createdAtUtc => dateTime().named('created_at_utc')(); - DateTimeColumn get updatedAtUtc => dateTime().named('updated_at_utc')(); - - @override - Set> get primaryKey => {id}; -} - -/// Generated task-row accessor. -@DriftAccessor(tables: [Tasks]) -class TaskDao extends DatabaseAccessor with _$TaskDaoMixin { - TaskDao(super.db); -} - -/// Generated project-row accessor. -@DriftAccessor(tables: [Projects]) -class ProjectDao extends DatabaseAccessor with _$ProjectDaoMixin { - ProjectDao(super.db); -} - -/// Generated locked-time accessors for blocks and date overrides. -@DriftAccessor(tables: [LockedBlocks, LockedOverrides]) -class LockedBlockDao extends DatabaseAccessor - with _$LockedBlockDaoMixin { - LockedBlockDao(super.db); -} - -/// Generated owner-settings accessor. -@DriftAccessor(tables: [SettingsTable]) -class SettingsDao extends DatabaseAccessor - with _$SettingsDaoMixin { - SettingsDao(super.db); -} - -/// Generated diagnostic snapshot accessor. -@DriftAccessor(tables: [Snapshots]) -class SnapshotDao extends DatabaseAccessor - with _$SnapshotDaoMixin { - SnapshotDao(super.db); -} - -/// Drift database for scheduler persistence. -@DriftDatabase( - tables: [ - Tasks, - Projects, - LockedBlocks, - LockedOverrides, - SettingsTable, - Snapshots, - ], - daos: [ - TaskDao, - ProjectDao, - LockedBlockDao, - SettingsDao, - SnapshotDao, - ], -) -class SchedulerDb extends _$SchedulerDb { - SchedulerDb(super.executor); - - @override - int get schemaVersion => 1; - - @override - MigrationStrategy get migration => MigrationStrategy( - onCreate: (Migrator migrator) async { - await migrator.createAll(); - }, - ); -} +part 'scheduler_db/tasks.dart'; +part 'scheduler_db/projects.dart'; +part 'scheduler_db/locked_blocks.dart'; +part 'scheduler_db/locked_overrides.dart'; +part 'scheduler_db/settings_table.dart'; +part 'scheduler_db/snapshots.dart'; +part 'scheduler_db/task_dao.dart'; +part 'scheduler_db/project_dao.dart'; +part 'scheduler_db/locked_block_dao.dart'; +part 'scheduler_db/settings_dao.dart'; +part 'scheduler_db/snapshot_dao.dart'; +part 'scheduler_db/scheduler_db.dart'; diff --git a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/locked_block_dao.dart b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/locked_block_dao.dart new file mode 100644 index 0000000..0fea943 --- /dev/null +++ b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/locked_block_dao.dart @@ -0,0 +1,8 @@ +part of '../scheduler_db.dart'; + +/// Generated locked-time accessors for blocks and date overrides. +@DriftAccessor(tables: [LockedBlocks, LockedOverrides]) +class LockedBlockDao extends DatabaseAccessor + with _$LockedBlockDaoMixin { + LockedBlockDao(super.db); +} diff --git a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/locked_blocks.dart b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/locked_blocks.dart new file mode 100644 index 0000000..1ddea2b --- /dev/null +++ b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/locked_blocks.dart @@ -0,0 +1,23 @@ +part of '../scheduler_db.dart'; + +/// Recurring and one-off locked-time rows. +@DataClassName('LockedBlockRow') +class LockedBlocks extends Table { + TextColumn get id => text()(); + TextColumn get ownerId => text().named('owner_id')(); + TextColumn get name => text()(); + TextColumn get date => text().nullable()(); + TextColumn get startTime => text().named('start_time')(); + TextColumn get endTime => text().named('end_time')(); + TextColumn get recurrenceJson => text().named('recurrence_json').nullable()(); + BoolColumn get hiddenByDefault => boolean().named('hidden_by_default')(); + TextColumn get projectId => text().named('project_id').nullable()(); + DateTimeColumn get archivedAtUtc => + dateTime().named('archived_at_utc').nullable()(); + IntColumn get revision => integer()(); + DateTimeColumn get createdAtUtc => dateTime().named('created_at_utc')(); + DateTimeColumn get updatedAtUtc => dateTime().named('updated_at_utc')(); + + @override + Set> get primaryKey => {id}; +} diff --git a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/locked_overrides.dart b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/locked_overrides.dart new file mode 100644 index 0000000..74c20e2 --- /dev/null +++ b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/locked_overrides.dart @@ -0,0 +1,22 @@ +part of '../scheduler_db.dart'; + +/// Date-scoped overrides for recurring locked blocks. +@DataClassName('LockedOverrideRow') +class LockedOverrides extends Table { + TextColumn get id => text()(); + TextColumn get ownerId => text().named('owner_id')(); + TextColumn get lockedBlockId => text().named('locked_block_id').nullable()(); + TextColumn get date => text()(); + TextColumn get type => text()(); + TextColumn get name => text().nullable()(); + TextColumn get startTime => text().named('start_time').nullable()(); + TextColumn get endTime => text().named('end_time').nullable()(); + BoolColumn get hiddenByDefault => boolean().named('hidden_by_default')(); + TextColumn get projectId => text().named('project_id').nullable()(); + IntColumn get revision => integer()(); + DateTimeColumn get createdAtUtc => dateTime().named('created_at_utc')(); + DateTimeColumn get updatedAtUtc => dateTime().named('updated_at_utc')(); + + @override + Set> get primaryKey => {id}; +} diff --git a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/project_dao.dart b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/project_dao.dart new file mode 100644 index 0000000..73cd834 --- /dev/null +++ b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/project_dao.dart @@ -0,0 +1,7 @@ +part of '../scheduler_db.dart'; + +/// Generated project-row accessor. +@DriftAccessor(tables: [Projects]) +class ProjectDao extends DatabaseAccessor with _$ProjectDaoMixin { + ProjectDao(super.db); +} diff --git a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/projects.dart b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/projects.dart new file mode 100644 index 0000000..a14561f --- /dev/null +++ b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/projects.dart @@ -0,0 +1,25 @@ +part of '../scheduler_db.dart'; + +/// Project configuration rows. +@DataClassName('ProjectRow') +class Projects extends Table { + TextColumn get id => text()(); + TextColumn get ownerId => text().named('owner_id')(); + TextColumn get name => text()(); + TextColumn get colorKey => text().named('color_key')(); + TextColumn get defaultPriority => text().named('default_priority')(); + TextColumn get defaultReward => text().named('default_reward')(); + TextColumn get defaultDifficulty => text().named('default_difficulty')(); + TextColumn get defaultReminderProfile => + text().named('default_reminder_profile')(); + IntColumn get defaultDurationMinutes => + integer().named('default_duration_minutes').nullable()(); + DateTimeColumn get archivedAtUtc => + dateTime().named('archived_at_utc').nullable()(); + IntColumn get revision => integer()(); + DateTimeColumn get createdAtUtc => dateTime().named('created_at_utc')(); + DateTimeColumn get updatedAtUtc => dateTime().named('updated_at_utc')(); + + @override + Set> get primaryKey => {id}; +} diff --git a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/scheduler_db.dart b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/scheduler_db.dart new file mode 100644 index 0000000..8274ed4 --- /dev/null +++ b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/scheduler_db.dart @@ -0,0 +1,33 @@ +part of '../scheduler_db.dart'; + +/// Drift database for scheduler persistence. +@DriftDatabase( + tables: [ + Tasks, + Projects, + LockedBlocks, + LockedOverrides, + SettingsTable, + Snapshots, + ], + daos: [ + TaskDao, + ProjectDao, + LockedBlockDao, + SettingsDao, + SnapshotDao, + ], +) +class SchedulerDb extends _$SchedulerDb { + SchedulerDb(super.executor); + + @override + int get schemaVersion => 1; + + @override + MigrationStrategy get migration => MigrationStrategy( + onCreate: (Migrator migrator) async { + await migrator.createAll(); + }, + ); +} diff --git a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/settings_dao.dart b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/settings_dao.dart new file mode 100644 index 0000000..c072830 --- /dev/null +++ b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/settings_dao.dart @@ -0,0 +1,8 @@ +part of '../scheduler_db.dart'; + +/// Generated owner-settings accessor. +@DriftAccessor(tables: [SettingsTable]) +class SettingsDao extends DatabaseAccessor + with _$SettingsDaoMixin { + SettingsDao(super.db); +} diff --git a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/settings_table.dart b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/settings_table.dart new file mode 100644 index 0000000..6a8115d --- /dev/null +++ b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/settings_table.dart @@ -0,0 +1,22 @@ +part of '../scheduler_db.dart'; + +/// Owner-level settings rows. +@DataClassName('SettingsRow') +class SettingsTable extends Table { + @override + String get tableName => 'settings'; + + TextColumn get ownerId => text().named('owner_id')(); + TextColumn get timeZoneId => text().named('timezone_id')(); + IntColumn get dayStartMinutes => integer().named('day_start_minutes')(); + IntColumn get dayEndMinutes => integer().named('day_end_minutes')(); + BoolColumn get compactMode => boolean().named('compact_mode')(); + TextColumn get backlogStalenessJson => + text().named('backlog_staleness_json')(); + IntColumn get revision => integer()(); + DateTimeColumn get createdAtUtc => dateTime().named('created_at_utc')(); + DateTimeColumn get updatedAtUtc => dateTime().named('updated_at_utc')(); + + @override + Set> get primaryKey => {ownerId}; +} diff --git a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/snapshot_dao.dart b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/snapshot_dao.dart new file mode 100644 index 0000000..7b67f2f --- /dev/null +++ b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/snapshot_dao.dart @@ -0,0 +1,8 @@ +part of '../scheduler_db.dart'; + +/// Generated diagnostic snapshot accessor. +@DriftAccessor(tables: [Snapshots]) +class SnapshotDao extends DatabaseAccessor + with _$SnapshotDaoMixin { + SnapshotDao(super.db); +} diff --git a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/snapshots.dart b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/snapshots.dart new file mode 100644 index 0000000..e4d71ab --- /dev/null +++ b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/snapshots.dart @@ -0,0 +1,29 @@ +part of '../scheduler_db.dart'; + +/// Bounded diagnostic schedule snapshots. +@DataClassName('SnapshotRow') +class Snapshots extends Table { + TextColumn get id => text()(); + TextColumn get ownerId => text().named('owner_id')(); + DateTimeColumn get capturedAtUtc => dateTime().named('captured_at_utc')(); + TextColumn get operationName => text().named('operation_name').nullable()(); + TextColumn get sourceDate => text().named('source_date').nullable()(); + TextColumn get targetDate => text().named('target_date').nullable()(); + TextColumn get windowJson => text().named('window_json')(); + TextColumn get tasksJson => text().named('tasks_json')(); + TextColumn get lockedIntervalsJson => text().named('locked_intervals_json')(); + TextColumn get requiredVisibleIntervalsJson => + text().named('required_visible_intervals_json')(); + TextColumn get noticesJson => text().named('notices_json')(); + TextColumn get changesJson => text().named('changes_json')(); + TextColumn get overlapsJson => text().named('overlaps_json')(); + DateTimeColumn get retentionExpiresUtc => + dateTime().named('retention_expires_utc')(); + BoolColumn get truncated => boolean()(); + IntColumn get revision => integer()(); + DateTimeColumn get createdAtUtc => dateTime().named('created_at_utc')(); + DateTimeColumn get updatedAtUtc => dateTime().named('updated_at_utc')(); + + @override + Set> get primaryKey => {id}; +} diff --git a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/task_dao.dart b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/task_dao.dart new file mode 100644 index 0000000..9eca99c --- /dev/null +++ b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/task_dao.dart @@ -0,0 +1,7 @@ +part of '../scheduler_db.dart'; + +/// Generated task-row accessor. +@DriftAccessor(tables: [Tasks]) +class TaskDao extends DatabaseAccessor with _$TaskDaoMixin { + TaskDao(super.db); +} diff --git a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/tasks.dart b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/tasks.dart new file mode 100644 index 0000000..9ec3f11 --- /dev/null +++ b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/tasks.dart @@ -0,0 +1,42 @@ +part of '../scheduler_db.dart'; + +/// Authoritative task rows. +@DataClassName('TaskRow') +class Tasks extends Table { + TextColumn get id => text()(); + TextColumn get ownerId => text().named('owner_id')(); + TextColumn get title => text()(); + TextColumn get projectId => text().named('project_id')(); + TextColumn get parentId => text().named('parent_id').nullable()(); + TextColumn get type => text()(); + TextColumn get status => text()(); + TextColumn get priority => text().nullable()(); + TextColumn get reward => text()(); + TextColumn get difficulty => text()(); + IntColumn get durationMinutes => + integer().named('duration_minutes').nullable()(); + DateTimeColumn get scheduledStartUtc => + dateTime().named('scheduled_start_utc').nullable()(); + DateTimeColumn get scheduledEndUtc => + dateTime().named('scheduled_end_utc').nullable()(); + DateTimeColumn get actualStartUtc => + dateTime().named('actual_start_utc').nullable()(); + DateTimeColumn get actualEndUtc => + dateTime().named('actual_end_utc').nullable()(); + DateTimeColumn get completedAtUtc => + dateTime().named('completed_at_utc').nullable()(); + TextColumn get backlogTagsJson => text().named('backlog_tags_json')(); + TextColumn get reminderOverride => + text().named('reminder_override').nullable()(); + TextColumn get statsJson => text().named('stats_json')(); + DateTimeColumn get backlogEnteredAtUtc => + dateTime().named('backlog_entered_at_utc').nullable()(); + TextColumn get backlogEnteredProvenance => + text().named('backlog_entered_provenance').nullable()(); + IntColumn get revision => integer()(); + DateTimeColumn get createdAtUtc => dateTime().named('created_at_utc')(); + DateTimeColumn get updatedAtUtc => dateTime().named('updated_at_utc')(); + + @override + Set> get primaryKey => {id}; +} diff --git a/packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories.dart b/packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories.dart index 574f213..4b5f71e 100644 --- a/packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories.dart +++ b/packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories.dart @@ -8,1363 +8,16 @@ import 'package:scheduler_core/scheduler_core.dart' as core; import 'package:scheduler_persistence/persistence.dart'; import 'scheduler_db.dart'; +part 'sqlite_repositories/sqlite_task_repository.dart'; +part 'sqlite_repositories/sqlite_project_repository.dart'; +part 'sqlite_repositories/sqlite_locked_block_repository.dart'; +part 'sqlite_repositories/sqlite_settings_repository.dart'; +part 'sqlite_repositories/sqlite_schedule_snapshot_repository.dart'; const _snapshotRetention = Duration(days: 30); final _epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); -/// Drift-backed task repository. -final class SqliteTaskRepository implements TaskRepository { - /// Creates a task repository using [db]. - SqliteTaskRepository(this.db); - - /// Shared SQLite database handle. - final SchedulerDb db; - - @override - Future> findById({ - required core.OwnerId ownerId, - required String taskId, - }) async { - final row = await _taskById(taskId); - if (row == null || row.ownerId != ownerId.value) { - return const Right(null); - } - return Right(_taskFromRow(row)); - } - - @override - Future>> findByOwner({ - required core.OwnerId ownerId, - core.PageRequest page = const core.PageRequest(), - }) async { - return Right(await _pageTaskRows( - page: page, - where: (table) => table.ownerId.equals(ownerId.value), - )); - } - - @override - Future>> findByParentTaskId({ - required core.OwnerId ownerId, - required String parentTaskId, - core.PageRequest page = const core.PageRequest(), - }) async { - return Right(await _pageTaskRows( - page: page, - where: (table) => - table.ownerId.equals(ownerId.value) & - table.parentId.equals(parentTaskId), - )); - } - - @override - Future>> findByProjectId({ - required core.OwnerId ownerId, - required String projectId, - core.PageRequest page = const core.PageRequest(), - }) async { - return Right(await _pageTaskRows( - page: page, - where: (table) => - table.ownerId.equals(ownerId.value) & - table.projectId.equals(projectId), - )); - } - - @override - Future>> findByStatus({ - required core.OwnerId ownerId, - required core.TaskStatus status, - core.PageRequest page = const core.PageRequest(), - }) async { - return Right(await _pageTaskRows( - page: page, - where: (table) => - table.ownerId.equals(ownerId.value) & - table.status.equals(core.PersistenceEnumMapping.encodeTaskStatus( - status, - )), - )); - } - - @override - Future> insert({ - required core.OwnerId ownerId, - required core.Task task, - }) async { - if (await _taskById(task.id) != null) { - return Left(RepositoryDuplicateId(entityId: task.id)); - } - await db.into(db.tasks).insert(_taskCompanion( - task, - ownerId: ownerId, - revision: core.Revision.initial, - )); - return const Right(core.Revision.initial); - } - - @override - Future> save({ - required core.OwnerId ownerId, - required core.Task task, - required core.Revision expectedRevision, - }) async { - final checked = await _checkTaskWrite( - task.id, - ownerId, - expectedRevision, - ); - if (checked is Left) { - return Left(checked.value); - } - final nextRevision = checked.right.revision.next(); - final updates = _taskCompanion( - task, - ownerId: ownerId, - revision: nextRevision, - ); - final updated = await (db.update(db.tasks) - ..where( - (table) => - table.id.equals(task.id) & - table.ownerId.equals(ownerId.value) & - table.revision.equals(expectedRevision.value), - )) - .write(updates); - if (updated != 1) { - return Left(await _staleTaskFailure(task.id, expectedRevision)); - } - return Right(nextRevision); - } - - @override - Future> delete({ - required core.OwnerId ownerId, - required String taskId, - required core.Revision expectedRevision, - }) async { - final checked = await _checkTaskWrite(taskId, ownerId, expectedRevision); - if (checked is Left) { - return Left(checked.value); - } - final nextRevision = checked.right.revision.next(); - final deleted = await (db.delete(db.tasks) - ..where( - (table) => - table.id.equals(taskId) & - table.ownerId.equals(ownerId.value) & - table.revision.equals(expectedRevision.value), - )) - .go(); - if (deleted != 1) { - return Left(await _staleTaskFailure(taskId, expectedRevision)); - } - return Right(nextRevision); - } - - Future> _pageTaskRows({ - required core.PageRequest page, - required drift.Expression Function($TasksTable table) where, - }) async { - final offset = _cursorOffset(page.cursor); - final query = db.select(db.tasks) - ..where(where) - ..orderBy([(table) => drift.OrderingTerm.asc(table.id)]) - ..limit(page.limit + 1, offset: offset); - final rows = await query.get(); - final pageRows = rows.take(page.limit).toList(growable: false); - return core.Page( - items: pageRows.map(_taskFromRow), - nextCursor: rows.length > page.limit ? '${offset + page.limit}' : null, - ); - } - - Future _taskById(String id) { - return (db.select(db.tasks)..where((table) => table.id.equals(id))) - .getSingleOrNull(); - } - - Future> _checkTaskWrite( - String id, - core.OwnerId ownerId, - core.Revision expectedRevision, - ) async { - if (expectedRevision.value <= 0) { - return Left(RepositoryInvalidRevision( - entityId: id, - expectedRevision: expectedRevision, - )); - } - final row = await _taskById(id); - if (row == null) { - return Left(RepositoryNotFound(entityId: id)); - } - if (row.ownerId != ownerId.value) { - return Left(RepositoryOwnerMismatch(entityId: id)); - } - if (row.revision != expectedRevision.value) { - return Left(RepositoryStaleRevision( - entityId: id, - expectedRevision: expectedRevision, - actualRevision: core.Revision(row.revision), - )); - } - return Right(row); - } - - Future _staleTaskFailure( - String id, - core.Revision expectedRevision, - ) async { - final row = await _taskById(id); - return RepositoryStaleRevision( - entityId: id, - expectedRevision: expectedRevision, - actualRevision: - row == null ? expectedRevision.next() : core.Revision(row.revision), - ); - } -} - -/// Drift-backed project repository. -final class SqliteProjectRepository implements ProjectRepository { - /// Creates a project repository using [db]. - SqliteProjectRepository(this.db); - - /// Shared SQLite database handle. - final SchedulerDb db; - - @override - Future> findById({ - required core.OwnerId ownerId, - required String projectId, - }) async { - final row = await _projectById(projectId); - if (row == null || row.ownerId != ownerId.value) { - return const Right(null); - } - return Right(_projectFromRow(row)); - } - - @override - Future>> findByOwner({ - required core.OwnerId ownerId, - bool includeArchived = false, - core.PageRequest page = const core.PageRequest(), - }) async { - final offset = _cursorOffset(page.cursor); - final query = db.select(db.projects) - ..where((table) { - final ownerClause = table.ownerId.equals(ownerId.value); - return includeArchived - ? ownerClause - : ownerClause & table.archivedAtUtc.isNull(); - }) - ..orderBy([(table) => drift.OrderingTerm.asc(table.id)]) - ..limit(page.limit + 1, offset: offset); - final rows = await query.get(); - final pageRows = rows.take(page.limit).toList(growable: false); - return Right(core.Page( - items: pageRows.map(_projectFromRow), - nextCursor: rows.length > page.limit ? '${offset + page.limit}' : null, - )); - } - - @override - Future> insert({ - required core.OwnerId ownerId, - required core.ProjectProfile project, - }) async { - if (await _projectById(project.id) != null) { - return Left(RepositoryDuplicateId(entityId: project.id)); - } - await db.into(db.projects).insert(_projectCompanion( - project, - ownerId: ownerId, - revision: core.Revision.initial, - createdAt: _epoch, - updatedAt: _epoch, - )); - return const Right(core.Revision.initial); - } - - @override - Future> save({ - required core.OwnerId ownerId, - required core.ProjectProfile project, - required core.Revision expectedRevision, - }) async { - final checked = await _checkProjectWrite( - project.id, - ownerId, - expectedRevision, - ); - if (checked is Left) { - return Left(checked.value); - } - final row = checked.right; - final nextRevision = core.Revision(row.revision).next(); - final updated = await (db.update(db.projects) - ..where( - (table) => - table.id.equals(project.id) & - table.ownerId.equals(ownerId.value) & - table.revision.equals(expectedRevision.value), - )) - .write(_projectCompanion( - project, - ownerId: ownerId, - revision: nextRevision, - createdAt: row.createdAtUtc, - updatedAt: _epoch, - )); - if (updated != 1) { - return Left(await _staleProjectFailure(project.id, expectedRevision)); - } - return Right(nextRevision); - } - - @override - Future> archive({ - required core.OwnerId ownerId, - required String projectId, - required core.Revision expectedRevision, - required DateTime archivedAtUtc, - }) async { - final checked = await _checkProjectWrite( - projectId, - ownerId, - expectedRevision, - ); - if (checked is Left) { - return Left(checked.value); - } - final row = checked.right; - final nextRevision = core.Revision(row.revision).next(); - final archived = _projectFromRow(row).copyWith(archivedAt: archivedAtUtc); - final updated = await (db.update(db.projects) - ..where( - (table) => - table.id.equals(projectId) & - table.ownerId.equals(ownerId.value) & - table.revision.equals(expectedRevision.value), - )) - .write(_projectCompanion( - archived, - ownerId: ownerId, - revision: nextRevision, - createdAt: row.createdAtUtc, - updatedAt: archivedAtUtc, - )); - if (updated != 1) { - return Left(await _staleProjectFailure(projectId, expectedRevision)); - } - return Right(nextRevision); - } - - Future _projectById(String id) { - return (db.select(db.projects)..where((table) => table.id.equals(id))) - .getSingleOrNull(); - } - - Future> _checkProjectWrite( - String id, - core.OwnerId ownerId, - core.Revision expectedRevision, - ) async { - if (expectedRevision.value <= 0) { - return Left(RepositoryInvalidRevision( - entityId: id, - expectedRevision: expectedRevision, - )); - } - final row = await _projectById(id); - if (row == null) { - return Left(RepositoryNotFound(entityId: id)); - } - if (row.ownerId != ownerId.value) { - return Left(RepositoryOwnerMismatch(entityId: id)); - } - if (row.revision != expectedRevision.value) { - return Left(RepositoryStaleRevision( - entityId: id, - expectedRevision: expectedRevision, - actualRevision: core.Revision(row.revision), - )); - } - return Right(row); - } - - Future _staleProjectFailure( - String id, - core.Revision expectedRevision, - ) async { - final row = await _projectById(id); - return RepositoryStaleRevision( - entityId: id, - expectedRevision: expectedRevision, - actualRevision: - row == null ? expectedRevision.next() : core.Revision(row.revision), - ); - } -} - -/// Drift-backed locked-block and override repository. -final class SqliteLockedBlockRepository implements LockedBlockRepository { - /// Creates a locked-time repository using [db]. - SqliteLockedBlockRepository(this.db); - - /// Shared SQLite database handle. - final SchedulerDb db; - - @override - Future> findBlockById({ - required core.OwnerId ownerId, - required String blockId, - }) async { - final row = await _blockById(blockId); - if (row == null || row.ownerId != ownerId.value) { - return const Right(null); - } - return Right(_lockedBlockFromRow(row)); - } - - @override - Future>> findBlocksByOwner({ - required core.OwnerId ownerId, - bool includeArchived = false, - core.PageRequest page = const core.PageRequest(), - }) async { - final offset = _cursorOffset(page.cursor); - final query = db.select(db.lockedBlocks) - ..where((table) { - final ownerClause = table.ownerId.equals(ownerId.value); - return includeArchived - ? ownerClause - : ownerClause & table.archivedAtUtc.isNull(); - }) - ..orderBy([(table) => drift.OrderingTerm.asc(table.id)]) - ..limit(page.limit + 1, offset: offset); - final rows = await query.get(); - final pageRows = rows.take(page.limit).toList(growable: false); - return Right(core.Page( - items: pageRows.map(_lockedBlockFromRow), - nextCursor: rows.length > page.limit ? '${offset + page.limit}' : null, - )); - } - - @override - Future> findOverrideById({ - required core.OwnerId ownerId, - required String overrideId, - }) async { - final row = await _overrideById(overrideId); - if (row == null || row.ownerId != ownerId.value) { - return const Right(null); - } - return Right(_lockedOverrideFromRow(row)); - } - - @override - Future>> findOverridesForDate({ - required core.OwnerId ownerId, - required core.CivilDate date, - core.PageRequest page = const core.PageRequest(), - }) async { - final offset = _cursorOffset(page.cursor); - final query = db.select(db.lockedOverrides) - ..where( - (table) => - table.ownerId.equals(ownerId.value) & - table.date.equals(date.toIsoString()), - ) - ..orderBy([(table) => drift.OrderingTerm.asc(table.id)]) - ..limit(page.limit + 1, offset: offset); - final rows = await query.get(); - final pageRows = rows.take(page.limit).toList(growable: false); - return Right(core.Page( - items: pageRows.map(_lockedOverrideFromRow), - nextCursor: rows.length > page.limit ? '${offset + page.limit}' : null, - )); - } - - @override - Future> insertBlock({ - required core.OwnerId ownerId, - required core.LockedBlock block, - }) async { - if (await _blockById(block.id) != null) { - return Left(RepositoryDuplicateId(entityId: block.id)); - } - await db.into(db.lockedBlocks).insert(_lockedBlockCompanion( - block, - ownerId: ownerId, - revision: core.Revision.initial, - )); - return const Right(core.Revision.initial); - } - - @override - Future> saveBlock({ - required core.OwnerId ownerId, - required core.LockedBlock block, - required core.Revision expectedRevision, - }) async { - final checked = await _checkBlockWrite( - block.id, - ownerId, - expectedRevision, - ); - if (checked is Left) { - return Left(checked.value); - } - final nextRevision = core.Revision(checked.right.revision).next(); - final updated = await (db.update(db.lockedBlocks) - ..where( - (table) => - table.id.equals(block.id) & - table.ownerId.equals(ownerId.value) & - table.revision.equals(expectedRevision.value), - )) - .write(_lockedBlockCompanion( - block, - ownerId: ownerId, - revision: nextRevision, - )); - if (updated != 1) { - return Left(await _staleBlockFailure(block.id, expectedRevision)); - } - return Right(nextRevision); - } - - @override - Future> archiveBlock({ - required core.OwnerId ownerId, - required String blockId, - required core.Revision expectedRevision, - required DateTime archivedAtUtc, - }) async { - final checked = await _checkBlockWrite(blockId, ownerId, expectedRevision); - if (checked is Left) { - return Left(checked.value); - } - final row = checked.right; - final nextRevision = core.Revision(row.revision).next(); - final archived = - _lockedBlockFromRow(row).copyWith(archivedAt: archivedAtUtc); - final updated = await (db.update(db.lockedBlocks) - ..where( - (table) => - table.id.equals(blockId) & - table.ownerId.equals(ownerId.value) & - table.revision.equals(expectedRevision.value), - )) - .write(_lockedBlockCompanion( - archived, - ownerId: ownerId, - revision: nextRevision, - )); - if (updated != 1) { - return Left(await _staleBlockFailure(blockId, expectedRevision)); - } - return Right(nextRevision); - } - - @override - Future> insertOverride({ - required core.OwnerId ownerId, - required core.LockedBlockOverride override, - }) async { - if (await _overrideById(override.id) != null) { - return Left(RepositoryDuplicateId(entityId: override.id)); - } - await db.into(db.lockedOverrides).insert(_lockedOverrideCompanion( - override, - ownerId: ownerId, - revision: core.Revision.initial, - )); - return const Right(core.Revision.initial); - } - - @override - Future> saveOverride({ - required core.OwnerId ownerId, - required core.LockedBlockOverride override, - required core.Revision expectedRevision, - }) async { - final checked = await _checkOverrideWrite( - override.id, - ownerId, - expectedRevision, - ); - if (checked is Left) { - return Left(checked.value); - } - final nextRevision = core.Revision(checked.right.revision).next(); - final updated = await (db.update(db.lockedOverrides) - ..where( - (table) => - table.id.equals(override.id) & - table.ownerId.equals(ownerId.value) & - table.revision.equals(expectedRevision.value), - )) - .write(_lockedOverrideCompanion( - override, - ownerId: ownerId, - revision: nextRevision, - )); - if (updated != 1) { - return Left(await _staleOverrideFailure( - override.id, - expectedRevision, - )); - } - return Right(nextRevision); - } - - Future _blockById(String id) { - return (db.select(db.lockedBlocks)..where((table) => table.id.equals(id))) - .getSingleOrNull(); - } - - Future _overrideById(String id) { - return (db.select(db.lockedOverrides) - ..where((table) => table.id.equals(id))) - .getSingleOrNull(); - } - - Future> _checkBlockWrite( - String id, - core.OwnerId ownerId, - core.Revision expectedRevision, - ) async { - if (expectedRevision.value <= 0) { - return Left(RepositoryInvalidRevision( - entityId: id, - expectedRevision: expectedRevision, - )); - } - final row = await _blockById(id); - if (row == null) return Left(RepositoryNotFound(entityId: id)); - if (row.ownerId != ownerId.value) { - return Left(RepositoryOwnerMismatch(entityId: id)); - } - if (row.revision != expectedRevision.value) { - return Left(RepositoryStaleRevision( - entityId: id, - expectedRevision: expectedRevision, - actualRevision: core.Revision(row.revision), - )); - } - return Right(row); - } - - Future> _checkOverrideWrite( - String id, - core.OwnerId ownerId, - core.Revision expectedRevision, - ) async { - if (expectedRevision.value <= 0) { - return Left(RepositoryInvalidRevision( - entityId: id, - expectedRevision: expectedRevision, - )); - } - final row = await _overrideById(id); - if (row == null) return Left(RepositoryNotFound(entityId: id)); - if (row.ownerId != ownerId.value) { - return Left(RepositoryOwnerMismatch(entityId: id)); - } - if (row.revision != expectedRevision.value) { - return Left(RepositoryStaleRevision( - entityId: id, - expectedRevision: expectedRevision, - actualRevision: core.Revision(row.revision), - )); - } - return Right(row); - } - - Future _staleBlockFailure( - String id, - core.Revision expectedRevision, - ) async { - final row = await _blockById(id); - return RepositoryStaleRevision( - entityId: id, - expectedRevision: expectedRevision, - actualRevision: - row == null ? expectedRevision.next() : core.Revision(row.revision), - ); - } - - Future _staleOverrideFailure( - String id, - core.Revision expectedRevision, - ) async { - final row = await _overrideById(id); - return RepositoryStaleRevision( - entityId: id, - expectedRevision: expectedRevision, - actualRevision: - row == null ? expectedRevision.next() : core.Revision(row.revision), - ); - } -} - -/// Drift-backed owner settings repository. -final class SqliteSettingsRepository implements SettingsRepository { - /// Creates a settings repository using [db]. - SqliteSettingsRepository(this.db); - - /// Shared SQLite database handle. - final SchedulerDb db; - - @override - Future> findByOwner({ - required core.OwnerId ownerId, - }) async { - final row = await _settingsByOwner(ownerId.value); - if (row == null) return const Right(null); - return Right(_settingsFromRow(row)); - } - - @override - Future> insert({ - required core.OwnerId ownerId, - required core.OwnerSettings settings, - }) async { - if (await _settingsByOwner(ownerId.value) != null) { - return Left(RepositoryDuplicateId(entityId: ownerId.value)); - } - final ownedSettings = settings.ownerId == ownerId.value - ? settings - : settings.copyWith(ownerId: ownerId.value); - await db.into(db.settingsTable).insert(_settingsCompanion( - ownedSettings, - revision: core.Revision.initial, - createdAt: _epoch, - updatedAt: _epoch, - )); - return const Right(core.Revision.initial); - } - - @override - Future> save({ - required core.OwnerId ownerId, - required core.OwnerSettings settings, - required core.Revision expectedRevision, - }) async { - final checked = await _checkSettingsWrite(ownerId, expectedRevision); - if (checked is Left) { - return Left(checked.value); - } - final row = checked.right; - final nextRevision = core.Revision(row.revision).next(); - final ownedSettings = settings.ownerId == ownerId.value - ? settings - : settings.copyWith(ownerId: ownerId.value); - final updated = await (db.update(db.settingsTable) - ..where( - (table) => - table.ownerId.equals(ownerId.value) & - table.revision.equals(expectedRevision.value), - )) - .write(_settingsCompanion( - ownedSettings, - revision: nextRevision, - createdAt: row.createdAtUtc, - updatedAt: _epoch, - )); - if (updated != 1) { - return Left(await _staleSettingsFailure(ownerId, expectedRevision)); - } - return Right(nextRevision); - } - - Future _settingsByOwner(String ownerId) { - return (db.select(db.settingsTable) - ..where((table) => table.ownerId.equals(ownerId))) - .getSingleOrNull(); - } - - Future> _checkSettingsWrite( - core.OwnerId ownerId, - core.Revision expectedRevision, - ) async { - if (expectedRevision.value <= 0) { - return Left(RepositoryInvalidRevision( - entityId: ownerId.value, - expectedRevision: expectedRevision, - )); - } - final row = await _settingsByOwner(ownerId.value); - if (row == null) return Left(RepositoryNotFound(entityId: ownerId.value)); - if (row.revision != expectedRevision.value) { - return Left(RepositoryStaleRevision( - entityId: ownerId.value, - expectedRevision: expectedRevision, - actualRevision: core.Revision(row.revision), - )); - } - return Right(row); - } - - Future _staleSettingsFailure( - core.OwnerId ownerId, - core.Revision expectedRevision, - ) async { - final row = await _settingsByOwner(ownerId.value); - return RepositoryStaleRevision( - entityId: ownerId.value, - expectedRevision: expectedRevision, - actualRevision: - row == null ? expectedRevision.next() : core.Revision(row.revision), - ); - } -} - -/// Drift-backed schedule snapshot repository. -final class SqliteScheduleSnapshotRepository - implements ScheduleSnapshotRepository { - /// Creates a snapshot repository using [db]. - SqliteScheduleSnapshotRepository(this.db); - - /// Shared SQLite database handle. - final SchedulerDb db; - - @override - Future> findById({ - required core.OwnerId ownerId, - required String snapshotId, - }) async { - final row = await _snapshotById(snapshotId); - if (row == null || row.ownerId != ownerId.value) { - return const Right(null); - } - return Right(_snapshotFromRow(row)); - } - - @override - Future>> findInWindow({ - required core.OwnerId ownerId, - required core.SchedulingWindow window, - core.PageRequest page = const core.PageRequest(), - }) async { - final rows = await (db.select(db.snapshots) - ..where((table) => table.ownerId.equals(ownerId.value)) - ..orderBy([(table) => drift.OrderingTerm.asc(table.id)])) - .get(); - final matching = rows - .map(_snapshotFromRow) - .where((snapshot) => _windowsOverlap(snapshot.window, window)) - .toList(growable: false); - final offset = _cursorOffset(page.cursor); - final pageItems = matching.skip(offset).take(page.limit).toList(); - final nextOffset = offset + page.limit; - return Right(core.Page( - items: pageItems, - nextCursor: nextOffset < matching.length ? '$nextOffset' : null, - )); - } - - @override - Future> insert({ - required core.OwnerId ownerId, - required core.SchedulingStateSnapshot snapshot, - }) async { - if (await _snapshotById(snapshot.id) != null) { - return Left(RepositoryDuplicateId(entityId: snapshot.id)); - } - await db.into(db.snapshots).insert(_snapshotCompanion( - snapshot, - ownerId: ownerId, - revision: core.Revision.initial, - )); - return const Right(core.Revision.initial); - } - - @override - Future> deleteExpired({ - required core.OwnerId ownerId, - required DateTime nowUtc, - }) async { - final deleted = await (db.delete(db.snapshots) - ..where( - (table) => - table.ownerId.equals(ownerId.value) & - table.retentionExpiresUtc.isSmallerThanValue(nowUtc.toUtc()), - )) - .go(); - return Right(deleted); - } - - Future _snapshotById(String id) { - return (db.select(db.snapshots)..where((table) => table.id.equals(id))) - .getSingleOrNull(); - } -} - -TasksCompanion _taskCompanion( - core.Task task, { - required core.OwnerId ownerId, - required core.Revision revision, -}) { - final doc = core.TaskDocumentMapping.toDocument( - task, - ownerId: ownerId.value, - revision: revision.value, - ); - return TasksCompanion.insert( - id: task.id, - ownerId: ownerId.value, - title: _string(doc, core.TaskDocumentFields.title), - projectId: _string(doc, core.TaskDocumentFields.projectId), - parentId: drift.Value(_nullableString( - doc, - core.TaskDocumentFields.parentTaskId, - )), - type: _string(doc, core.TaskDocumentFields.type), - status: _string(doc, core.TaskDocumentFields.status), - priority: drift.Value(_nullableString( - doc, - core.TaskDocumentFields.priority, - )), - reward: _string(doc, core.TaskDocumentFields.reward), - difficulty: _string(doc, core.TaskDocumentFields.difficulty), - durationMinutes: drift.Value(_nullableInt( - doc, - core.TaskDocumentFields.durationMinutes, - )), - scheduledStartUtc: drift.Value(_nullableDateTime( - doc, - core.TaskDocumentFields.scheduledStart, - )), - scheduledEndUtc: drift.Value(_nullableDateTime( - doc, - core.TaskDocumentFields.scheduledEnd, - )), - actualStartUtc: drift.Value(_nullableDateTime( - doc, - core.TaskDocumentFields.actualStart, - )), - actualEndUtc: drift.Value(_nullableDateTime( - doc, - core.TaskDocumentFields.actualEnd, - )), - completedAtUtc: drift.Value(_nullableDateTime( - doc, - core.TaskDocumentFields.completedAt, - )), - backlogTagsJson: jsonEncode(doc[core.TaskDocumentFields.backlogTags]), - reminderOverride: drift.Value(_nullableString( - doc, - core.TaskDocumentFields.reminderOverride, - )), - statsJson: jsonEncode(doc[core.TaskDocumentFields.stats]), - backlogEnteredAtUtc: drift.Value(_nullableDateTime( - doc, - core.TaskDocumentFields.backlogEnteredAt, - )), - backlogEnteredProvenance: drift.Value(_nullableString( - doc, - core.TaskDocumentFields.backlogEnteredAtProvenance, - )), - revision: revision.value, - createdAtUtc: task.createdAt.toUtc(), - updatedAtUtc: task.updatedAt.toUtc(), - ); -} - -core.Task _taskFromRow(TaskRow row) { - return core.TaskDocumentMapping.fromDocument({ - ..._commonDocumentFields( - row.id, row.ownerId, row.revision, row.createdAtUtc, row.updatedAtUtc), - core.TaskDocumentFields.title: row.title, - core.TaskDocumentFields.projectId: row.projectId, - core.TaskDocumentFields.type: row.type, - core.TaskDocumentFields.status: row.status, - core.TaskDocumentFields.priority: row.priority, - core.TaskDocumentFields.reward: row.reward, - core.TaskDocumentFields.difficulty: row.difficulty, - core.TaskDocumentFields.durationMinutes: row.durationMinutes, - core.TaskDocumentFields.scheduledStart: - _storedDateTimeOrNull(row.scheduledStartUtc), - core.TaskDocumentFields.scheduledEnd: - _storedDateTimeOrNull(row.scheduledEndUtc), - core.TaskDocumentFields.actualStart: - _storedDateTimeOrNull(row.actualStartUtc), - core.TaskDocumentFields.actualEnd: _storedDateTimeOrNull(row.actualEndUtc), - core.TaskDocumentFields.completedAt: - _storedDateTimeOrNull(row.completedAtUtc), - core.TaskDocumentFields.parentTaskId: row.parentId, - core.TaskDocumentFields.backlogTags: _jsonList(row.backlogTagsJson), - core.TaskDocumentFields.reminderOverride: row.reminderOverride, - core.TaskDocumentFields.stats: _jsonMap(row.statsJson), - core.TaskDocumentFields.backlogEnteredAt: - _storedDateTimeOrNull(row.backlogEnteredAtUtc), - core.TaskDocumentFields.backlogEnteredAtProvenance: - row.backlogEnteredProvenance, - }); -} - -ProjectsCompanion _projectCompanion( - core.ProjectProfile project, { - required core.OwnerId ownerId, - required core.Revision revision, - required DateTime createdAt, - required DateTime updatedAt, -}) { - final doc = core.ProjectDocumentMapping.toDocument( - project, - ownerId: ownerId.value, - revision: revision.value, - createdAt: createdAt, - updatedAt: updatedAt, - ); - return ProjectsCompanion.insert( - id: project.id, - ownerId: ownerId.value, - name: _string(doc, core.ProjectDocumentFields.name), - colorKey: _string(doc, core.ProjectDocumentFields.colorKey), - defaultPriority: _string(doc, core.ProjectDocumentFields.defaultPriority), - defaultReward: _string(doc, core.ProjectDocumentFields.defaultReward), - defaultDifficulty: - _string(doc, core.ProjectDocumentFields.defaultDifficulty), - defaultReminderProfile: - _string(doc, core.ProjectDocumentFields.defaultReminderProfile), - defaultDurationMinutes: drift.Value(_nullableInt( - doc, - core.ProjectDocumentFields.defaultDurationMinutes, - )), - archivedAtUtc: drift.Value(_nullableDateTime( - doc, - core.ProjectDocumentFields.archivedAt, - )), - revision: revision.value, - createdAtUtc: createdAt.toUtc(), - updatedAtUtc: updatedAt.toUtc(), - ); -} - -core.ProjectProfile _projectFromRow(ProjectRow row) { - return core.ProjectDocumentMapping.fromDocument({ - ..._commonDocumentFields( - row.id, row.ownerId, row.revision, row.createdAtUtc, row.updatedAtUtc), - core.ProjectDocumentFields.name: row.name, - core.ProjectDocumentFields.colorKey: row.colorKey, - core.ProjectDocumentFields.defaultPriority: row.defaultPriority, - core.ProjectDocumentFields.defaultReward: row.defaultReward, - core.ProjectDocumentFields.defaultDifficulty: row.defaultDifficulty, - core.ProjectDocumentFields.defaultReminderProfile: - row.defaultReminderProfile, - core.ProjectDocumentFields.defaultDurationMinutes: - row.defaultDurationMinutes, - core.ProjectDocumentFields.archivedAt: _storedDateTimeOrNull( - row.archivedAtUtc, - ), - }); -} - -LockedBlocksCompanion _lockedBlockCompanion( - core.LockedBlock block, { - required core.OwnerId ownerId, - required core.Revision revision, -}) { - final doc = core.LockedBlockDocumentMapping.toDocument( - block, - ownerId: ownerId.value, - revision: revision.value, - ); - return LockedBlocksCompanion.insert( - id: block.id, - ownerId: ownerId.value, - name: _string(doc, core.LockedBlockDocumentFields.name), - date: - drift.Value(_nullableString(doc, core.LockedBlockDocumentFields.date)), - startTime: _string(doc, core.LockedBlockDocumentFields.startTime), - endTime: _string(doc, core.LockedBlockDocumentFields.endTime), - recurrenceJson: drift.Value(_jsonEncodeNullable( - doc[core.LockedBlockDocumentFields.recurrence], - )), - hiddenByDefault: _bool(doc, core.LockedBlockDocumentFields.hiddenByDefault), - projectId: drift.Value(_nullableString( - doc, - core.LockedBlockDocumentFields.projectId, - )), - archivedAtUtc: drift.Value(_nullableDateTime( - doc, - core.LockedBlockDocumentFields.archivedAt, - )), - revision: revision.value, - createdAtUtc: block.createdAt.toUtc(), - updatedAtUtc: block.updatedAt.toUtc(), - ); -} - -core.LockedBlock _lockedBlockFromRow(LockedBlockRow row) { - return core.LockedBlockDocumentMapping.fromDocument({ - ..._commonDocumentFields( - row.id, row.ownerId, row.revision, row.createdAtUtc, row.updatedAtUtc), - core.LockedBlockDocumentFields.name: row.name, - core.LockedBlockDocumentFields.startTime: row.startTime, - core.LockedBlockDocumentFields.endTime: row.endTime, - core.LockedBlockDocumentFields.date: row.date, - core.LockedBlockDocumentFields.recurrence: - row.recurrenceJson == null ? null : _jsonMap(row.recurrenceJson!), - core.LockedBlockDocumentFields.hiddenByDefault: row.hiddenByDefault, - core.LockedBlockDocumentFields.projectId: row.projectId, - core.LockedBlockDocumentFields.archivedAt: - _storedDateTimeOrNull(row.archivedAtUtc), - }); -} - -LockedOverridesCompanion _lockedOverrideCompanion( - core.LockedBlockOverride override, { - required core.OwnerId ownerId, - required core.Revision revision, -}) { - final doc = core.LockedBlockOverrideDocumentMapping.toDocument( - override, - ownerId: ownerId.value, - revision: revision.value, - ); - return LockedOverridesCompanion.insert( - id: override.id, - ownerId: ownerId.value, - lockedBlockId: drift.Value(_nullableString( - doc, - core.LockedBlockOverrideDocumentFields.lockedBlockId, - )), - date: _string(doc, core.LockedBlockOverrideDocumentFields.date), - type: _string(doc, core.LockedBlockOverrideDocumentFields.type), - name: drift.Value( - _nullableString(doc, core.LockedBlockOverrideDocumentFields.name), - ), - startTime: drift.Value(_nullableString( - doc, - core.LockedBlockOverrideDocumentFields.startTime, - )), - endTime: drift.Value(_nullableString( - doc, - core.LockedBlockOverrideDocumentFields.endTime, - )), - hiddenByDefault: - _bool(doc, core.LockedBlockOverrideDocumentFields.hiddenByDefault), - projectId: drift.Value(_nullableString( - doc, - core.LockedBlockOverrideDocumentFields.projectId, - )), - revision: revision.value, - createdAtUtc: override.createdAt.toUtc(), - updatedAtUtc: override.updatedAt.toUtc(), - ); -} - -core.LockedBlockOverride _lockedOverrideFromRow(LockedOverrideRow row) { - return core.LockedBlockOverrideDocumentMapping.fromDocument({ - ..._commonDocumentFields( - row.id, row.ownerId, row.revision, row.createdAtUtc, row.updatedAtUtc), - core.LockedBlockOverrideDocumentFields.lockedBlockId: row.lockedBlockId, - core.LockedBlockOverrideDocumentFields.date: row.date, - core.LockedBlockOverrideDocumentFields.type: row.type, - core.LockedBlockOverrideDocumentFields.name: row.name, - core.LockedBlockOverrideDocumentFields.startTime: row.startTime, - core.LockedBlockOverrideDocumentFields.endTime: row.endTime, - core.LockedBlockOverrideDocumentFields.hiddenByDefault: row.hiddenByDefault, - core.LockedBlockOverrideDocumentFields.projectId: row.projectId, - }); -} - -SettingsTableCompanion _settingsCompanion( - core.OwnerSettings settings, { - required core.Revision revision, - required DateTime createdAt, - required DateTime updatedAt, -}) { - final doc = core.OwnerSettingsDocumentMapping.toDocument( - settings, - revision: revision.value, - createdAt: createdAt, - updatedAt: updatedAt, - ); - return SettingsTableCompanion.insert( - ownerId: settings.ownerId, - timeZoneId: _string(doc, core.OwnerSettingsDocumentFields.timeZoneId), - dayStartMinutes: settings.dayStart.minutesSinceMidnight, - dayEndMinutes: settings.dayEnd.minutesSinceMidnight, - compactMode: - _bool(doc, core.OwnerSettingsDocumentFields.compactModeEnabled), - backlogStalenessJson: - jsonEncode(doc[core.OwnerSettingsDocumentFields.backlogStaleness]), - revision: revision.value, - createdAtUtc: createdAt.toUtc(), - updatedAtUtc: updatedAt.toUtc(), - ); -} - -core.OwnerSettings _settingsFromRow(SettingsRow row) { - return core.OwnerSettingsDocumentMapping.fromDocument({ - ..._commonDocumentFields(row.ownerId, row.ownerId, row.revision, - row.createdAtUtc, row.updatedAtUtc), - core.OwnerSettingsDocumentFields.timeZoneId: row.timeZoneId, - core.OwnerSettingsDocumentFields.dayStart: - _wallTimeFromMinutes(row.dayStartMinutes).toIsoString(), - core.OwnerSettingsDocumentFields.dayEnd: - _wallTimeFromMinutes(row.dayEndMinutes).toIsoString(), - core.OwnerSettingsDocumentFields.compactModeEnabled: row.compactMode, - core.OwnerSettingsDocumentFields.backlogStaleness: - _jsonMap(row.backlogStalenessJson), - }); -} - -SnapshotsCompanion _snapshotCompanion( - core.SchedulingStateSnapshot snapshot, { - required core.OwnerId ownerId, - required core.Revision revision, -}) { - final retentionExpiresAt = snapshot.capturedAt.add(_snapshotRetention); - final doc = core.SchedulingSnapshotDocumentMapping.toDocument( - snapshot, - ownerId: ownerId.value, - revision: revision.value, - retentionExpiresAt: retentionExpiresAt, - ); - return SnapshotsCompanion.insert( - id: snapshot.id, - ownerId: ownerId.value, - capturedAtUtc: snapshot.capturedAt.toUtc(), - operationName: drift.Value(_nullableString( - doc, - core.SchedulingSnapshotDocumentFields.operationName, - )), - sourceDate: drift.Value(_nullableString( - doc, - core.SchedulingSnapshotDocumentFields.sourceDate, - )), - targetDate: drift.Value(_nullableString( - doc, - core.SchedulingSnapshotDocumentFields.targetDate, - )), - windowJson: jsonEncode(doc[core.SchedulingSnapshotDocumentFields.window]), - tasksJson: jsonEncode(doc[core.SchedulingSnapshotDocumentFields.tasks]), - lockedIntervalsJson: - jsonEncode(doc[core.SchedulingSnapshotDocumentFields.lockedIntervals]), - requiredVisibleIntervalsJson: jsonEncode( - doc[core.SchedulingSnapshotDocumentFields.requiredVisibleIntervals], - ), - noticesJson: jsonEncode(doc[core.SchedulingSnapshotDocumentFields.notices]), - changesJson: jsonEncode(doc[core.SchedulingSnapshotDocumentFields.changes]), - overlapsJson: - jsonEncode(doc[core.SchedulingSnapshotDocumentFields.overlaps]), - retentionExpiresUtc: retentionExpiresAt.toUtc(), - truncated: _bool(doc, core.SchedulingSnapshotDocumentFields.truncated), - revision: revision.value, - createdAtUtc: snapshot.capturedAt.toUtc(), - updatedAtUtc: snapshot.capturedAt.toUtc(), - ); -} - -core.SchedulingStateSnapshot _snapshotFromRow(SnapshotRow row) { - return core.SchedulingSnapshotDocumentMapping.fromDocument({ - ..._commonDocumentFields( - row.id, row.ownerId, row.revision, row.createdAtUtc, row.updatedAtUtc), - core.SchedulingSnapshotDocumentFields.capturedAt: - core.PersistenceDateTimeConvention.toStoredString(row.capturedAtUtc), - core.SchedulingSnapshotDocumentFields.sourceDate: row.sourceDate, - core.SchedulingSnapshotDocumentFields.targetDate: row.targetDate, - core.SchedulingSnapshotDocumentFields.window: _jsonMap(row.windowJson), - core.SchedulingSnapshotDocumentFields.tasks: _jsonList(row.tasksJson), - core.SchedulingSnapshotDocumentFields.lockedIntervals: - _jsonList(row.lockedIntervalsJson), - core.SchedulingSnapshotDocumentFields.requiredVisibleIntervals: - _jsonList(row.requiredVisibleIntervalsJson), - core.SchedulingSnapshotDocumentFields.notices: _jsonList(row.noticesJson), - core.SchedulingSnapshotDocumentFields.changes: _jsonList(row.changesJson), - core.SchedulingSnapshotDocumentFields.overlaps: _jsonList(row.overlapsJson), - core.SchedulingSnapshotDocumentFields.operationName: row.operationName, - core.SchedulingSnapshotDocumentFields.retentionExpiresAt: - core.PersistenceDateTimeConvention.toStoredString( - row.retentionExpiresUtc, - ), - core.SchedulingSnapshotDocumentFields.truncated: row.truncated, - }); -} - -Map _commonDocumentFields( - String id, - String ownerId, - int revision, - DateTime createdAt, - DateTime updatedAt, -) { - return { - core.DocumentFields.schemaVersion: core.v1SchemaVersion, - core.DocumentFields.id: id, - core.DocumentFields.ownerId: ownerId, - core.DocumentFields.revision: revision, - core.DocumentFields.createdAt: - core.PersistenceDateTimeConvention.toStoredString(createdAt), - core.DocumentFields.updatedAt: - core.PersistenceDateTimeConvention.toStoredString(updatedAt), - }; -} - -String _string(Map doc, String fieldName) { - return doc[fieldName] as String; -} - -String? _nullableString(Map doc, String fieldName) { - return doc[fieldName] as String?; -} - -int? _nullableInt(Map doc, String fieldName) { - return doc[fieldName] as int?; -} - -bool _bool(Map doc, String fieldName) { - return doc[fieldName] as bool; -} - -DateTime? _nullableDateTime(Map doc, String fieldName) { - final value = doc[fieldName] as String?; - return value == null - ? null - : core.PersistenceDateTimeConvention.fromStoredString(value); -} - -String? _storedDateTimeOrNull(DateTime? value) { - return value == null - ? null - : core.PersistenceDateTimeConvention.toStoredString(value); -} - -String? _jsonEncodeNullable(Object? value) { - return value == null ? null : jsonEncode(value); -} - -Map _jsonMap(String value) { - return Map.from( - jsonDecode(value) as Map, - ); -} - -List _jsonList(String value) { - return List.from(jsonDecode(value) as List); -} - -core.WallTime _wallTimeFromMinutes(int minutes) { - return core.WallTime(hour: minutes ~/ 60, minute: minutes % 60); -} - -int _cursorOffset(String? cursor) { - if (cursor == null) return 0; - final parsed = int.tryParse(cursor); - if (parsed == null || parsed < 0) return 0; - return parsed; -} - extension on int { core.Revision next() => core.Revision(this).next(); } diff --git a/packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories/sqlite_locked_block_repository.dart b/packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories/sqlite_locked_block_repository.dart new file mode 100644 index 0000000..2161405 --- /dev/null +++ b/packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories/sqlite_locked_block_repository.dart @@ -0,0 +1,304 @@ +part of '../sqlite_repositories.dart'; + +/// Drift-backed locked-block and override repository. +final class SqliteLockedBlockRepository implements LockedBlockRepository { + /// Creates a locked-time repository using [db]. + SqliteLockedBlockRepository(this.db); + + /// Shared SQLite database handle. + final SchedulerDb db; + + @override + Future> findBlockById({ + required core.OwnerId ownerId, + required String blockId, + }) async { + final row = await _blockById(blockId); + if (row == null || row.ownerId != ownerId.value) { + return const Right(null); + } + return Right(_lockedBlockFromRow(row)); + } + + @override + Future>> findBlocksByOwner({ + required core.OwnerId ownerId, + bool includeArchived = false, + core.PageRequest page = const core.PageRequest(), + }) async { + final offset = _cursorOffset(page.cursor); + final query = db.select(db.lockedBlocks) + ..where((table) { + final ownerClause = table.ownerId.equals(ownerId.value); + return includeArchived + ? ownerClause + : ownerClause & table.archivedAtUtc.isNull(); + }) + ..orderBy([(table) => drift.OrderingTerm.asc(table.id)]) + ..limit(page.limit + 1, offset: offset); + final rows = await query.get(); + final pageRows = rows.take(page.limit).toList(growable: false); + return Right(core.Page( + items: pageRows.map(_lockedBlockFromRow), + nextCursor: rows.length > page.limit ? '${offset + page.limit}' : null, + )); + } + + @override + Future> findOverrideById({ + required core.OwnerId ownerId, + required String overrideId, + }) async { + final row = await _overrideById(overrideId); + if (row == null || row.ownerId != ownerId.value) { + return const Right(null); + } + return Right(_lockedOverrideFromRow(row)); + } + + @override + Future>> findOverridesForDate({ + required core.OwnerId ownerId, + required core.CivilDate date, + core.PageRequest page = const core.PageRequest(), + }) async { + final offset = _cursorOffset(page.cursor); + final query = db.select(db.lockedOverrides) + ..where( + (table) => + table.ownerId.equals(ownerId.value) & + table.date.equals(date.toIsoString()), + ) + ..orderBy([(table) => drift.OrderingTerm.asc(table.id)]) + ..limit(page.limit + 1, offset: offset); + final rows = await query.get(); + final pageRows = rows.take(page.limit).toList(growable: false); + return Right(core.Page( + items: pageRows.map(_lockedOverrideFromRow), + nextCursor: rows.length > page.limit ? '${offset + page.limit}' : null, + )); + } + + @override + Future> insertBlock({ + required core.OwnerId ownerId, + required core.LockedBlock block, + }) async { + if (await _blockById(block.id) != null) { + return Left(RepositoryDuplicateId(entityId: block.id)); + } + await db.into(db.lockedBlocks).insert(_lockedBlockCompanion( + block, + ownerId: ownerId, + revision: core.Revision.initial, + )); + return const Right(core.Revision.initial); + } + + @override + Future> saveBlock({ + required core.OwnerId ownerId, + required core.LockedBlock block, + required core.Revision expectedRevision, + }) async { + final checked = await _checkBlockWrite( + block.id, + ownerId, + expectedRevision, + ); + if (checked is Left) { + return Left(checked.value); + } + final nextRevision = core.Revision(checked.right.revision).next(); + final updated = await (db.update(db.lockedBlocks) + ..where( + (table) => + table.id.equals(block.id) & + table.ownerId.equals(ownerId.value) & + table.revision.equals(expectedRevision.value), + )) + .write(_lockedBlockCompanion( + block, + ownerId: ownerId, + revision: nextRevision, + )); + if (updated != 1) { + return Left(await _staleBlockFailure(block.id, expectedRevision)); + } + return Right(nextRevision); + } + + @override + Future> archiveBlock({ + required core.OwnerId ownerId, + required String blockId, + required core.Revision expectedRevision, + required DateTime archivedAtUtc, + }) async { + final checked = await _checkBlockWrite(blockId, ownerId, expectedRevision); + if (checked is Left) { + return Left(checked.value); + } + final row = checked.right; + final nextRevision = core.Revision(row.revision).next(); + final archived = + _lockedBlockFromRow(row).copyWith(archivedAt: archivedAtUtc); + final updated = await (db.update(db.lockedBlocks) + ..where( + (table) => + table.id.equals(blockId) & + table.ownerId.equals(ownerId.value) & + table.revision.equals(expectedRevision.value), + )) + .write(_lockedBlockCompanion( + archived, + ownerId: ownerId, + revision: nextRevision, + )); + if (updated != 1) { + return Left(await _staleBlockFailure(blockId, expectedRevision)); + } + return Right(nextRevision); + } + + @override + Future> insertOverride({ + required core.OwnerId ownerId, + required core.LockedBlockOverride override, + }) async { + if (await _overrideById(override.id) != null) { + return Left(RepositoryDuplicateId(entityId: override.id)); + } + await db.into(db.lockedOverrides).insert(_lockedOverrideCompanion( + override, + ownerId: ownerId, + revision: core.Revision.initial, + )); + return const Right(core.Revision.initial); + } + + @override + Future> saveOverride({ + required core.OwnerId ownerId, + required core.LockedBlockOverride override, + required core.Revision expectedRevision, + }) async { + final checked = await _checkOverrideWrite( + override.id, + ownerId, + expectedRevision, + ); + if (checked is Left) { + return Left(checked.value); + } + final nextRevision = core.Revision(checked.right.revision).next(); + final updated = await (db.update(db.lockedOverrides) + ..where( + (table) => + table.id.equals(override.id) & + table.ownerId.equals(ownerId.value) & + table.revision.equals(expectedRevision.value), + )) + .write(_lockedOverrideCompanion( + override, + ownerId: ownerId, + revision: nextRevision, + )); + if (updated != 1) { + return Left(await _staleOverrideFailure( + override.id, + expectedRevision, + )); + } + return Right(nextRevision); + } + + Future _blockById(String id) { + return (db.select(db.lockedBlocks)..where((table) => table.id.equals(id))) + .getSingleOrNull(); + } + + Future _overrideById(String id) { + return (db.select(db.lockedOverrides) + ..where((table) => table.id.equals(id))) + .getSingleOrNull(); + } + + Future> _checkBlockWrite( + String id, + core.OwnerId ownerId, + core.Revision expectedRevision, + ) async { + if (expectedRevision.value <= 0) { + return Left(RepositoryInvalidRevision( + entityId: id, + expectedRevision: expectedRevision, + )); + } + final row = await _blockById(id); + if (row == null) return Left(RepositoryNotFound(entityId: id)); + if (row.ownerId != ownerId.value) { + return Left(RepositoryOwnerMismatch(entityId: id)); + } + if (row.revision != expectedRevision.value) { + return Left(RepositoryStaleRevision( + entityId: id, + expectedRevision: expectedRevision, + actualRevision: core.Revision(row.revision), + )); + } + return Right(row); + } + + Future> _checkOverrideWrite( + String id, + core.OwnerId ownerId, + core.Revision expectedRevision, + ) async { + if (expectedRevision.value <= 0) { + return Left(RepositoryInvalidRevision( + entityId: id, + expectedRevision: expectedRevision, + )); + } + final row = await _overrideById(id); + if (row == null) return Left(RepositoryNotFound(entityId: id)); + if (row.ownerId != ownerId.value) { + return Left(RepositoryOwnerMismatch(entityId: id)); + } + if (row.revision != expectedRevision.value) { + return Left(RepositoryStaleRevision( + entityId: id, + expectedRevision: expectedRevision, + actualRevision: core.Revision(row.revision), + )); + } + return Right(row); + } + + Future _staleBlockFailure( + String id, + core.Revision expectedRevision, + ) async { + final row = await _blockById(id); + return RepositoryStaleRevision( + entityId: id, + expectedRevision: expectedRevision, + actualRevision: + row == null ? expectedRevision.next() : core.Revision(row.revision), + ); + } + + Future _staleOverrideFailure( + String id, + core.Revision expectedRevision, + ) async { + final row = await _overrideById(id); + return RepositoryStaleRevision( + entityId: id, + expectedRevision: expectedRevision, + actualRevision: + row == null ? expectedRevision.next() : core.Revision(row.revision), + ); + } +} diff --git a/packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories/sqlite_project_repository.dart b/packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories/sqlite_project_repository.dart new file mode 100644 index 0000000..c2011f7 --- /dev/null +++ b/packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories/sqlite_project_repository.dart @@ -0,0 +1,184 @@ +part of '../sqlite_repositories.dart'; + +/// Drift-backed project repository. +final class SqliteProjectRepository implements ProjectRepository { + /// Creates a project repository using [db]. + SqliteProjectRepository(this.db); + + /// Shared SQLite database handle. + final SchedulerDb db; + + @override + Future> findById({ + required core.OwnerId ownerId, + required String projectId, + }) async { + final row = await _projectById(projectId); + if (row == null || row.ownerId != ownerId.value) { + return const Right(null); + } + return Right(_projectFromRow(row)); + } + + @override + Future>> findByOwner({ + required core.OwnerId ownerId, + bool includeArchived = false, + core.PageRequest page = const core.PageRequest(), + }) async { + final offset = _cursorOffset(page.cursor); + final query = db.select(db.projects) + ..where((table) { + final ownerClause = table.ownerId.equals(ownerId.value); + return includeArchived + ? ownerClause + : ownerClause & table.archivedAtUtc.isNull(); + }) + ..orderBy([(table) => drift.OrderingTerm.asc(table.id)]) + ..limit(page.limit + 1, offset: offset); + final rows = await query.get(); + final pageRows = rows.take(page.limit).toList(growable: false); + return Right(core.Page( + items: pageRows.map(_projectFromRow), + nextCursor: rows.length > page.limit ? '${offset + page.limit}' : null, + )); + } + + @override + Future> insert({ + required core.OwnerId ownerId, + required core.ProjectProfile project, + }) async { + if (await _projectById(project.id) != null) { + return Left(RepositoryDuplicateId(entityId: project.id)); + } + await db.into(db.projects).insert(_projectCompanion( + project, + ownerId: ownerId, + revision: core.Revision.initial, + createdAt: _epoch, + updatedAt: _epoch, + )); + return const Right(core.Revision.initial); + } + + @override + Future> save({ + required core.OwnerId ownerId, + required core.ProjectProfile project, + required core.Revision expectedRevision, + }) async { + final checked = await _checkProjectWrite( + project.id, + ownerId, + expectedRevision, + ); + if (checked is Left) { + return Left(checked.value); + } + final row = checked.right; + final nextRevision = core.Revision(row.revision).next(); + final updated = await (db.update(db.projects) + ..where( + (table) => + table.id.equals(project.id) & + table.ownerId.equals(ownerId.value) & + table.revision.equals(expectedRevision.value), + )) + .write(_projectCompanion( + project, + ownerId: ownerId, + revision: nextRevision, + createdAt: row.createdAtUtc, + updatedAt: _epoch, + )); + if (updated != 1) { + return Left(await _staleProjectFailure(project.id, expectedRevision)); + } + return Right(nextRevision); + } + + @override + Future> archive({ + required core.OwnerId ownerId, + required String projectId, + required core.Revision expectedRevision, + required DateTime archivedAtUtc, + }) async { + final checked = await _checkProjectWrite( + projectId, + ownerId, + expectedRevision, + ); + if (checked is Left) { + return Left(checked.value); + } + final row = checked.right; + final nextRevision = core.Revision(row.revision).next(); + final archived = _projectFromRow(row).copyWith(archivedAt: archivedAtUtc); + final updated = await (db.update(db.projects) + ..where( + (table) => + table.id.equals(projectId) & + table.ownerId.equals(ownerId.value) & + table.revision.equals(expectedRevision.value), + )) + .write(_projectCompanion( + archived, + ownerId: ownerId, + revision: nextRevision, + createdAt: row.createdAtUtc, + updatedAt: archivedAtUtc, + )); + if (updated != 1) { + return Left(await _staleProjectFailure(projectId, expectedRevision)); + } + return Right(nextRevision); + } + + Future _projectById(String id) { + return (db.select(db.projects)..where((table) => table.id.equals(id))) + .getSingleOrNull(); + } + + Future> _checkProjectWrite( + String id, + core.OwnerId ownerId, + core.Revision expectedRevision, + ) async { + if (expectedRevision.value <= 0) { + return Left(RepositoryInvalidRevision( + entityId: id, + expectedRevision: expectedRevision, + )); + } + final row = await _projectById(id); + if (row == null) { + return Left(RepositoryNotFound(entityId: id)); + } + if (row.ownerId != ownerId.value) { + return Left(RepositoryOwnerMismatch(entityId: id)); + } + if (row.revision != expectedRevision.value) { + return Left(RepositoryStaleRevision( + entityId: id, + expectedRevision: expectedRevision, + actualRevision: core.Revision(row.revision), + )); + } + return Right(row); + } + + Future _staleProjectFailure( + String id, + core.Revision expectedRevision, + ) async { + final row = await _projectById(id); + return RepositoryStaleRevision( + entityId: id, + expectedRevision: expectedRevision, + actualRevision: + row == null ? expectedRevision.next() : core.Revision(row.revision), + ); + } +} diff --git a/packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories/sqlite_schedule_snapshot_repository.dart b/packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories/sqlite_schedule_snapshot_repository.dart new file mode 100644 index 0000000..edd07f1 --- /dev/null +++ b/packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories/sqlite_schedule_snapshot_repository.dart @@ -0,0 +1,541 @@ +part of '../sqlite_repositories.dart'; + +/// Drift-backed schedule snapshot repository. +final class SqliteScheduleSnapshotRepository + implements ScheduleSnapshotRepository { + /// Creates a snapshot repository using [db]. + SqliteScheduleSnapshotRepository(this.db); + + /// Shared SQLite database handle. + final SchedulerDb db; + + @override + Future> findById({ + required core.OwnerId ownerId, + required String snapshotId, + }) async { + final row = await _snapshotById(snapshotId); + if (row == null || row.ownerId != ownerId.value) { + return const Right(null); + } + return Right(_snapshotFromRow(row)); + } + + @override + Future>> findInWindow({ + required core.OwnerId ownerId, + required core.SchedulingWindow window, + core.PageRequest page = const core.PageRequest(), + }) async { + final rows = await (db.select(db.snapshots) + ..where((table) => table.ownerId.equals(ownerId.value)) + ..orderBy([(table) => drift.OrderingTerm.asc(table.id)])) + .get(); + final matching = rows + .map(_snapshotFromRow) + .where((snapshot) => _windowsOverlap(snapshot.window, window)) + .toList(growable: false); + final offset = _cursorOffset(page.cursor); + final pageItems = matching.skip(offset).take(page.limit).toList(); + final nextOffset = offset + page.limit; + return Right(core.Page( + items: pageItems, + nextCursor: nextOffset < matching.length ? '$nextOffset' : null, + )); + } + + @override + Future> insert({ + required core.OwnerId ownerId, + required core.SchedulingStateSnapshot snapshot, + }) async { + if (await _snapshotById(snapshot.id) != null) { + return Left(RepositoryDuplicateId(entityId: snapshot.id)); + } + await db.into(db.snapshots).insert(_snapshotCompanion( + snapshot, + ownerId: ownerId, + revision: core.Revision.initial, + )); + return const Right(core.Revision.initial); + } + + @override + Future> deleteExpired({ + required core.OwnerId ownerId, + required DateTime nowUtc, + }) async { + final deleted = await (db.delete(db.snapshots) + ..where( + (table) => + table.ownerId.equals(ownerId.value) & + table.retentionExpiresUtc.isSmallerThanValue(nowUtc.toUtc()), + )) + .go(); + return Right(deleted); + } + + Future _snapshotById(String id) { + return (db.select(db.snapshots)..where((table) => table.id.equals(id))) + .getSingleOrNull(); + } +} + +TasksCompanion _taskCompanion( + core.Task task, { + required core.OwnerId ownerId, + required core.Revision revision, +}) { + final doc = core.TaskDocumentMapping.toDocument( + task, + ownerId: ownerId.value, + revision: revision.value, + ); + return TasksCompanion.insert( + id: task.id, + ownerId: ownerId.value, + title: _string(doc, core.TaskDocumentFields.title), + projectId: _string(doc, core.TaskDocumentFields.projectId), + parentId: drift.Value(_nullableString( + doc, + core.TaskDocumentFields.parentTaskId, + )), + type: _string(doc, core.TaskDocumentFields.type), + status: _string(doc, core.TaskDocumentFields.status), + priority: drift.Value(_nullableString( + doc, + core.TaskDocumentFields.priority, + )), + reward: _string(doc, core.TaskDocumentFields.reward), + difficulty: _string(doc, core.TaskDocumentFields.difficulty), + durationMinutes: drift.Value(_nullableInt( + doc, + core.TaskDocumentFields.durationMinutes, + )), + scheduledStartUtc: drift.Value(_nullableDateTime( + doc, + core.TaskDocumentFields.scheduledStart, + )), + scheduledEndUtc: drift.Value(_nullableDateTime( + doc, + core.TaskDocumentFields.scheduledEnd, + )), + actualStartUtc: drift.Value(_nullableDateTime( + doc, + core.TaskDocumentFields.actualStart, + )), + actualEndUtc: drift.Value(_nullableDateTime( + doc, + core.TaskDocumentFields.actualEnd, + )), + completedAtUtc: drift.Value(_nullableDateTime( + doc, + core.TaskDocumentFields.completedAt, + )), + backlogTagsJson: jsonEncode(doc[core.TaskDocumentFields.backlogTags]), + reminderOverride: drift.Value(_nullableString( + doc, + core.TaskDocumentFields.reminderOverride, + )), + statsJson: jsonEncode(doc[core.TaskDocumentFields.stats]), + backlogEnteredAtUtc: drift.Value(_nullableDateTime( + doc, + core.TaskDocumentFields.backlogEnteredAt, + )), + backlogEnteredProvenance: drift.Value(_nullableString( + doc, + core.TaskDocumentFields.backlogEnteredAtProvenance, + )), + revision: revision.value, + createdAtUtc: task.createdAt.toUtc(), + updatedAtUtc: task.updatedAt.toUtc(), + ); +} + +core.Task _taskFromRow(TaskRow row) { + return core.TaskDocumentMapping.fromDocument({ + ..._commonDocumentFields( + row.id, row.ownerId, row.revision, row.createdAtUtc, row.updatedAtUtc), + core.TaskDocumentFields.title: row.title, + core.TaskDocumentFields.projectId: row.projectId, + core.TaskDocumentFields.type: row.type, + core.TaskDocumentFields.status: row.status, + core.TaskDocumentFields.priority: row.priority, + core.TaskDocumentFields.reward: row.reward, + core.TaskDocumentFields.difficulty: row.difficulty, + core.TaskDocumentFields.durationMinutes: row.durationMinutes, + core.TaskDocumentFields.scheduledStart: + _storedDateTimeOrNull(row.scheduledStartUtc), + core.TaskDocumentFields.scheduledEnd: + _storedDateTimeOrNull(row.scheduledEndUtc), + core.TaskDocumentFields.actualStart: + _storedDateTimeOrNull(row.actualStartUtc), + core.TaskDocumentFields.actualEnd: _storedDateTimeOrNull(row.actualEndUtc), + core.TaskDocumentFields.completedAt: + _storedDateTimeOrNull(row.completedAtUtc), + core.TaskDocumentFields.parentTaskId: row.parentId, + core.TaskDocumentFields.backlogTags: _jsonList(row.backlogTagsJson), + core.TaskDocumentFields.reminderOverride: row.reminderOverride, + core.TaskDocumentFields.stats: _jsonMap(row.statsJson), + core.TaskDocumentFields.backlogEnteredAt: + _storedDateTimeOrNull(row.backlogEnteredAtUtc), + core.TaskDocumentFields.backlogEnteredAtProvenance: + row.backlogEnteredProvenance, + }); +} + +ProjectsCompanion _projectCompanion( + core.ProjectProfile project, { + required core.OwnerId ownerId, + required core.Revision revision, + required DateTime createdAt, + required DateTime updatedAt, +}) { + final doc = core.ProjectDocumentMapping.toDocument( + project, + ownerId: ownerId.value, + revision: revision.value, + createdAt: createdAt, + updatedAt: updatedAt, + ); + return ProjectsCompanion.insert( + id: project.id, + ownerId: ownerId.value, + name: _string(doc, core.ProjectDocumentFields.name), + colorKey: _string(doc, core.ProjectDocumentFields.colorKey), + defaultPriority: _string(doc, core.ProjectDocumentFields.defaultPriority), + defaultReward: _string(doc, core.ProjectDocumentFields.defaultReward), + defaultDifficulty: + _string(doc, core.ProjectDocumentFields.defaultDifficulty), + defaultReminderProfile: + _string(doc, core.ProjectDocumentFields.defaultReminderProfile), + defaultDurationMinutes: drift.Value(_nullableInt( + doc, + core.ProjectDocumentFields.defaultDurationMinutes, + )), + archivedAtUtc: drift.Value(_nullableDateTime( + doc, + core.ProjectDocumentFields.archivedAt, + )), + revision: revision.value, + createdAtUtc: createdAt.toUtc(), + updatedAtUtc: updatedAt.toUtc(), + ); +} + +core.ProjectProfile _projectFromRow(ProjectRow row) { + return core.ProjectDocumentMapping.fromDocument({ + ..._commonDocumentFields( + row.id, row.ownerId, row.revision, row.createdAtUtc, row.updatedAtUtc), + core.ProjectDocumentFields.name: row.name, + core.ProjectDocumentFields.colorKey: row.colorKey, + core.ProjectDocumentFields.defaultPriority: row.defaultPriority, + core.ProjectDocumentFields.defaultReward: row.defaultReward, + core.ProjectDocumentFields.defaultDifficulty: row.defaultDifficulty, + core.ProjectDocumentFields.defaultReminderProfile: + row.defaultReminderProfile, + core.ProjectDocumentFields.defaultDurationMinutes: + row.defaultDurationMinutes, + core.ProjectDocumentFields.archivedAt: _storedDateTimeOrNull( + row.archivedAtUtc, + ), + }); +} + +LockedBlocksCompanion _lockedBlockCompanion( + core.LockedBlock block, { + required core.OwnerId ownerId, + required core.Revision revision, +}) { + final doc = core.LockedBlockDocumentMapping.toDocument( + block, + ownerId: ownerId.value, + revision: revision.value, + ); + return LockedBlocksCompanion.insert( + id: block.id, + ownerId: ownerId.value, + name: _string(doc, core.LockedBlockDocumentFields.name), + date: + drift.Value(_nullableString(doc, core.LockedBlockDocumentFields.date)), + startTime: _string(doc, core.LockedBlockDocumentFields.startTime), + endTime: _string(doc, core.LockedBlockDocumentFields.endTime), + recurrenceJson: drift.Value(_jsonEncodeNullable( + doc[core.LockedBlockDocumentFields.recurrence], + )), + hiddenByDefault: _bool(doc, core.LockedBlockDocumentFields.hiddenByDefault), + projectId: drift.Value(_nullableString( + doc, + core.LockedBlockDocumentFields.projectId, + )), + archivedAtUtc: drift.Value(_nullableDateTime( + doc, + core.LockedBlockDocumentFields.archivedAt, + )), + revision: revision.value, + createdAtUtc: block.createdAt.toUtc(), + updatedAtUtc: block.updatedAt.toUtc(), + ); +} + +core.LockedBlock _lockedBlockFromRow(LockedBlockRow row) { + return core.LockedBlockDocumentMapping.fromDocument({ + ..._commonDocumentFields( + row.id, row.ownerId, row.revision, row.createdAtUtc, row.updatedAtUtc), + core.LockedBlockDocumentFields.name: row.name, + core.LockedBlockDocumentFields.startTime: row.startTime, + core.LockedBlockDocumentFields.endTime: row.endTime, + core.LockedBlockDocumentFields.date: row.date, + core.LockedBlockDocumentFields.recurrence: + row.recurrenceJson == null ? null : _jsonMap(row.recurrenceJson!), + core.LockedBlockDocumentFields.hiddenByDefault: row.hiddenByDefault, + core.LockedBlockDocumentFields.projectId: row.projectId, + core.LockedBlockDocumentFields.archivedAt: + _storedDateTimeOrNull(row.archivedAtUtc), + }); +} + +LockedOverridesCompanion _lockedOverrideCompanion( + core.LockedBlockOverride override, { + required core.OwnerId ownerId, + required core.Revision revision, +}) { + final doc = core.LockedBlockOverrideDocumentMapping.toDocument( + override, + ownerId: ownerId.value, + revision: revision.value, + ); + return LockedOverridesCompanion.insert( + id: override.id, + ownerId: ownerId.value, + lockedBlockId: drift.Value(_nullableString( + doc, + core.LockedBlockOverrideDocumentFields.lockedBlockId, + )), + date: _string(doc, core.LockedBlockOverrideDocumentFields.date), + type: _string(doc, core.LockedBlockOverrideDocumentFields.type), + name: drift.Value( + _nullableString(doc, core.LockedBlockOverrideDocumentFields.name), + ), + startTime: drift.Value(_nullableString( + doc, + core.LockedBlockOverrideDocumentFields.startTime, + )), + endTime: drift.Value(_nullableString( + doc, + core.LockedBlockOverrideDocumentFields.endTime, + )), + hiddenByDefault: + _bool(doc, core.LockedBlockOverrideDocumentFields.hiddenByDefault), + projectId: drift.Value(_nullableString( + doc, + core.LockedBlockOverrideDocumentFields.projectId, + )), + revision: revision.value, + createdAtUtc: override.createdAt.toUtc(), + updatedAtUtc: override.updatedAt.toUtc(), + ); +} + +core.LockedBlockOverride _lockedOverrideFromRow(LockedOverrideRow row) { + return core.LockedBlockOverrideDocumentMapping.fromDocument({ + ..._commonDocumentFields( + row.id, row.ownerId, row.revision, row.createdAtUtc, row.updatedAtUtc), + core.LockedBlockOverrideDocumentFields.lockedBlockId: row.lockedBlockId, + core.LockedBlockOverrideDocumentFields.date: row.date, + core.LockedBlockOverrideDocumentFields.type: row.type, + core.LockedBlockOverrideDocumentFields.name: row.name, + core.LockedBlockOverrideDocumentFields.startTime: row.startTime, + core.LockedBlockOverrideDocumentFields.endTime: row.endTime, + core.LockedBlockOverrideDocumentFields.hiddenByDefault: row.hiddenByDefault, + core.LockedBlockOverrideDocumentFields.projectId: row.projectId, + }); +} + +SettingsTableCompanion _settingsCompanion( + core.OwnerSettings settings, { + required core.Revision revision, + required DateTime createdAt, + required DateTime updatedAt, +}) { + final doc = core.OwnerSettingsDocumentMapping.toDocument( + settings, + revision: revision.value, + createdAt: createdAt, + updatedAt: updatedAt, + ); + return SettingsTableCompanion.insert( + ownerId: settings.ownerId, + timeZoneId: _string(doc, core.OwnerSettingsDocumentFields.timeZoneId), + dayStartMinutes: settings.dayStart.minutesSinceMidnight, + dayEndMinutes: settings.dayEnd.minutesSinceMidnight, + compactMode: + _bool(doc, core.OwnerSettingsDocumentFields.compactModeEnabled), + backlogStalenessJson: + jsonEncode(doc[core.OwnerSettingsDocumentFields.backlogStaleness]), + revision: revision.value, + createdAtUtc: createdAt.toUtc(), + updatedAtUtc: updatedAt.toUtc(), + ); +} + +core.OwnerSettings _settingsFromRow(SettingsRow row) { + return core.OwnerSettingsDocumentMapping.fromDocument({ + ..._commonDocumentFields(row.ownerId, row.ownerId, row.revision, + row.createdAtUtc, row.updatedAtUtc), + core.OwnerSettingsDocumentFields.timeZoneId: row.timeZoneId, + core.OwnerSettingsDocumentFields.dayStart: + _wallTimeFromMinutes(row.dayStartMinutes).toIsoString(), + core.OwnerSettingsDocumentFields.dayEnd: + _wallTimeFromMinutes(row.dayEndMinutes).toIsoString(), + core.OwnerSettingsDocumentFields.compactModeEnabled: row.compactMode, + core.OwnerSettingsDocumentFields.backlogStaleness: + _jsonMap(row.backlogStalenessJson), + }); +} + +SnapshotsCompanion _snapshotCompanion( + core.SchedulingStateSnapshot snapshot, { + required core.OwnerId ownerId, + required core.Revision revision, +}) { + final retentionExpiresAt = snapshot.capturedAt.add(_snapshotRetention); + final doc = core.SchedulingSnapshotDocumentMapping.toDocument( + snapshot, + ownerId: ownerId.value, + revision: revision.value, + retentionExpiresAt: retentionExpiresAt, + ); + return SnapshotsCompanion.insert( + id: snapshot.id, + ownerId: ownerId.value, + capturedAtUtc: snapshot.capturedAt.toUtc(), + operationName: drift.Value(_nullableString( + doc, + core.SchedulingSnapshotDocumentFields.operationName, + )), + sourceDate: drift.Value(_nullableString( + doc, + core.SchedulingSnapshotDocumentFields.sourceDate, + )), + targetDate: drift.Value(_nullableString( + doc, + core.SchedulingSnapshotDocumentFields.targetDate, + )), + windowJson: jsonEncode(doc[core.SchedulingSnapshotDocumentFields.window]), + tasksJson: jsonEncode(doc[core.SchedulingSnapshotDocumentFields.tasks]), + lockedIntervalsJson: + jsonEncode(doc[core.SchedulingSnapshotDocumentFields.lockedIntervals]), + requiredVisibleIntervalsJson: jsonEncode( + doc[core.SchedulingSnapshotDocumentFields.requiredVisibleIntervals], + ), + noticesJson: jsonEncode(doc[core.SchedulingSnapshotDocumentFields.notices]), + changesJson: jsonEncode(doc[core.SchedulingSnapshotDocumentFields.changes]), + overlapsJson: + jsonEncode(doc[core.SchedulingSnapshotDocumentFields.overlaps]), + retentionExpiresUtc: retentionExpiresAt.toUtc(), + truncated: _bool(doc, core.SchedulingSnapshotDocumentFields.truncated), + revision: revision.value, + createdAtUtc: snapshot.capturedAt.toUtc(), + updatedAtUtc: snapshot.capturedAt.toUtc(), + ); +} + +core.SchedulingStateSnapshot _snapshotFromRow(SnapshotRow row) { + return core.SchedulingSnapshotDocumentMapping.fromDocument({ + ..._commonDocumentFields( + row.id, row.ownerId, row.revision, row.createdAtUtc, row.updatedAtUtc), + core.SchedulingSnapshotDocumentFields.capturedAt: + core.PersistenceDateTimeConvention.toStoredString(row.capturedAtUtc), + core.SchedulingSnapshotDocumentFields.sourceDate: row.sourceDate, + core.SchedulingSnapshotDocumentFields.targetDate: row.targetDate, + core.SchedulingSnapshotDocumentFields.window: _jsonMap(row.windowJson), + core.SchedulingSnapshotDocumentFields.tasks: _jsonList(row.tasksJson), + core.SchedulingSnapshotDocumentFields.lockedIntervals: + _jsonList(row.lockedIntervalsJson), + core.SchedulingSnapshotDocumentFields.requiredVisibleIntervals: + _jsonList(row.requiredVisibleIntervalsJson), + core.SchedulingSnapshotDocumentFields.notices: _jsonList(row.noticesJson), + core.SchedulingSnapshotDocumentFields.changes: _jsonList(row.changesJson), + core.SchedulingSnapshotDocumentFields.overlaps: _jsonList(row.overlapsJson), + core.SchedulingSnapshotDocumentFields.operationName: row.operationName, + core.SchedulingSnapshotDocumentFields.retentionExpiresAt: + core.PersistenceDateTimeConvention.toStoredString( + row.retentionExpiresUtc, + ), + core.SchedulingSnapshotDocumentFields.truncated: row.truncated, + }); +} + +Map _commonDocumentFields( + String id, + String ownerId, + int revision, + DateTime createdAt, + DateTime updatedAt, +) { + return { + core.DocumentFields.schemaVersion: core.v1SchemaVersion, + core.DocumentFields.id: id, + core.DocumentFields.ownerId: ownerId, + core.DocumentFields.revision: revision, + core.DocumentFields.createdAt: + core.PersistenceDateTimeConvention.toStoredString(createdAt), + core.DocumentFields.updatedAt: + core.PersistenceDateTimeConvention.toStoredString(updatedAt), + }; +} + +String _string(Map doc, String fieldName) { + return doc[fieldName] as String; +} + +String? _nullableString(Map doc, String fieldName) { + return doc[fieldName] as String?; +} + +int? _nullableInt(Map doc, String fieldName) { + return doc[fieldName] as int?; +} + +bool _bool(Map doc, String fieldName) { + return doc[fieldName] as bool; +} + +DateTime? _nullableDateTime(Map doc, String fieldName) { + final value = doc[fieldName] as String?; + return value == null + ? null + : core.PersistenceDateTimeConvention.fromStoredString(value); +} + +String? _storedDateTimeOrNull(DateTime? value) { + return value == null + ? null + : core.PersistenceDateTimeConvention.toStoredString(value); +} + +String? _jsonEncodeNullable(Object? value) { + return value == null ? null : jsonEncode(value); +} + +Map _jsonMap(String value) { + return Map.from( + jsonDecode(value) as Map, + ); +} + +List _jsonList(String value) { + return List.from(jsonDecode(value) as List); +} + +core.WallTime _wallTimeFromMinutes(int minutes) { + return core.WallTime(hour: minutes ~/ 60, minute: minutes % 60); +} + +int _cursorOffset(String? cursor) { + if (cursor == null) return 0; + final parsed = int.tryParse(cursor); + if (parsed == null || parsed < 0) return 0; + return parsed; +} diff --git a/packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories/sqlite_settings_repository.dart b/packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories/sqlite_settings_repository.dart new file mode 100644 index 0000000..be78987 --- /dev/null +++ b/packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories/sqlite_settings_repository.dart @@ -0,0 +1,113 @@ +part of '../sqlite_repositories.dart'; + +/// Drift-backed owner settings repository. +final class SqliteSettingsRepository implements SettingsRepository { + /// Creates a settings repository using [db]. + SqliteSettingsRepository(this.db); + + /// Shared SQLite database handle. + final SchedulerDb db; + + @override + Future> findByOwner({ + required core.OwnerId ownerId, + }) async { + final row = await _settingsByOwner(ownerId.value); + if (row == null) return const Right(null); + return Right(_settingsFromRow(row)); + } + + @override + Future> insert({ + required core.OwnerId ownerId, + required core.OwnerSettings settings, + }) async { + if (await _settingsByOwner(ownerId.value) != null) { + return Left(RepositoryDuplicateId(entityId: ownerId.value)); + } + final ownedSettings = settings.ownerId == ownerId.value + ? settings + : settings.copyWith(ownerId: ownerId.value); + await db.into(db.settingsTable).insert(_settingsCompanion( + ownedSettings, + revision: core.Revision.initial, + createdAt: _epoch, + updatedAt: _epoch, + )); + return const Right(core.Revision.initial); + } + + @override + Future> save({ + required core.OwnerId ownerId, + required core.OwnerSettings settings, + required core.Revision expectedRevision, + }) async { + final checked = await _checkSettingsWrite(ownerId, expectedRevision); + if (checked is Left) { + return Left(checked.value); + } + final row = checked.right; + final nextRevision = core.Revision(row.revision).next(); + final ownedSettings = settings.ownerId == ownerId.value + ? settings + : settings.copyWith(ownerId: ownerId.value); + final updated = await (db.update(db.settingsTable) + ..where( + (table) => + table.ownerId.equals(ownerId.value) & + table.revision.equals(expectedRevision.value), + )) + .write(_settingsCompanion( + ownedSettings, + revision: nextRevision, + createdAt: row.createdAtUtc, + updatedAt: _epoch, + )); + if (updated != 1) { + return Left(await _staleSettingsFailure(ownerId, expectedRevision)); + } + return Right(nextRevision); + } + + Future _settingsByOwner(String ownerId) { + return (db.select(db.settingsTable) + ..where((table) => table.ownerId.equals(ownerId))) + .getSingleOrNull(); + } + + Future> _checkSettingsWrite( + core.OwnerId ownerId, + core.Revision expectedRevision, + ) async { + if (expectedRevision.value <= 0) { + return Left(RepositoryInvalidRevision( + entityId: ownerId.value, + expectedRevision: expectedRevision, + )); + } + final row = await _settingsByOwner(ownerId.value); + if (row == null) return Left(RepositoryNotFound(entityId: ownerId.value)); + if (row.revision != expectedRevision.value) { + return Left(RepositoryStaleRevision( + entityId: ownerId.value, + expectedRevision: expectedRevision, + actualRevision: core.Revision(row.revision), + )); + } + return Right(row); + } + + Future _staleSettingsFailure( + core.OwnerId ownerId, + core.Revision expectedRevision, + ) async { + final row = await _settingsByOwner(ownerId.value); + return RepositoryStaleRevision( + entityId: ownerId.value, + expectedRevision: expectedRevision, + actualRevision: + row == null ? expectedRevision.next() : core.Revision(row.revision), + ); + } +} diff --git a/packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories/sqlite_task_repository.dart b/packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories/sqlite_task_repository.dart new file mode 100644 index 0000000..40759f7 --- /dev/null +++ b/packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories/sqlite_task_repository.dart @@ -0,0 +1,215 @@ +part of '../sqlite_repositories.dart'; + +/// Drift-backed task repository. +final class SqliteTaskRepository implements TaskRepository { + /// Creates a task repository using [db]. + SqliteTaskRepository(this.db); + + /// Shared SQLite database handle. + final SchedulerDb db; + + @override + Future> findById({ + required core.OwnerId ownerId, + required String taskId, + }) async { + final row = await _taskById(taskId); + if (row == null || row.ownerId != ownerId.value) { + return const Right(null); + } + return Right(_taskFromRow(row)); + } + + @override + Future>> findByOwner({ + required core.OwnerId ownerId, + core.PageRequest page = const core.PageRequest(), + }) async { + return Right(await _pageTaskRows( + page: page, + where: (table) => table.ownerId.equals(ownerId.value), + )); + } + + @override + Future>> findByParentTaskId({ + required core.OwnerId ownerId, + required String parentTaskId, + core.PageRequest page = const core.PageRequest(), + }) async { + return Right(await _pageTaskRows( + page: page, + where: (table) => + table.ownerId.equals(ownerId.value) & + table.parentId.equals(parentTaskId), + )); + } + + @override + Future>> findByProjectId({ + required core.OwnerId ownerId, + required String projectId, + core.PageRequest page = const core.PageRequest(), + }) async { + return Right(await _pageTaskRows( + page: page, + where: (table) => + table.ownerId.equals(ownerId.value) & + table.projectId.equals(projectId), + )); + } + + @override + Future>> findByStatus({ + required core.OwnerId ownerId, + required core.TaskStatus status, + core.PageRequest page = const core.PageRequest(), + }) async { + return Right(await _pageTaskRows( + page: page, + where: (table) => + table.ownerId.equals(ownerId.value) & + table.status.equals(core.PersistenceEnumMapping.encodeTaskStatus( + status, + )), + )); + } + + @override + Future> insert({ + required core.OwnerId ownerId, + required core.Task task, + }) async { + if (await _taskById(task.id) != null) { + return Left(RepositoryDuplicateId(entityId: task.id)); + } + await db.into(db.tasks).insert(_taskCompanion( + task, + ownerId: ownerId, + revision: core.Revision.initial, + )); + return const Right(core.Revision.initial); + } + + @override + Future> save({ + required core.OwnerId ownerId, + required core.Task task, + required core.Revision expectedRevision, + }) async { + final checked = await _checkTaskWrite( + task.id, + ownerId, + expectedRevision, + ); + if (checked is Left) { + return Left(checked.value); + } + final nextRevision = checked.right.revision.next(); + final updates = _taskCompanion( + task, + ownerId: ownerId, + revision: nextRevision, + ); + final updated = await (db.update(db.tasks) + ..where( + (table) => + table.id.equals(task.id) & + table.ownerId.equals(ownerId.value) & + table.revision.equals(expectedRevision.value), + )) + .write(updates); + if (updated != 1) { + return Left(await _staleTaskFailure(task.id, expectedRevision)); + } + return Right(nextRevision); + } + + @override + Future> delete({ + required core.OwnerId ownerId, + required String taskId, + required core.Revision expectedRevision, + }) async { + final checked = await _checkTaskWrite(taskId, ownerId, expectedRevision); + if (checked is Left) { + return Left(checked.value); + } + final nextRevision = checked.right.revision.next(); + final deleted = await (db.delete(db.tasks) + ..where( + (table) => + table.id.equals(taskId) & + table.ownerId.equals(ownerId.value) & + table.revision.equals(expectedRevision.value), + )) + .go(); + if (deleted != 1) { + return Left(await _staleTaskFailure(taskId, expectedRevision)); + } + return Right(nextRevision); + } + + Future> _pageTaskRows({ + required core.PageRequest page, + required drift.Expression Function($TasksTable table) where, + }) async { + final offset = _cursorOffset(page.cursor); + final query = db.select(db.tasks) + ..where(where) + ..orderBy([(table) => drift.OrderingTerm.asc(table.id)]) + ..limit(page.limit + 1, offset: offset); + final rows = await query.get(); + final pageRows = rows.take(page.limit).toList(growable: false); + return core.Page( + items: pageRows.map(_taskFromRow), + nextCursor: rows.length > page.limit ? '${offset + page.limit}' : null, + ); + } + + Future _taskById(String id) { + return (db.select(db.tasks)..where((table) => table.id.equals(id))) + .getSingleOrNull(); + } + + Future> _checkTaskWrite( + String id, + core.OwnerId ownerId, + core.Revision expectedRevision, + ) async { + if (expectedRevision.value <= 0) { + return Left(RepositoryInvalidRevision( + entityId: id, + expectedRevision: expectedRevision, + )); + } + final row = await _taskById(id); + if (row == null) { + return Left(RepositoryNotFound(entityId: id)); + } + if (row.ownerId != ownerId.value) { + return Left(RepositoryOwnerMismatch(entityId: id)); + } + if (row.revision != expectedRevision.value) { + return Left(RepositoryStaleRevision( + entityId: id, + expectedRevision: expectedRevision, + actualRevision: core.Revision(row.revision), + )); + } + return Right(row); + } + + Future _staleTaskFailure( + String id, + core.Revision expectedRevision, + ) async { + final row = await _taskById(id); + return RepositoryStaleRevision( + entityId: id, + expectedRevision: expectedRevision, + actualRevision: + row == null ? expectedRevision.next() : core.Revision(row.revision), + ); + } +}