diff --git a/frontend/src/features/reminder/domain/geofence.ts b/frontend/src/features/reminder/domain/geofence.ts new file mode 100644 index 0000000..ad75edb --- /dev/null +++ b/frontend/src/features/reminder/domain/geofence.ts @@ -0,0 +1,67 @@ +import type { GeoPoint, LocalReminderSchedule } from './reminder'; + +const EARTH_RADIUS_METERS = 6_371_000; + +export type GeofenceWatchMode = 'arrive' | 'return'; +export type GeofenceTransition = 'no_change' | 'armed' | 'triggered'; + +/** Haversine 球面距离(米)。 */ +export function distanceMeters(from: GeoPoint | null | undefined, to: GeoPoint): number { + if (from == null || from.latitude == null || from.longitude == null) { + return Number.POSITIVE_INFINITY; + } + const phi1 = toRadians(from.latitude); + const phi2 = toRadians(to.latitude); + const deltaPhi = toRadians(to.latitude - from.latitude); + const deltaLambda = toRadians(to.longitude - from.longitude); + const a = + Math.sin(deltaPhi / 2) ** 2 + Math.cos(phi1) * Math.cos(phi2) * Math.sin(deltaLambda / 2) ** 2; + return 2 * EARTH_RADIUS_METERS * Math.asin(Math.sqrt(a)); +} + +/** + * 本地围栏边沿状态机: + * - geofence_armed=false:在圈内未离开;离开后变为 armed + * - geofence_armed=true:再次进入才 triggered + * - mode 决定中心点语义(arrive=日程坐标,return=记录点) + */ +export function evaluateGeofence( + schedule: LocalReminderSchedule, + sample: GeoPoint, + mode: GeofenceWatchMode = resolveWatchMode(schedule), +): GeofenceTransition { + const center = resolveGeofenceCenter(schedule, mode); + if (center == null) return 'no_change'; + + const inside = distanceMeters(center, sample) <= schedule.geofence_radius_meters; + const armed = schedule.runtime.geofence_armed; + + if (armed) { + return inside ? 'triggered' : 'no_change'; + } + return inside ? 'no_change' : 'armed'; +} + +export function resolveWatchMode(schedule: LocalReminderSchedule): GeofenceWatchMode { + return schedule.reminder?.reminder_type === 'return_to_recorded_location' ? 'return' : 'arrive'; +} + +export function resolveGeofenceCenter( + schedule: LocalReminderSchedule, + mode: GeofenceWatchMode = resolveWatchMode(schedule), +): GeoPoint | null { + if (mode === 'return') { + if (schedule.runtime.recorded_location != null) { + return schedule.runtime.recorded_location; + } + return null; + } + if (schedule.latitude == null || schedule.longitude == null) { + return null; + } + return { latitude: schedule.latitude, longitude: schedule.longitude }; +} + +function toRadians(degrees: number): number { + return (degrees * Math.PI) / 180; +} diff --git a/frontend/src/features/reminder/domain/index.ts b/frontend/src/features/reminder/domain/index.ts index 0ce175c..86f8740 100644 --- a/frontend/src/features/reminder/domain/index.ts +++ b/frontend/src/features/reminder/domain/index.ts @@ -20,3 +20,18 @@ export type { ReminderType, } from './reminder'; export { DEFAULT_SNOOZE_MINUTES } from './reminder'; +export type { GeofenceTransition, GeofenceWatchMode } from './geofence'; +export { + distanceMeters, + evaluateGeofence, + resolveGeofenceCenter, + resolveWatchMode, +} from './geofence'; +export { + isSnoozeActive, + isSnoozeExpired, + isTimeWindowReached, + resolveEffectiveTriggerAt, + resolveSnoozeUntil, + resolveTimeTriggerAt, +} from './timeWindow'; diff --git a/frontend/src/features/reminder/domain/timeWindow.ts b/frontend/src/features/reminder/domain/timeWindow.ts new file mode 100644 index 0000000..9a44c52 --- /dev/null +++ b/frontend/src/features/reminder/domain/timeWindow.ts @@ -0,0 +1,73 @@ +import { DEFAULT_SNOOZE_MINUTES, type LocalReminderSchedule } from './reminder'; + +/** 计算时间型提醒的应触发时刻(ISO)。无有效时间则返回 null。 */ +export function resolveTimeTriggerAt(schedule: LocalReminderSchedule): string | null { + const reminder = schedule.reminder; + if (reminder == null) return null; + + if (reminder.reminder_trigger_at != null) { + return reminder.reminder_trigger_at; + } + + if (schedule.start_time == null) return null; + + if (reminder.reminder_type === 'before_start') { + const offsetMinutes = reminder.reminder_offset_minutes ?? 0; + const startMs = Date.parse(schedule.start_time); + if (Number.isNaN(startMs)) return null; + return new Date(startMs - offsetMinutes * 60_000).toISOString(); + } + + if (reminder.reminder_type === 'at_time') { + return schedule.start_time; + } + + return null; +} + +/** 当前有效触发时刻:snooze 未结束时以 snoozed_until 为准。 */ +export function resolveEffectiveTriggerAt(schedule: LocalReminderSchedule): string | null { + if ( + schedule.runtime.reminder_disposition_state === 'snoozed' && + schedule.runtime.snoozed_until != null + ) { + return schedule.runtime.snoozed_until; + } + return resolveTimeTriggerAt(schedule); +} + +export function resolveSnoozeUntil( + nowIso: string, + snoozeUntil: string | null | undefined, + snoozeMinutes: number | null | undefined, +): string { + if (snoozeUntil != null) return snoozeUntil; + const minutes = snoozeMinutes ?? DEFAULT_SNOOZE_MINUTES; + const nowMs = Date.parse(nowIso); + const base = Number.isNaN(nowMs) ? Date.now() : nowMs; + return new Date(base + minutes * 60_000).toISOString(); +} + +export function isSnoozeActive(schedule: LocalReminderSchedule, nowIso: string): boolean { + if (schedule.runtime.reminder_disposition_state !== 'snoozed') return false; + const until = schedule.runtime.snoozed_until; + if (until == null) return false; + return Date.parse(nowIso) < Date.parse(until); +} + +export function isTimeWindowReached(schedule: LocalReminderSchedule, nowIso: string): boolean { + if (schedule.status !== 'active') return false; + if (schedule.schedule_type !== 'time') return false; + if (isSnoozeActive(schedule, nowIso)) return false; + if (schedule.runtime.reminder_disposition_state === 'snoozed') return false; + const triggerAt = resolveEffectiveTriggerAt(schedule); + if (triggerAt == null) return false; + return Date.parse(nowIso) >= Date.parse(triggerAt); +} + +export function isSnoozeExpired(schedule: LocalReminderSchedule, nowIso: string): boolean { + const until = schedule.runtime.snoozed_until; + if (until == null) return false; + if (schedule.runtime.reminder_disposition_state !== 'snoozed') return false; + return Date.parse(nowIso) >= Date.parse(until); +}