Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions frontend/src/features/reminder/domain/geofence.ts
Original file line number Diff line number Diff line change
@@ -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;
}
15 changes: 15 additions & 0 deletions frontend/src/features/reminder/domain/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
73 changes: 73 additions & 0 deletions frontend/src/features/reminder/domain/timeWindow.ts
Original file line number Diff line number Diff line change
@@ -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);
}