Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 51 additions & 4 deletions src/features/dashboard/layout/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,15 @@ import type { DashboardLayout, DashboardWidgetId } from "./types";
* trade for a preference: rebuilding a dashboard costs a minute, and a half-migrated one
* would be a puzzle.
*/
const LAYOUT_VERSION = 2;
export const LAYOUT_VERSION = 2;

/**
* Keyed per user, because two people share a browser more often than a dashboard.
*
* Local storage rather than the backend: there is no endpoint for a per-user layout, and a
* preference that follows the machine is closer to right than one that does not exist.
* Moving it server-side later means replacing these two functions.
* Local storage, and no longer only local storage: `useDashboardLayoutSync.ts` sends the layout to
* `PUT /users/me/dashboard/layout` after every change and reads it back on arrival, so the
* arrangement follows the user to another machine. This is still where the client writes first and
* reads from, which keeps every gesture instant and a failed request free.
*/
function storageKey(userId: string): string {
return `sprintstart:dashboard-layout:${userId}`;
Expand Down Expand Up @@ -96,3 +97,49 @@ export function clearStoredLayout(userId: string): void {
// See storeLayout.
}
}

/**
* Whether this browser's copy of the layout is exactly what the server had at the last exchange.
*
* What lets the sync tell a change that never made it up from a reset somewhere else, when the
* server has nothing: a local layout that is not in sync — never uploaded, or changed since and the
* upload failed — is the newer statement and is sent up; one that is in sync is a stale copy of a
* layout the user has since reset on another device, and must not bring it back.
*
* So it is cleared on every local change and set only after a request confirmed that both sides
* agree. Its own key rather than a field on the layout, because it has to describe "no layout" too:
* after a reset, nothing here and nothing there is in sync.
*/
function syncedKey(userId: string): string {
return `sprintstart:dashboard-layout-synced:${userId}`;
}

export function readLayoutSynced(userId: string): boolean {
if (!userId) return false;

try {
return window.localStorage.getItem(syncedKey(userId)) === "true";
} catch {
return false;
}
}

export function markLayoutSynced(userId: string): void {
if (!userId) return;

try {
window.localStorage.setItem(syncedKey(userId), "true");
} catch {
// See storeLayout.
}
}

