Files
Crussell/frontend/src/lib/utils/timeSlots.ts
T
popertots b7122be3a0 fix: SCA review round + gitea pipeline green — GDPR audit scrub, backend test gaps, frontend SCA/Square-API, docs parity
7 review agents (pipeline run, self-review, codebase-context, frontend-placement,
backend testing-gaps, Square-API, docs-parity) audited the SCA-primary work.
ALL findings fixed, including every pre-existing red CI job:

GDPR (HIGH):
- anonymize_user() now scrubs admin_audit_log.target_user_id (mirrors
  delete_guest_user) so 2fa_fallback_charge rows (customer id + card_last4 PII)
  no longer survive registered-user account deletion; gdpr test added

BACKEND TEST GAPS (all 10):
- delivery-unavailable 503 branch: prod-tag predicate test + dev-variant marker
- twoFactorFallbackEnabled alias/case/default matrix tests + exported wrapper
- insertTwoFAFallbackAudit details-JSON shape + audit-row assertions for all
  6 gate sites (booking/tip/gift-card/payment-method/terminal/till, both actors)
- CreateTerminalPayment.VerificationToken: passthrough, too-long 400, 2FA-skip,
  token-less fallback + SCA-required (new terminal_sca_test.go)
- isVerificationRequiredError at all 5 charge sites (402 + code:verification_required)
- customer_initiated handler-level assertions (MIT false admin / CIT true customer)
- Mock: ApprovePendingVerification, ChallengeResult auto/deny, _deny token suffix,
  parseVerifyToken unit tests

FRONTEND SCA + Square-API (CRITICAL):
- tokenizeSavedCardWithVerification reads result.token (the verified token) not
  result.verificationResult (deprecated verifyBuyer shape — saved-card SCA could
  never succeed in production before); parseTokenizeVerificationResult pure fn
  extracted + pinned in square.test.ts; 'verified' with no token proceeds tokenless
- HIGH: saved-card idempotency key regenerated after a definitive 402 (fresh token
  under the same key = IDEMPOTENCY_KEY_REUSED dead-loop); kept on 503/cancelled
- challenge-cancelled copy no longer promises a 2FA fallback the UI doesn't show;
  'waiting for approval in your banking app' state on CIT surfaces
- sca-unavailable demotion resets per attempt; card selection disabled mid-challenge;
  genuine saved-card declines no longer relabeled 'requires verification';
  modal-close guard during processing; retry affordance standardized

PIPELINE (every red job now green):
- prod-tag build break fixed (shared square stub + test_helpers_test.go, prod-safe)
- govulncheck: x/image 0.45.0 bumped (x/text resolved); go mod tidy clean
- race: TestDeleteAccount_InvalidatesSquareCustomerCache made deterministic
- DAV_ADMIN_PASSWORD placeholder in .env.example (compose config passes)
- frontend: prettier 28 files, eslint, a11y 38 errors, knip (currentZIndex),
  deps in-range, audit vulns (nanoid/postcss) — all fixed; 67 vitest cases

DOCS PARITY (6 DRIFTs + 5 GAPs): payments doc Ch4/Ch14/Appendix A, Technical
Manual 2FA + counter-reset + payment sections, README test counts + SNAPSHOT_ENC_KEY,
Feature Catalog, .env.example REQUIRE_2FA — SCA-primary/2FA-backup posture verified
against code everywhere

Verified: 26/26 dev + 24/24 prod packages, both vet tags, golangci-lint/staticcheck/
gosec 0 on both tags, gitleaks clean, 2,464 backend + 67 frontend tests.
2026-08-22 00:34:50 +01:00

291 lines
9.8 KiB
TypeScript

