diff --git a/__tests__/components/ui/dashboard/session-scheduled-message.test.ts b/__tests__/components/ui/dashboard/session-scheduled-message.test.ts new file mode 100644 index 000000000..bf9ff10b4 --- /dev/null +++ b/__tests__/components/ui/dashboard/session-scheduled-message.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { DateTime } from "ts-luxon"; +import { rescheduledMessage, scheduledMessage } from "@/components/ui/dashboard/session-change-message"; +import { Frequency } from "@/types/recurrence"; +import { None, Some } from "@/types/option"; + +const at = (iso: string) => DateTime.fromISO(iso, { zone: "America/Chicago" }); + +describe("scheduledMessage", () => { + it("lists who, when and how long", () => { + const message = scheduledMessage({ + personName: "Ehab Badawi", + start: at("2026-08-18T14:30"), + minutes: 50, + repeat: { kind: "none" }, + otherMove: None, + }); + expect(message).toBe("Scheduled · Ehab Badawi · Tuesday, August 18 · 2:30 PM · 50 min"); + }); + + it("adds the repeat and the other session's move", () => { + const message = scheduledMessage({ + personName: "Ehab Badawi", + start: at("2026-08-18T14:30"), + minutes: 50, + repeat: { kind: "repeat", frequency: Frequency.Weekly, end: { kind: "count", count: 8 } }, + otherMove: Some({ personName: "Priya Raman", start: at("2026-08-19T15:30") }), + }); + expect(message).toBe( + "Scheduled · Ehab Badawi · Tuesday, August 18 · 2:30 PM · 50 min · Weekly, for 8 sessions · Priya moves to Wed 19, 3:30 PM" + ); + }); +}); + +describe("rescheduledMessage", () => { + it("names the person and the new time", () => { + const message = rescheduledMessage({ + personName: "Ehab Badawi", + start: at("2026-08-20T14:00"), + following: false, + otherMove: None, + }); + expect(message).toBe("Rescheduled · Ehab to Thursday, August 20, 2:00 PM"); + }); + + it("marks a series move and the other session's move", () => { + const message = rescheduledMessage({ + personName: "Ehab Badawi", + start: at("2026-08-20T14:00"), + following: true, + otherMove: Some({ personName: "Alex Chen", start: at("2026-08-20T15:30") }), + }); + expect(message).toBe("Rescheduled · Ehab to Thursday, August 20, 2:00 PM · and following · Alex moves to Thu 20, 3:30 PM"); + }); +}); diff --git a/__tests__/lib/api/coaching-session-errors.test.ts b/__tests__/lib/api/coaching-session-errors.test.ts new file mode 100644 index 000000000..5c29e14e3 --- /dev/null +++ b/__tests__/lib/api/coaching-session-errors.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { SessionWrite, sessionWriteFailure } from "@/lib/api/coaching-session-errors"; +import { EntityApiError, PERMISSION_DENIED_MESSAGE } from "@/types/entity-api-error"; + +function apiError(status: number, data: unknown = {}): EntityApiError { + const axiosLike = Object.assign(new Error("Request failed"), { + isAxiosError: true, + response: { status, statusText: "Error", data }, + }); + return new EntityApiError("POST", "/coaching_sessions", axiosLike); +} + +function networkError(): EntityApiError { + return new EntityApiError("POST", "/coaching_sessions", Object.assign(new Error("Network Error"), { isAxiosError: true })); +} + +const toast = (message: string) => ({ kind: "toast", message }); + +describe("sessionWriteFailure", () => { + it("leaves errors that aren't API errors to the caller", () => { + expect(sessionWriteFailure(new Error("boom"), SessionWrite.Create)).toEqual({ kind: "unexpected" }); + }); + + it("sends a revoked Google connection to Settings", () => { + expect(sessionWriteFailure(apiError(409, { error: "oauth_token_revoked" }), SessionWrite.Create)).toEqual({ + kind: "reconnect", + message: "Google Meet is disconnected · Reconnect it in Settings", + }); + }); + + it("explains an archived organization", () => { + expect(sessionWriteFailure(apiError(409, { error: "organization_archived" }), SessionWrite.Update)).toEqual( + toast("This organization is archived · It can't be changed") + ); + }); + + it("asks to check the connection when offline", () => { + expect(sessionWriteFailure(networkError(), SessionWrite.Create)).toEqual( + toast("Couldn't create the session · Check your connection and try again") + ); + expect(sessionWriteFailure(networkError(), SessionWrite.Update)).toEqual( + toast("Couldn't update the session · Check your connection and try again") + ); + }); + + it("names a failed meeting link", () => { + expect(sessionWriteFailure(apiError(502), SessionWrite.Create)).toEqual( + toast("Couldn't create the Google Meet link · Try again") + ); + }); + + it("asks to check the details on a validation error", () => { + expect(sessionWriteFailure(apiError(422), SessionWrite.Create)).toEqual( + toast("Couldn't create the session · Check the details and try again") + ); + expect(sessionWriteFailure(apiError(422), SessionWrite.CreateSeries)).toEqual( + toast("Couldn't create the recurring sessions · Check the details and try again") + ); + expect(sessionWriteFailure(apiError(422), SessionWrite.Update)).toEqual( + toast("Couldn't update the session · Check the details and try again") + ); + }); + + it("says when permission is missing", () => { + expect(sessionWriteFailure(apiError(403), SessionWrite.Create)).toEqual(toast(PERMISSION_DENIED_MESSAGE)); + }); + + it("falls back to a retry message", () => { + expect(sessionWriteFailure(apiError(500), SessionWrite.CreateSeries)).toEqual( + toast("Couldn't create the coaching session · Try again") + ); + expect(sessionWriteFailure(apiError(500), SessionWrite.Update)).toEqual( + toast("Couldn't update the coaching session · Try again") + ); + }); +}); diff --git a/__tests__/lib/scheduling/fixtures.ts b/__tests__/lib/scheduling/fixtures.ts new file mode 100644 index 000000000..9c6754005 --- /dev/null +++ b/__tests__/lib/scheduling/fixtures.ts @@ -0,0 +1,23 @@ +import { DateTime } from "ts-luxon"; +import type { BusySession } from "@/lib/scheduling"; + +export const ZONE = "America/Chicago"; + +export const at = (iso: string) => DateTime.fromISO(iso, { zone: ZONE }); + +export const busy = (id: string, personName: string, start: string, end: string): BusySession => ({ + id, + personName, + start: at(start), + end: at(end), +}); + +/** Monday, the day before the fixtures' Tuesday. */ +export const NOW = at("2026-08-17T10:00"); +export const TUESDAY = at("2026-08-18T00:00"); + +export const EHAB = busy("s-ehab", "Ehab Badawi", "2026-08-18T14:30", "2026-08-18T15:20"); +export const PRIYA = busy("s-priya", "Priya Raman", "2026-08-18T16:30", "2026-08-18T17:20"); +export const ALEX_WED = busy("s-alex", "Alex Chen", "2026-08-19T09:00", "2026-08-19T09:50"); + +export const hhmm = (times: DateTime[]) => times.map((t) => t.toFormat("HH:mm")); diff --git a/__tests__/lib/scheduling/scheduling-copy.test.ts b/__tests__/lib/scheduling/scheduling-copy.test.ts new file mode 100644 index 000000000..0326b3a4b --- /dev/null +++ b/__tests__/lib/scheduling/scheduling-copy.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "vitest"; +import { + busyHeader, + busyRange, + createsLine, + currentlyLine, + meetingLine, + moveSubmitLabel, + nothingElseLine, + overlapLine, + repeatLabel, + resolveSubline, + scheduleSubmitLabel, + insteadLabel, + usualSlotLabel, + whoSubline, +} from "@/lib/scheduling-copy"; +import { Frequency } from "@/types/recurrence"; +import { Provider } from "@/types/provider"; +import { None, Some } from "@/types/option"; +import { EHAB, NOW, TUESDAY, at, busy } from "./fixtures"; + +describe("usualSlotLabel", () => { + it("names the weekday in the plural and the time", () => { + expect(usualSlotLabel({ weekday: 2, minute: 870 })).toBe("Usually Tuesdays, 2:30 PM"); + expect(usualSlotLabel({ weekday: 5, minute: 540 })).toBe("Usually Fridays, 9:00 AM"); + }); +}); + +describe("whoSubline", () => { + it("shows the usual slot first", () => { + const usual = Some({ weekday: 2, minute: 870 }); + expect(whoSubline({ usual, next: None, last: Some(at("2026-08-11T14:30")) }, NOW)).toBe( + "Usually Tuesdays, 2:30 PM" + ); + }); + + it("names a first session by weekday within the week", () => { + expect(whoSubline({ usual: None, next: Some(at("2026-08-20T10:00")), last: None }, NOW)).toBe( + "First session Thursday" + ); + }); + + it("names a later first session by date", () => { + expect(whoSubline({ usual: None, next: Some(at("2026-09-03T10:00")), last: None }, NOW)).toBe( + "First session Sep 3" + ); + }); + + it("falls back to the last session", () => { + const line = whoSubline({ usual: None, next: Some(at("2026-08-20T10:00")), last: Some(at("2026-06-12T10:00")) }, NOW); + expect(line).toBe("Last session Jun 12"); + }); + + it("says when there are no sessions", () => { + expect(whoSubline({ usual: None, next: None, last: None }, NOW)).toBe("No sessions yet"); + }); +}); + +describe("When step lines", () => { + it("heads the busy list with the long date", () => { + expect(busyHeader(TUESDAY)).toBe("Already on Tuesday, August 18"); + expect(nothingElseLine(TUESDAY)).toBe("Nothing else on Tuesday, August 18."); + }); + + it("shows a busy range with both meridiems", () => { + expect(busyRange(EHAB)).toBe("2:30 PM–3:20 PM"); + }); + + it("names the clash by first name with a compact range", () => { + expect(overlapLine(EHAB)).toBe("Overlaps with Ehab, 2:30–3:20 PM"); + }); + + it("keeps both meridiems when the clash crosses noon", () => { + const noon = busy("s-noon", "Sarah Kim", "2026-08-18T11:30", "2026-08-18T12:20"); + expect(overlapLine(noon)).toBe("Overlaps with Sarah, 11:30 AM–12:20 PM"); + }); + + it("offers the free time", () => { + expect(insteadLabel(at("2026-08-18T15:30"))).toBe("Use 3:30 PM instead"); + }); +}); + +describe("Repeat labels", () => { + it("describes each choice", () => { + expect(repeatLabel({ kind: "none" })).toBe("Doesn't repeat"); + expect(repeatLabel({ kind: "repeat", frequency: Frequency.Weekly, end: { kind: "count", count: 8 } })).toBe( + "Weekly, for 8 sessions" + ); + expect(repeatLabel({ kind: "repeat", frequency: Frequency.Biweekly, end: { kind: "until", until: "2026-12-15" } })).toBe( + "Every 2 weeks, until Dec 15" + ); + expect(repeatLabel({ kind: "repeat", frequency: Frequency.Monthly, end: { kind: "count", count: 1 } })).toBe( + "Monthly, for 1 session" + ); + }); +}); + +describe("submit labels", () => { + it("counts what scheduling creates", () => { + expect(scheduleSubmitLabel({ kind: "none" })).toBe("Schedule session"); + expect(scheduleSubmitLabel({ kind: "repeat", frequency: Frequency.Weekly, end: { kind: "count", count: 8 } })).toBe( + "Schedule 8 sessions" + ); + expect(scheduleSubmitLabel({ kind: "repeat", frequency: Frequency.Weekly, end: { kind: "until", until: "2026-12-15" } })).toBe( + "Schedule series" + ); + }); + + it("names the move scope", () => { + expect(moveSubmitLabel(false)).toBe("Reschedule session"); + expect(moveSubmitLabel(true)).toBe("Reschedule this and following"); + }); +}); + +describe("rail and resolve lines", () => { + it("summarizes what scheduling creates", () => { + expect(createsLine(1, "Ehab")).toBe("1 session with Ehab"); + expect(createsLine(8, "Ehab")).toBe("8 sessions with Ehab"); + }); + + it("names the meeting link by provider, or nothing", () => { + expect(meetingLine(Some(Provider.Google))).toEqual(Some("Google Meet link included")); + expect(meetingLine(Some(Provider.Zoom))).toEqual(Some("Zoom link included")); + expect(meetingLine(None)).toEqual(None); + }); + + it("says where the moved session is now", () => { + expect(currentlyLine(at("2026-08-18T14:30"))).toBe("Currently Tue, Aug 18, 2:30 PM"); + }); + + it("explains which session has to move", () => { + expect(resolveSubline(EHAB)).toBe("Ehab's session on Tue 18 at 2:30 PM overlaps the new one. Move it to:"); + }); +}); diff --git a/__tests__/lib/scheduling/scheduling.test.ts b/__tests__/lib/scheduling/scheduling.test.ts new file mode 100644 index 000000000..82a39cb43 --- /dev/null +++ b/__tests__/lib/scheduling/scheduling.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_WORKING_HOURS, + StepKind, + busyOn, + currentStep, + freeSlots, + nextFreeStart, + nextStep, + overlapping, + prevStep, + stepOrder, + stepPosition, + suggestTimes, + usualSlot, +} from "@/lib/scheduling"; +import { None, Some } from "@/types/option"; +import { ALEX_WED, EHAB, NOW, PRIYA, TUESDAY, at, busy, hhmm } from "./fixtures"; + +const times = (...isos: string[]) => isos.map(at); + +describe("busyOn", () => { + const all = [PRIYA, ALEX_WED, EHAB]; + + it("keeps the sessions that start on the day, sorted by start", () => { + expect(busyOn(all, TUESDAY, None)).toEqual([EHAB, PRIYA]); + }); + + it("leaves out the excluded session", () => { + expect(busyOn(all, TUESDAY, Some(EHAB.id))).toEqual([PRIYA]); + }); + + it("returns nothing for a free day", () => { + expect(busyOn(all, at("2026-08-20T00:00"), None)).toEqual([]); + }); + + it("uses the local day, not the UTC day", () => { + const late = busy("s-late", "Sarah Kim", "2026-08-18T23:30", "2026-08-19T00:20"); + expect(busyOn([late], TUESDAY, None)).toEqual([late]); + expect(busyOn([late], at("2026-08-19T00:00"), None)).toEqual([]); + }); + + it("accepts any time on the day", () => { + expect(busyOn(all, at("2026-08-18T21:45"), None)).toEqual([EHAB, PRIYA]); + }); +}); + +describe("overlapping", () => { + it("finds the session a slot runs into", () => { + expect(overlapping({ start: at("2026-08-18T14:00"), minutes: 50 }, [EHAB, PRIYA])).toEqual([EHAB]); + }); + + it("treats touching edges as free", () => { + expect(overlapping({ start: at("2026-08-18T13:40"), minutes: 50 }, [EHAB, PRIYA])).toEqual([]); + expect(overlapping({ start: at("2026-08-18T15:20"), minutes: 50 }, [EHAB, PRIYA])).toEqual([]); + }); + + it("returns every clash, sorted by start", () => { + expect(overlapping({ start: at("2026-08-18T15:00"), minutes: 120 }, [PRIYA, EHAB])).toEqual([EHAB, PRIYA]); + }); +}); + +describe("nextFreeStart", () => { + it("steps forward 30 minutes at a time to the first free start", () => { + const next = nextFreeStart({ start: at("2026-08-18T14:30"), minutes: 50 }, [EHAB, PRIYA], DEFAULT_WORKING_HOURS); + expect(next.some && next.val.toFormat("HH:mm")).toBe("15:30"); + }); + + it("gives up when nothing free ends inside working hours", () => { + const next = nextFreeStart({ start: at("2026-08-18T16:00"), minutes: 50 }, [EHAB, PRIYA], DEFAULT_WORKING_HOURS); + expect(next).toEqual(None); + }); +}); + +describe("freeSlots", () => { + it("lists free half-hour starts that end inside working hours", () => { + const hours = { start: 14 * 60, end: 16 * 60 + 30 }; + expect(hhmm(freeSlots(TUESDAY, [EHAB, PRIYA], 30, hours, NOW))).toEqual(["14:00", "15:30", "16:00"]); + }); + + it("drops starts before now", () => { + const hours = { start: 9 * 60, end: 12 * 60 }; + expect(hhmm(freeSlots(TUESDAY, [], 50, hours, at("2026-08-18T09:45")))).toEqual(["10:00", "10:30", "11:00"]); + }); + + it("returns slots on the day, in its zone", () => { + const [first] = freeSlots(TUESDAY, [], 50, DEFAULT_WORKING_HOURS, NOW); + expect(first.zoneName).toBe(TUESDAY.zoneName); + expect(first.toISODate()).toBe("2026-08-18"); + }); +}); + +describe("usualSlot", () => { + it("takes the series start when there is one", () => { + expect(usualSlot(Some(at("2026-06-23T14:30")), [])).toEqual(Some({ weekday: 2, minute: 870 })); + }); + + it("prefers the series over history", () => { + const history = times("2026-07-02T09:00", "2026-07-09T09:00", "2026-07-16T09:00"); + expect(usualSlot(Some(at("2026-06-23T14:30")), history)).toEqual(Some({ weekday: 2, minute: 870 })); + }); + + it("falls back to the most common weekday and time in history", () => { + const history = times("2026-06-02T14:30", "2026-06-09T14:30", "2026-07-02T09:00"); + expect(usualSlot(None, history)).toEqual(Some({ weekday: 2, minute: 870 })); + }); + + it("needs a weekday and time to repeat before calling it usual", () => { + expect(usualSlot(None, times("2026-06-02T14:30", "2026-07-02T09:00"))).toEqual(None); + expect(usualSlot(None, [])).toEqual(None); + }); + + it("breaks a tie by the most recent session", () => { + const history = times("2026-06-02T14:30", "2026-06-09T14:30", "2026-07-02T09:00", "2026-07-09T09:00"); + expect(usualSlot(None, history)).toEqual(Some({ weekday: 4, minute: 540 })); + }); +}); + +describe("suggestTimes", () => { + const base = { + day: TUESDAY, + minutes: 50, + usual: None, + coachStarts: [], + busy: [EHAB, PRIYA], + hours: DEFAULT_WORKING_HOURS, + now: NOW, + }; + + it("ranks usual, then the coach's frequent starts, then round hours, keeping only free ones", () => { + const coachStarts = times( + "2026-07-01T09:00", "2026-07-08T09:00", "2026-07-15T09:00", + "2026-07-02T10:00", "2026-07-09T10:00", + "2026-07-03T16:30", "2026-07-10T16:30", + "2026-07-06T11:00" + ); + const result = suggestTimes({ ...base, usual: Some({ weekday: 2, minute: 870 }), coachStarts }); + expect(hhmm(result)).toEqual(["09:00", "10:00", "11:00", "12:00", "13:00"]); + }); + + it("keeps a free usual slot ahead of the cap, then sorts by time", () => { + const result = suggestTimes({ ...base, usual: Some({ weekday: 2, minute: 930 }) }); + expect(hhmm(result)).toEqual(["09:00", "10:00", "11:00", "12:00", "15:30"]); + }); + + it("puts frequent coach starts ahead of round hours", () => { + const coachStarts = times("2026-07-01T15:30", "2026-07-08T15:30"); + const result = suggestTimes({ ...base, coachStarts }); + expect(hhmm(result)).toEqual(["09:00", "10:00", "11:00", "12:00", "15:30"]); + }); + + it("uses the usual time of day whatever its weekday", () => { + const result = suggestTimes({ ...base, usual: Some({ weekday: 4, minute: 930 }) }); + expect(hhmm(result)).toContain("15:30"); + }); + + it("drops times already past today", () => { + const result = suggestTimes({ ...base, now: at("2026-08-18T11:15") }); + expect(hhmm(result)).toEqual(["12:00", "13:00"]); + }); + + it("recomputes for a longer duration and may return fewer than five", () => { + const result = suggestTimes({ ...base, minutes: 120 }); + expect(hhmm(result)).toEqual(["09:00", "10:00", "11:00", "12:00"]); + }); + + it("never returns more than five, deduplicated", () => { + const coachStarts = times("2026-07-01T09:00", "2026-07-08T09:00", "2026-07-02T13:00"); + const result = suggestTimes({ ...base, busy: [], usual: Some({ weekday: 2, minute: 540 }), coachStarts }); + expect(result).toHaveLength(5); + expect(new Set(hhmm(result)).size).toBe(5); + }); + + it("returns times on the chosen day, in its zone", () => { + const [first] = suggestTimes(base); + expect(first.toISODate()).toBe("2026-08-18"); + expect(first.zoneName).toBe(TUESDAY.zoneName); + }); +}); + +describe("step order", () => { + it("schedules through Who, When and Repeat, adding Resolve on a clash", () => { + expect(stepOrder({ kind: "schedule" }, false)).toEqual([StepKind.Who, StepKind.When, StepKind.Repeat]); + expect(stepOrder({ kind: "schedule" }, true)).toEqual([ + StepKind.Who, + StepKind.When, + StepKind.Repeat, + StepKind.Resolve, + ]); + }); + + it("moves through When, with Applies to only when offered", () => { + expect(stepOrder({ kind: "move", appliesTo: false }, false)).toEqual([StepKind.When]); + expect(stepOrder({ kind: "move", appliesTo: true }, true)).toEqual([ + StepKind.When, + StepKind.AppliesTo, + StepKind.Resolve, + ]); + }); + + it("falls back to the last step when the current one drops out", () => { + const order = stepOrder({ kind: "schedule" }, false); + expect(currentStep(order, StepKind.Resolve)).toBe(StepKind.Repeat); + expect(currentStep(order, StepKind.When)).toBe(StepKind.When); + }); + + it("reports a 1-based position", () => { + const order = stepOrder({ kind: "schedule" }, true); + expect(stepPosition(order, StepKind.When)).toEqual({ index: 2, total: 4 }); + }); + + it("has no previous step on the first and no next step on the last", () => { + const order = stepOrder({ kind: "schedule" }, false); + expect(prevStep(order, StepKind.Who)).toEqual(None); + expect(nextStep(order, StepKind.Who)).toEqual(Some(StepKind.When)); + expect(nextStep(order, StepKind.Repeat)).toEqual(None); + expect(prevStep(order, StepKind.Repeat)).toEqual(Some(StepKind.When)); + }); +}); diff --git a/__tests__/lib/scheduling/suggest-times-midnight.test.ts b/__tests__/lib/scheduling/suggest-times-midnight.test.ts new file mode 100644 index 000000000..0a72bd251 --- /dev/null +++ b/__tests__/lib/scheduling/suggest-times-midnight.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_WORKING_HOURS, suggestTimes } from "@/lib/scheduling"; +import { Some } from "@/types/option"; +import { NOW, TUESDAY, busy, hhmm } from "./fixtures"; + +const base = { day: TUESDAY, coachStarts: [], hours: DEFAULT_WORKING_HOURS, now: NOW }; + +describe("suggestTimes across midnight", () => { + it("skips a late start that runs into a session the next morning", () => { + const wednesday = busy("s-wed", "Alex Chen", "2026-08-19T00:00", "2026-08-19T00:50"); + const result = suggestTimes({ ...base, minutes: 60, usual: Some({ weekday: 2, minute: 23 * 60 + 30 }), busy: [wednesday] }); + expect(hhmm(result)).not.toContain("23:30"); + }); + + it("skips an early start inside a session left over from the night before", () => { + const monday = busy("s-mon", "Alex Chen", "2026-08-17T23:30", "2026-08-18T00:30"); + const result = suggestTimes({ ...base, minutes: 30, usual: Some({ weekday: 2, minute: 0 }), busy: [monday] }); + expect(hhmm(result)).not.toContain("00:00"); + }); + + it("still suggests a late start when the next morning is free", () => { + const result = suggestTimes({ ...base, minutes: 60, usual: Some({ weekday: 2, minute: 23 * 60 + 30 }), busy: [] }); + expect(hhmm(result)).toContain("23:30"); + }); +}); diff --git a/__tests__/lib/session-window-write.test.ts b/__tests__/lib/session-window-write.test.ts new file mode 100644 index 000000000..5a159a170 --- /dev/null +++ b/__tests__/lib/session-window-write.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { DateTime } from "ts-luxon"; +import { sessionEndIn, sessionStartIn, toSessionDate } from "@/lib/session-window"; + +const ZONE = "America/Chicago"; + +describe("sessionEndIn", () => { + it("is the start plus the duration, in the user's zone", () => { + const end = sessionEndIn({ date: "2026-08-18T19:30:00", duration_minutes: 50 }, ZONE); + expect(end.toFormat("yyyy-MM-dd HH:mm")).toBe("2026-08-18 15:20"); + expect(end.zoneName).toBe(ZONE); + }); +}); + +describe("toSessionDate", () => { + it("writes a local time as the stored naive UTC date", () => { + expect(toSessionDate(DateTime.fromISO("2026-08-18T14:30", { zone: ZONE }))).toBe("2026-08-18T19:30:00"); + }); + + it("round-trips with sessionStartIn", () => { + const local = DateTime.fromISO("2026-11-03T09:00", { zone: ZONE }); + expect(sessionStartIn(toSessionDate(local), ZONE).toMillis()).toBe(local.toMillis()); + }); +}); diff --git a/__tests__/types/coaching-session-duration-field.test.ts b/__tests__/types/coaching-session-duration-field.test.ts new file mode 100644 index 000000000..fe73e0f02 --- /dev/null +++ b/__tests__/types/coaching-session-duration-field.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { + DURATION_CHIPS, + DURATION_FIELD_MAX, + DURATION_FIELD_MIN, + DURATION_FIELD_STEP, + clampDuration, + matchingChip, + stepDuration, +} from "@/types/coaching-session-duration"; +import { None, Some } from "@/types/option"; + +describe("duration field rules", () => { + it("offers the four chips and the stepper bounds", () => { + expect(DURATION_CHIPS).toEqual([30, 45, 50, 60]); + expect([DURATION_FIELD_MIN, DURATION_FIELD_MAX, DURATION_FIELD_STEP]).toEqual([15, 180, 5]); + }); + + it("rounds to the nearest 5 and clamps to 15–180", () => { + expect(clampDuration(52)).toBe(50); + expect(clampDuration(53)).toBe(55); + expect(clampDuration(7)).toBe(15); + expect(clampDuration(400)).toBe(180); + expect(clampDuration(15)).toBe(15); + expect(clampDuration(180)).toBe(180); + }); + + it("treats a non-number as the minimum", () => { + expect(clampDuration(Number.NaN)).toBe(15); + }); + + it("steps by 5 inside the bounds", () => { + expect(stepDuration(50, 1)).toBe(55); + expect(stepDuration(50, -1)).toBe(45); + expect(stepDuration(180, 1)).toBe(180); + expect(stepDuration(15, -1)).toBe(15); + expect(stepDuration(52, 1)).toBe(55); + }); + + it("selects a chip only on an exact match", () => { + expect(matchingChip(50)).toEqual(Some(50)); + expect(matchingChip(55)).toEqual(None); + }); +}); diff --git a/src/components/ui/dashboard/coaching-session-form.tsx b/src/components/ui/dashboard/coaching-session-form.tsx index 6538f50ad..9e6736356 100644 --- a/src/components/ui/dashboard/coaching-session-form.tsx +++ b/src/components/ui/dashboard/coaching-session-form.tsx @@ -25,7 +25,7 @@ import { import { CoachingSessionSeriesApi } from "@/lib/api/coaching-session-series"; import { useOAuthConnections } from "@/lib/api/oauth-connection"; import { useCoachingRelationshipList } from "@/lib/api/coaching-relationships"; -import { organizationArchivedMessage } from "@/lib/api/organization-errors"; +import { SessionWrite, sessionWriteFailure } from "@/lib/api/coaching-session-errors"; import { useCurrentOrganization } from "@/lib/hooks/use-current-organization"; import { DateTime } from "ts-luxon"; import { useAuthStore } from "@/lib/providers/auth-store-provider"; @@ -33,7 +33,7 @@ import { useState, useMemo, useEffect, useRef, useCallback, type FormEvent } fro import { useRouter } from "next/navigation"; import { defaultCoachingSession } from "@/types/coaching-session"; import { calendarDayOf, getBrowserTimezone, pickedDayIn } from "@/lib/timezone-utils"; -import { EntityApiError, PERMISSION_DENIED_MESSAGE, isForbiddenError } from "@/types/entity-api-error"; +import { toSessionDate } from "@/lib/session-window"; import { toast } from "@/lib/toast"; import { seriesCreatedMessage, @@ -276,8 +276,7 @@ export default function CoachingSessionForm({ const userTimezone = userSession?.timezone || getBrowserTimezone(); const localDateTime = pickedDayIn(sessionDate, userTimezone).set({ hour: hours, minute: minutes }); - // Convert to UTC for consistent storage - const utcDateTime = localDateTime.toUTC().toFormat("yyyy-MM-dd'T'HH:mm:ss"); + const utcDateTime = toSessionDate(localDateTime); const handler = mode === "update" @@ -290,30 +289,29 @@ export default function CoachingSessionForm({ await handler(utcDateTime); refresh(); } catch (error) { - const archivedMessage = organizationArchivedMessage(error); - if (!(error instanceof EntityApiError)) { - console.error(`Failed to ${mode} coaching session:`, error); - } else if (error.status === 409 && error.data?.error === "oauth_token_revoked") { - toast.error("Google Meet is disconnected · Reconnect it in Settings"); - router.push("/settings/integrations"); - } else if (archivedMessage !== null) { - toast.error(archivedMessage); - console.error(`Failed to ${mode} coaching session:`, error); - } else { - let message: string; - if (error.isNetworkError()) { - message = `Couldn't ${mode} the session · Check your connection and try again`; - } else if (error.status === 502) { - message = "Couldn't create the Google Meet link · Try again"; - } else if (error.status === 422) { - message = `Couldn't ${mode === "update" ? "update" : "create"} ${isRecurring ? "the recurring sessions" : "the session"} · Check the details and try again`; - } else if (isForbiddenError(error)) { - message = PERMISSION_DENIED_MESSAGE; - } else { - message = `Couldn't ${mode} the coaching session · Try again`; + const write = + mode === "update" + ? SessionWrite.Update + : isRecurring + ? SessionWrite.CreateSeries + : SessionWrite.Create; + const failure = sessionWriteFailure(error, write); + switch (failure.kind) { + case "reconnect": + toast.error(failure.message); + router.push("/settings/integrations"); + break; + case "toast": + toast.error(failure.message); + console.error(`Failed to ${mode} coaching session:`, error); + break; + case "unexpected": + console.error(`Failed to ${mode} coaching session:`, error); + break; + default: { + const _exhaustive: never = failure; + throw new Error(`Unhandled session write failure: ${JSON.stringify(_exhaustive)}`); } - toast.error(message); - console.error(`Failed to ${mode} coaching session:`, error); } } finally { resetForm(); diff --git a/src/components/ui/dashboard/reschedule-series-dialog.tsx b/src/components/ui/dashboard/reschedule-series-dialog.tsx index c408b5753..154cc4fb3 100644 --- a/src/components/ui/dashboard/reschedule-series-dialog.tsx +++ b/src/components/ui/dashboard/reschedule-series-dialog.tsx @@ -20,6 +20,7 @@ import { RecurrenceFields } from "@/components/ui/dashboard/recurrence-fields"; import { useRecurrenceState } from "@/lib/hooks/use-recurrence-state"; import { useCoachingSessionSeriesMutation } from "@/lib/api/coaching-session-series"; import { useAuthStore } from "@/lib/providers/auth-store-provider"; +import { toSessionDate } from "@/lib/session-window"; import { calendarDayOf, getBrowserTimezone, pickedDayIn } from "@/lib/timezone-utils"; import { EntityApiError, @@ -173,10 +174,9 @@ function RescheduleSeriesForm({ setIsSubmitting(true); const [hours, minutes] = sessionTime.split(":").map(Number); - const utcDateTime = pickedDayIn(sessionDate, userTimezone) - .set({ hour: hours, minute: minutes }) - .toUTC() - .toFormat("yyyy-MM-dd'T'HH:mm:ss"); + const utcDateTime = toSessionDate( + pickedDayIn(sessionDate, userTimezone).set({ hour: hours, minute: minutes }) + ); const recurrence: Recurrence = recurrenceToPayload( frequency, diff --git a/src/components/ui/dashboard/session-change-message.ts b/src/components/ui/dashboard/session-change-message.ts index 01d44cf87..51150d255 100644 --- a/src/components/ui/dashboard/session-change-message.ts +++ b/src/components/ui/dashboard/session-change-message.ts @@ -1,8 +1,12 @@ import type { DateTime } from "ts-luxon"; -import { sessionStartIn, shortDate } from "@/lib/session-window"; +import type { RepeatChoice } from "@/lib/scheduling"; +import { firstNameOf, repeatLabel, shortDay } from "@/lib/scheduling-copy"; +import { sessionStartIn, shortDate, timeOfDay } from "@/lib/session-window"; +import { formatWeekdayDate } from "@/lib/utils/date"; import { getCoacheeName } from "@/lib/utils/relationship"; import type { CoachingRelationshipWithUserNames } from "@/types/coaching-relationship"; import type { Id } from "@/types/general"; +import type { Option } from "@/types/option"; /** " with James Hodapp", or nothing if the relationship isn't loaded. */ function withCoachee(relationships: CoachingRelationshipWithUserNames[], relationshipId: Id): string { @@ -67,3 +71,51 @@ export function seriesCreatedMessage( ): string { return `Created ${count} recurring session${count === 1 ? "" : "s"}${withCoachee(relationships, relationshipId)}`; } + +export interface OtherMove { + personName: string; + start: DateTime; +} + +/** "Priya moves to Wed 19, 3:30 PM". */ +function otherMoveParts(otherMove: Option): string[] { + if (otherMove.none) return []; + const { personName, start } = otherMove.val; + return [`${firstNameOf(personName)} moves to ${shortDay(start)}, ${timeOfDay(start)}`]; +} + +/** e.g. "Scheduled · Ehab Badawi · Tuesday, August 18 · 2:30 PM · 50 min · Weekly, for 8 sessions". */ +export function scheduledMessage(input: { + personName: string; + start: DateTime; + minutes: number; + repeat: RepeatChoice; + otherMove: Option; +}): string { + const { personName, start, minutes, repeat, otherMove } = input; + return [ + "Scheduled", + personName, + formatWeekdayDate(start), + timeOfDay(start), + `${minutes} min`, + ...(repeat.kind === "repeat" ? [repeatLabel(repeat)] : []), + ...otherMoveParts(otherMove), + ].join(" · "); +} + +/** e.g. "Rescheduled · Ehab to Thursday, August 20, 2:00 PM · and following". */ +export function rescheduledMessage(input: { + personName: string; + start: DateTime; + following: boolean; + otherMove: Option; +}): string { + const { personName, start, following, otherMove } = input; + return [ + "Rescheduled", + `${firstNameOf(personName)} to ${formatWeekdayDate(start)}, ${timeOfDay(start)}`, + ...(following ? ["and following"] : []), + ...otherMoveParts(otherMove), + ].join(" · "); +} diff --git a/src/lib/api/coaching-session-errors.ts b/src/lib/api/coaching-session-errors.ts new file mode 100644 index 000000000..5b16fe912 --- /dev/null +++ b/src/lib/api/coaching-session-errors.ts @@ -0,0 +1,57 @@ +import { organizationArchivedMessage } from "@/lib/api/organization-errors"; +import { + EntityApiError, + PERMISSION_DENIED_MESSAGE, + isForbiddenError, +} from "@/types/entity-api-error"; + +export enum SessionWrite { + Create = "create", + CreateSeries = "create_series", + Update = "update", +} + +export type SessionWriteFailure = + | { kind: "reconnect"; message: string } + | { kind: "toast"; message: string } + | { kind: "unexpected" }; + +function verb(write: SessionWrite): string { + switch (write) { + case SessionWrite.Create: + case SessionWrite.CreateSeries: + return "create"; + case SessionWrite.Update: + return "update"; + default: { + const _exhaustive: never = write; + throw new Error(`Unhandled session write: ${_exhaustive}`); + } + } +} + +function invalidTarget(write: SessionWrite): string { + return write === SessionWrite.CreateSeries ? "the recurring sessions" : "the session"; +} + +function toastMessage(error: EntityApiError, write: SessionWrite): string { + if (error.isNetworkError()) { + return `Couldn't ${verb(write)} the session · Check your connection and try again`; + } + if (error.status === 502) return "Couldn't create the Google Meet link · Try again"; + if (error.status === 422) { + return `Couldn't ${verb(write)} ${invalidTarget(write)} · Check the details and try again`; + } + if (isForbiddenError(error)) return PERMISSION_DENIED_MESSAGE; + return `Couldn't ${verb(write)} the coaching session · Try again`; +} + +export function sessionWriteFailure(error: unknown, write: SessionWrite): SessionWriteFailure { + if (!(error instanceof EntityApiError)) return { kind: "unexpected" }; + if (error.status === 409 && error.data?.error === "oauth_token_revoked") { + return { kind: "reconnect", message: "Google Meet is disconnected · Reconnect it in Settings" }; + } + const archivedMessage = organizationArchivedMessage(error); + if (archivedMessage !== null) return { kind: "toast", message: archivedMessage }; + return { kind: "toast", message: toastMessage(error, write) }; +} diff --git a/src/lib/hooks/use-join-session-menu.ts b/src/lib/hooks/use-join-session-menu.ts index 044e6d931..5f3fc8d44 100644 --- a/src/lib/hooks/use-join-session-menu.ts +++ b/src/lib/hooks/use-join-session-menu.ts @@ -27,7 +27,6 @@ import { activityRelationship, byActivity, isInactive, - personActivity, whenLine, } from "@/lib/person-activity"; import { @@ -37,13 +36,14 @@ import { sessionStartIn, sessionWindow, } from "@/lib/session-window"; +import { + type PersonEntry, + activityByPerson, + menuPerson, + peopleFor, +} from "@/lib/people"; import { getBrowserTimezone } from "@/lib/timezone-utils"; import { calculateSessionUrgency } from "@/lib/utils/session"; -import { - type CoachingRelationshipWithUserNames, - getRelationshipsForUser, - sortRelationshipsByParticipantName, -} from "@/types/coaching-relationship"; import type { CoachingSession, EnrichedCoachingSession, @@ -59,12 +59,6 @@ export interface TodayEntry { person: MenuPerson; } -/** A person and every relationship the user has with them (two when they coach each other). */ -export interface PersonEntry { - person: MenuPerson; - relationshipIds: Id[]; -} - /** The selected person, narrowed to the relationship whose sessions the menu shows. */ interface SelectedPerson { person: MenuPerson; @@ -120,44 +114,6 @@ export function sessionIdFromPath(pathname: string): Option { return match ? Some(match[1]) : None; } -function initialsOf(first: string, last: string): string { - return `${first.charAt(0)}${last.charAt(0)}`.toUpperCase(); -} - -function menuPerson(userId: Id, first: string, last: string): MenuPerson { - return { - userId, - firstName: first, - fullName: `${first} ${last}`.trim(), - initials: initialsOf(first, last), - }; -} - -function otherParticipant(rel: CoachingRelationshipWithUserNames, userId: Id): MenuPerson { - return rel.coach_id === userId - ? menuPerson(rel.coachee_id, rel.coachee_first_name, rel.coachee_last_name) - : menuPerson(rel.coach_id, rel.coach_first_name, rel.coach_last_name); -} - -/** One entry per other person, holding each relationship the user has with them. */ -export function peopleFor( - userId: Id, - relationships: CoachingRelationshipWithUserNames[] -): PersonEntry[] { - const sorted = sortRelationshipsByParticipantName( - getRelationshipsForUser(userId, relationships), - userId - ); - const byPerson = new Map(); - for (const rel of sorted) { - const person = otherParticipant(rel, userId); - const entry = byPerson.get(person.userId); - if (entry) entry.relationshipIds.push(rel.id); - else byPerson.set(person.userId, { person, relationshipIds: [rel.id] }); - } - return [...byPerson.values()]; -} - export function toMenuSession(session: EnrichedCoachingSession, timezone: string): MenuSession { return { id: session.id, @@ -234,31 +190,10 @@ function utcInstant(dt: DateTime): string { return dt.toUTC().toFormat("yyyy-MM-dd'T'HH:mm:ss'Z'"); } -type ActivityByPerson = Map; +type ActivityByPerson = ReturnType; const NO_SESSIONS: PersonActivity = { kind: "inactive", last: None }; -/** Each person's activity from the sessions nearest now across their relationships. */ -export function activityByPerson( - people: PersonEntry[], - sessions: EnrichedCoachingSession[], - now: DateTime, - timezone: string -): ActivityByPerson { - return new Map( - people.map(({ person, relationshipIds }) => { - const theirs = sessions - .filter((s) => relationshipIds.includes(s.coaching_relationship_id)) - .map((s) => ({ - start: sessionStartIn(s.date, timezone), - over: calculateSessionUrgency(s) === SessionUrgency.Past, - relationshipId: s.coaching_relationship_id, - })); - return [person.userId, personActivity(theirs, now)]; - }) - ); -} - function carouselPerson( person: MenuPerson, activities: Option, diff --git a/src/lib/people.ts b/src/lib/people.ts new file mode 100644 index 000000000..da0fe76b0 --- /dev/null +++ b/src/lib/people.ts @@ -0,0 +1,80 @@ +import type { DateTime } from "ts-luxon"; +import type { MenuPerson } from "@/components/ui/join-session/types"; +import { type PersonActivity, personActivity } from "@/lib/person-activity"; +import { sessionStartIn } from "@/lib/session-window"; +import { calculateSessionUrgency } from "@/lib/utils/session"; +import { + type CoachingRelationshipWithUserNames, + getRelationshipsForUser, + sortRelationshipsByParticipantName, +} from "@/types/coaching-relationship"; +import type { EnrichedCoachingSession } from "@/types/coaching-session"; +import type { Id } from "@/types/general"; +import { SessionUrgency } from "@/types/session-display"; + +/** A person and every relationship the user has with them (two when they coach each other). */ +export interface PersonEntry { + person: MenuPerson; + relationshipIds: Id[]; +} + +export function initialsOf(first: string, last: string): string { + return `${first.charAt(0)}${last.charAt(0)}`.toUpperCase(); +} + +export function menuPerson(userId: Id, first: string, last: string): MenuPerson { + return { + userId, + firstName: first, + fullName: `${first} ${last}`.trim(), + initials: initialsOf(first, last), + }; +} + +function otherParticipant(rel: CoachingRelationshipWithUserNames, userId: Id): MenuPerson { + return rel.coach_id === userId + ? menuPerson(rel.coachee_id, rel.coachee_first_name, rel.coachee_last_name) + : menuPerson(rel.coach_id, rel.coach_first_name, rel.coach_last_name); +} + +/** One entry per other person, holding each relationship the user has with them. */ +export function peopleFor( + userId: Id, + relationships: CoachingRelationshipWithUserNames[] +): PersonEntry[] { + const sorted = sortRelationshipsByParticipantName( + getRelationshipsForUser(userId, relationships), + userId + ); + const byPerson = new Map(); + for (const rel of sorted) { + const person = otherParticipant(rel, userId); + const entry = byPerson.get(person.userId); + if (entry) entry.relationshipIds.push(rel.id); + else byPerson.set(person.userId, { person, relationshipIds: [rel.id] }); + } + return [...byPerson.values()]; +} + +type ActivityByPerson = Map; + +/** Each person's activity from the sessions nearest now across their relationships. */ +export function activityByPerson( + people: PersonEntry[], + sessions: EnrichedCoachingSession[], + now: DateTime, + timezone: string +): ActivityByPerson { + return new Map( + people.map(({ person, relationshipIds }) => { + const theirs = sessions + .filter((s) => relationshipIds.includes(s.coaching_relationship_id)) + .map((s) => ({ + start: sessionStartIn(s.date, timezone), + over: calculateSessionUrgency(s) === SessionUrgency.Past, + relationshipId: s.coaching_relationship_id, + })); + return [person.userId, personActivity(theirs, now)]; + }) + ); +} diff --git a/src/lib/scheduling-copy.ts b/src/lib/scheduling-copy.ts new file mode 100644 index 000000000..fdc4c5cb4 --- /dev/null +++ b/src/lib/scheduling-copy.ts @@ -0,0 +1,174 @@ +import { DateTime, Info } from "ts-luxon"; +import type { BusySession, RepeatChoice, UsualSlot } from "@/lib/scheduling"; +import { shortDate, timeOfDay } from "@/lib/session-window"; +import { formatWeekdayDate } from "@/lib/utils/date"; +import { type Option, None, Some } from "@/types/option"; +import { Provider } from "@/types/provider"; +import { Frequency, type RecurrenceEnd } from "@/types/recurrence"; + +const FIRST_SESSION_WEEKDAY_DAYS = 6; + +function sessions(count: number): string { + return `${count} session${count === 1 ? "" : "s"}`; +} + +export function firstNameOf(fullName: string): string { + return fullName.trim().split(/\s+/)[0]; +} + +/** "Tue 18". */ +export function shortDay(dt: DateTime): string { + return dt.toFormat("ccc d"); +} + +/** A minute of the day as "2:30 PM"; UTC so no DST gap can shift it. */ +function clockAt(minute: number): string { + return timeOfDay(DateTime.fromObject({ hour: Math.floor(minute / 60), minute: minute % 60 }, { zone: "utc" })); +} + +export function usualSlotLabel(slot: UsualSlot): string { + return `Usually ${Info.weekdays("long")[slot.weekday - 1]}s, ${clockAt(slot.minute)}`; +} + +export interface WhoSublineInput { + usual: Option; + next: Option; + last: Option; +} + +function daysAhead(day: DateTime, now: DateTime): number { + return Math.round(day.startOf("day").diff(now.setZone(day.zone).startOf("day"), "days").days); +} + +function firstSessionLine(next: DateTime, now: DateTime): string { + const days = daysAhead(next, now); + const when = days >= 0 && days <= FIRST_SESSION_WEEKDAY_DAYS ? next.toFormat("cccc") : shortDate(next); + return `First session ${when}`; +} + +export function whoSubline(input: WhoSublineInput, now: DateTime): string { + const { usual, next, last } = input; + if (usual.some) return usualSlotLabel(usual.val); + if (last.none && next.some) return firstSessionLine(next.val, now); + if (last.some) return `Last session ${shortDate(last.val)}`; + return "No sessions yet"; +} + +export function busyHeader(day: DateTime): string { + return `Already on ${formatWeekdayDate(day)}`; +} + +export function nothingElseLine(day: DateTime): string { + return `Nothing else on ${formatWeekdayDate(day)}.`; +} + +export function busyRange(session: BusySession): string { + return `${timeOfDay(session.start)}–${timeOfDay(session.end)}`; +} + +function sameMeridiem(a: DateTime, b: DateTime): boolean { + return a.hour < 12 === b.hour < 12; +} + +/** "2:30–3:20 PM", keeping both meridiems when they differ. */ +function compactRange(start: DateTime, end: DateTime): string { + const from = sameMeridiem(start, end) ? start.toFormat("h:mm") : timeOfDay(start); + return `${from}–${timeOfDay(end)}`; +} + +export function overlapLine(clash: BusySession): string { + return `Overlaps with ${firstNameOf(clash.personName)}, ${compactRange(clash.start, clash.end)}`; +} + +export function insteadLabel(start: DateTime): string { + return `Use ${timeOfDay(start)} instead`; +} + +function frequencyPhrase(frequency: Frequency): string { + switch (frequency) { + case Frequency.Daily: + return "Daily"; + case Frequency.Weekly: + return "Weekly"; + case Frequency.Biweekly: + return "Every 2 weeks"; + case Frequency.Monthly: + return "Monthly"; + default: { + const _exhaustive: never = frequency; + throw new Error(`Unhandled frequency: ${_exhaustive}`); + } + } +} + +function endPhrase(end: RecurrenceEnd): string { + switch (end.kind) { + case "count": + return `for ${sessions(end.count)}`; + case "until": + return `until ${shortDate(DateTime.fromISO(end.until))}`; + default: { + const _exhaustive: never = end; + throw new Error(`Unhandled recurrence end: ${JSON.stringify(_exhaustive)}`); + } + } +} + +export function repeatLabel(choice: RepeatChoice): string { + switch (choice.kind) { + case "none": + return "Doesn't repeat"; + case "repeat": + return `${frequencyPhrase(choice.frequency)}, ${endPhrase(choice.end)}`; + default: { + const _exhaustive: never = choice; + throw new Error(`Unhandled repeat choice: ${JSON.stringify(_exhaustive)}`); + } + } +} + +export function scheduleSubmitLabel(choice: RepeatChoice): string { + switch (choice.kind) { + case "none": + return "Schedule session"; + case "repeat": + return choice.end.kind === "count" ? `Schedule ${sessions(choice.end.count)}` : "Schedule series"; + default: { + const _exhaustive: never = choice; + throw new Error(`Unhandled repeat choice: ${JSON.stringify(_exhaustive)}`); + } + } +} + +export function moveSubmitLabel(following: boolean): string { + return following ? "Reschedule this and following" : "Reschedule session"; +} + +export function createsLine(count: number, firstName: string): string { + return `${sessions(count)} with ${firstName}`; +} + +function providerName(provider: Provider): string { + switch (provider) { + case Provider.Google: + return "Google Meet"; + case Provider.Zoom: + return "Zoom"; + default: { + const _exhaustive: never = provider; + throw new Error(`Unhandled provider: ${_exhaustive}`); + } + } +} + +export function meetingLine(provider: Option): Option { + return provider.some ? Some(`${providerName(provider.val)} link included`) : None; +} + +export function currentlyLine(start: DateTime): string { + return `Currently ${start.toFormat("ccc")}, ${shortDate(start)}, ${timeOfDay(start)}`; +} + +export function resolveSubline(clash: BusySession): string { + return `${firstNameOf(clash.personName)}'s session on ${shortDay(clash.start)} at ${timeOfDay(clash.start)} overlaps the new one. Move it to:`; +} diff --git a/src/lib/scheduling.ts b/src/lib/scheduling.ts new file mode 100644 index 000000000..63ac86be9 --- /dev/null +++ b/src/lib/scheduling.ts @@ -0,0 +1,224 @@ +import type { DateTime } from "ts-luxon"; +import type { Id } from "@/types/general"; +import { type Option, None, Some } from "@/types/option"; +import type { Frequency, RecurrenceEnd } from "@/types/recurrence"; + +export interface BusySession { + id: Id; + personName: string; + start: DateTime; + end: DateTime; +} + +/** Minutes after local midnight. */ +export interface WorkingHours { + start: number; + end: number; +} + +export const DEFAULT_WORKING_HOURS: WorkingHours = { start: 9 * 60, end: 17 * 60 }; + +export interface Slot { + start: DateTime; + minutes: number; +} + +/** `weekday` is Luxon's, 1 = Monday through 7 = Sunday. */ +export interface UsualSlot { + weekday: number; + minute: number; +} + +export type RepeatChoice = + | { kind: "none" } + | { kind: "repeat"; frequency: Frequency; end: RecurrenceEnd }; + +const SLOT_STEP_MINUTES = 30; +const MAX_SUGGESTIONS = 5; + +function byStart(a: T, b: T): number { + return a.start.toMillis() - b.start.toMillis(); +} + +function chronological(a: DateTime, b: DateTime): number { + return a.toMillis() - b.toMillis(); +} + +function minuteOfDay(dt: DateTime): number { + return dt.hour * 60 + dt.minute; +} + +/** `minute` past midnight on `day`'s local date, in its zone. */ +function atMinute(day: DateTime, minute: number): DateTime { + return day.startOf("day").set({ hour: Math.floor(minute / 60), minute: minute % 60 }); +} + +function endOf(slot: Slot): DateTime { + return slot.start.plus({ minutes: slot.minutes }); +} + +export function busyOn(busy: BusySession[], day: DateTime, exclude: Option): BusySession[] { + return busy + .filter((b) => b.start.setZone(day.zone).hasSame(day, "day")) + .filter((b) => !(exclude.some && b.id === exclude.val)) + .sort(byStart); +} + +export function overlapping(slot: Slot, busy: BusySession[]): BusySession[] { + const end = endOf(slot); + return busy.filter((b) => slot.start < b.end && end > b.start).sort(byStart); +} + +export function nextFreeStart(slot: Slot, busy: BusySession[], hours: WorkingHours): Option { + const dayEnd = atMinute(slot.start, hours.end); + for (let offset = SLOT_STEP_MINUTES; ; offset += SLOT_STEP_MINUTES) { + const candidate = { start: slot.start.plus({ minutes: offset }), minutes: slot.minutes }; + if (endOf(candidate) > dayEnd) return None; + if (overlapping(candidate, busy).length === 0) return Some(candidate.start); + } +} + +export function freeSlots( + day: DateTime, + busy: BusySession[], + minutes: number, + hours: WorkingHours, + now: DateTime +): DateTime[] { + const starts: DateTime[] = []; + for (let minute = hours.start; minute + minutes <= hours.end; minute += SLOT_STEP_MINUTES) { + const slot = { start: atMinute(day, minute), minutes }; + if (slot.start >= now && overlapping(slot, busy).length === 0) starts.push(slot.start); + } + return starts; +} + +interface SlotTally { + slot: UsualSlot; + count: number; + latest: DateTime; +} + +function tallySlots(history: DateTime[]): SlotTally[] { + const tallies = new Map(); + for (const start of history) { + const slot = { weekday: start.weekday, minute: minuteOfDay(start) }; + const key = `${slot.weekday}:${slot.minute}`; + const tally = tallies.get(key); + if (!tally) tallies.set(key, { slot, count: 1, latest: start }); + else { + tally.count += 1; + if (start > tally.latest) tally.latest = start; + } + } + return [...tallies.values()]; +} + +export function usualSlot(seriesStart: Option, history: DateTime[]): Option { + if (seriesStart.some) { + return Some({ weekday: seriesStart.val.weekday, minute: minuteOfDay(seriesStart.val) }); + } + const [top] = tallySlots(history) + .filter((t) => t.count >= 2) + .sort((a, b) => b.count - a.count || b.latest.toMillis() - a.latest.toMillis()); + return top ? Some(top.slot) : None; +} + +export interface SuggestTimesInput { + day: DateTime; + minutes: number; + usual: Option; + coachStarts: DateTime[]; + busy: BusySession[]; + hours: WorkingHours; + now: DateTime; +} + +/** The coach's start times of day, most frequent first, ties earlier first. */ +function frequentMinutes(starts: DateTime[]): number[] { + const counts = new Map(); + for (const start of starts) { + const minute = minuteOfDay(start); + counts.set(minute, (counts.get(minute) ?? 0) + 1); + } + return [...counts.entries()].sort(([ma, ca], [mb, cb]) => cb - ca || ma - mb).map(([m]) => m); +} + +function roundHourMinutes(minutes: number, hours: WorkingHours): number[] { + const result: number[] = []; + for (let minute = Math.ceil(hours.start / 60) * 60; minute + minutes <= hours.end; minute += 60) { + result.push(minute); + } + return result; +} + +export function suggestTimes(input: SuggestTimesInput): DateTime[] { + const { day, minutes, usual, coachStarts, busy, hours, now } = input; + const candidates = [ + ...(usual.some ? [usual.val.minute] : []), + ...frequentMinutes(coachStarts), + ...roundHourMinutes(minutes, hours), + ]; + const seen = new Set(); + const kept: DateTime[] = []; + for (const minute of candidates) { + const start = atMinute(day, minute); + if (start < now || seen.has(start.toMillis())) continue; + // All of busy, not just the day's: a session can cross midnight either way. + if (overlapping({ start, minutes }, busy).length > 0) continue; + seen.add(start.toMillis()); + kept.push(start); + if (kept.length === MAX_SUGGESTIONS) break; + } + return kept.sort(chronological); +} + +export enum StepKind { + Who = "who", + When = "when", + Repeat = "repeat", + AppliesTo = "applies_to", + Resolve = "resolve", +} + +export type StepMode = { kind: "schedule" } | { kind: "move"; appliesTo: boolean }; + +function baseSteps(mode: StepMode): StepKind[] { + switch (mode.kind) { + case "schedule": + return [StepKind.Who, StepKind.When, StepKind.Repeat]; + case "move": + return mode.appliesTo ? [StepKind.When, StepKind.AppliesTo] : [StepKind.When]; + default: { + const _exhaustive: never = mode; + throw new Error(`Unhandled step mode: ${JSON.stringify(_exhaustive)}`); + } + } +} + +export function stepOrder(mode: StepMode, clash: boolean): StepKind[] { + const steps = baseSteps(mode); + return clash ? [...steps, StepKind.Resolve] : steps; +} + +export function currentStep(order: StepKind[], step: StepKind): StepKind { + return order.includes(step) ? step : order[order.length - 1]; +} + +export function stepPosition(order: StepKind[], step: StepKind): { index: number; total: number } { + return { index: order.indexOf(currentStep(order, step)) + 1, total: order.length }; +} + +function stepAt(order: StepKind[], index: number): Option { + return index >= 0 && index < order.length ? Some(order[index]) : None; +} + +export function nextStep(order: StepKind[], step: StepKind): Option { + const index = order.indexOf(step); + return index < 0 ? None : stepAt(order, index + 1); +} + +export function prevStep(order: StepKind[], step: StepKind): Option { + const index = order.indexOf(step); + return index < 0 ? None : stepAt(order, index - 1); +} diff --git a/src/lib/session-window.ts b/src/lib/session-window.ts index eb50d7d07..327da0446 100644 --- a/src/lib/session-window.ts +++ b/src/lib/session-window.ts @@ -145,6 +145,18 @@ export function sessionStartIn(date: string, timezone: string): DateTime { return DateTime.fromISO(date, { zone: "utc" }).setZone(timezone); } +export function sessionEndIn( + session: { date: string; duration_minutes: number }, + timezone: string +): DateTime { + return sessionStartIn(session.date, timezone).plus({ minutes: session.duration_minutes }); +} + +/** A local time as the stored naive UTC session date. */ +export function toSessionDate(local: DateTime): string { + return local.toUTC().toFormat("yyyy-MM-dd'T'HH:mm:ss"); +} + export function timeOfDay(dt: DateTime): string { return dt.toFormat("h:mm a"); } diff --git a/src/types/coaching-session-duration.ts b/src/types/coaching-session-duration.ts index a085407f7..74c8e703d 100644 --- a/src/types/coaching-session-duration.ts +++ b/src/types/coaching-session-duration.ts @@ -1,5 +1,6 @@ import { Result, ok, err } from "neverthrow"; import { EntityApiError } from "@/types/general"; +import { type Option, None, Some } from "@/types/option"; export const MIN_DURATION_MINUTES = 1; export const MAX_DURATION_MINUTES = 480; @@ -47,3 +48,23 @@ export function isDurationValidationError( typeof data.message === "string" ); } + +export const DURATION_CHIPS = [30, 45, 50, 60] as const; +export const DURATION_FIELD_MIN = 15; +export const DURATION_FIELD_MAX = 180; +export const DURATION_FIELD_STEP = 5; + +export function clampDuration(minutes: number): number { + if (Number.isNaN(minutes)) return DURATION_FIELD_MIN; + const rounded = Math.round(minutes / DURATION_FIELD_STEP) * DURATION_FIELD_STEP; + return Math.min(DURATION_FIELD_MAX, Math.max(DURATION_FIELD_MIN, rounded)); +} + +export function stepDuration(minutes: number, direction: 1 | -1): number { + return clampDuration(clampDuration(minutes) + direction * DURATION_FIELD_STEP); +} + +export function matchingChip(minutes: number): Option { + const chip = DURATION_CHIPS.find((c) => c === minutes); + return chip === undefined ? None : Some(chip); +}