Gift-card rolling expiry, SvelteDate→Date purge, strict DST tests, UTC scan-location + settings legal floor

Gift-card rolling expiry (setting-driven, was dead config):
- GetGiftCardExpiryMonths(): single source of truth (business_settings
  gift_card_expiry_months, fallback 24) shared by payment handlers and the
  CleanupExpiredGiftCards job (was hardcoded 24).
- expiry_date now maintained on ALL 9 gift-card write sites (buy, topup,
  transfer, redeem, terminal payment, refund credit, till) so the refund-time
  guard at refunds.go actually fires. Schema default 12->24 + migration note;
  test-DB seed aligned. Stale "expiry_date IS NULL" test rewritten; new
  expired-card-rejected regression test.

Frontend SvelteDate purge (docs' stated convention, wide):
- All 180+ raw `new SvelteDate(...)` uses across routes/components replaced
  with parseWallClockDate (backend UTC ISO) or new Date (wall-clock
  constructors). SvelteDate imports removed. timeSlots.ts getDayWithOrdinal
  fixed. Zero SvelteDate references remain; svelte-check clean.

Strict timezone/DST testing + QA fixes:
- 8 new hermetic boundary tests: clock.DST transitions (both 2026 folds),
  closing-hours GMT vs BST, booking date-window midnight, refund-tier
  elapsed-time independence, deposit-window UTC-instant, scheduling
  LondonDateString midnight, today AT TIME ZONE window + UTC round-trip.
- today.go summary date labels fixed to London wall-clock (were showing the
  previous UTC day during BST) + regression test.
- pgx ScanLocation fixed to UTC via AfterConnect (was host-local -> JSON
  offsets depended on deployment TZ, contradicting the documented UTC
  invariant) + regression test. Registered as a new *Type to avoid a data
  race on the shared type map (caught by -race).

Admin Business Settings (setting now functional => legal floor):
- gift_card_expiry_months validation floor raised 1 -> 12 months (CMA/
  Consumer Rights Act 2015 unfair-contract-term guidance) in endpoint + UI,
  with rolling-expiry semantics shown in both display and edit form.
- 3 new expiry validation tests; 2 pre-existing message assertions updated.

Full suite 25/25 + race clean via run-tests.sh lockfile; svelte-check 0
errors/warnings; production build succeeds.
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent 7f1c649f1e
commit 197d4c4b9b
54 changed files with 1204 additions and 275 deletions
+71
View File
@@ -11,6 +11,77 @@ func TestNow_ReturnsUTC(t *testing.T) {
if now.Location() != time.UTC {
t.Errorf("clock.Now() returned time in %v, expected UTC", now.Location())
}
// UTC is a fixed-offset zone: the zone abbreviation and offset must both be
// the canonical UTC values so SQL TIMESTAMPTZ comparisons and duration math
// never drift.
name, off := now.Zone()
if name != "UTC" || off != 0 {
t.Errorf("clock.Now() returned zone %q offset %d, expected UTC offset 0", name, off)
}
}
// TestLondon_Location_IsEuropeLondon proves the single London location used for
// wall-clock business decisions is the IANA Europe/London zone (which carries
// the full DST rule table for GMT<->BST).
func TestLondon_Location_IsEuropeLondon(t *testing.T) {
t.Parallel()
if got := London.String(); got != "Europe/London" {
t.Errorf("clock.London.String() = %q, expected %q", got, "Europe/London")
}
}
// TestLondon_DSTTransitions pins the two 2026 Europe/London transitions:
//
// Spring forward (2026-03-29 01:00 GMT -> 02:00 BST):
// - 2026-03-29 00:30 UTC = 01:30 GMT, offset +0 (still winter time)
// - 2026-03-29 01:00 UTC = 02:00 BST, offset +1 (the transition instant)
// Fall back (2026-10-25 02:00 BST -> 01:00 GMT):
// - 2026-10-25 00:30 UTC = 01:30 BST, offset +1 (still summer time)
// - 2026-10-25 01:00 UTC = 01:00 GMT, offset +0 (the ambiguous hour, which
// Go resolves to the SECOND occurrence)
//
// These instants are the boundary of every London wall-clock decision the app
// makes (closing hours, default-hours effective dates, today summaries), so
// clock.London must get them exactly right.
func TestLondon_DSTTransitions(t *testing.T) {
t.Parallel()
cases := []struct {
name string
utc time.Time
wantWall string // London wall-clock, "2006-01-02 15:04"
wantZone string // "GMT" or "BST"
wantOffsetH int
}{
{"spring before", time.Date(2026, 3, 29, 0, 30, 0, 0, time.UTC), "2026-03-29 00:30", "GMT", 0},
{"spring transition", time.Date(2026, 3, 29, 1, 0, 0, 0, time.UTC), "2026-03-29 02:00", "BST", 1},
{"autumn before", time.Date(2026, 10, 25, 0, 30, 0, 0, time.UTC), "2026-10-25 01:30", "BST", 1},
{"autumn ambiguous->GMT", time.Date(2026, 10, 25, 1, 0, 0, 0, time.UTC), "2026-10-25 01:00", "GMT", 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
l := tc.utc.In(London)
if got := l.Format("2006-01-02 15:04"); got != tc.wantWall {
t.Errorf("%s UTC in London = %q, expected wall-clock %q", tc.utc.Format(time.RFC3339), got, tc.wantWall)
}
zone, off := l.Zone()
if zone != tc.wantZone {
t.Errorf("%s UTC in London zone = %q, expected %q", tc.utc.Format(time.RFC3339), zone, tc.wantZone)
}
if off != tc.wantOffsetH*3600 {
t.Errorf("%s UTC in London offset = %ds, expected %dh", tc.utc.Format(time.RFC3339), off, tc.wantOffsetH)
}
})
}
// Sanity: the two wall-clock times on the autumn transition day share the
// same 01:xx wall hour (the ambiguous hour) but at different UTC instants —
// proving Go resolves the fold to the second occurrence when converting the
// UTC instant back to London.
fold1 := time.Date(2026, 10, 25, 0, 30, 0, 0, time.UTC).In(London).Format("15:04") // 01:30 BST
fold2 := time.Date(2026, 10, 25, 1, 30, 0, 0, time.UTC).In(London).Format("15:04") // 01:30 GMT
if fold1 != "01:30" || fold2 != "01:30" {
t.Errorf("expected both sides of the autumn fold to show 01:30, got %q and %q", fold1, fold2)
}
}
func TestNow_IsReasonable(t *testing.T) {
+24
View File
@@ -6,7 +6,10 @@ import (
"context"
"fmt"
"os"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
)
@@ -27,6 +30,27 @@ func Connect() error {
return err
}
poolCfg.ConnConfig.RuntimeParams["timezone"] = "UTC"
// Scan TIMESTAMPTZ into time.Time in UTC, never the host's local timezone.
// Without this, pgx scans into time.Local, so the JSON offset in API
// responses silently depends on the deployment host's TZ (e.g. +01:00 on a
// London host, Z on a UTC Docker host) — the instant is the same but the
// documented "backend emits UTC" invariant would be violated.
// The codec is registered as a NEW *Type rather than mutating the Type
// returned by TypeForOID: every connection's type map shares the same
// *Type pointers with the package default map, so mutating .Codec on the
// shared Type would be a data race when concurrent connections establish.
poolCfg.AfterConnect = func(_ context.Context, conn *pgx.Conn) error {
tzType, ok := conn.TypeMap().TypeForOID(pgtype.TimestamptzOID)
if !ok {
return nil
}
conn.TypeMap().RegisterType(&pgtype.Type{
Codec: &pgtype.TimestamptzCodec{ScanLocation: time.UTC},
Name: tzType.Name,
OID: tzType.OID,
})
return nil
}
pool, err := pgxpool.NewWithConfig(context.Background(), poolCfg)
if err != nil {
return err
+24
View File
@@ -6,7 +6,10 @@ import (
"context"
"fmt"
"os"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
)
@@ -27,6 +30,27 @@ func Connect() error {
return err
}
poolCfg.ConnConfig.RuntimeParams["timezone"] = "UTC"
// Scan TIMESTAMPTZ into time.Time in UTC, never the host's local timezone.
// Without this, pgx scans into time.Local, so the JSON offset in API
// responses silently depends on the deployment host's TZ (e.g. +01:00 on a
// London host, Z on a UTC Docker host) — the instant is the same but the
// documented "backend emits UTC" invariant would be violated.
// The codec is registered as a NEW *Type rather than mutating the Type
// returned by TypeForOID: every connection's type map shares the same
// *Type pointers with the package default map, so mutating .Codec on the
// shared Type would be a data race when concurrent connections establish.
poolCfg.AfterConnect = func(_ context.Context, conn *pgx.Conn) error {
tzType, ok := conn.TypeMap().TypeForOID(pgtype.TimestamptzOID)
if !ok {
return nil
}
conn.TypeMap().RegisterType(&pgtype.Type{
Codec: &pgtype.TimestamptzCodec{ScanLocation: time.UTC},
Name: tzType.Name,
OID: tzType.OID,
})
return nil
}
pool, err := pgxpool.NewWithConfig(context.Background(), poolCfg)
if err != nil {
return err
+37
View File
@@ -8,6 +8,7 @@ import (
"os"
"sync"
"testing"
"time"
)
func resetEnv() {
@@ -71,6 +72,42 @@ func TestConnect_PingViaTestDB(t *testing.T) {
}
}
// TestScanLocation_UTC proves the AfterConnect hook: TIMESTAMPTZ values are
// scanned into time.Time in UTC, never the host's local timezone. Without the
// hook, pgx scans into time.Local, so a London-host dev server would emit
// +01:00 JSON offsets while a UTC Docker host emits Z — same instant, but the
// documented "backend emits UTC" invariant would silently depend on the
// deployment host's TZ.
func TestScanLocation_UTC(t *testing.T) {
closePool()
resetEnv()
err := Connect()
if err != nil {
t.Fatalf("Connect() failed: %v", err)
}
defer closePool()
poolConn, err := Conn.Acquire(context.Background())
if err != nil {
t.Fatalf("Acquire failed: %v", err)
}
defer poolConn.Release()
// A known instant: 2026-06-15 00:30 BST = 2026-06-14 23:30 UTC.
instant := time.Date(2026, 6, 14, 23, 30, 0, 0, time.UTC)
var scanned time.Time
if err := poolConn.QueryRow(context.Background(), "SELECT $1::timestamptz", instant).Scan(&scanned); err != nil {
t.Fatalf("timestamptz scan failed: %v", err)
}
if !scanned.Equal(instant) {
t.Errorf("scanned instant = %s, expected %s", scanned.Format(time.RFC3339Nano), instant.Format(time.RFC3339Nano))
}
if scanned.Location() != time.UTC {
t.Errorf("scanned time.Time location = %v, expected time.UTC (host TZ is %v)", scanned.Location(), time.Local)
}
}
// =============================================================================
// Connection failure scenarios
// =============================================================================
+6 -2
View File
@@ -169,8 +169,12 @@ func UpdateBusinessSettings(w http.ResponseWriter, r *http.Request) {
http.Error(w, "voucher_type must be 'SPV' or 'MPV'", http.StatusBadRequest)
return
}
if req.GiftCardExpiryMonths != nil && *req.GiftCardExpiryMonths < 1 {
http.Error(w, "gift_card_expiry_months must be at least 1", http.StatusBadRequest)
if req.GiftCardExpiryMonths != nil && *req.GiftCardExpiryMonths < 12 {
// Legal floor, not arbitrary: UK Consumer Rights Act 2015 requires
// expiry terms to be "fair and transparent", and CMA guidance flags
// sub-12-month expiry windows as at risk of being an unfair contract
// term. 24 months is the documented default (matches John Lewis, M&S).
http.Error(w, "gift_card_expiry_months must be at least 12 (CMA guidance flags sub-12-month expiry as an unfair contract term; 24 is recommended)", http.StatusBadRequest)
return
}
if req.DefaultVATRate != nil && (*req.DefaultVATRate < 0 || *req.DefaultVATRate > 100) {
+62 -3
View File
@@ -725,6 +725,65 @@ func TestUpdateBusinessSettings_VoucherType_SPV(t *testing.T) {
}
}
// TestUpdateBusinessSettings_Expiry_RejectsSub12 rejects gift_card_expiry_months
// below the legal floor. The setting now actually drives gift-card expiry (the
// cleanup job + every expiry_date write), so a sub-12-month window — flagged by
// CMA guidance as an unfair contract term under the Consumer Rights Act 2015 —
// must not be settable.
func TestUpdateBusinessSettings_Expiry_RejectsSub12(t *testing.T) {
ctx, _ := testutils.SetupTestTx(t)
handler := http.HandlerFunc(UpdateBusinessSettings)
body := UpdateBusinessSettingsRequest{
GiftCardExpiryMonths: intPtr(6),
}
w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400 for 6-month expiry, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestUpdateBusinessSettings_Expiry_RejectsZero rejects gift_card_expiry_months
// of 0 (previously the only invalid value, now the legal floor subsumes it).
func TestUpdateBusinessSettings_Expiry_RejectsZero(t *testing.T) {
ctx, _ := testutils.SetupTestTx(t)
handler := http.HandlerFunc(UpdateBusinessSettings)
body := UpdateBusinessSettingsRequest{
GiftCardExpiryMonths: intPtr(0),
}
w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400 for 0-month expiry, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestUpdateBusinessSettings_Expiry_Accepts24 verifies a 24-month window (the
// documented default) is accepted and persisted.
func TestUpdateBusinessSettings_Expiry_Accepts24(t *testing.T) {
ctx, _ := testutils.SetupTestTx(t)
handler := http.HandlerFunc(UpdateBusinessSettings)
body := UpdateBusinessSettingsRequest{
GiftCardExpiryMonths: intPtr(24),
}
w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200 for 24-month expiry, got %d. body: %s", w.Code, w.Body.String())
}
var s BusinessSettings
if err := parseResponseBody(w, &s); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if s.GiftCardExpiryMonths != 24 {
t.Errorf("expected GiftCardExpiryMonths 24, got %d", s.GiftCardExpiryMonths)
}
}
// TestUpdateBusinessSettings_VoucherType_MPV accepts MPV.
func TestUpdateBusinessSettings_VoucherType_MPV(t *testing.T) {
ctx, _ := testutils.SetupTestTx(t)
@@ -764,7 +823,7 @@ func TestUpdateBusinessSettings_NegativeExpiryMonths(t *testing.T) {
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
if w.Body.String() != "gift_card_expiry_months must be at least 1\n" {
if !strings.Contains(w.Body.String(), "must be at least 12") {
t.Errorf("unexpected error message: %s", w.Body.String())
}
}
@@ -782,8 +841,8 @@ func TestUpdateBusinessSettings_ExpiryMonths_Negative(t *testing.T) {
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400 for negative expiry, got %d. body: %s", w.Code, w.Body.String())
}
if w.Body.String() != "gift_card_expiry_months must be at least 1\n" {
t.Errorf("unexpected error message: %s", w.Body.String())
if !strings.Contains(w.Body.String(), "must be at least 12") {
t.Errorf("expected legal-floor error message, got: %s", w.Body.String())
}
}
@@ -0,0 +1,173 @@
//go:build test && dev
package bookings
// Strict timezone/DST/midnight-boundary tests for the booking closing-hours
// pipeline and the date-window queries. All assertions use fixed time.Date
// instants (no wall-clock "now" drift) so they are deterministic on every run.
import (
"net/http"
"testing"
"time"
"crussell/clock"
"crussell/testutils"
"crussell/testutils/fixtures"
"crussell/testutils/jwt"
)
// TestCheckClosingHours_GMTvsBST proves the closing-time pipeline is
// DST-safe: the same 16:30 wall-clock end is accepted and the same 17:30
// wall-clock end is rejected during BOTH a GMT season (2026-01-15, offset +0)
// and a BST season (2026-06-15, offset +1). The same wall-clock instant maps
// to UTC instants that differ by exactly the 1h DST offset between seasons —
// the conversion via londonLocation must absorb that shift.
func TestCheckClosingHours_GMTvsBST(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
// 2026-01-15 is a Thursday (DB weekday 3), 2026-06-15 is a Monday (DB
// weekday 0). Pin closing at 17:00 on both so the resolution goes through
// the real getClosingTimeForDate -> working_hours path.
for _, wd := range []int{0, 3} {
_, err := tx.Exec(ctx, `
INSERT INTO working_hours (weekday, start_time, end_time, is_open)
VALUES ($1, '08:00', '17:00', true)
ON CONFLICT (weekday) DO UPDATE SET start_time = '08:00', end_time = '17:00', is_open = true
`, wd)
if err != nil {
t.Fatalf("failed to set working hours for weekday %d: %v", wd, err)
}
}
cases := []struct {
name string
localEnd time.Time // Europe/London wall-clock booking end
wantUTC int // expected UTC hour of that same instant
}{
{"GMT 2026-01-15", time.Date(2026, 1, 15, 16, 30, 0, 0, clock.London), 16},
{"BST 2026-06-15", time.Date(2026, 6, 15, 16, 30, 0, 0, clock.London), 15},
}
// The SAME 16:30 wall-clock end is 16:30 UTC in GMT and 15:30 UTC in BST —
// a shift of exactly 1h (the DST offset).
if diff := cases[0].localEnd.UTC().Hour() - cases[1].localEnd.UTC().Hour(); diff != 1 {
t.Fatalf("expected the two 16:30-local ends to differ by exactly 1h in UTC, got %dh", diff)
}
for _, tc := range cases {
t.Run(tc.name+" pass 16:30", func(t *testing.T) {
weekday := int((tc.localEnd.Weekday() + 6) % 7)
closeStr, err := getClosingTimeForDate(ctx, tx, weekday, tc.localEnd)
if err != nil {
t.Fatalf("getClosingTimeForDate failed: %v", err)
}
// The DB stores end_time as TIME so ::text returns "17:00:00".
if closeStr != "17:00" && closeStr != "17:00:00" {
t.Fatalf("expected closing 17:00 for %s, got %q", tc.name, closeStr)
}
if err := checkClosingHours(tc.localEnd, closeStr); err != nil {
t.Errorf("16:30 local end on %s must pass closing 17:00, got: %v", tc.name, err)
}
if got := tc.localEnd.UTC().Hour(); got != tc.wantUTC {
t.Errorf("16:30 local on %s should be %02d:30 UTC, got hour %d", tc.name, tc.wantUTC, got)
}
})
}
failCases := []struct {
name string
localEnd time.Time
}{
{"GMT 2026-01-15", time.Date(2026, 1, 15, 17, 30, 0, 0, clock.London)},
{"BST 2026-06-15", time.Date(2026, 6, 15, 17, 30, 0, 0, clock.London)},
}
for _, tc := range failCases {
t.Run(tc.name+" fail 17:30", func(t *testing.T) {
if err := checkClosingHours(tc.localEnd, "17:00"); !IsPastClosing(err) {
t.Errorf("17:30 local end on %s must be rejected, got: %v", tc.name, err)
}
})
}
}
// TestBookingDateWindow_DSTMidnight proves the booking date-window queries
// (bookings.go GetAllUserBookingsHandler start_date/end_date conversion) bucket
// bookings by their LONDON calendar date, not their UTC date. A booking at
// 2026-06-14 23:30 UTC is 2026-06-15 00:30 BST — inside the 00:00-01:00 BST
// window where the UTC date differs from the London date — and must appear in
// the "2026-06-15" window, not "2026-06-14".
func TestBookingDateWindow_DSTMidnight(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
// Precondition: 2026-06-14 23:30 UTC must be 2026-06-15 00:30 BST (the
// UTC-date != London-date window). This is the exact property under test.
boundary := time.Date(2026, 6, 14, 23, 30, 0, 0, time.UTC)
if got := boundary.In(clock.London).Format("2006-01-02"); got != "2026-06-15" {
t.Fatalf("test setup invariant: 2026-06-14 23:30 UTC must be London date 2026-06-15, got %s", got)
}
// Booking A: the midnight-boundary booking (00:30 BST on the 15th).
bookingA, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, boundary)
if err != nil {
t.Fatalf("failed to create boundary booking: %v", err)
}
// Booking B: 22:30 UTC on the 14th = 23:30 BST on the 14th (clearly the 14th).
bookingB, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, time.Date(2026, 6, 14, 22, 30, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create day-before booking: %v", err)
}
token := jwt.GenerateUserToken(userID)
// Window 2026-06-15: only the boundary booking belongs (00:30 BST on the
// 15th); the 22:30-UTC booking is 23:30 BST on the 14th.
w := makeRequest(http.HandlerFunc(GetAllUserBookingsHandler), "GET",
"/api/bookings?start_date=2026-06-15&end_date=2026-06-15", nil, token, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp BookingListResponse
if err := parseResponseBody(w, &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp.Total != 1 || len(resp.Bookings) != 1 || resp.Bookings[0].ID != bookingA {
t.Errorf("window 2026-06-15: expected only booking A (%s) at 00:30 BST, got total=%d ids=%v",
bookingA, resp.Total, bookingIDs(resp.Bookings))
}
// Window 2026-06-14: only booking B belongs; the 23:30-UTC boundary booking
// has already rolled over to London date 2026-06-15.
w2 := makeRequest(http.HandlerFunc(GetAllUserBookingsHandler), "GET",
"/api/bookings?start_date=2026-06-14&end_date=2026-06-14", nil, token, ctx)
if w2.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w2.Code, w2.Body.String())
}
var resp2 BookingListResponse
if err := parseResponseBody(w2, &resp2); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp2.Total != 1 || len(resp2.Bookings) != 1 || resp2.Bookings[0].ID != bookingB {
t.Errorf("window 2026-06-14: expected only booking B (%s), got total=%d ids=%v",
bookingB, resp2.Total, bookingIDs(resp2.Bookings))
}
}
func bookingIDs(bookings []Booking) []string {
ids := make([]string, len(bookings))
for i, b := range bookings {
ids[i] = b.ID
}
return ids
}
@@ -0,0 +1,124 @@
//go:build test && dev
package payments
// Strict timezone/DST tests for the payment refund tiers and the deposit
// protection window. All assertions use fixed time.Date instants.
import (
"testing"
"time"
"crussell/clock"
)
// TestRefundTiers_TimezoneIndependent proves refund tiers depend only on the
// ELAPSED time between cancellation and start (startTime.Sub(cancellationTime)
// .Hours()), never on wall-clock days or the London calendar date. Two bookings
// in different seasons — 2026-01-20 10:00 UTC (GMT) and 2026-06-20 10:00 UTC
// (BST) — cancelled with identical notice must produce identical results for
// every tier.
func TestRefundTiers_TimezoneIndependent(t *testing.T) {
t.Parallel()
gmtStart := time.Date(2026, 1, 20, 10, 0, 0, 0, time.UTC)
bstStart := time.Date(2026, 6, 20, 10, 0, 0, 0, time.UTC)
// Precondition: the two starts are genuinely in different seasons — same
// UTC instant-of-day but different London wall-clock zones.
if gz, _ := gmtStart.In(clock.London).Zone(); gz != "GMT" {
t.Fatalf("expected 2026-01-20 in London to be GMT, got %q", gz)
}
if bz, _ := bstStart.In(clock.London).Zone(); bz != "BST" {
t.Fatalf("expected 2026-06-20 in London to be BST, got %q", bz)
}
notices := []struct {
name string
hours float64
want string
}{
{"over 72h -> full refund", 72.5, FullRefundTier},
{"at 72h -> partial (strict > boundary)", 72, PartialRefundTier},
{"48h -> partial refund", 48, PartialRefundTier},
{"at 24h -> partial refund", 24, PartialRefundTier},
{"under 24h -> no refund", 23.5, NoRefundTier},
}
for _, tc := range notices {
noticeDur := time.Duration(tc.hours * float64(time.Hour))
cancellationGmt := gmtStart.Add(-noticeDur)
cancellationBst := bstStart.Add(-noticeDur)
gmtRes := CalculateRefundForCancellation(100, 50, cancellationGmt, gmtStart)
bstRes := CalculateRefundForCancellation(100, 50, cancellationBst, bstStart)
if gmtRes.Tier != tc.want || bstRes.Tier != tc.want {
t.Errorf("%s: want tier %q, got GMT=%q BST=%q", tc.name, tc.want, gmtRes.Tier, bstRes.Tier)
}
// Elapsed-hours logic is DST-safe: identical inputs across seasons
// produce byte-for-byte identical outputs.
if gmtRes != bstRes {
t.Errorf("%s: GMT and BST bookings with identical notice produced different refunds: GMT=%+v BST=%+v",
tc.name, gmtRes, bstRes)
}
if gmtRes.HoursUntilAppointment != tc.hours {
t.Errorf("%s: expected %.1f elapsed hours, got %.1f", tc.name, tc.hours, gmtRes.HoursUntilAppointment)
}
}
}
// TestDepositProtectionWindow_UTCInstant proves buildSplitRecords decides
// "after booking starts" by an ABSOLUTE UTC-instant comparison
// (clock.Now().After(info.StartTime)), never by the London calendar date. A
// booking at 2026-06-15 00:30 BST == 2026-06-14 23:30 UTC — where the UTC date
// and the London date disagree — is classified purely by its UTC instant.
func TestDepositProtectionWindow_UTCInstant(t *testing.T) {
t.Parallel()
// 2026-06-14 23:30 UTC = 2026-06-15 00:30 BST: the 00:00-01:00 BST window
// where the UTC date (2026-06-14) is the day before the London date.
boundaryUTC := time.Date(2026, 6, 14, 23, 30, 0, 0, time.UTC)
if got := boundaryUTC.In(clock.London).Format("2006-01-02"); got != "2026-06-15" {
t.Fatalf("test setup invariant: 2026-06-14 23:30 UTC must be London 2026-06-15 00:30 BST, got %s", got)
}
if got := boundaryUTC.Format("2006-01-02"); got != "2026-06-14" {
t.Fatalf("test setup invariant: boundary must remain UTC date 2026-06-14, got %s", got)
}
// This instant is in the past -> the booking has started -> no deposit
// protection window -> a single unsplit record.
record := makeTestRecord("b-boundary-past", "full", 50)
info := &BookingPaymentInfo{StartTime: boundaryUTC, TotalAmount: 50, TotalPaid: 0}
records := buildSplitRecords(record, "full", info, 50)
if len(records) != 1 {
t.Fatalf("past BST-midnight booking: expected 1 record (no split), got %d", len(records))
}
if records[0].PaymentType != "full" {
t.Errorf("past BST-midnight booking: expected single 'full' record, got %q", records[0].PaymentType)
}
// Mirror case with the IDENTICAL UTC-date != London-date property but in the
// future (2099-06-14 23:30 UTC = 2099-06-15 00:30 BST): the booking has NOT
// started, so the deposit split must happen. Only the UTC instant differs
// from the case above — the London date plays no role.
futureBoundary := time.Date(2099, 6, 14, 23, 30, 0, 0, time.UTC)
if got := futureBoundary.In(clock.London).Format("2006-01-02"); got != "2099-06-15" {
t.Fatalf("test setup invariant: 2099-06-14 23:30 UTC must be London 2099-06-15, got %s", got)
}
record2 := makeTestRecord("b-boundary-future", "full", 50)
info2 := &BookingPaymentInfo{StartTime: futureBoundary, TotalAmount: 50, TotalPaid: 0}
records2 := buildSplitRecords(record2, "full", info2, 50)
if len(records2) != 2 {
t.Fatalf("future BST-midnight booking: expected 2 records (deposit split), got %d", len(records2))
}
if records2[0].PaymentType != "deposit" || records2[0].Amount != 25 {
t.Errorf("future BST-midnight booking: expected first record deposit £25, got %q £%.2f",
records2[0].PaymentType, records2[0].Amount)
}
if records2[1].PaymentType != "balance" || records2[1].Amount != 25 {
t.Errorf("future BST-midnight booking: expected second record balance £25, got %q £%.2f",
records2[1].PaymentType, records2[1].Amount)
}
}
+79 -17
View File
@@ -1,6 +1,7 @@
package payments
import (
"context"
"database/sql"
"encoding/json"
"errors"
@@ -25,6 +26,31 @@ import (
// --- Types ---
// defaultGiftCardExpiryMonths is the CMA-recommended rolling expiry window used
// whenever business_settings.gift_card_expiry_months is unset or invalid (< 1).
const defaultGiftCardExpiryMonths = 24
// GetGiftCardExpiryMonths returns the configured gift-card expiry window in
// months — the SINGLE source of truth for rolling expiry. It reads
// business_settings.gift_card_expiry_months and falls back to
// defaultGiftCardExpiryMonths when the settings row is missing (pgx.ErrNoRows)
// or the stored value is < 1 (a sub-1-month window would make every card
// effectively expired on creation). Exported so the scheduling package's
// CleanupExpiredGiftCards job and the payment handlers share one implementation.
func GetGiftCardExpiryMonths(ctx context.Context, q db.Querier) (int, error) {
var months int
if err := q.QueryRow(ctx, `SELECT gift_card_expiry_months FROM business_settings LIMIT 1`).Scan(&months); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return defaultGiftCardExpiryMonths, nil
}
return 0, err
}
if months < 1 {
return defaultGiftCardExpiryMonths, nil
}
return months, nil
}
type GiftCard struct {
ID string `json:"id"`
TotalFundsAdded float64 `json:"total_funds_added"`
@@ -385,11 +411,16 @@ func CreateGiftCard(w http.ResponseWriter, r *http.Request) {
if purchaseVoucherType == "" {
purchaseVoucherType = "SPV"
}
expiryMonths, err := GetGiftCardExpiryMonths(ctx, tx)
if err != nil {
log.Printf("Failed to query gift card expiry months (using default %d): %v", defaultGiftCardExpiryMonths, err)
expiryMonths = defaultGiftCardExpiryMonths
}
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, last_used_at, voucher_type_at_purchase)
VALUES ($1, $1, $2, $3, NOW(), $4)
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, last_used_at, expiry_date, voucher_type_at_purchase)
VALUES ($1, $1, $2, $3, NOW(), NOW() + ($5 * INTERVAL '1 month'), $4)
RETURNING id, total_funds_added, amount_remaining, created_by, created_at, is_inventory, last_used_at
`, req.Amount, adminID, req.IsInventory, purchaseVoucherType).Scan(
`, req.Amount, adminID, req.IsInventory, purchaseVoucherType, expiryMonths).Scan(
&gc.ID,
&gc.TotalFundsAdded,
&gc.AmountRemaining,
@@ -501,6 +532,12 @@ func TopUpGiftCard(w http.ResponseWriter, r *http.Request) {
return
}
expiryMonths, err := GetGiftCardExpiryMonths(ctx, tx)
if err != nil {
log.Printf("Failed to query gift card expiry months (using default %d): %v", defaultGiftCardExpiryMonths, err)
expiryMonths = defaultGiftCardExpiryMonths
}
txType := "topup"
var notes *string
if isInventory && currentTotalFunds == 0 {
@@ -515,10 +552,11 @@ func TopUpGiftCard(w http.ResponseWriter, r *http.Request) {
UPDATE gift_cards
SET total_funds_added = total_funds_added + $1,
amount_remaining = amount_remaining + $1,
last_used_at = NOW()
last_used_at = NOW(),
expiry_date = NOW() + ($3 * INTERVAL '1 month')
WHERE id = $2
RETURNING id, total_funds_added, amount_remaining, created_by, created_at
`, req.Amount, cardID).Scan(
`, req.Amount, cardID, expiryMonths).Scan(
&gc.ID,
&gc.TotalFundsAdded,
&gc.AmountRemaining,
@@ -665,12 +703,21 @@ func TransferGiftCard(w http.ResponseWriter, r *http.Request) {
return
}
// A transfer is a "use" of BOTH cards per the rolling-expiry terms — each
// card's timer resets at the same moment its balance moves.
expiryMonths, err := GetGiftCardExpiryMonths(ctx, tx)
if err != nil {
log.Printf("Failed to query gift card expiry months (using default %d): %v", defaultGiftCardExpiryMonths, err)
expiryMonths = defaultGiftCardExpiryMonths
}
_, err = tx.Exec(ctx, `
UPDATE gift_cards
SET amount_remaining = amount_remaining - $1,
last_used_at = NOW()
last_used_at = NOW(),
expiry_date = NOW() + ($3 * INTERVAL '1 month')
WHERE id = $2
`, req.Amount, fromCardID)
`, req.Amount, fromCardID, expiryMonths)
if err != nil {
log.Printf("Failed to deduct from source: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
@@ -681,9 +728,10 @@ func TransferGiftCard(w http.ResponseWriter, r *http.Request) {
UPDATE gift_cards
SET amount_remaining = amount_remaining + $1,
total_funds_added = total_funds_added + $1,
last_used_at = NOW()
last_used_at = NOW(),
expiry_date = NOW() + ($3 * INTERVAL '1 month')
WHERE id = $2
`, req.Amount, req.ToCardID)
`, req.Amount, req.ToCardID, expiryMonths)
if err != nil {
log.Printf("Failed to add to destination: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
@@ -764,13 +812,21 @@ func RedeemGiftCard(w http.ResponseWriter, r *http.Request) {
return
}
expiryMonths, err := GetGiftCardExpiryMonths(ctx, tx)
if err != nil {
log.Printf("Failed to query gift card expiry months (using default %d): %v", defaultGiftCardExpiryMonths, err)
expiryMonths = defaultGiftCardExpiryMonths
}
_, err = tx.Exec(ctx, `
UPDATE gift_cards
SET amount_remaining = 0,
redeemed_at = NOW(),
redeemed_by = $1
redeemed_by = $1,
last_used_at = NOW(),
expiry_date = NOW() + ($3 * INTERVAL '1 month')
WHERE id = $2
`, userID, code)
`, userID, code, expiryMonths)
if err != nil {
log.Printf("Failed to update gift card: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
@@ -1165,6 +1221,12 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
var cardID string
expiryMonths, err := GetGiftCardExpiryMonths(ctx, issueTx)
if err != nil {
log.Printf("Failed to query gift card expiry months (using default %d): %v", defaultGiftCardExpiryMonths, err)
expiryMonths = defaultGiftCardExpiryMonths
}
if req.RecipientType == "self" {
var purchaseVoucherType string
err = issueTx.QueryRow(ctx, `SELECT COALESCE(voucher_type, 'SPV') FROM business_settings LIMIT 1`).Scan(&purchaseVoucherType)
@@ -1177,10 +1239,10 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
purchaseVoucherType = "SPV"
}
err = issueTx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, redeemed_at, redeemed_by, is_inventory, voucher_type_at_purchase)
VALUES ($1, 0, $2, NOW(), $2, FALSE, $3)
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, redeemed_at, redeemed_by, is_inventory, last_used_at, expiry_date, voucher_type_at_purchase)
VALUES ($1, 0, $2, NOW(), $2, FALSE, NOW(), NOW() + ($4 * INTERVAL '1 month'), $3)
RETURNING id
`, amountPounds, userID, purchaseVoucherType).Scan(&cardID)
`, amountPounds, userID, purchaseVoucherType, expiryMonths).Scan(&cardID)
if err != nil {
log.Printf("CRITICAL: Square payment succeeded (ID=%s) but gift card creation failed: %v — manual reconciliation required", paymentResult.SquarePayID, err)
http.Error(w, "internal server error", http.StatusInternalServerError)
@@ -1221,10 +1283,10 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
purchaseVoucherType = "SPV"
}
err = issueTx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase)
VALUES ($1, $1, $2, FALSE, $3)
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, last_used_at, expiry_date, voucher_type_at_purchase)
VALUES ($1, $1, $2, FALSE, NOW(), NOW() + ($4 * INTERVAL '1 month'), $3)
RETURNING id
`, amountPounds, userID, purchaseVoucherType).Scan(&cardID)
`, amountPounds, userID, purchaseVoucherType, expiryMonths).Scan(&cardID)
if err != nil {
log.Printf("CRITICAL: Square payment succeeded (ID=%s) but gift card creation failed: %v — manual reconciliation required", paymentResult.SquarePayID, err)
http.Error(w, "internal server error", http.StatusInternalServerError)
+20 -6
View File
@@ -1264,9 +1264,12 @@ func TestBuyGiftCard_RetryPending_ReattemptsCharge(t *testing.T) {
}
}
// TestAdminCreateGiftCard_ExpiryDateIsNull verifies that gift cards created via
// CreateGiftCard no longer have expiry_date set (rolling 24-month expiry via last_used_at).
func TestAdminCreateGiftCard_ExpiryDateIsNull(t *testing.T) {
// TestAdminCreateGiftCard_SetsRollingExpiry verifies that gift cards created via
// CreateGiftCard get an expiry_date of last_used_at + gift_card_expiry_months
// (the configured rolling-expiry window, default 24 months). The test DB seeds
// business_settings.gift_card_expiry_months = 24, so the expiry must be ~24
// months in the future.
func TestAdminCreateGiftCard_SetsRollingExpiry(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
@@ -1299,14 +1302,25 @@ func TestAdminCreateGiftCard_ExpiryDateIsNull(t *testing.T) {
t.Fatalf("failed to decode response: %v", err)
}
// Verify expiry_date is NULL in the database
// Verify expiry_date IS set to last_used_at + the configured window (24mo).
var expiryDate *time.Time
err = tx.QueryRow(ctx, `SELECT expiry_date FROM gift_cards WHERE id = $1`, gc.ID).Scan(&expiryDate)
if err != nil {
t.Fatalf("failed to query gift card expiry_date: %v", err)
}
if expiryDate != nil {
t.Error("expected expiry_date to be NULL for rolling-expiry gift cards")
if expiryDate == nil {
t.Fatal("expected expiry_date to be set for rolling-expiry gift cards")
}
// The test DB seeds gift_card_expiry_months = 24; the SQL computes
// NOW() + (24 * INTERVAL '1 month') = exactly +24 calendar months, so
// compare against AddDate(0, 24, 0) with slack for clock skew.
now := time.Now().UTC()
want := now.AddDate(0, 24, 0)
if expiryDate.Before(want.Add(-24 * time.Hour)) {
t.Errorf("expected expiry_date ~24 calendar months in the future, got %v (now %v)", expiryDate, now)
}
if expiryDate.After(want.Add(24 * time.Hour)) {
t.Errorf("expected expiry_date ~24 calendar months in the future, got %v (now %v)", expiryDate, now)
}
}
+8 -2
View File
@@ -423,8 +423,14 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
cardVoucherType = "SPV"
}
// Deduct directly from card remaining amount
_, err = tx.Exec(r.Context(), "UPDATE gift_cards SET amount_remaining = amount_remaining - $1, last_used_at = NOW() WHERE id = $2", amountPounds, cleanCardID)
// Deduct directly from card remaining amount. A payment is a
// "use" per the rolling-expiry terms — reset the timer.
gcExpiryMonths, expiryErr := GetGiftCardExpiryMonths(r.Context(), tx)
if expiryErr != nil {
log.Printf("Failed to query gift card expiry months (using default %d): %v", defaultGiftCardExpiryMonths, expiryErr)
gcExpiryMonths = defaultGiftCardExpiryMonths
}
_, err = tx.Exec(r.Context(), "UPDATE gift_cards SET amount_remaining = amount_remaining - $1, last_used_at = NOW(), expiry_date = NOW() + ($3 * INTERVAL '1 month') WHERE id = $2", amountPounds, cleanCardID, gcExpiryMonths)
if err != nil {
log.Printf("Failed to deduct gift card amount: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
+14 -2
View File
@@ -283,6 +283,11 @@ func ProcessCancellationRefundTx(
log.Printf("Giftcard payment %s has no gift_card_id — cannot refund to card. Skipping.", paymentID)
break
}
// expiry_date is maintained by EVERY gift-card write that counts as
// a "use" (balance check, top-up, transfer, redeem, payment, refund
// credit), so a non-NULL expiry_date means the rolling timer is
// authoritative. NULL semantics stay fail-open: an unset expiry_date
// cannot prove the card is expired, so the refund proceeds.
var expired bool
if err := tx.QueryRow(ctx, `
SELECT expiry_date IS NOT NULL AND expiry_date < NOW()
@@ -293,10 +298,17 @@ func ProcessCancellationRefundTx(
log.Printf("Gift card %s has expired — money retained by salon, no refund due for booking %s", *giftCardID, bookingID)
break
}
// Refunding to the card is a "use" per the rolling-expiry terms —
// reset the timer at the same moment the balance is credited.
gcExpiryMonths, expiryErr := GetGiftCardExpiryMonths(ctx, tx)
if expiryErr != nil {
log.Printf("Failed to query gift card expiry months (using default %d): %v", defaultGiftCardExpiryMonths, expiryErr)
gcExpiryMonths = defaultGiftCardExpiryMonths
}
if _, err := tx.Exec(ctx, `
UPDATE gift_cards SET amount_remaining = amount_remaining + $1, last_used_at = NOW()
UPDATE gift_cards SET amount_remaining = amount_remaining + $1, last_used_at = NOW(), expiry_date = NOW() + ($3 * INTERVAL '1 month')
WHERE id = $2
`, refundThisPayment, *giftCardID); err != nil {
`, refundThisPayment, *giftCardID, gcExpiryMonths); err != nil {
log.Printf("Failed to refund £%.2f to gift card %s: %v", refundThisPayment, *giftCardID, err)
break
}
+78
View File
@@ -455,6 +455,84 @@ func TestProcessCancellationRefund_GiftCardCreditsUserBalance(t *testing.T) {
}
}
// TestProcessCancellationRefund_ExpiredGiftCard_Retained verifies the
// refunds.go expiry guard: when the payment was made with an EXPIRED gift card
// (expiry_date in the past), the cancellation refund must NOT credit the card —
// the money is retained by the salon. Regression for the previously-dead
// `expiry_date IS NOT NULL AND expiry_date < NOW()` check, which never fired
// because no write path populated expiry_date.
func TestProcessCancellationRefund_ExpiredGiftCard_Retained(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
// Expired card: balance 40, expiry_date 1 day in the past, last used 25mo ago.
var giftCardID string
if err := tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, expiry_date, last_used_at)
VALUES (100, 40, $1, false, NOW() - INTERVAL '1 day', NOW() - INTERVAL '25 months')
RETURNING id
`, userID).Scan(&giftCardID); err != nil {
t.Fatalf("failed to create expired gift card: %v", err)
}
// Payment made WITH the expired card (this can happen for legacy cards that
// were still spendable before the expiry_date write paths were wired up).
_, err = tx.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, gift_card_id, created_at, updated_at)
VALUES ($1, 'full', 'giftcard', 'completed', 60, $2, NOW(), NOW())
`, bookingID, giftCardID)
if err != nil {
t.Fatalf("failed to create giftcard payment: %v", err)
}
farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
_, err = ProcessCancellationRefund(
ctx, bookingID, 100, 60,
farFuture, clock.Now(), "client_cancelled", &userID,
)
if err != nil {
t.Fatalf("ProcessCancellationRefund failed: %v", err)
}
// The expired-card guard must retain the money: balance unchanged at 40
// (NOT credited +60), and no refund-to-card transaction recorded.
var amountRemaining float64
if err := tx.QueryRow(ctx,
"SELECT amount_remaining FROM gift_cards WHERE id = $1", giftCardID).Scan(&amountRemaining); err != nil {
t.Fatalf("failed to query gift card balance: %v", err)
}
if amountRemaining != 40 {
t.Errorf("expired card must NOT be credited: expected amount_remaining 40, got %.2f", amountRemaining)
}
var txCount int
if err := tx.QueryRow(ctx,
"SELECT COUNT(*) FROM gift_card_transactions WHERE gift_card_id = $1 AND transaction_type = 'refund'", giftCardID).Scan(&txCount); err != nil {
t.Fatalf("failed to query gift card transactions: %v", err)
}
if txCount != 0 {
t.Errorf("expected NO refund-to-expired-card transaction, got %d", txCount)
}
}
func TestProcessCancellationRefund_CashCreditsUserBalance(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
+14 -7
View File
@@ -411,6 +411,11 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
if existingPendingID != "" {
giftCardID = existingPendingGiftCard
} else {
expiryMonths, expiryErr := GetGiftCardExpiryMonths(ctx, tx)
if expiryErr != nil {
log.Printf("Failed to query gift card expiry months (using default %d): %v", defaultGiftCardExpiryMonths, expiryErr)
expiryMonths = defaultGiftCardExpiryMonths
}
if req.Action == "create" {
var purchaseVoucherType string
err = tx.QueryRow(ctx, `SELECT COALESCE(voucher_type, 'SPV') FROM business_settings LIMIT 1`).Scan(&purchaseVoucherType)
@@ -423,10 +428,10 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
purchaseVoucherType = "SPV"
}
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase)
VALUES ($1, $1, $2, FALSE, $3)
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, last_used_at, expiry_date, voucher_type_at_purchase)
VALUES ($1, $1, $2, FALSE, NOW(), NOW() + ($4 * INTERVAL '1 month'), $3)
RETURNING id
`, req.Amount, adminID, purchaseVoucherType).Scan(&giftCardID)
`, req.Amount, adminID, purchaseVoucherType, expiryMonths).Scan(&giftCardID)
if err != nil {
log.Printf("Failed to create gift card: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
@@ -454,9 +459,10 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
SET amount_remaining = 0,
redeemed_at = NOW(),
redeemed_by = $1,
last_used_at = NOW()
last_used_at = NOW(),
expiry_date = NOW() + ($3 * INTERVAL '1 month')
WHERE id = $2
`, *req.RedeemToUserID, giftCardID)
`, *req.RedeemToUserID, giftCardID, expiryMonths)
if err != nil {
log.Printf("Failed to redeem gift card to user account: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
@@ -507,9 +513,10 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
UPDATE gift_cards
SET total_funds_added = total_funds_added + $1,
amount_remaining = amount_remaining + $1,
last_used_at = NOW()
last_used_at = NOW(),
expiry_date = NOW() + ($3 * INTERVAL '1 month')
WHERE id = $2
`, req.Amount, cardID)
`, req.Amount, cardID, expiryMonths)
if err != nil {
log.Printf("Failed to top up gift card: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
@@ -0,0 +1,56 @@
//go:build test
package scheduling
// Strict timezone/DST/midnight-boundary tests for the scheduling package's
// London-date derivation. All assertions use fixed time.Date instants.
import (
"testing"
"time"
"crussell/clock"
)
// TestScheduling_DefaultHours_DST_Midnight proves ApplyScheduledDefaultHours's
// "today" date (derived via LondonDateString) is the LONDON calendar date, not
// the UTC date. During BST, London dates begin at 23:00 UTC the previous day:
// both 2026-06-14 23:30 UTC (= 2026-06-15 00:30 BST) and 2026-06-15 00:30 UTC
// (= 2026-06-15 01:30 BST) fall on London date 2026-06-15 — a naive UTC date
// would report 2026-06-14 for the first instant and misdate the effective day
// of a staged default-hours change.
func TestScheduling_DefaultHours_DST_Midnight(t *testing.T) {
t.Parallel()
cases := []struct {
name string
utc time.Time
want string
}{
{"00:30 BST window", time.Date(2026, 6, 14, 23, 30, 0, 0, time.UTC), "2026-06-15"},
{"01:30 BST same day", time.Date(2026, 6, 15, 0, 30, 0, 0, time.UTC), "2026-06-15"},
{"GMT midnight is UTC midnight", time.Date(2026, 1, 14, 23, 30, 0, 0, time.UTC), "2026-01-14"},
{"GMT next day", time.Date(2026, 1, 15, 0, 30, 0, 0, time.UTC), "2026-01-15"},
{"autumn BST->GMT transition day", time.Date(2026, 10, 25, 0, 30, 0, 0, time.UTC), "2026-10-25"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := LondonDateString(tc.utc); got != tc.want {
t.Errorf("LondonDateString(%s UTC) = %q, expected London date %q", tc.utc.Format(time.RFC3339), got, tc.want)
}
})
}
// Direct proof of the DST window: the UTC date of the first instant differs
// from its London date — exactly the drift the helper must absorb.
boundary := time.Date(2026, 6, 14, 23, 30, 0, 0, time.UTC)
if boundary.Format("2006-01-02") == LondonDateString(boundary) {
t.Fatal("test setup invariant: 2026-06-14 23:30 UTC must straddle the London midnight boundary")
}
// The helper must be consistent with clock.London's own wall-clock for the
// same instant (the production seam ApplyScheduledDefaultHours now uses).
nowish := time.Date(2026, 6, 14, 23, 45, 0, 0, time.UTC)
if LondonDateString(nowish) != nowish.In(clock.London).Format("2006-01-02") {
t.Errorf("LondonDateString disagrees with the direct London conversion for %s", nowish.Format(time.RFC3339))
}
}
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"log/slog"
"time"
"crussell/clock"
"crussell/db"
@@ -256,12 +257,20 @@ func CleanupExpiredRefreshTokens(ctx context.Context) (int, error) {
return int(result.RowsAffected()), nil
}
// LondonDateString returns the Europe/London calendar date (YYYY-MM-DD) for
// the given instant. ApplyScheduledDefaultHours uses this so staged
// default-hours changes roll over at London midnight rather than UTC midnight:
// during BST a London date begins at 23:00 UTC the previous day, so using the
// UTC date would misdate the effective day inside the 00:00-01:00 BST window.
func LondonDateString(t time.Time) string {
return t.In(clock.London).Format("2006-01-02")
}
// ApplyScheduledDefaultHours applies any pending default hours changes that
// have reached their effective_date. Runs daily at 00:05 to catch midnight
// roll-overs even if the cron is slightly delayed.
func ApplyScheduledDefaultHours(ctx context.Context) (int, error) {
londonNow := clock.Now().In(clock.London)
todayStr := londonNow.Format("2006-01-02")
todayStr := LondonDateString(clock.Now())
tx, err := db.Conn.Begin(ctx)
if err != nil {
+14 -4
View File
@@ -907,15 +907,21 @@ func CleanupExpiredDeposits(ctx context.Context) (int, error) {
return n, tx.Commit(ctx)
}
// CleanupExpiredGiftCards expires gift cards unused for 24 months (rolling expiry).
// CleanupExpiredGiftCards expires gift cards unused for the configured rolling
// window (default 24 months) after last use.
//
// Legal basis:
// - UK Consumer Rights Act 2015: Expiry terms must be "fair and transparent"
// - CMA guidance: 24 months is industry standard (John Lewis, M&S, Sainsbury's)
// - Under 12 months risks being challenged as unfair contract term
//
// The window is read from business_settings.gift_card_expiry_months (the SINGLE
// source of truth shared with the payment handlers' expiry_date writes via
// payments.GetGiftCardExpiryMonths) so the job and the refund-time check never
// drift apart.
//
// This function:
// 1. Finds unredeemed cards (redeemed_by IS NULL) unused for 24+ months
// 1. Finds unredeemed cards (redeemed_by IS NULL) unused for the window
// 2. Inserts into gift_card_expired_balances for recovery claims
// 3. Sets amount_remaining to 0
// 4. Records transaction in gift_card_transactions
@@ -936,14 +942,18 @@ func CleanupExpiredGiftCards(ctx context.Context) (int, error) {
slog.Error("failed to rollback transaction", "err", err)
}
}()
expiryMonths, monthsErr := payments.GetGiftCardExpiryMonths(ctx, tx)
if monthsErr != nil {
return 0, fmt.Errorf("failed to read gift card expiry months: %w", monthsErr)
}
rows, err := tx.Query(ctx, `
SELECT id, amount_remaining
FROM gift_cards
WHERE redeemed_by IS NULL
AND amount_remaining > 0
AND last_used_at < NOW() - INTERVAL '24 months'
`)
AND last_used_at < NOW() - ($1 * INTERVAL '1 month')
`, expiryMonths)
if err != nil {
return 0, fmt.Errorf("failed to query expired gift cards: %w", err)
}
+151
View File
@@ -0,0 +1,151 @@
//go:build test
package today
// Strict timezone tests for today.go's `AT TIME ZONE 'Europe/London'` date
// math. All assertions use fixed time.Date instants.
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"crussell/clock"
"crussell/db"
"crussell/testutils"
"crussell/testutils/fixtures"
)
// TestToday_LondonDateWindow proves the SQL used by findNewBookingServices
// (today.go) computes the London calendar date from a UTC instant. At
// 2026-06-14 23:30 UTC (= 2026-06-15 00:30 BST) the London date is 2026-06-15
// even though the UTC date is 2026-06-14 — the 00:00-01:00 BST window where a
// naive UTC date would start the 14-day "last working day" scan a day early.
func TestToday_LondonDateWindow(t *testing.T) {
t.Parallel()
ctx, _ := testutils.SetupTestTx(t)
londonDate := func(utc time.Time) string {
t.Helper()
var date string
err := db.Conn.QueryRow(ctx, `
SELECT (($1::timestamptz AT TIME ZONE 'Europe/London')::date)::text
`, utc).Scan(&date)
if err != nil {
t.Fatalf("London-date query failed for %s: %v", utc.Format(time.RFC3339), err)
}
return date
}
cases := []struct {
name string
utc time.Time
want string
}{
{"00:30 BST window", time.Date(2026, 6, 14, 23, 30, 0, 0, time.UTC), "2026-06-15"},
{"01:30 BST same London day", time.Date(2026, 6, 15, 0, 30, 0, 0, time.UTC), "2026-06-15"},
{"GMT winter day", time.Date(2026, 1, 15, 16, 0, 0, 0, time.UTC), "2026-01-15"},
{"spring-forward day", time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC), "2026-03-29"},
{"autumn-transition day", time.Date(2026, 10, 25, 12, 0, 0, 0, time.UTC), "2026-10-25"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := londonDate(tc.utc); got != tc.want {
t.Errorf("London date of %s UTC = %s, expected %s", tc.utc.Format(time.RFC3339), got, tc.want)
}
})
}
}
// TestToday_UTCUnchanged_Boundary pins that the same boundary instant is
// stored as a TIMESTAMPTZ unchanged (UTC), so the Go-side clock.Now() and the
// SQL-side NOW()/TIMESTAMPTZ never disagree about the instant itself.
func TestToday_UTCUnchanged_Boundary(t *testing.T) {
t.Parallel()
ctx, _ := testutils.SetupTestTx(t)
boundary := time.Date(2026, 6, 14, 23, 30, 0, 0, time.UTC)
var roundTrip time.Time
err := db.Conn.QueryRow(ctx, `SELECT $1::timestamptz`, boundary).Scan(&roundTrip)
if err != nil {
t.Fatalf("timestamptz round-trip failed: %v", err)
}
if !roundTrip.Equal(boundary) {
t.Errorf("timestamptz round-trip altered the instant: got %s, want %s", roundTrip.Format(time.RFC3339Nano), boundary.Format(time.RFC3339Nano))
}
// The London date derived from the round-tripped value must still be the
// BST-day date — pgx may decode with a nil Go location, so only the instant
// and its London wall-clock date matter.
zone, off := roundTrip.In(londonLocation).Zone()
if zone != "BST" || off != 3600 {
t.Errorf("round-tripped instant in London = zone %q offset %ds, expected BST +3600", zone, off)
}
if got := roundTrip.In(londonLocation).Format("2006-01-02"); got != "2026-06-15" {
t.Errorf("round-tripped instant maps to London date %s, expected 2026-06-15", got)
}
}
// TestToday_SummaryDateLabels_LondonBusinessDay is a regression test for the
// QA-flagged DST bug: the done-for-the-day summary header showed the PREVIOUS
// day during BST. todayStart is London midnight converted to UTC (e.g.
// 2026-08-04 00:00 BST = 2026-08-03 23:00 UTC), and formatting that UTC
// instant directly with "2006-01-02" yielded "2026-08-03" for the Aug 4
// business day. The summary labels must use the London-located instant so the
// header shows the business day (and the same date as the UTC-derived London
// calendar day the rest of the pipeline uses).
func TestToday_SummaryDateLabels_LondonBusinessDay(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
svcID := createTodayService(t, ctx, tx)
// Seed a completed booking TODAY (London) so the handler is in the
// "done for the day" branch with a summary.
now := clock.Now()
var bookingID string
err = tx.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status)
VALUES ($1, $2, 'completed')
RETURNING id
`, userID, now).Scan(&bookingID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
addBookingService(t, ctx, tx, bookingID, svcID)
req := httptest.NewRequest(http.MethodGet, "/api/admin/today/current-next", nil)
req = req.WithContext(ctx)
rr := httptest.NewRecorder()
GetCurrentAndNextHandler(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var resp CurrentNextResponse
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if resp.Summary == nil {
t.Fatal("expected a summary (done-for-the-day branch)")
}
londonToday := now.In(londonLocation).Format("2006-01-02")
if resp.Summary.SummaryStartDate != londonToday {
t.Errorf("summary_start_date = %s, expected %s (the London business day — the pre-fix code emitted the previous UTC day during BST)",
resp.Summary.SummaryStartDate, londonToday)
}
// End date is the London day AFTER todayStart (todayEnd = todayStart + 24h,
// which in London wall-clock is the NEXT day).
londonTomorrow := now.In(londonLocation).AddDate(0, 0, 1).Format("2006-01-02")
if resp.Summary.SummaryEndDate != londonTomorrow {
t.Errorf("summary_end_date = %s, expected %s (the London day after the business day)",
resp.Summary.SummaryEndDate, londonTomorrow)
}
}
+11 -7
View File
@@ -228,11 +228,15 @@ func GetCurrentAndNextHandler(w http.ResponseWriter, r *http.Request) {
response.DoneForDay = &done
if todayOpen {
// Regular done-for-the-day: show daily summary
// Regular done-for-the-day: show daily summary. Format the dates in
// Europe/London: todayStart is London midnight converted to UTC (e.g.
// 2026-08-04 00:00 BST = 2026-08-03 23:00 UTC), so formatting the UTC
// instant directly yields the PREVIOUS day during BST. Use the
// London-located instant so the summary header shows the business day.
summary := computeAggregateSummary(r, todayStart, todayEnd)
summary.SummaryScope = "day"
summary.SummaryStartDate = todayStart.Format("2006-01-02")
summary.SummaryEndDate = todayEnd.Format("2006-01-02")
summary.SummaryStartDate = todayStart.In(londonLocation).Format("2006-01-02")
summary.SummaryEndDate = todayEnd.In(londonLocation).Format("2006-01-02")
response.Summary = summary
// If tomorrow is closed, also compute a week summary
@@ -241,8 +245,8 @@ func GetCurrentAndNextHandler(w http.ResponseWriter, r *http.Request) {
weekStart, _ := findWeekSummaryRange(r, tomorrow)
ws := computeAggregateSummary(r, weekStart, todayEnd)
ws.SummaryScope = "week"
ws.SummaryStartDate = weekStart.Format("2006-01-02")
ws.SummaryEndDate = todayStart.Format("2006-01-02")
ws.SummaryStartDate = weekStart.In(londonLocation).Format("2006-01-02")
ws.SummaryEndDate = todayStart.In(londonLocation).Format("2006-01-02")
response.WeekSummary = ws
}
} else {
@@ -250,8 +254,8 @@ func GetCurrentAndNextHandler(w http.ResponseWriter, r *http.Request) {
weekStart, _ := findWeekSummaryRange(r, londonNow)
summary := computeAggregateSummary(r, weekStart, todayEnd)
summary.SummaryScope = "week"
summary.SummaryStartDate = weekStart.Format("2006-01-02")
summary.SummaryEndDate = todayStart.Format("2006-01-02")
summary.SummaryStartDate = weekStart.In(londonLocation).Format("2006-01-02")
summary.SummaryEndDate = todayStart.In(londonLocation).Format("2006-01-02")
response.Summary = summary
}
}
+2 -2
View File
@@ -251,7 +251,7 @@ func SeedBaseline(pool *pgxpool.Pool) {
// Business settings: required by admin/settings tests and booking deposit logic.
_, err := pool.Exec(ctx, `
INSERT INTO business_settings (business_name, business_address, currency_code, gift_card_expiry_months, voucher_type)
VALUES ('Test Salon', '123 Test St', 'GBP', 12, 'SPV')
VALUES ('Test Salon', '123 Test St', 'GBP', 24, 'SPV')
ON CONFLICT DO NOTHING
`)
if err != nil {
@@ -292,7 +292,7 @@ func SeedBaselineScheduling(pool *pgxpool.Pool) {
// Business settings same as baseline.
_, err := pool.Exec(ctx, `
INSERT INTO business_settings (business_name, business_address, currency_code, gift_card_expiry_months, voucher_type)
VALUES ('Test Salon', '123 Test St', 'GBP', 12, 'SPV')
VALUES ('Test Salon', '123 Test St', 'GBP', 24, 'SPV')
ON CONFLICT DO NOTHING
`)
if err != nil {
@@ -1,6 +1,6 @@
<script lang="ts">
import { apiFetch } from '$lib/utils/api';
import { SvelteDate, SvelteMap } from 'svelte/reactivity';
import { SvelteMap } from 'svelte/reactivity';
import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
@@ -16,7 +16,7 @@
getLunchProtectionForSlots,
timeToMinutes
} from '$lib/lunchProtection';
import { formatLocalDateTime, getLondonTodayCalendarDate } from '$lib/utils/timeSlots';
import { formatLocalDateTime, getLondonTodayCalendarDate, parseWallClockDate } from '$lib/utils/timeSlots';
import ClockIcon from '@lucide/svelte/icons/clock';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
@@ -71,7 +71,7 @@
// ─── Date constants ─────────────────────────────────────
const todayCalendarDate = getLondonTodayCalendarDate();
const minDate = todayCalendarDate;
const maxDate = new SvelteDate(
const maxDate = new Date(
todayCalendarDate.year,
todayCalendarDate.month - 1,
todayCalendarDate.day
@@ -85,7 +85,7 @@
let placeholderDate = $state<CalendarDate>(minDate);
const hoursUntilAppointment = $derived(
(new SvelteDate(booking.start_time).getTime() - new SvelteDate().getTime()) / (1000 * 60 * 60)
(parseWallClockDate(booking.start_time).getTime() - new Date().getTime()) / (1000 * 60 * 60)
);
const hasPayments = $derived((booking.amount_paid ?? 0) > 0);
const noticePeriodBlocked = $derived(
@@ -420,7 +420,7 @@
$effect(() => {
if (editMode === 'services' && !servicesHoursFetched) {
servicesHoursFetched = true;
const bookingDate = new SvelteDate(booking.start_time);
const bookingDate = parseWallClockDate(booking.start_time);
const calDate = new CalendarDate(
bookingDate.getFullYear(),
bookingDate.getMonth() + 1,
@@ -445,8 +445,8 @@
return;
editRequestAutoSelectDone = true;
const currentDate = new SvelteDate(getLondonTodayCalendarDate().toString() + 'T00:00:00');
const maxDateJs = new SvelteDate(
const currentDate = new Date(getLondonTodayCalendarDate().toString() + 'T00:00:00');
const maxDateJs = new Date(
maxCalendarDate.year,
maxCalendarDate.month - 1,
maxCalendarDate.day
@@ -458,7 +458,7 @@
const daysToCheck = Math.min(daysDifference, 180);
for (let i = 0; i <= daysToCheck; i++) {
const nextDate = new SvelteDate(currentDate);
const nextDate = new Date(currentDate);
nextDate.setDate(currentDate.getDate() + i);
const dateStr = nextDate.toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
@@ -637,7 +637,7 @@
function calculateRemainingTime(): number {
if (!workingHours || !availableHours) return 0;
const bookingDate = new SvelteDate(booking.start_time);
const bookingDate = parseWallClockDate(booking.start_time);
const dateStr = bookingDate.toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
const dayWH = workingHours[dateStr];
@@ -4,7 +4,6 @@
import { authStore } from '$lib/stores/auth.svelte';
import { apiFetch } from '$lib/utils/api';
import { ensureBusinessInfo, getBusinessInfo } from '$lib/stores/businessInfo.svelte';
import { SvelteDate } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import * as Modal from '$lib/components/ui/dialog';
@@ -56,7 +55,7 @@
);
const isFutureBooking = $derived(
selectedBooking ? new SvelteDate(selectedBooking.start_time) > new SvelteDate() : false
selectedBooking ? parseWallClockDate(selectedBooking.start_time) > new Date() : false
);
const hasPayments = $derived(
@@ -112,7 +111,7 @@
const hoursUntilAppointment = $derived(
selectedBooking
? (new SvelteDate(selectedBooking.start_time).getTime() - new SvelteDate().getTime()) /
? (parseWallClockDate(selectedBooking.start_time).getTime() - new Date().getTime()) /
(1000 * 60 * 60)
: Infinity
);
@@ -555,7 +554,7 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
</div>
{#if selectedBooking}
{@const isPastBooking = new SvelteDate(selectedBooking.start_time) < new SvelteDate()}
{@const isPastBooking = parseWallClockDate(selectedBooking.start_time) < new Date()}
{@const isUnpaid = selectedBooking.amount_due > 0}
{@const showChip = !isPastBooking || isUnpaid}
{@const isConfirmedOrLater = ['confirmed', 'in_progress', 'completed'].includes(
@@ -849,7 +848,7 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
</div>
{/if}
<div class="mt-1 text-xs text-gray-400">
{new SvelteDate(payment.created_at).toLocaleString()}
{parseWallClockDate(payment.created_at).toLocaleString()}
</div>
</div>
<div class="text-right font-semibold">
@@ -875,7 +874,7 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
{refund.reason || 'Refund processed'}
</div>
<div class="mt-1 text-xs text-gray-400">
{new SvelteDate(refund.created_at).toLocaleString()}
{parseWallClockDate(refund.created_at).toLocaleString()}
</div>
</div>
<div class="text-right font-semibold text-red-600">
@@ -1,5 +1,4 @@
<script lang="ts">
import { SvelteDate } from 'svelte/reactivity';
import { apiFetch } from '$lib/utils/api';
import { toast } from 'svelte-sonner';
import { extractErrorMessage, sanitizeText } from '$lib/utils/toast-safe';
@@ -161,12 +160,12 @@
function findOldestBooking(): { id: string; isCurrent: boolean } | null {
if (overlappingBookings.length === 0) return null;
const currentCreatedAt = new SvelteDate(booking.created_at).getTime();
const currentCreatedAt = parseWallClockDate(booking.created_at).getTime();
let oldestId = booking.id;
let oldestTime = currentCreatedAt;
for (const ob of overlappingBookings) {
const obTime = new SvelteDate(ob.created_at).getTime();
const obTime = parseWallClockDate(ob.created_at).getTime();
if (obTime < oldestTime) {
oldestTime = obTime;
oldestId = ob.id;
@@ -450,7 +449,7 @@
{ob.status}
</span>
<div class="mt-1 text-xs text-gray-400">
{new SvelteDate(ob.created_at).toLocaleDateString('en-GB', {
{parseWallClockDate(ob.created_at).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
hour: 'numeric',
@@ -2,7 +2,7 @@
import { apiFetch } from '$lib/utils/api';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import { SvelteDate, SvelteSet, SvelteURLSearchParams } from 'svelte/reactivity';
import { SvelteSet, SvelteURLSearchParams } from 'svelte/reactivity';
import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
import { isValidUKPhone, toE164UK } from '$lib/utils/phone';
import { formatUserName } from '$lib/utils/nameDisplay';
@@ -217,7 +217,7 @@
// Date Boundaries
const today = getLondonTodayCalendarDate();
const minDate = today;
const maxDate = new SvelteDate(today.year, today.month - 1, today.day);
const maxDate = new Date(today.year, today.month - 1, today.day);
maxDate.setMonth(today.month - 1 + 6);
const maxCalendarDate = new CalendarDate(
maxDate.getFullYear(),
@@ -397,8 +397,8 @@
!bookingCreateAutoSelectDone
) {
bookingCreateAutoSelectDone = true;
const now = new SvelteDate(getLondonTodayCalendarDate().toString() + 'T00:00:00');
const maxDateJs = new SvelteDate(
const now = new Date(getLondonTodayCalendarDate().toString() + 'T00:00:00');
const maxDateJs = new Date(
maxCalendarDate.year,
maxCalendarDate.month - 1,
maxCalendarDate.day
@@ -408,7 +408,7 @@
);
const daysToCheck = Math.min(daysDifference, 180);
for (let i = 0; i <= daysToCheck; i++) {
const checkDate = new SvelteDate(now);
const checkDate = new Date(now);
checkDate.setDate(now.getDate() + i);
const dateStr = checkDate.toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
const calDate = new CalendarDate(
@@ -757,7 +757,7 @@
return;
}
const now = new SvelteDate();
const now = new Date();
const diff = reservationExpiresAt.getTime() - now.getTime();
if (diff <= 0) {
@@ -3,7 +3,7 @@
import { apiFetch } from '$lib/utils/api';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import { SvelteDate } from 'svelte/reactivity';
import { parseWallClockDate } from '$lib/utils/timeSlots';
import * as Modal from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { Checkbox } from '$lib/components/ui/checkbox';
@@ -48,7 +48,7 @@
const balanceDue = $derived(selectedBooking ? computeBalanceDue(selectedBooking) : 0);
const hoursUntilAppt = $derived(
selectedBooking
? (new SvelteDate(selectedBooking.start_time).getTime() - new SvelteDate().getTime()) /
? (parseWallClockDate(selectedBooking.start_time).getTime() - new Date().getTime()) /
(1000 * 60 * 60)
: Infinity
);
@@ -1,6 +1,7 @@
<script lang="ts">
import { apiFetch } from '$lib/utils/api';
import { SvelteDate, SvelteURLSearchParams } from 'svelte/reactivity';
import { SvelteURLSearchParams } from 'svelte/reactivity';
import { parseWallClockDate } from '$lib/utils/timeSlots';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
@@ -117,10 +118,10 @@
// Format booking date/time
function formatBookingDateTime(startTime: string): string {
const date = new SvelteDate(startTime);
const now = new SvelteDate();
const today = new SvelteDate(now.getFullYear(), now.getMonth(), now.getDate());
const bookingDate = new SvelteDate(date.getFullYear(), date.getMonth(), date.getDate());
const date = parseWallClockDate(startTime);
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const bookingDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
const daysDiff = Math.floor((bookingDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
@@ -143,7 +143,9 @@
if (value == null || value === '') return 'Required';
const num = Number(value);
if (isNaN(num)) return 'Must be a number';
if (num < 1) return 'Must be at least 1';
// Legal floor: CMA guidance flags sub-12-month expiry windows as an
// unfair contract term under the Consumer Rights Act 2015.
if (num < 12) return 'Must be at least 12 months';
return '';
}
@@ -385,6 +387,10 @@
<div>
<span class="text-xs text-muted-foreground">Expiry Period</span>
<p class="text-sm font-medium">{settings.gift_card_expiry_months} months</p>
<p class="text-xs text-muted-foreground">
Rolling from last use — each balance check, top-up, redemption or payment
resets the timer.
</p>
</div>
<div>
<span class="text-xs text-muted-foreground">Voucher Type</span>
@@ -609,11 +615,16 @@
<Input
id="gift_card_expiry_months"
type="number"
min="1"
min="12"
bind:value={form.gift_card_expiry_months}
oninput={() => validateField('gift_card_expiry_months')}
onblur={() => validateField('gift_card_expiry_months')}
/>
<p class="text-xs text-muted-foreground">
Rolling expiry: the timer resets on every use (balance check, top-up, redeem,
payment). 24 months is the recommended default; CMA guidance flags sub-12-month
windows as an unfair contract term.
</p>
{#if formErrors.gift_card_expiry_months}
<p class="text-xs text-destructive">{formErrors.gift_card_expiry_months}</p>
{/if}
@@ -1,5 +1,4 @@
<script lang="ts">
import { SvelteDate } from 'svelte/reactivity';
import { apiFetch } from '$lib/utils/api';
import { range } from '$lib/utils/format';
import { toast } from 'svelte-sonner';
@@ -92,7 +91,7 @@
if (form.discount_percent <= 0 || form.discount_percent > 100) return false;
if (form.campaign_type === 'time_based') {
if (!form.start_date || !form.end_date) return false;
if (new SvelteDate(form.end_date) < new SvelteDate(form.start_date)) return false;
if (new Date(form.end_date) < new Date(form.start_date)) return false;
}
if (form.campaign_type === 'milestone') {
if (form.milestone_value <= 0) return false;
@@ -1,6 +1,5 @@
<script lang="ts">
import { apiFetch } from '$lib/utils/api';
import { SvelteDate } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import { formatDuration } from '$lib/utils/format';
@@ -280,10 +279,10 @@
const maxAvailableDuration = $derived.by(() => {
if (!booking?.start_time || !nextAppointmentStart) return null;
const bookingStart = new SvelteDate(booking.start_time).getTime();
const bookingStart = parseWallClockDate(booking.start_time).getTime();
const currentDurationMs = totalDuration * 60 * 1000;
const bookingEnd = bookingStart + currentDurationMs;
const nextStart = new SvelteDate(nextAppointmentStart).getTime();
const nextStart = parseWallClockDate(nextAppointmentStart).getTime();
const availableMs = nextStart - bookingEnd;
return Math.max(0, Math.floor(availableMs / (60 * 1000)));
@@ -600,7 +599,7 @@
{/if}
</div>
<div class="mt-1 text-xs text-gray-400">
{new SvelteDate(payment.created_at).toLocaleDateString('en-GB', {
{parseWallClockDate(payment.created_at).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric'
@@ -13,7 +13,8 @@
import { isSquareConfigured } from '$lib/square/square';
import { range } from '$lib/utils/format';
import { formatUserName } from '$lib/utils/nameDisplay';
import { SvelteDate, SvelteURLSearchParams } from 'svelte/reactivity';
import { parseWallClockDate } from '$lib/utils/timeSlots';
import { SvelteURLSearchParams } from 'svelte/reactivity';
interface GiftCard {
id: string;
@@ -726,7 +727,7 @@
}
function formatDate(dateStr: string): string {
return new SvelteDate(dateStr).toLocaleDateString('en-GB', {
return parseWallClockDate(dateStr).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric',
@@ -736,14 +737,14 @@
function getExpiryDate(lastUsedAt?: string): Date | null {
if (!lastUsedAt) return null;
const date = new SvelteDate(lastUsedAt);
const date = parseWallClockDate(lastUsedAt);
date.setMonth(date.getMonth() + 24);
return date;
}
function isExpired(lastUsedAt?: string): boolean {
const expiry = getExpiryDate(lastUsedAt);
return expiry !== null && expiry < new SvelteDate();
return expiry !== null && expiry < new Date();
}
// =============== Sorting ===============
@@ -802,7 +803,7 @@
return (a.amount_remaining - b.amount_remaining) * mul;
case 'created':
return (
(new SvelteDate(a.created_at).getTime() - new SvelteDate(b.created_at).getTime()) * mul
(parseWallClockDate(a.created_at).getTime() - parseWallClockDate(b.created_at).getTime()) * mul
);
case 'status': {
const aVal = a.redeemed_by ? 2 : a.amount_remaining === 0 ? 1 : 0;
@@ -833,7 +834,7 @@
return (a.balance - b.balance) * mul;
case 'updated':
return (
(new SvelteDate(a.updated_at).getTime() - new SvelteDate(b.updated_at).getTime()) * mul
(parseWallClockDate(a.updated_at).getTime() - parseWallClockDate(b.updated_at).getTime()) * mul
);
default:
return 0;
@@ -1,5 +1,4 @@
<script lang="ts">
import { SvelteDate } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import { extractErrorMessage, sanitizeText } from '$lib/utils/toast-safe';
import { apiFetch } from '$lib/utils/api';
@@ -178,9 +177,9 @@
}
function addWeeksToException(fromISO: string, toISO: string, dest: string[]) {
const from = new SvelteDate(fromISO + 'T00:00:00Z');
const to = new SvelteDate(toISO + 'T00:00:00Z');
const first = new SvelteDate(from);
const from = new Date(fromISO + 'T00:00:00Z');
const to = new Date(toISO + 'T00:00:00Z');
const first = new Date(from);
const day = first.getDay();
const daysToMonday = day === 0 ? -6 : 1 - day;
@@ -188,8 +187,8 @@
first.setDate(first.getDate() + daysToMonday);
// Add all Mondays in the range
for (let d = new SvelteDate(first); d <= to; d.setDate(d.getDate() + 7)) {
dest.push(isoDateOf(new SvelteDate(d)));
for (let d = new Date(first); d <= to; d.setDate(d.getDate() + 7)) {
dest.push(isoDateOf(new Date(d)));
}
}
@@ -487,7 +486,7 @@
{g.weekStarts
?.slice(0, 3)
.map((w) =>
new SvelteDate(w).toLocaleDateString('en-GB', {
new Date(w).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short'
})
@@ -869,7 +868,7 @@
<div class="grid grid-cols-2 gap-2 md:grid-cols-3">
{#each viewingException.weekStarts as week (week)}
<div class="text-sm">
Week of {new SvelteDate(week).toLocaleDateString('en-GB', {
Week of {new Date(week).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric'
@@ -1,5 +1,5 @@
<script lang="ts">
import { SvelteDate, SvelteSet } from 'svelte/reactivity';
import { SvelteSet } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import { apiFetch } from '$lib/utils/api';
@@ -58,7 +58,7 @@
const today = getLondonTodayCalendarDate();
const minDate = today;
const maxDate = new SvelteDate(today.year, today.month - 1, today.day);
const maxDate = new Date(today.year, today.month - 1, today.day);
maxDate.setMonth(today.month - 1 + 6);
const maxCalendarDate = new CalendarDate(
maxDate.getFullYear(),
@@ -67,7 +67,7 @@
);
const hoursUntilAppointment = $derived(
(new SvelteDate(booking.start_time).getTime() - new SvelteDate().getTime()) / (1000 * 60 * 60)
(parseWallClockDate(booking.start_time).getTime() - new Date().getTime()) / (1000 * 60 * 60)
);
const hasPayments = $derived((booking.amount_paid ?? 0) > 0);
const showNoticeWarning = $derived(
@@ -380,8 +380,8 @@
!rescheduleAutoSelectDone
) {
rescheduleAutoSelectDone = true;
const now = new SvelteDate(getLondonTodayCalendarDate().toString() + 'T00:00:00');
const maxDateJs = new SvelteDate(
const now = new Date(getLondonTodayCalendarDate().toString() + 'T00:00:00');
const maxDateJs = new Date(
maxCalendarDate.year,
maxCalendarDate.month - 1,
maxCalendarDate.day
@@ -391,7 +391,7 @@
);
const daysToCheck = Math.min(daysDifference, 180);
for (let i = 1; i <= daysToCheck; i++) {
const checkDate = new SvelteDate(now);
const checkDate = new Date(now);
checkDate.setDate(now.getDate() + i);
const dateStr = checkDate.toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
const calDate = new CalendarDate(
@@ -1,5 +1,4 @@
<script lang="ts">
import { SvelteDate } from 'svelte/reactivity';
import { apiFetch } from '$lib/utils/api';
import { CalendarDate } from '@internationalized/date';
import { toast } from 'svelte-sonner';
@@ -123,7 +122,7 @@
function getWorkingHoursForDate(dateStr: string): WorkingHourRow | null {
if (!dateStr || defaultHours.length === 0) return null;
const d = new SvelteDate(dateStr + 'T00:00:00Z');
const d = new Date(dateStr + 'T00:00:00Z');
const jsDay = d.getDay();
const weekday = jsDay === 0 ? 6 : jsDay - 1;
return defaultHours.find((h) => h.weekday === weekday) ?? null;
@@ -186,8 +185,8 @@
});
function formatRelativeTime(iso: string): string {
const d = new SvelteDate(iso);
const now = new SvelteDate();
const d = parseWallClockDate(iso);
const now = new Date();
const diffMs = d.getTime() - now.getTime();
if (diffMs < 0) {
const absMin = Math.floor(Math.abs(diffMs) / 60000);
@@ -205,7 +204,7 @@
const sortedBlockers = $derived.by(() => {
return [...blockers].sort(
(a, b) => new SvelteDate(a.start_time).getTime() - new SvelteDate(b.start_time).getTime()
(a, b) => parseWallClockDate(a.start_time).getTime() - parseWallClockDate(b.start_time).getTime()
);
});
@@ -297,8 +296,8 @@
return;
}
const blockerStart = new SvelteDate(startIso);
const blockerEnd = new SvelteDate(endIso);
const blockerStart = new Date(startIso);
const blockerEnd = new Date(endIso);
if (blockerEnd.getTime() <= blockerStart.getTime()) {
overlappingBookings = [];
hasOverlap = false;
@@ -322,8 +321,8 @@
const allBookings: OverlappingBooking[] = data.bookings || [];
// Client-side filter: only bookings that overlap with the proposed blocker timespan
const filtered = allBookings.filter((b) => {
const bStart = new SvelteDate(b.start_time);
const bEnd = new SvelteDate(bStart.getTime() + b.duration_minutes * 60000);
const bStart = parseWallClockDate(b.start_time);
const bEnd = new Date(bStart.getTime() + b.duration_minutes * 60000);
return bStart < blockerEnd && bEnd > blockerStart;
});
overlappingBookings = filtered;
@@ -774,7 +773,7 @@
<p class="text-sm text-gray-400">Select a date first</p>
{:else if !selectedWorkingHours.isOpen}
<p class="text-sm text-red-500">
Closed on {new SvelteDate(newStartDate + 'T00:00:00Z').toLocaleDateString('en-GB', {
Closed on {new Date(newStartDate + 'T00:00:00Z').toLocaleDateString('en-GB', {
weekday: 'long'
})}
</p>
@@ -1,5 +1,5 @@
<script lang="ts">
import { SvelteDate, SvelteURLSearchParams } from 'svelte/reactivity';
import { SvelteURLSearchParams } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import { apiFetch } from '$lib/utils/api';
@@ -325,13 +325,13 @@
<div class="font-medium">
{#if selectedUser.dateOfBirth}
{(() => {
const dob = new SvelteDate(selectedUser.dateOfBirth);
const dob = new Date(selectedUser.dateOfBirth);
const dateStr = dob.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
const today = new SvelteDate();
const today = new Date();
let age = today.getFullYear() - dob.getFullYear();
const m = today.getMonth() - dob.getMonth();
if (m < 0 || (m === 0 && today.getDate() < dob.getDate())) age--;
@@ -346,7 +346,7 @@
<div class="text-xs text-gray-500">First Visit</div>
<div class="font-medium">
{#if customerRelationship?.firstVisitDate}
{new SvelteDate(customerRelationship.firstVisitDate).toLocaleDateString('en-US', {
{new Date(customerRelationship.firstVisitDate).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
@@ -360,7 +360,7 @@
<div class="text-xs text-gray-500">Last Visit</div>
<div class="font-medium">
{#if customerRelationship?.lastVisitDate}
{new SvelteDate(customerRelationship.lastVisitDate).toLocaleDateString('en-US', {
{new Date(customerRelationship.lastVisitDate).toLocaleDateString('en-US', {
weekday: 'short',
day: 'numeric',
month: 'short',
@@ -3,7 +3,6 @@
import WalkInCreateModal from '$lib/components/admin/WalkInCreateModal.svelte';
import { CalendarDate } from '@internationalized/date';
import { apiFetch } from '$lib/utils/api';
import { SvelteDate } from 'svelte/reactivity';
import { onMount, onDestroy } from 'svelte';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
@@ -28,7 +27,7 @@
} | null>(null);
let loading = $state(true);
let noSlotsToday = $state(false);
let currentTime = new SvelteDate();
let currentTime = new Date();
let _reservationId = $state<string | null>(null);
let reservationExpiresAt = $state<Date | null>(null);
@@ -58,7 +57,7 @@
fetchShortestService();
const interval = setInterval(() => {
currentTime = new SvelteDate();
currentTime = new Date();
}, 60000);
return () => clearInterval(interval);
@@ -176,7 +175,7 @@
noSlotsToday = false;
try {
const now = new SvelteDate();
const now = new Date();
const londonDateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
const [y, m, d] = londonDateStr.split('-').map(Number);
const today = new CalendarDate(y, m, d);
@@ -275,7 +274,7 @@
const londonDateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
const [y, m, d] = londonDateStr.split('-').map(Number);
const [hours, minutes] = startTime.split(':').map(Number);
const start = new SvelteDate(y, m - 1, d, hours, minutes, 0, 0);
const start = new Date(y, m - 1, d, hours, minutes, 0, 0);
const startTimeISO = formatLocalDateTime(start);
const response = await apiFetch('/api/admin/bookings/reserve', {
@@ -329,7 +328,7 @@
return;
}
const now = new SvelteDate();
const now = new Date();
const diff = reservationExpiresAt.getTime() - now.getTime();
if (diff <= 0) {
@@ -369,7 +368,7 @@
const liveRemaining = getLiveRemainingMinutes() ?? 0;
if (liveRemaining > RESERVATION_TTL) {
// Available now with >15min remaining — reserve from now to slot end
const now = new SvelteDate();
const now = new Date();
const currentMin = now.getHours() * 60 + now.getMinutes();
reserveTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
reserveDuration = slotInfo.slotEndMinutes! - currentMin;
@@ -3,7 +3,7 @@
import { apiFetch } from '$lib/utils/api';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import { SvelteDate, SvelteURLSearchParams } from 'svelte/reactivity';
import { SvelteURLSearchParams } from 'svelte/reactivity';
import { isValidUKPhone, toE164UK } from '$lib/utils/phone';
import { formatUserName } from '$lib/utils/nameDisplay';
import { formatLocalDateTime } from '$lib/utils/timeSlots';
@@ -243,7 +243,7 @@
return;
}
const now = new SvelteDate();
const now = new Date();
const diff = reservationExpiresAt.getTime() - now.getTime();
if (diff <= 0) {
@@ -479,7 +479,7 @@
const [hours, minutes] = availableStartTime.split(':').map(Number);
const londonDateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
const [y, m, d] = londonDateStr.split('-').map(Number);
start = new SvelteDate(y, m - 1, d, hours, minutes, 0, 0);
start = new Date(y, m - 1, d, hours, minutes, 0, 0);
} else {
// Fallback: Calculate immediate start time (rounded to next 15 min)
const londonDateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
@@ -491,8 +491,8 @@
});
const [y, m, d] = londonDateStr.split('-').map(Number);
const [h, min] = londonTimeStr.split(':').map(Number);
const now = new SvelteDate(y, m - 1, d, h, min, 0, 0);
start = new SvelteDate(now);
const now = new Date(y, m - 1, d, h, min, 0, 0);
start = new Date(now);
const minutes = start.getMinutes();
const remainder = 15 - (minutes % 15);
if (remainder !== 15 && remainder !== 0) {
@@ -1,5 +1,4 @@
<script lang="ts">
import { SvelteDate } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import { browser } from '$app/environment';
@@ -229,7 +228,7 @@
}
function getDefaultEffectiveDate(): string {
const d = new SvelteDate();
const d = new Date();
d.setDate(d.getDate() + 1);
return d.toISOString().slice(0, 10);
}
@@ -27,7 +27,6 @@
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import { onDestroy } from 'svelte';
import { SvelteDate } from 'svelte/reactivity';
// Components
import BookingActions from '$lib/components/booking/BookingActions.svelte';
@@ -41,7 +40,7 @@
import { POLICY } from '$lib/constants/policy';
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
import { extractBookedSlots, getLunchProtectionForSlots } from '$lib/lunchProtection';
import { formatLocalDateTime, getLondonTodayCalendarDate } from '$lib/utils/timeSlots';
import { formatLocalDateTime, getLondonTodayCalendarDate, parseWallClockDate } from '$lib/utils/timeSlots';
import { generateUUID } from '$lib/utils/uuid';
import type {
@@ -575,7 +574,7 @@
return;
}
const now = new SvelteDate();
const now = new Date();
const diff = reservationExpiresAt.getTime() - now.getTime();
if (diff <= 0) {
@@ -685,7 +684,7 @@
// Initialize date boundaries
const today = getLondonTodayCalendarDate();
const minDate = today;
const maxDate = new SvelteDate(today.year, today.month - 1, today.day);
const maxDate = new Date(today.year, today.month - 1, today.day);
maxDate.setMonth(today.month - 1 + 6);
const maxCalendarDate = new CalendarDate(
maxDate.getFullYear(),
@@ -756,8 +755,8 @@
) {
bookingFlowAutoSelectDone = true;
const currentDate = new SvelteDate(getLondonTodayCalendarDate().toString() + 'T00:00:00');
const maxDateJs = new SvelteDate(
const currentDate = new Date(getLondonTodayCalendarDate().toString() + 'T00:00:00');
const maxDateJs = new Date(
maxCalendarDate.year,
maxCalendarDate.month - 1,
maxCalendarDate.day
@@ -769,7 +768,7 @@
const daysToCheck = Math.min(daysDifference, 180);
for (let i = 1; i <= daysToCheck; i++) {
const nextDate = new SvelteDate(currentDate);
const nextDate = new Date(currentDate);
nextDate.setDate(currentDate.getDate() + i);
const dateStr = nextDate.toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
@@ -992,7 +991,7 @@
// =============== Time Slot Generation ===============
function calculateEndTime(startTime: string, durationMinutes: number): string {
const [hours, minutes] = startTime.split(':').map(Number);
const date = new SvelteDate();
const date = new Date();
date.setHours(hours, minutes, 0, 0);
date.setMinutes(date.getMinutes() + durationMinutes);
const endHours = date.getHours().toString().padStart(2, '0');
@@ -1253,7 +1252,7 @@
15,
false
);
const now = new SvelteDate();
const now = new Date();
const validSlots = availableSlots.filter((t) => {
if (lunchProtection.get(t)?.isBlocked) return false;
if (userDepositsRequired > 0) {
@@ -1410,7 +1409,7 @@
}
function getDayWithOrdinal(date: CalendarDate): string {
const monthName = new SvelteDate(date.year, date.month - 1, date.day).toLocaleDateString(
const monthName = new Date(date.year, date.month - 1, date.day).toLocaleDateString(
'en-GB',
{
month: 'long'
@@ -2131,7 +2130,7 @@
{#if currentStep === finalStep}
{#if confirmedBooking}
{@const isRequested = confirmedBooking.notes && confirmedBooking.notes.length > 0}
{@const bookingDate = new SvelteDate(confirmedBooking.start_time)}
{@const bookingDate = parseWallClockDate(confirmedBooking.start_time)}
{@const dateStr = bookingDate.toLocaleDateString('en-GB', {
weekday: 'long',
day: 'numeric',
@@ -2446,8 +2445,8 @@
deposit_paid: bk.deposit_paid,
payments: bk.payments,
duration_minutes: bk.duration_minutes,
created_at: new SvelteDate().toISOString(),
updated_at: new SvelteDate().toISOString()
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
}}
onClose={() => (showPayEarlyModal = false)}
onComplete={() => {
@@ -1,5 +1,4 @@
<script lang="ts">
import { SvelteDate } from 'svelte/reactivity';
import { onMount, onDestroy } from 'svelte';
import { formatTime } from '$lib/utils/timeSlots';
@@ -81,7 +80,7 @@
const today = getLondonDate();
const dayOfWeek = today.getDay(); // 0=Sun
const offset = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
const monday = new SvelteDate(today);
const monday = new Date(today);
monday.setDate(today.getDate() + offset);
return fmtDate(monday);
}
@@ -2,9 +2,9 @@
import { goto } from '$app/navigation';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import { SvelteDate } from 'svelte/reactivity';
import { authStore } from '$lib/stores/auth.svelte';
import { apiFetch } from '$lib/utils/api';
import { parseWallClockDate } from '$lib/utils/timeSlots';
import CardSelection from '$lib/components/payments/CardSelection.svelte';
import { savedCardsStore, type SavedCard } from '$lib/stores/savedCards.svelte';
import { Button } from '$lib/components/ui/button';
@@ -110,7 +110,7 @@
});
function formatDate(dateStr: string): string {
const date = new SvelteDate(dateStr);
const date = parseWallClockDate(dateStr);
return date.toLocaleDateString('en-GB', {
weekday: 'long',
day: 'numeric',
@@ -127,7 +127,7 @@
services: BookingService[],
fallbackDuration: number
): string {
const start = new SvelteDate(startStr);
const start = parseWallClockDate(startStr);
const totalMinutes =
services?.reduce(
(sum, s) => sum + (s.override_duration_minutes ?? s.duration_minutes ?? 0),
@@ -135,7 +135,7 @@
) ??
fallbackDuration ??
0;
const end = new SvelteDate(start.getTime() + totalMinutes * 60000);
const end = new Date(start.getTime() + totalMinutes * 60000);
const formatOpt: Intl.DateTimeFormatOptions = {
hour: 'numeric',
@@ -1,10 +1,10 @@
<script lang="ts">
import { apiFetch } from '$lib/utils/api';
import { ensureBusinessInfo, getBusinessInfo } from '$lib/stores/businessInfo.svelte';
import { SvelteDate } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import { formatDuration } from '$lib/utils/format';
import { formatUserName } from '$lib/utils/nameDisplay';
import { parseWallClockDate } from '$lib/utils/timeSlots';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
@@ -106,19 +106,19 @@
const minutesUntilClosing = $derived.by(() => {
if (!closingTime) return 0;
const [ch, cm] = closingTime.split(':').map(Number);
const now = new SvelteDate();
const closing = new SvelteDate(now.getFullYear(), now.getMonth(), now.getDate(), ch, cm);
const now = new Date();
const closing = new Date(now.getFullYear(), now.getMonth(), now.getDate(), ch, cm);
return Math.max(0, Math.floor((closing.getTime() - now.getTime()) / 60000));
});
function calculateTimes() {
const now = new SvelteDate();
const now = new Date();
if (currentAppointment) {
isInProgress = currentAppointment.status === 'in_progress';
const startTime = new SvelteDate(currentAppointment.start_time);
const startTime = parseWallClockDate(currentAppointment.start_time);
const endTime = new SvelteDate(
const endTime = new Date(
startTime.getTime() + currentAppointment.duration_minutes * 60 * 1000
);
@@ -137,15 +137,15 @@
let rawFreeMinutes = 0;
if (nextAppointment) {
const nextStart = new SvelteDate(nextAppointment.start_time);
const nextStart = parseWallClockDate(nextAppointment.start_time);
const gapMs = nextStart.getTime() - endTime.getTime();
rawFreeMinutes = Math.max(0, Math.floor(gapMs / 60000));
}
if (closingTime) {
const [ch, cm] = closingTime.split(':').map(Number);
const today = new SvelteDate();
const closing = new SvelteDate(
const today = new Date();
const closing = new Date(
today.getFullYear(),
today.getMonth(),
today.getDate(),
@@ -325,14 +325,14 @@
</Card.Title>
{#if activeAppointment}
<Card.Description class="text-base">
{new SvelteDate(activeAppointment.start_time).toLocaleTimeString('en-US', {
{parseWallClockDate(activeAppointment.start_time).toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
})}
-
{new SvelteDate(
new SvelteDate(activeAppointment.start_time).getTime() +
{new Date(
parseWallClockDate(activeAppointment.start_time).getTime() +
activeAppointment.duration_minutes * 60 * 1000
).toLocaleTimeString('en-US', {
hour: 'numeric',
@@ -439,13 +439,13 @@
{#if summary.summary_scope === 'week'}
<div class="text-xs text-gray-500">
Summary: {new SvelteDate(summary.summary_start_date).toLocaleDateString('en-US', {
Summary: {parseWallClockDate(summary.summary_start_date).toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric'
})}
&ndash;
{new SvelteDate(summary.summary_end_date).toLocaleDateString('en-US', {
{parseWallClockDate(summary.summary_end_date).toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric'
@@ -552,7 +552,7 @@
<div>
<h4 class="mb-2 text-sm font-semibold text-gray-700">
New bookings made since{summary.summary_scope === 'week'
? ` opening ${new SvelteDate(summary.summary_start_date).toLocaleDateString('en-US', { weekday: 'long' })}`
? ` opening ${parseWallClockDate(summary.summary_start_date).toLocaleDateString('en-US', { weekday: 'long' })}`
: ' closing yesterday'}
</h4>
<div class="flex flex-wrap gap-2">
@@ -573,13 +573,13 @@
<div>
<h4 class="text-sm font-semibold text-gray-700">This week so far</h4>
<div class="text-xs text-gray-500">
{new SvelteDate(weekSummary.summary_start_date).toLocaleDateString('en-US', {
{parseWallClockDate(weekSummary.summary_start_date).toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric'
})}
&ndash;
{new SvelteDate(weekSummary.summary_end_date).toLocaleDateString('en-US', {
{parseWallClockDate(weekSummary.summary_end_date).toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric'
@@ -1,6 +1,5 @@
<script lang="ts">
import { apiFetch } from '$lib/utils/api';
import { SvelteDate } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
@@ -10,6 +9,7 @@
import EditRequestModal from '$lib/components/admin/EditRequestModal.svelte';
import { range } from '$lib/utils/format';
import { formatUserName } from '$lib/utils/nameDisplay';
import { parseWallClockDate } from '$lib/utils/timeSlots';
interface Props {
openBookingModal?: (bookingId: string) => void;
@@ -105,7 +105,7 @@
// Helper function to format date nicely
function formatDateTime(dateTimeString: string): string {
const date = new SvelteDate(dateTimeString);
const date = parseWallClockDate(dateTimeString);
const dateStr = date.toLocaleDateString('en-US', {
weekday: 'long',
day: 'numeric',
@@ -120,8 +120,8 @@
}
function formatRelativeTime(iso: string): string {
const d = new SvelteDate(iso);
const now = new SvelteDate();
const d = parseWallClockDate(iso);
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffMin = Math.floor(diffMs / 60000);
@@ -138,7 +138,7 @@
const servicesChanged = areEditServicesChanged(er);
if (timeChanged) {
const d = new SvelteDate(er.proposed.start_time!);
const d = parseWallClockDate(er.proposed.start_time!);
const dateStr = d.toLocaleDateString('en-US', {
weekday: 'long',
day: 'numeric',
@@ -187,7 +187,8 @@
const data = await response.json();
const newApprovals = (data.approvals || []).sort(
(a: PendingApproval, b: PendingApproval) =>
new SvelteDate(a.created_at).getTime() - new SvelteDate(b.created_at).getTime()
parseWallClockDate(a.created_at).getTime() -
parseWallClockDate(b.created_at).getTime()
);
const newJson = JSON.stringify(newApprovals);
if (newJson !== prevApprovalsJson) {
@@ -218,7 +219,8 @@
const data = await response.json();
const newEditRequests = (data.edit_requests || []).sort(
(a: EditRequest, b: EditRequest) =>
new SvelteDate(a.requested_at).getTime() - new SvelteDate(b.requested_at).getTime()
parseWallClockDate(a.requested_at).getTime() -
parseWallClockDate(b.requested_at).getTime()
);
const newJson = JSON.stringify(newEditRequests);
if (newJson !== prevEditRequestsJson) {
@@ -1,6 +1,5 @@
<script lang="ts">
import { apiFetch } from '$lib/utils/api';
import { SvelteDate } from 'svelte/reactivity';
import { CalendarDate } from '@internationalized/date';
import { toast } from 'svelte-sonner';
import { extractErrorMessage, sanitizeText } from '$lib/utils/toast-safe';
@@ -22,7 +21,7 @@
timeToMinutes
} from '$lib/lunchProtection';
import { formatDuration, formatDateISO } from '$lib/utils/format';
import { formatLocalDateTime } from '$lib/utils/timeSlots';
import { formatLocalDateTime, parseWallClockDate } from '$lib/utils/timeSlots';
interface Props {
openBookingModal: (bookingId: string) => void;
@@ -83,8 +82,8 @@
};
function isPastAppointment(startTime: string, durationMinutes: number): boolean {
const start = new SvelteDate(startTime);
const end = new SvelteDate(start.getTime() + durationMinutes * 60_000);
const start = parseWallClockDate(startTime);
const end = new Date(start.getTime() + durationMinutes * 60_000);
return end.getTime() < Date.now();
}
@@ -118,7 +117,7 @@
const weekStartStr = $derived.by(() => {
const londonDateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
const d = new SvelteDate(londonDateStr + 'T00:00:00Z');
const d = new Date(londonDateStr + 'T00:00:00Z');
const day = d.getDay();
const diff = day === 0 ? 6 : day - 1;
d.setDate(d.getDate() - diff);
@@ -126,7 +125,7 @@
});
const weekEndStr = $derived.by(() => {
const d = new SvelteDate(weekStartStr + 'T00:00:00Z');
const d = new Date(weekStartStr + 'T00:00:00Z');
d.setDate(d.getDate() + 6);
return formatDateISO(d);
});
@@ -286,7 +285,7 @@
const items: TimelineItem[] = [];
for (const apt of appointments) {
const start = new SvelteDate(apt.start_time);
const start = parseWallClockDate(apt.start_time);
const startM = start.getHours() * 60 + start.getMinutes();
items.push({
id: `apt-${apt.id}`,
@@ -298,7 +297,7 @@
}
for (const b of blockers) {
const start = new SvelteDate(b.start_time);
const start = parseWallClockDate(b.start_time);
const startM = start.getHours() * 60 + start.getMinutes();
items.push({
id: `blk-${b.id}`,
@@ -440,8 +439,8 @@
hasOverlap = false;
return;
}
const blockerStart = new SvelteDate(startIso);
const blockerEnd = new SvelteDate(endIso);
const blockerStart = new Date(startIso);
const blockerEnd = new Date(endIso);
if (blockerEnd.getTime() <= blockerStart.getTime()) {
overlappingBookings = [];
hasOverlap = false;
@@ -459,8 +458,8 @@
const data = await response.json();
const allBookings: OverlappingBooking[] = data.bookings || [];
const filtered = allBookings.filter((b) => {
const bStart = new SvelteDate(b.start_time);
const bEnd = new SvelteDate(bStart.getTime() + b.duration_minutes * 60000);
const bStart = parseWallClockDate(b.start_time);
const bEnd = new Date(bStart.getTime() + b.duration_minutes * 60000);
return bStart < blockerEnd && bEnd > blockerStart;
});
overlappingBookings = filtered;
@@ -627,7 +626,7 @@
}
function formatTime(dateString: string): string {
const date = new SvelteDate(dateString);
const date = parseWallClockDate(dateString);
return date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
@@ -1011,7 +1010,7 @@
)}
</div>
<div class="text-xs text-gray-500">
{new SvelteDate(booking.start_time).toLocaleTimeString('en-GB', {
{parseWallClockDate(booking.start_time).toLocaleTimeString('en-GB', {
hour: 'numeric',
minute: '2-digit',
hour12: true
@@ -1,5 +1,4 @@
<script lang="ts">
import { SvelteDate } from 'svelte/reactivity';
import { apiFetch } from '$lib/utils/api';
import { Skeleton } from '$lib/components/ui/skeleton';
import * as Card from '$lib/components/ui/card';
@@ -51,11 +50,11 @@
async function findLastWorkingDayClose(): Promise<{ cutoff: string; spansClosed: boolean }> {
const londonDateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
const now = new SvelteDate(londonDateStr + 'T00:00:00Z');
const now = new Date(londonDateStr + 'T00:00:00Z');
let spansClosed = false;
for (let i = 1; i <= 14; i++) {
const d = new SvelteDate(now);
const d = new Date(now);
d.setDate(d.getDate() - i);
const dateStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
@@ -72,7 +71,7 @@
if (day?.isOpen) {
const closeTime = day.endTime;
const [h, m] = closeTime.split(':').map(Number);
const closeDate = new SvelteDate(d);
const closeDate = new Date(d);
closeDate.setHours(h, m, 0, 0);
return { cutoff: formatLocalDateTime(closeDate), spansClosed };
} else {
@@ -81,7 +80,7 @@
}
}
const fallback = new SvelteDate(now);
const fallback = new Date(now);
fallback.setDate(fallback.getDate() - 1);
fallback.setHours(17, 0, 0, 0);
return { cutoff: formatLocalDateTime(fallback), spansClosed };
@@ -144,7 +143,7 @@
}
const { cutoff, spansClosed } = await findLastWorkingDayClose();
const nowISO = formatLocalDateTime(new SvelteDate());
const nowISO = formatLocalDateTime(new Date());
let bookingsMade = 0;
try {
const bmRes = await apiFetch(
+1 -2
View File
@@ -1,4 +1,3 @@
import { SvelteDate } from 'svelte/reactivity';
import { CalendarDate } from '@internationalized/date';
import {
extractBookedSlots,
@@ -96,7 +95,7 @@ export function timeToMinutes(time: string): number {
}
export function getDayWithOrdinal(date: CalendarDate): string {
const monthName = new SvelteDate(date.year, date.month - 1, date.day).toLocaleDateString(
const monthName = new Date(date.year, date.month - 1, date.day).toLocaleDateString(
'en-GB',
{ month: 'long' }
);
+9 -9
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { authStore, type User } from '$lib/stores/auth.svelte';
import { SvelteDate } from 'svelte/reactivity';
import { parseWallClockDate } from '$lib/utils/timeSlots';
import { browser } from '$app/environment';
import { toast } from 'svelte-sonner';
import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
@@ -896,13 +896,13 @@
}
const data = await response.json();
const now = new SvelteDate();
const now = new Date();
// Filter: Calculate end time (Start + Duration) and check if it's in the future
const activeOrFutureBookings = (data.bookings || []).filter((b: Booking) => {
const startTime = new SvelteDate(b.start_time);
const startTime = parseWallClockDate(b.start_time);
// Add duration (in ms)
const endTime = new SvelteDate(startTime.getTime() + (b.duration_minutes || 0) * 60000);
const endTime = new Date(startTime.getTime() + (b.duration_minutes || 0) * 60000);
return endTime > now;
});
@@ -938,10 +938,10 @@
let bookings = data.bookings || [];
// Only include bookings that have actually finished (endTime <= now)
const now = new SvelteDate();
const now = new Date();
bookings = bookings.filter((b: Booking) => {
const startTime = new SvelteDate(b.start_time);
const endTime = new SvelteDate(startTime.getTime() + (b.duration_minutes || 0) * 60000);
const startTime = parseWallClockDate(b.start_time);
const endTime = new Date(startTime.getTime() + (b.duration_minutes || 0) * 60000);
return endTime <= now;
});
@@ -968,7 +968,7 @@
if (!aUnpaid && bUnpaid) return 1;
// If both have same payment status, sort by Date DESC (newest first)
return new SvelteDate(b.start_time).getTime() - new SvelteDate(a.start_time).getTime();
return parseWallClockDate(b.start_time).getTime() - parseWallClockDate(a.start_time).getTime();
});
pastBookings = bookings;
@@ -1120,7 +1120,7 @@
}
function formatDateTime(dateString: string): string {
const date = new SvelteDate(dateString);
const date = parseWallClockDate(dateString);
// Get date parts
const day = date.getDate();
@@ -1,5 +1,5 @@
<script lang="ts">
import { SvelteDate } from 'svelte/reactivity';
import { parseWallClockDate } from '$lib/utils/timeSlots';
import { onMount } from 'svelte';
import { fly } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
@@ -265,8 +265,8 @@
}
function formatRelative(iso: string): string {
const d = new SvelteDate(iso);
const now = new SvelteDate();
const d = parseWallClockDate(iso);
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffMin = Math.floor(diffMs / 60000);
const diffHr = Math.floor(diffMin / 60);
@@ -286,12 +286,12 @@
}
function formatBookingDate(iso: string): string {
const d = new SvelteDate(iso);
const now = new SvelteDate();
const today = new SvelteDate(now.getFullYear(), now.getMonth(), now.getDate());
const tomorrow = new SvelteDate(today);
const d = parseWallClockDate(iso);
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const bookingDay = new SvelteDate(d.getFullYear(), d.getMonth(), d.getDate());
const bookingDay = new Date(d.getFullYear(), d.getMonth(), d.getDate());
const diffDays = Math.round((bookingDay.getTime() - today.getTime()) / 86400000);
if (diffDays === 0) return 'Today';
+21 -21
View File
@@ -10,7 +10,7 @@
import { Button } from '$lib/components/ui/button';
import { formatDuration } from '$lib/utils/format';
import { formatUserName } from '$lib/utils/nameDisplay';
import { SvelteDate } from 'svelte/reactivity';
import { parseWallClockDate } from '$lib/utils/timeSlots';
import BookingModal from '$lib/components/admin/BookingModal.svelte';
// -- Types --
@@ -83,7 +83,7 @@
const today = getLondonToday();
const dayOfWeek = today.getDay();
const diff = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
const monday = new SvelteDate(today);
const monday = new Date(today);
monday.setDate(monday.getDate() - diff);
monday.setHours(0, 0, 0, 0);
weekStart = monday;
@@ -93,7 +93,7 @@
// -- Helpers --
function getWeekDays(start: Date): Date[] {
return Array.from({ length: 7 }, (_, i) => {
const d = new SvelteDate(start);
const d = new Date(start);
d.setDate(d.getDate() + i);
return d;
});
@@ -106,15 +106,15 @@
/** Return the current London date as a Date set to midnight in the local timezone.
* Uses Intl.DateTimeFormat with Europe/London to handle BST/GMT correctly. */
function getLondonToday(): Date {
const dateStr = new SvelteDate().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
return new SvelteDate(dateStr + 'T00:00:00');
const dateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
return new Date(dateStr + 'T00:00:00');
}
/** Return the current time-of-day in London as minutes since midnight, using
* Intl.DateTimeFormat with Europe/London so the current-time blue line is
* positioned correctly regardless of the browser's system timezone. */
function getLondonNowMinutes(): number {
const timeStr = new SvelteDate().toLocaleTimeString('en-GB', {
const timeStr = new Date().toLocaleTimeString('en-GB', {
timeZone: 'Europe/London',
hour: '2-digit',
minute: '2-digit',
@@ -129,11 +129,11 @@
* at 23:30 UTC (00:30 BST next day) are grouped under the correct
* London date column rather than the UTC date. */
function getLondonDateKey(iso: string): string {
return new SvelteDate(iso).toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
return parseWallClockDate(iso).toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
}
function formatWeekLabel(start: Date): string {
const end = new SvelteDate(start);
const end = new Date(start);
end.setDate(end.getDate() + 6);
const opts: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric' };
const endOpts: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', year: 'numeric' };
@@ -147,7 +147,7 @@
}
function formatTimeShort(iso: string): string {
return new SvelteDate(iso).toLocaleTimeString('en-US', {
return parseWallClockDate(iso).toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
@@ -213,9 +213,9 @@
}
function bookingsOverlap(a: ScheduleBooking, b: ScheduleBooking): boolean {
const aStart = new SvelteDate(a.start_time).getTime();
const aStart = parseWallClockDate(a.start_time).getTime();
const aEnd = aStart + a.duration_minutes * 60000;
const bStart = new SvelteDate(b.start_time).getTime();
const bStart = parseWallClockDate(b.start_time).getTime();
const bEnd = bStart + b.duration_minutes * 60000;
return aStart < bEnd && bStart < aEnd;
}
@@ -226,7 +226,7 @@
if (!initialized) loading = true;
try {
const startStr = formatDate(weekStart);
const end = new SvelteDate(weekStart);
const end = new Date(weekStart);
end.setDate(end.getDate() + 6);
const endStr = formatDate(end);
@@ -312,7 +312,7 @@
function navigateWeek(delta: number) {
if (!weekStart) return;
const d = new SvelteDate(weekStart);
const d = new Date(weekStart);
d.setDate(d.getDate() + delta * 7);
weekStart = d;
}
@@ -321,7 +321,7 @@
const today = getLondonToday();
const dayOfWeek = today.getDay();
const diff = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
const monday = new SvelteDate(today);
const monday = new Date(today);
monday.setDate(monday.getDate() - diff);
monday.setHours(0, 0, 0, 0);
weekStart = monday;
@@ -385,14 +385,14 @@
if (ef > latest) latest = ef;
}
for (const b of bookingsByDate.get(dateStr) || []) {
const d = new SvelteDate(b.start_time);
const d = parseWallClockDate(b.start_time);
const sf = d.getHours() + d.getMinutes() / 60;
if (sf < earliest) earliest = sf;
const ef = sf + b.duration_minutes / 60;
if (ef > latest) latest = ef;
}
for (const b of blockersByDate.get(dateStr) || []) {
const d = new SvelteDate(b.start_time);
const d = parseWallClockDate(b.start_time);
const sf = d.getHours() + d.getMinutes() / 60;
if (sf < earliest) earliest = sf;
const ef = sf + b.duration_minutes / 60;
@@ -420,7 +420,7 @@
const isCurrentWeek = $derived(
weekStart !== undefined &&
(() => {
const end = new SvelteDate(weekStart);
const end = new Date(weekStart);
end.setDate(end.getDate() + 6);
end.setHours(23, 59, 59, 999);
return today >= weekStart && today <= end;
@@ -585,8 +585,8 @@
<!-- Half-hour dashed line -->
<div class="half-hour-line absolute inset-x-0 h-px" style="top: 50%;"></div>
{#each dayBlockers.filter((b) => new SvelteDate(b.start_time).getHours() === h) as b (b.id)}
{@const startMinutes = new SvelteDate(b.start_time).getMinutes()}
{#each dayBlockers.filter((b) => parseWallClockDate(b.start_time).getHours() === h) as b (b.id)}
{@const startMinutes = parseWallClockDate(b.start_time).getMinutes()}
{@const topOffset = (startMinutes / 60) * HOUR_HEIGHT}
{@const heightPx = Math.max((b.duration_minutes / 60) * HOUR_HEIGHT, 20)}
@@ -609,8 +609,8 @@
{/each}
<!-- BOOKING BLOCKS -->
{#each dayBookings.filter((b) => new SvelteDate(b.start_time).getHours() === h) as b (b.id)}
{@const startMinutes = new SvelteDate(b.start_time).getMinutes()}
{#each dayBookings.filter((b) => parseWallClockDate(b.start_time).getHours() === h) as b (b.id)}
{@const startMinutes = parseWallClockDate(b.start_time).getMinutes()}
{@const topOffset = (startMinutes / 60) * HOUR_HEIGHT}
{@const heightPx = Math.max((b.duration_minutes / 60) * HOUR_HEIGHT, 20)}
{@const isCancelled =
+1 -2
View File
@@ -1,5 +1,4 @@
<script lang="ts">
import { SvelteDate } from 'svelte/reactivity';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { Checkbox } from '$lib/components/ui/checkbox';
@@ -116,7 +115,7 @@
{ customer: 'Ava Davis', stamps: 7, action: 'Earned 1 stamp' }
];
const today = new SvelteDate();
const today = new Date();
const dateString = today.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
+5 -6
View File
@@ -8,7 +8,6 @@
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
import RequiredLabel from '$lib/components/layout/RequiredLabel.svelte';
import { isValidUKPhone, toE164UK } from '$lib/utils/phone';
import { SvelteDate } from 'svelte/reactivity';
// zxcvbn-ts imports
import { ZxcvbnFactory } from '@zxcvbn-ts/core';
@@ -81,9 +80,9 @@
return true;
}
const dob = new SvelteDate(dateStr);
const today = new SvelteDate();
const sixteenYearsAgo = new SvelteDate(
const dob = new Date(dateStr);
const today = new Date();
const sixteenYearsAgo = new Date(
today.getFullYear() - 16,
today.getMonth(),
today.getDate()
@@ -503,8 +502,8 @@
type="date"
bind:value={formData.dateOfBirth}
onblur={() => validateAge(formData.dateOfBirth)}
max={new SvelteDate(
new SvelteDate().setFullYear(new SvelteDate().getFullYear() - 16)
max={new Date(
new Date().setFullYear(new Date().getFullYear() - 16)
).toLocaleDateString('en-CA', { timeZone: 'Europe/London' })}
required
/>
+10 -10
View File
@@ -6,7 +6,7 @@
import { ensureBusinessInfo, getBusinessInfo } from '$lib/stores/businessInfo.svelte';
import { browser } from '$app/environment';
import { toast } from 'svelte-sonner';
import { SvelteDate } from 'svelte/reactivity';
import { parseWallClockDate } from '$lib/utils/timeSlots';
import { fly } from 'svelte/transition';
import { range } from '$lib/utils/format';
import UserBookingModal from '$lib/components/account/UserBookingModal.svelte';
@@ -49,16 +49,16 @@
return;
}
const data = await response.json();
const now = new SvelteDate();
const now = new Date();
bookings = (data.bookings || [])
.filter((b: Booking) => {
const startTime = new SvelteDate(b.start_time);
const endTime = new SvelteDate(startTime.getTime() + (b.duration_minutes || 0) * 60000);
const startTime = parseWallClockDate(b.start_time);
const endTime = new Date(startTime.getTime() + (b.duration_minutes || 0) * 60000);
return endTime > now;
})
.sort(
(a: Booking, b: Booking) =>
new SvelteDate(a.start_time).getTime() - new SvelteDate(b.start_time).getTime()
parseWallClockDate(a.start_time).getTime() - parseWallClockDate(b.start_time).getTime()
);
} catch (err) {
console.error('Error fetching bookings:', err);
@@ -87,7 +87,7 @@
const monthKey = booking.start_time.slice(0, 7);
if (monthKey !== currentMonth) {
currentMonth = monthKey;
const d = new SvelteDate(booking.start_time);
const d = parseWallClockDate(booking.start_time);
const label = d.toLocaleDateString('en-GB', {
month: 'long',
year: 'numeric'
@@ -101,14 +101,14 @@
});
function formatTime(iso: string): string {
return new SvelteDate(iso).toLocaleTimeString('en-GB', {
return parseWallClockDate(iso).toLocaleTimeString('en-GB', {
hour: 'numeric',
minute: '2-digit'
});
}
function formatCardDate(iso: string): string {
return new SvelteDate(iso).toLocaleDateString('en-GB', {
return parseWallClockDate(iso).toLocaleDateString('en-GB', {
weekday: 'short',
day: 'numeric',
month: 'short'
@@ -116,8 +116,8 @@
}
function formatEndTime(iso: string, durationMinutes: number): string {
const start = new SvelteDate(iso);
const end = new SvelteDate(start.getTime() + durationMinutes * 60000);
const start = parseWallClockDate(iso);
const end = new Date(start.getTime() + durationMinutes * 60000);
return end.toLocaleTimeString('en-GB', { hour: 'numeric', minute: '2-digit' });
}
+5 -5
View File
@@ -3,7 +3,7 @@
import { browser } from '$app/environment';
import { authStore } from '$lib/stores/auth.svelte';
import { apiFetch } from '$lib/utils/api';
import { SvelteDate } from 'svelte/reactivity';
import { parseWallClockDate } from '$lib/utils/timeSlots';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { Skeleton } from '$lib/components/ui/skeleton';
@@ -83,16 +83,16 @@
return;
}
const now = new SvelteDate();
const now = new Date();
const sorted = bookings.sort((a, b) => {
const dateA = new SvelteDate(a.start_time).getTime();
const dateB = new SvelteDate(b.start_time).getTime();
const dateA = parseWallClockDate(a.start_time).getTime();
const dateB = parseWallClockDate(b.start_time).getTime();
return dateB - dateA;
});
// Find most recent past booking (start_time <= now)
const pastBooking = sorted.find((b) => {
const startTime = new SvelteDate(b.start_time);
const startTime = parseWallClockDate(b.start_time);
return startTime <= now;
});
+1 -2
View File
@@ -1,7 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { SvelteDate } from 'svelte/reactivity';
import { browser } from '$app/environment';
import { Skeleton } from '$lib/components/ui/skeleton';
@@ -71,7 +70,7 @@
}
// Get current date string for display
const today = new SvelteDate();
const today = new Date();
const dateString = today.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
+9 -4
View File
@@ -790,7 +790,7 @@ CREATE TABLE business_settings (
default_vat_rate NUMERIC(5,2) NOT NULL DEFAULT 20.00,
currency_code CHAR(3) NOT NULL DEFAULT 'GBP',
website_url TEXT,
gift_card_expiry_months INT NOT NULL DEFAULT 12,
gift_card_expiry_months INT NOT NULL DEFAULT 24,
voucher_type VARCHAR(3) NOT NULL DEFAULT 'SPV',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
@@ -824,7 +824,8 @@ INSERT INTO business_settings (
is_vat_registered,
default_vat_rate,
currency_code,
website_url
website_url,
gift_card_expiry_months
) VALUES (
'Crussell Nail Art Studio',
'Address',
@@ -834,7 +835,8 @@ INSERT INTO business_settings (
FALSE, -- Set to TRUE when we register for VAT
20.00,
'GBP',
'https://www.website.co.uk'
'https://www.website.co.uk',
24
);
-- 'critical_payment_log' surfaces unresolved money events (stale pending
@@ -2152,7 +2154,10 @@ CREATE INDEX idx_affiliate_payouts_affiliate ON affiliate_payouts(affiliate_id);
-- Gift cards are Single-Purpose Vouchers (SPVs) under UK VAT law.
-- VAT is charged at point of sale, NOT at redemption.
--
-- Expiry: 24 months rolling from last usage (industry standard per CMA guidance).
-- Expiry: rolling from last usage (industry standard per CMA guidance),
-- default 24 months. The window is configurable via
-- business_settings.gift_card_expiry_months (admin settings) — the SINGLE
-- source of truth read by the expiry job and every expiry_date write.
-- UK Consumer Rights Act 2015 requires expiry terms to be "fair and transparent".
-- 24 months matches premium retailers (John Lewis, M&S, Sainsbury's) and is
-- widely considered reasonable by the CMA. Under 12 months risks being challenged