import { CalendarDate } from '@internationalized/date';
import {
extractBookedSlots,
getLunchProtectionForSlots,
type LunchProtectionResult
} from '$lib/lunchProtection';
/**
* Returns the UTC datetime string with explicit +00:00 offset, suitable for
* sending to the backend for scheduling/booking purposes.
* This is functionally equivalent to `Date.toISOString()` except the suffix
* is `+00:00` instead of `Z`. Both are valid RFC3339, but Go's `time.Time`
* JSON unmarshalling prefers the explicit offset form when constructing
* wall-clock timestamps that should not be shifted by the backend's timezone.
* The DST correctness of the resulting datetime comes from the CalendarDate
* / getLocalTimeZone() path used to construct the Date object — not from
* this formatting function.
*/
export function formatLocalDateTime(date: Date): string {
const y = date.getUTCFullYear();
const m = String(date.getUTCMonth() + 1).padStart(2, '0');
const d = String(date.getUTCDate()).padStart(2, '0');
const h = String(date.getUTCHours()).padStart(2, '0');
const min = String(date.getUTCMinutes()).padStart(2, '0');
const s = String(date.getUTCSeconds()).padStart(2, '0');
return `${y}-${m}-${d}T${h}:${min}:${s}+00:00`;
}
/**
* Parses a UTC ISO datetime string (e.g. "2026-06-15T09:00:00Z" or
* "2026-06-15T09:00:00+00:00") from the backend and returns a Date
* whose getHours()/getMinutes() in the browser's local timezone
* reflect the local wall-clock time.
*
* The backend stores all times as UTC wall-clock values. This function
* uses the standard Date parser which correctly interprets the ISO
* string as UTC and applies the browser timezone for get*() accessors.
* Example: "2026-06-15T09:00:00Z" → getHours() returns 10 in BST.
*/
export function parseWallClockDate(iso: string): Date {
return new Date(iso);
}
/**
* Returns a CalendarDate representing today's date in the Europe/London timezone.
* This ensures "is today" comparisons are correct even during BST (British Summer Time)
* when there is a window between 00:00-01:00 BST where the UTC date differs from the
* London date. Avoids using browser-local time which can be wrong in that window.
*/
export function getLondonTodayCalendarDate(): CalendarDate {
const londonDateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
const [y, m, d] = londonDateStr.split('-').map(Number);
return new CalendarDate(y, m, d);
}
export interface TimeSlotGroup {
type: 'available' | 'unavailable';
startTime: string;
endTime: string;
isGrouped?: boolean;
outOfHours?: boolean;
}
export interface DayHours {
isOpen: boolean;
startTime: string;
endTime: string;
}
export interface DayAvailability {
isOpen: boolean;
slots: Array<{ startTime: string; endTime: string }>;
}
export function formatTime(time: string): string {
const parts = time.split(':').map(Number);
const hours = parts[0];
const minutes = parts.length > 1 ? parts[1] : 0;
if (hours === 12 && minutes === 0) return 'Noon';
if (hours === 0 && minutes === 0) return 'Midnight';
const period = hours >= 12 ? 'PM' : 'AM';
const displayHours = hours % 12 || 12;
return `${displayHours}:${minutes.toString().padStart(2, '0')} ${period}`;
}
export function calculateEndTime(startTime: string, durationMinutes: number): string {
const [hours, minutes] = startTime.split(':').map(Number);
const totalMinutes = hours * 60 + minutes + durationMinutes;
return `${String(Math.floor(totalMinutes / 60)).padStart(2, '0')}:${String(totalMinutes % 60).padStart(2, '0')}`;
}
export function timeToMinutes(time: string): number {
const [hours, minutes] = time.split(':').map(Number);
return hours * 60 + minutes;
}
export function getDayWithOrdinal(date: CalendarDate): string {
const monthName = new Date(date.year, date.month - 1, date.day).toLocaleDateString('en-GB', {
month: 'long'
});
const day = date.day;
if (day > 3 && day < 21) return monthName + ' ' + day + 'th';
switch (day % 10) {
case 1:
return monthName + ' ' + day + 'st';
case 2:
return monthName + ' ' + day + 'nd';
case 3:
return monthName + ' ' + day + 'rd';
default:
return monthName + ' ' + day + 'th';
}
}
export function buildLunchProtection(
date: CalendarDate,
workingHours: Record<string, DayHours> | null,
availableHours: Record<string, DayAvailability> | null,
duration: number,
isAdmin: boolean
): Map<string, LunchProtectionResult> {
if (!workingHours || !availableHours) return new Map();
const dateStr = date.toString();
const dayWH = workingHours[dateStr];
const dayAH = availableHours[dateStr];
if (!dayWH?.isOpen || !dayAH?.slots) return new Map();
const existingBookings = extractBookedSlots(dayWH.startTime, dayWH.endTime, dayAH.slots);
return getLunchProtectionForSlots(
dayWH.startTime,
dayWH.endTime,
existingBookings,
duration,
15,
isAdmin
);
}
export function generateAvailableTimeSlots(
date: CalendarDate,
workingHours: Record<string, DayHours> | null,
availableHours: Record<string, DayAvailability> | null,
duration: number,
protection: Map<string, LunchProtectionResult>
): string[] {
if (!workingHours || !availableHours) return [];
const dateStr = date.toString();
const dayWH = workingHours[dateStr];
const dayAH = availableHours[dateStr];
if (!dayWH?.isOpen || !dayAH?.slots) {
return [];
}
const slots: string[] = [];
const todayCal = getLondonTodayCalendarDate();
const isToday = date.compare(todayCal) === 0;
for (const slot of dayAH.slots) {
const [startHour, startMinute] = slot.startTime.split(':').map(Number);
const [endHour, endMinute] = slot.endTime.split(':').map(Number);
let startTotalMinutes = Math.ceil((startHour * 60 + startMinute) / 15) * 15;
const endTotalMinutes = endHour * 60 + endMinute;
if (isToday) {
const now = new Date();
const londonTimeStr = now.toLocaleTimeString('en-GB', {
timeZone: 'Europe/London',
hour: '2-digit',
minute: '2-digit',
hour12: false
});
const [londonHours, londonMinutes] = londonTimeStr.split(':').map(Number);
const currentMinutes = londonHours * 60 + londonMinutes;
const minimumStart = Math.ceil((currentMinutes + 60) / 15) * 15;
startTotalMinutes = Math.max(startTotalMinutes, minimumStart);
}
for (let minutes = startTotalMinutes; minutes < endTotalMinutes; minutes += 15) {
if (minutes + duration <= endTotalMinutes) {
const timeStr = `${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}`;
const p = protection.get(timeStr);
if (!p || !p.isBlocked) {
slots.push(timeStr);
}
}
}
}
return slots;
}
export function generateGroupedTimeSlots(
date: CalendarDate,
workingHours: Record<string, DayHours> | null,
availableHours: Record<string, DayAvailability> | null,
duration: number,
protection: Map<string, LunchProtectionResult>
): TimeSlotGroup[] {
if (!workingHours) {
return [];
}
const dateStr = date.toString();
const dayWH = workingHours[dateStr];
if (!dayWH || !dayWH.isOpen) {
return [];
}
const grouped: TimeSlotGroup[] = [];
const [startHour, startMinute] = dayWH.startTime.split(':').map(Number);
const [endHour, endMinute] = dayWH.endTime.split(':').map(Number);
let startTotalMinutes = startHour * 60 + startMinute;
const endTotalMinutes = endHour * 60 + endMinute;
const todayCal = getLondonTodayCalendarDate();
if (date.compare(todayCal) === 0) {
const now = new Date();
const londonTimeStr = now.toLocaleTimeString('en-GB', {
timeZone: 'Europe/London',
hour: '2-digit',
minute: '2-digit',
hour12: false
});
const [londonHours, londonMinutes] = londonTimeStr.split(':').map(Number);
const currentMinutes = londonHours * 60 + londonMinutes;
startTotalMinutes = Math.max(startTotalMinutes, Math.ceil((currentMinutes + 15) / 15) * 15);
}
const availableSlots = generateAvailableTimeSlots(
date,
workingHours,
availableHours,
duration,
protection
);
let currentUnavailableStart: string | null = null;
let lastAvailableEndTime: string | null = null;
for (let minutes = startTotalMinutes; minutes < endTotalMinutes; minutes += 15) {
const timeStr = `${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}`;
const p = protection.get(timeStr);
const isAvailable = availableSlots.includes(timeStr) && !p?.isBlocked;
if (isAvailable) {
if (currentUnavailableStart !== null) {
const unavailStart = lastAvailableEndTime || currentUnavailableStart;
const prevParts = timeStr.split(':').map(Number);
const prevTotal = prevParts[0] * 60 + prevParts[1] - 15;
const groupEnd = `${String(Math.floor(prevTotal / 60)).padStart(2, '0')}:${String(prevTotal % 60).padStart(2, '0')}`;
if (timeToMinutes(unavailStart) < timeToMinutes(groupEnd)) {
grouped.push({
type: 'unavailable',
startTime: unavailStart,
endTime: groupEnd,
isGrouped: true
});
}
currentUnavailableStart = null;
}
const slotEnd = calculateEndTime(timeStr, duration);
lastAvailableEndTime = slotEnd;
grouped.push({ type: 'available', startTime: timeStr, endTime: slotEnd });
if (timeToMinutes(slotEnd) >= endTotalMinutes) break;
} else {
if (currentUnavailableStart === null) {
currentUnavailableStart = timeStr;
}
}
}
if (currentUnavailableStart !== null) {
const lastAvail = grouped.filter((s) => s.type === 'available').pop();
const lastAvailEnd = lastAvail ? timeToMinutes(lastAvail.endTime) : 0;
const unavailStartMin = timeToMinutes(currentUnavailableStart);
if (unavailStartMin < endTotalMinutes && lastAvailEnd < endTotalMinutes) {
grouped.push({
type: 'unavailable',
startTime: lastAvail ? lastAvail.endTime : currentUnavailableStart,
endTime: (() => {
const p = dayWH.endTime.split(':');
return p.length < 2
? p[0].padStart(5, '0')
: `${p[0].padStart(2, '0')}:${p[1].padStart(2, '0')}`;
})(),
isGrouped: true
});
}
}
return grouped;
}