diff --git a/frontend/e2e/support/dates.ts b/frontend/e2e/support/dates.ts
index 84d8d6d..d0ca40a 100644
--- a/frontend/e2e/support/dates.ts
+++ b/frontend/e2e/support/dates.ts
@@ -60,6 +60,66 @@ export function whitSunday(easter: Date): Date {
return addDays(easter, 49)
}
+/** Midsummer's Day (Midsommardagen) — the Saturday falling between June 20
+ * and June 26 inclusive, per Swedish law. */
+export function midsummerDay(year: number): Date {
+ for (let day = 20; day <= 26; day++) {
+ const candidate = new Date(Date.UTC(year, 5, day)) // June = month 5
+ if (candidate.getUTCDay() === 6) return candidate
+ }
+ /* istanbul ignore next -- a Saturday always falls in a 7-day window */
+ throw new Error(`no Saturday found in June 20-26 ${year} — impossible`)
+}
+
+/**
+ * True for a Saturday, a Sunday, or a Swedish public/"de facto" free day —
+ * i.e. exactly the set of dates the planner treats as free (see
+ * `is_free_day`/`_FREE_CATEGORIES` in the backend's `app/utils/holidays.py`
+ * and `app/services/allocation_service.py`; `PlanningService.
+ * _reject_illegal_free_day` rejects a `vacation`-typed pin on any of them).
+ * Computed purely from date math, like `easterSunday` above — deliberately
+ * independent of the backend's `swedish_calendar.json` cache, so a test
+ * that uses this to pick "a range with no free days in it" isn't
+ * validating itself against the very data the system under test reads.
+ * Not meant to be exhaustive for every calendar nuance (klämdagar are
+ * intentionally excluded — they're workable, not free) — only for the
+ * free days a test needs to steer clear of.
+ */
+export function isSwedishFreeDay(date: Date): boolean {
+ const day = date.getUTCDay()
+ if (day === 0 || day === 6) return true // Sunday or Saturday
+
+ const year = date.getUTCFullYear()
+ const iso = toIsoDate(date)
+
+ const fixed = new Set([
+ `${year}-01-01`, // Nyårsdagen
+ `${year}-01-06`, // Trettondedag jul
+ `${year}-05-01`, // Första maj
+ `${year}-06-06`, // Sveriges nationaldag
+ `${year}-12-24`, // Julafton (de facto)
+ `${year}-12-25`, // Juldagen
+ `${year}-12-26`, // Annandag jul
+ `${year}-12-31`, // Nyårsafton (de facto)
+ ])
+ if (fixed.has(iso)) return true
+
+ const easter = easterSunday(year)
+ const midsummer = midsummerDay(year)
+ const moveable = [
+ addDays(easter, -2), // Långfredagen
+ addDays(easter, -1), // Påskafton (de facto)
+ easter, // Påskdagen
+ addDays(easter, 1), // Annandag påsk
+ addDays(easter, 39), // Kristi himmelsfärdsdag
+ addDays(easter, 48), // Pingstafton (de facto)
+ addDays(easter, 49), // Pingstdagen
+ addDays(midsummer, -1), // Midsommarafton (de facto) — midsummer day itself is always a Saturday, already covered above
+ ].map(toIsoDate)
+
+ return moveable.includes(iso)
+}
+
/** Monday of the ISO week containing `date` (Mon=0 .. Sun=6 offset). */
export function mondayOfWeek(date: Date): Date {
const isoWeekday = (date.getUTCDay() + 6) % 7 // 0 = Monday
diff --git a/frontend/e2e/tests/drag-create-delete.spec.ts b/frontend/e2e/tests/drag-create-delete.spec.ts
index eca9ff8..36d6af6 100644
--- a/frontend/e2e/tests/drag-create-delete.spec.ts
+++ b/frontend/e2e/tests/drag-create-delete.spec.ts
@@ -29,7 +29,7 @@
*/
import { test, expect } from '@playwright/test'
import { goToCalendarMonth, calendarDayCell, dragSelectDates } from '../support/calendarNav'
-import { toIsoDate, addDays } from '../support/dates'
+import { toIsoDate, addDays, isSwedishFreeDay } from '../support/dates'
import { uniqueEmail, registerAndReachOnboarding, fillChildStep, continueSoloAndSkipBalances } from '../support/onboarding'
test.use({ storageState: { cookies: [], origins: [] } })
@@ -41,20 +41,30 @@ test.use({ storageState: { cookies: [], origins: [] } })
* all after the "no phantom week" fix; see `Calendar.tsx`'s week-building
* loop). Returns that month plus the last two of its days and the first day
* of the next month — the exact range the drag below crosses.
+ *
+ * None of the three dates may be a free day (weekend or red day): the
+ * first test below saves them with an explicit `vacation` day type, and
+ * the second lets the planner fund them as a wish — both deliberately drop
+ * free days from what actually gets persisted (see `handleSavePinnedDay`
+ * in `src/pages/Calendar.tsx` and the backend's `PlanningService.
+ * _reject_illegal_free_day`/`is_free_day`). Checking only the month's last
+ * day for a Sunday missed two cases: (1) that day being a Monday instead,
+ * which makes `dates[0]` (the day before it) a Sunday, and (2) a red day
+ * landing on any of the three regardless of weekday (e.g. the month rolls
+ * into a New Year's Day) — either would silently persist/fund fewer than
+ * three days, which the assertions below don't expect.
*/
function targetRange(): { month: Date; dates: [string, string, string] } {
const now = new Date()
let month = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 2, 1))
let lastDayOfMonth = new Date(Date.UTC(month.getUTCFullYear(), month.getUTCMonth() + 1, 0))
- while (lastDayOfMonth.getUTCDay() === 0 /* Sunday */) {
+ let candidateDates = [addDays(lastDayOfMonth, -1), lastDayOfMonth, addDays(lastDayOfMonth, 1)]
+ while (candidateDates.some(isSwedishFreeDay)) {
month = new Date(Date.UTC(month.getUTCFullYear(), month.getUTCMonth() + 1, 1))
lastDayOfMonth = new Date(Date.UTC(month.getUTCFullYear(), month.getUTCMonth() + 1, 0))
+ candidateDates = [addDays(lastDayOfMonth, -1), lastDayOfMonth, addDays(lastDayOfMonth, 1)]
}
- const dates = [addDays(lastDayOfMonth, -1), lastDayOfMonth, addDays(lastDayOfMonth, 1)].map(toIsoDate) as [
- string,
- string,
- string,
- ]
+ const dates = candidateDates.map(toIsoDate) as [string, string, string]
return { month, dates }
}
diff --git a/frontend/src/components/settings/PartnerInvitePanel.test.tsx b/frontend/src/components/settings/PartnerInvitePanel.test.tsx
index f85ef05..682885e 100644
--- a/frontend/src/components/settings/PartnerInvitePanel.test.tsx
+++ b/frontend/src/components/settings/PartnerInvitePanel.test.tsx
@@ -92,4 +92,14 @@ describe('PartnerInvitePanel', () => {
expect(await screen.findByRole('alert')).toHaveTextContent('list boom')
})
+
+ it('does not request invitations while workspaceId is still empty (Settings mounts before WorkspaceContext populates it)', async () => {
+ const { rerender } = render()
+ await waitFor(() => expect(screen.getByText('Laddar')).toBeInTheDocument())
+ expect(mockListInvitations).not.toHaveBeenCalled()
+
+ // Once the real workspace id lands, the panel fetches for it.
+ rerender()
+ await waitFor(() => expect(mockListInvitations).toHaveBeenCalledWith('ws-1'))
+ })
})
diff --git a/frontend/src/components/settings/PartnerInvitePanel.tsx b/frontend/src/components/settings/PartnerInvitePanel.tsx
index 5df5003..fefe68c 100644
--- a/frontend/src/components/settings/PartnerInvitePanel.tsx
+++ b/frontend/src/components/settings/PartnerInvitePanel.tsx
@@ -30,6 +30,15 @@ export function PartnerInvitePanel({ workspaceId }: PartnerInvitePanelProps) {
const [revokeError, setRevokeError] = useState(null)
const loadInvitations = useCallback(async () => {
+ // Settings mounts (via RequireAuth) before WorkspaceContext has
+ // populated `workspaceId`, so this can run once with `workspaceId`
+ // still `''`. An empty path segment collapses in Vite's dev proxy, so
+ // `GET /api/workspace//invitations` becomes `GET /api/workspace/invitations`
+ // — FastAPI then matches `workspace_id="invitations"` against the
+ // `{workspace_id}` route and 422s on UUID parsing. Same guard as
+ // `useWorkspaceMembers.ts`; the effect below re-runs once the real id
+ // lands (it's a dependency of this callback).
+ if (!workspaceId) return
setIsListLoading(true)
setListError(null)
try {
diff --git a/frontend/src/context/PlanSyncProvider.test.tsx b/frontend/src/context/PlanSyncProvider.test.tsx
index 3c19e87..f9565a4 100644
--- a/frontend/src/context/PlanSyncProvider.test.tsx
+++ b/frontend/src/context/PlanSyncProvider.test.tsx
@@ -185,6 +185,27 @@ describe('PlanSyncProvider', () => {
consoleErrorSpy.mockRestore()
})
+ it('refreshes the workspace context instead of just logging when the target workspace 404s (stale id)', async () => {
+ const refreshContext = vi.fn()
+ mockWorkspace({ workspaceId: 'ws-stale', planVersion: 0, bumpPlanVersion, refreshContext })
+ vi.mocked(api.syncPlan).mockRejectedValueOnce(
+ new ApiRequestError(404, { code: 'not_found', message: 'Workspace ws-stale not found', details: {} }),
+ )
+ const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
+
+ renderHook(() => usePlanSyncContext(), { wrapper })
+
+ await waitFor(() => expect(refreshContext).toHaveBeenCalledTimes(1))
+ // A 404 is the expected "stale reference" case, not an unhandled
+ // background failure — it must not also go through the generic
+ // console.error path.
+ expect(consoleErrorSpy).not.toHaveBeenCalled()
+ expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('ws-stale'))
+ consoleWarnSpy.mockRestore()
+ consoleErrorSpy.mockRestore()
+ })
+
it('logs a non-Error rejection without swallowing it', async () => {
vi.mocked(api.syncPlan).mockRejectedValueOnce('network exploded')
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
diff --git a/frontend/src/context/PlanSyncProvider.tsx b/frontend/src/context/PlanSyncProvider.tsx
index 92d70c5..2bbf79f 100644
--- a/frontend/src/context/PlanSyncProvider.tsx
+++ b/frontend/src/context/PlanSyncProvider.tsx
@@ -59,7 +59,7 @@ const PlanSyncContext = createContext(null)
* a "plan input" write itself.
*/
export function PlanSyncProvider({ children }: { children: React.ReactNode }) {
- const { workspaceId, planVersion } = useWorkspace()
+ const { workspaceId, planVersion, refreshContext } = useWorkspace()
const { isAuthenticated } = useAuth()
const [latestProposal, setLatestProposal] = useState(null)
const [latestProposalVersion, setLatestProposalVersion] = useState(0)
@@ -98,15 +98,32 @@ export function PlanSyncProvider({ children }: { children: React.ReactNode }) {
setLatestProposal(result.proposal)
setLatestProposalVersion((v) => v + 1)
} catch (err) {
- // No toast exists for a background sync — this is the minimum bar
- // from the module doc comment: never an empty catch, always logged
- // through the shared API-error model instead of silently dropped.
- const message = isApiError(err)
- ? `${err.status} ${err.error.message}`
- : err instanceof Error
- ? err.message
- : String(err)
- console.error(`Background plan sync failed for workspace ${id}: ${message}`)
+ if (isApiError(err) && err.status === 404) {
+ // `id` doesn't exist server-side any more — not the active
+ // workspace, just a stale one this effect was handed (e.g.
+ // `workspaceId` seeded synchronously from localStorage on cold load
+ // — see `WorkspaceContext.tsx` — racing ahead of that context's own
+ // `refreshContext()` call, or a workspace a reseed since dropped).
+ // Retrying `id` forever would just 404 on every future planVersion
+ // bump too, so refetch the caller's *current* workspace from the
+ // server instead of only logging; that both corrects `workspaceId`
+ // (re-triggering this effect for the real id) and overwrites the
+ // stale value this provider never itself sets/clears.
+ console.warn(
+ `Background plan sync targeted stale workspace ${id} (not found) — refreshing workspace context`,
+ )
+ void refreshContext()
+ } else {
+ // No toast exists for a background sync — this is the minimum bar
+ // from the module doc comment: never an empty catch, always logged
+ // through the shared API-error model instead of silently dropped.
+ const message = isApiError(err)
+ ? `${err.status} ${err.error.message}`
+ : err instanceof Error
+ ? err.message
+ : String(err)
+ console.error(`Background plan sync failed for workspace ${id}: ${message}`)
+ }
} finally {
inFlightRef.current = false
}
@@ -116,7 +133,7 @@ export function PlanSyncProvider({ children }: { children: React.ReactNode }) {
pendingWorkspaceIdRef.current = null
await runSyncOnce(nextId)
}
- }, [])
+ }, [refreshContext])
useEffect(() => {
// `workspaceId` is seeded synchronously from `localStorage` (see