export function markLayoutUnsynced(userId: string): void {
if (!userId) return;

try {
window.localStorage.removeItem(syncedKey(userId));
} catch {
// See storeLayout.
}
}
16 changes: 14 additions & 2 deletions src/features/dashboard/layout/useDashboardLayout.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { useState } from "react";
import { useCallback, useState } from "react";
import { useAuth } from "../../../context/useAuth";
import { useMyKnowledgeGaps } from "../../knowledge-gaps/useMyKnowledgeGaps";
import { useMyOnboardingStatus } from "../../onboarding/hooks/useMyOnboardingStatus";
import { useProjectContext } from "../../projects/useProjectContext";
import { DASHBOARD_WIDGET_IDS, getAvailableWidgets } from "./catalog";
import * as operations from "./layoutOperations";
import { clearStoredLayout, readStoredLayout, storeLayout } from "./storage";
import { useDashboardLayoutSync } from "./useDashboardLayoutSync";
import type {
DashboardLayout,
DashboardWidgetDefinition,
Expand Down Expand Up @@ -59,7 +60,8 @@ export type DashboardLayoutController = {
* Storage is only read while the user has not touched anything this session; from the first
* edit on, `arrangedLayout` is the truth and storage is write-only. Every mutation writes
* through immediately — there is no save button because there is nothing to lose: each
* change is small, reversible, and the user is looking straight at the result.
* change is small, reversible, and the user is looking straight at the result. The server copy
* follows a moment later — see {@link useDashboardLayoutSync}.
*/
export function useDashboardLayout(): DashboardLayoutController {
const { profile } = useAuth();
Expand Down Expand Up @@ -90,6 +92,14 @@ export function useDashboardLayout(): DashboardLayoutController {

const [arrangedLayout, setArrangedLayout] = useState<DashboardLayout | null>(null);

/*
Bumped when the server's layout has been written into storage, so this render reads it. The
value itself is never used — storage is the source, this only asks for the read to happen.
*/
const [, setPulledRevision] = useState(0);
const onPulled = useCallback(() => setPulledRevision((revision) => revision + 1), []);
const sync = useDashboardLayoutSync(userId, onPulled);

const availableWidgets = getAvailableWidgets({
profile,
canManageSelectedProject: canManageSelected,
Expand All @@ -111,6 +121,7 @@ export function useDashboardLayout(): DashboardLayoutController {
function apply(next: DashboardLayout) {
setArrangedLayout(next);
storeLayout(userId, next);
sync.push(next);
}

/** A drag fires per pointer move; writing an unchanged layout would hammer storage. */
Expand All @@ -131,6 +142,7 @@ export function useDashboardLayout(): DashboardLayoutController {

resetLayout: () => {
clearStoredLayout(userId);
sync.reset();
setArrangedLayout(null);
},
};
Expand Down
208 changes: 208 additions & 0 deletions src/features/dashboard/layout/useDashboardLayoutSync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import { useCallback, useEffect, useRef } from "react";

import { dashboardLayoutService } from "../../../services/dashboardLayoutService";
import { DASHBOARD_WIDGET_IDS } from "./catalog";
import {
clearStoredLayout,
LAYOUT_VERSION,
markLayoutSynced,
markLayoutUnsynced,
readLayoutSynced,
readStoredLayout,
storeLayout,
} from "./storage";
import type { DashboardLayout } from "./types";

/**
* How long the dashboard waits after a change before sending the layout up.
*
* A drag or a few resizes in a row are one arrangement, not one request each. Same number as the
* board's sync, for the same reason.
*/
const QUIET_MS = 1200;

/** A change made before the first read settled, held until it has. */
type Pending = { kind: "layout"; layout: DashboardLayout } | { kind: "reset" } | null;

export type DashboardLayoutSync = {
/** Sends the layout up, debounced. */
push: (layout: DashboardLayout) => void;
/** Forgets the layout on the server too. */
reset: () => void;
};

/**
* Keeps the user's dashboard layout on the server instead of only in this browser.
*
* Modelled on the board's `useBoardStructureSync`, and it decides the same way on arrival:
*
* - The server has a layout → it wins and is written into local storage, and `onPulled` asks the
* dashboard to read it again. It is the one that followed the user here.
* - The server has none and this browser's copy is not in sync with it → the browser's copy is
* sent up. That is the migration for everybody who arranged their dashboard before this
* existed, and equally a change whose upload failed last time.
* - The server has none and this browser's copy *was* in sync → the user reset it on another
* device. The stale copy here is dropped instead of uploaded, or a reset would only hold on the
* device it was made on. See `readLayoutSynced` for what "in sync" means.
* - Neither has one → nothing happens; the default is derived, not stored.
*
* A change made before that first read has settled is not lost and not overwritten: it is held,
* and sent instead of applying the server's copy — the user's latest gesture is the newer
* statement.
*
* Failures interrupt nothing. The layout is already on screen and in local storage, so a request
* that did not go through costs nothing today and is corrected by the next change.
*/
export function useDashboardLayoutSync(userId: string, onPulled: () => void): DashboardLayoutSync {
/** Set once the first read for this user has settled, so nothing is pushed before it. */
const pulledFor = useRef<string | null>(null);
const pending = useRef<Pending>(null);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
/** The layout waiting in `timer`, so leaving the page can still send it. */
const queued = useRef<DashboardLayout | null>(null);
/**
* Counts local changes. A request marks the layout as in sync only if no change was made while it
* was on its way — otherwise it would vouch for a newer local layout it never carried.
*/
const revision = useRef(0);

/** Marks the layout as in sync, unless something changed locally since `sentAt`. */
const confirmSynced = useCallback(
(sentAt: number) => {
if (revision.current === sentAt) markLayoutSynced(userId);
},
[userId],
);

useEffect(() => {
if (!userId) return;

let active = true;
pulledFor.current = null;
pending.current = null;

void (async () => {
try {
const server = await dashboardLayoutService.fetchLayout(LAYOUT_VERSION);
if (!active) return;

// Settled before anything below is awaited: a change made while the held one is being
// sent goes through the debounce like any other, instead of landing in `pending` after
// it has already been read and being thrown away with it.
const waiting = pending.current;
pending.current = null;
pulledFor.current = userId;
const sentAt = revision.current;

if (waiting?.kind === "reset") {
await dashboardLayoutService.resetLayout();
confirmSynced(sentAt);
} else if (waiting?.kind === "layout") {
await dashboardLayoutService.saveLayout(LAYOUT_VERSION, waiting.layout);
confirmSynced(sentAt);
} else if (server.updatedAt !== null) {
// Not checked here: `readStoredLayout` checks every item on the way out, the same as for
// anything else in storage, and drops widgets and sizes this version does not know.
storeLayout(userId, server.items as DashboardLayout);
markLayoutSynced(userId);
onPulled();
} else {
const local = readStoredLayout(userId, DASHBOARD_WIDGET_IDS);

if (local && readLayoutSynced(userId)) {
clearStoredLayout(userId);
onPulled();
} else if (local) {
await dashboardLayoutService.saveLayout(LAYOUT_VERSION, local);
// Only once it is up there: marked before, a failed upload would read as a reset on
// the next visit and drop the very layout it exists to keep.
confirmSynced(sentAt);
} else {
markLayoutSynced(userId);
}
}
} catch {
// Offline, or the endpoint is not deployed yet: the dashboard keeps working from local
// storage exactly as it did before any of this existed.
} finally {
// A failed read settles too, so later changes are not held forever.
if (active && pulledFor.current !== userId) {
pulledFor.current = userId;
pending.current = null;
}
}
})();

return () => {
active = false;
};
}, [userId, onPulled, confirmSynced]);

useEffect(() => {
return () => {
// Leaving the page inside the quiet window would otherwise drop the last change. Sent with
// whatever token is current: harmless while signing out reloads the app, but a user switch
// without a reload would send the previous user's layout under the new user's token.
if (timer.current) clearTimeout(timer.current);
timer.current = null;

const last = queued.current;
queued.current = null;
// Deliberately never marked as in sync: by the time this answers, the next mount may already
// have changed the layout again, and "not in sync" only ever costs an upload of what the
// server already has.
if (last) void dashboardLayoutService.saveLayout(LAYOUT_VERSION, last).catch(() => {});
};
}, [userId]);

const push = useCallback(
(layout: DashboardLayout) => {
if (!userId) return;

revision.current += 1;
markLayoutUnsynced(userId);

if (pulledFor.current !== userId) {
pending.current = { kind: "layout", layout };
return;
}

if (timer.current) clearTimeout(timer.current);
queued.current = layout;
timer.current = setTimeout(() => {
timer.current = null;
queued.current = null;
const sentAt = revision.current;
void dashboardLayoutService
.saveLayout(LAYOUT_VERSION, layout)
.then(() => confirmSynced(sentAt))
.catch(() => {});
}, QUIET_MS);
},
[userId, confirmSynced],
);

const reset = useCallback(() => {
if (!userId) return;

revision.current += 1;
markLayoutUnsynced(userId);

if (timer.current) clearTimeout(timer.current);
timer.current = null;
queued.current = null;

if (pulledFor.current !== userId) {
pending.current = { kind: "reset" };
return;
}

const sentAt = revision.current;
void dashboardLayoutService
.resetLayout()
.then(() => confirmSynced(sentAt))
.catch(() => {});
}, [userId, confirmSynced]);

return { push, reset };
}
43 changes: 43 additions & 0 deletions src/services/dashboardLayoutService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { apiClient } from "./apiClient";

const BASE = "/api/v1/users/me/dashboard/layout";

/** One placed widget as the server keeps it: the client's own id and size, stored as given. */
export type DashboardLayoutItemWire = {
id: string;
size: string;
};

export type DashboardLayoutWire = {
version: number;
items: DashboardLayoutItemWire[];
/** Null when there is nothing to use — none stored, or stored under another version. */
updatedAt: string | null;
};

export const dashboardLayoutService = {
/**
* The signed-in user's dashboard arrangement, if one was stored under `version`.
*
* No arrangement, or one written under another version, answers with no items and a null
* `updatedAt` rather than a 404 — both mean "show the default".
*/
async fetchLayout(version: number): Promise<DashboardLayoutWire> {
return await apiClient.fetch<DashboardLayoutWire>(
`${BASE}?version=${encodeURIComponent(String(version))}`,
);
},

/** Stores the whole arrangement, replacing whatever was there. */
async saveLayout(version: number, items: DashboardLayoutItemWire[]): Promise<void> {
await apiClient.fetch<unknown>(BASE, {
method: "PUT",
body: JSON.stringify({ version, items }),
});
},

/** Forgets the arrangement, so every device falls back to the default. */
async resetLayout(): Promise<void> {
await apiClient.fetch<unknown>(BASE, { method: "DELETE" });
},
};
Loading
Loading