fix(agenda): one slot generator for professional and patient
The two calendars each built their own list of hours. The patient's
enforced a 3-hour booking lead time; the professional's did not, so at
15:00 an 08:00-18:00 agenda reported "3 libres" (15, 16, 17) that no
patient could actually take.
Both now go through SlotGenerator. The professional still sees the
near-term hours (blocking the next hour is legitimate) but they are
labelled "Sin reserva" and excluded from the "libres" count, so the
number on his agenda means what the patient sees.
Also in this pass:
- endOf() clamps the slot end at 23:59; a 120-minute slot booked at
23:00 was sending "25:00" to the backend.
- ScheduleEntity.copyWith can set an hour back to null, so returning a
day to jornada continua no longer keeps the split hours around.
- Removed the Firebase-era schedule map ('habilitado', 'range1Hour1')
together with the dead ProfessionalEntity.fromDocument that fed it.
- Settings loads guard on mounted and swallow failures instead of
calling setState after dispose.
- "Pedir cita" says what is missing instead of doing nothing.
Tests: 9 passing, including the clamp and the empty/inverted ranges.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3327220557
commit
0a8e5d11a2
@@ -0,0 +1,64 @@
|
||||
import 'package:professional_repository/professional_repository.dart';
|
||||
import 'package:prosappco/utils/time_of_day_extension.dart';
|
||||
|
||||
const _dayNames = [
|
||||
'Lunes',
|
||||
'Martes',
|
||||
'Miércoles',
|
||||
'Jueves',
|
||||
'Viernes',
|
||||
'Sábado',
|
||||
'Domingo',
|
||||
];
|
||||
|
||||
/// Human-readable problems with a week of opening hours, empty when it is fine.
|
||||
///
|
||||
/// Nothing validated these before: an inverted or half-filled day saved
|
||||
/// happily and then produced zero bookable slots, with no clue as to why.
|
||||
List<String> validateSchedules(Schedules schedules) {
|
||||
final days = [
|
||||
schedules.monday,
|
||||
schedules.tuesday,
|
||||
schedules.wednesday,
|
||||
schedules.thursday,
|
||||
schedules.friday,
|
||||
schedules.saturday,
|
||||
schedules.sunday,
|
||||
];
|
||||
|
||||
final problems = <String>[];
|
||||
for (var i = 0; i < days.length; i++) {
|
||||
final problem = _validateDay(days[i]);
|
||||
if (problem != null) problems.add('${_dayNames[i]}: $problem');
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
String? _validateDay(ScheduleEntity day) {
|
||||
if (!day.enabled) return null;
|
||||
|
||||
if (day.continuousDay) {
|
||||
if (day.range1Hour1 == null || day.range2Hour2 == null) {
|
||||
return 'falta la hora de apertura o de cierre';
|
||||
}
|
||||
if (!day.range1Hour1!.isBefore(day.range2Hour2!)) {
|
||||
return 'la hora de cierre debe ser posterior a la de apertura';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
final r = [day.range1Hour1, day.range1Hour2, day.range2Hour1, day.range2Hour2];
|
||||
if (r.any((t) => t == null)) {
|
||||
return 'faltan horas en la jornada partida';
|
||||
}
|
||||
if (!r[0]!.isBefore(r[1]!)) {
|
||||
return 'la primera jornada termina antes de empezar';
|
||||
}
|
||||
if (!r[2]!.isBefore(r[3]!)) {
|
||||
return 'la segunda jornada termina antes de empezar';
|
||||
}
|
||||
if (r[2]!.isBefore(r[1]!)) {
|
||||
return 'las dos jornadas se cruzan';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/// The `day` value the services API expects: `yyyy-MM-dd`.
|
||||
///
|
||||
/// `DateTime.toString()` produces "2026-08-25 00:00:00.000Z", which happens to
|
||||
/// slip past the backend validator but is not canonical ISO 8601 and differs
|
||||
/// from what the web sends for the same table.
|
||||
String serviceDayParam(DateTime d) =>
|
||||
'${d.year.toString().padLeft(4, '0')}-'
|
||||
'${d.month.toString().padLeft(2, '0')}-'
|
||||
'${d.day.toString().padLeft(2, '0')}';
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:professional_repository/professional_repository.dart';
|
||||
import 'package:prosappco/utils/time_of_day_extension.dart';
|
||||
import 'package:prosappco/utils/time_of_day_utils.dart';
|
||||
|
||||
/// Minimum notice a patient has to give before an appointment.
|
||||
///
|
||||
/// It used to live only in the patient's calendar, so the professional's own
|
||||
/// agenda generated a different list: at 15:00 a professional working
|
||||
/// 08:00-18:00 with hourly slots saw "3 libres" (15:00, 16:00, 17:00) while
|
||||
/// the patient could only book 18:00. Both screens now go through
|
||||
/// [SlotGenerator], so the two lists always agree.
|
||||
const Duration kBookingLeadTime = Duration(hours: 3);
|
||||
|
||||
/// The single source of truth for "which times exist on this day".
|
||||
class SlotGenerator {
|
||||
const SlotGenerator._();
|
||||
|
||||
/// Times offered on [day] for [schedule].
|
||||
///
|
||||
/// [notBefore] drops the slots that are already in the past (or inside the
|
||||
/// lead time). Pass it as `DateTime.now().add(kBookingLeadTime)` for the
|
||||
/// patient flow and `DateTime.now()` for the professional's own agenda,
|
||||
/// where the professional may still block the next hour.
|
||||
static List<TimeOfDay> forDay({
|
||||
required ScheduleEntity? schedule,
|
||||
required DateTime day,
|
||||
required int slotDurationMinutes,
|
||||
DateTime? notBefore,
|
||||
}) {
|
||||
final raw = _rawSlots(schedule, slotDurationMinutes);
|
||||
if (notBefore == null) return raw;
|
||||
return raw.where((t) {
|
||||
final at = DateTime(day.year, day.month, day.day, t.hour, t.minute);
|
||||
return !at.isBefore(notBefore);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// Where a slot starting at [start] ends.
|
||||
///
|
||||
/// Clamped to 23:59: a 120-minute slot starting at 23:00 used to produce
|
||||
/// "25:00", which is not a real time and the backend stored verbatim.
|
||||
static TimeOfDay endOf(TimeOfDay start, int slotDurationMinutes) {
|
||||
final total = start.hour * 60 + start.minute + slotDurationMinutes;
|
||||
if (total >= 24 * 60) return const TimeOfDay(hour: 23, minute: 59);
|
||||
return TimeOfDay(hour: total ~/ 60, minute: total % 60);
|
||||
}
|
||||
|
||||
static List<TimeOfDay> _rawSlots(ScheduleEntity? s, int stepMinutes) {
|
||||
if (s == null || !s.enabled) return [];
|
||||
if (s.continuousDay) {
|
||||
if (s.range1Hour1 == null || s.range2Hour2 == null) return [];
|
||||
if (s.range1Hour1!.compareTo(s.range2Hour2!) >= 0) return [];
|
||||
return TimeOfDayUtils.genRanges(s.range1Hour1!, s.range2Hour2!,
|
||||
stepMinutes: stepMinutes);
|
||||
}
|
||||
if (s.range1Hour1 == null ||
|
||||
s.range1Hour2 == null ||
|
||||
s.range2Hour1 == null ||
|
||||
s.range2Hour2 == null) {
|
||||
return [];
|
||||
}
|
||||
if (s.range1Hour1!.compareTo(s.range1Hour2!) >= 0 ||
|
||||
s.range2Hour1!.compareTo(s.range2Hour2!) >= 0) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
...TimeOfDayUtils.genRanges(s.range1Hour1!, s.range1Hour2!,
|
||||
stepMinutes: stepMinutes),
|
||||
...TimeOfDayUtils.genRanges(s.range2Hour1!, s.range2Hour2!,
|
||||
stepMinutes: stepMinutes),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user