Skip to content

Commit

Permalink
configure: sync progress
Browse files Browse the repository at this point in the history
  • Loading branch information
oscartbeaumont committed Jul 26, 2024
1 parent f0ae40b commit eb2e8f5
Show file tree
Hide file tree
Showing 5 changed files with 202 additions and 16 deletions.
6 changes: 5 additions & 1 deletion apps/configure/src/lib/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,11 @@ export const db = openDB<Database>("data", 1, {

const syncBroadcastChannel = new BroadcastChannel("sync");

type InvalidationKey = StoreNames<Database> | "auth" | "isSyncing";
type InvalidationKey =
| StoreNames<Database>
| "auth"
| "isSyncing"
| "syncProgress";

// Subscribe to store invalidations to trigger queries to rerun of the data
export function subscribeToInvalidations(
Expand Down
82 changes: 76 additions & 6 deletions apps/configure/src/lib/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@ export function initSyncEngine() {
};

const [isSyncing, setIsSyncing] = createSignal(false);
const [progress, setProgress] = createSignal(0); // TODO: Sync `progress` between tabs

subscribeToInvalidations((store) => {
if (store === "syncProgress")
setProgress(
// TODO: Make this ephemeral instead of using `localStorage`?
Number.parseInt(localStorage.getItem("syncProgress") ?? "0") || 0,
);
});

// Polling is not *great* but it's the most reliable way to keep track across tabs.
const isSyncingCheck = createTimer2(
Expand Down Expand Up @@ -89,7 +98,7 @@ export function initSyncEngine() {

return {
isSyncing: isSyncing,
progress: () => 0,
progress,
user,
async logout() {
await (await db).delete("_kv", "accessToken");
Expand All @@ -114,6 +123,7 @@ export function initSyncEngine() {
const result = await navigator.locks.request("sync", async (lock) => {
if (!lock) return;

setProgress(0);
invalidateStore("isSyncing");
isSyncingCheck.trigger();

Expand All @@ -123,6 +133,7 @@ export function initSyncEngine() {
} catch (err) {
console.error("Error syncing", err);
}
setProgress(0);
const elapsed = ((performance.now() - start) / 1000).toFixed(2);
console.log("Synced in", elapsed, "s");
return elapsed;
Expand All @@ -147,15 +158,16 @@ function mapUser(data: any) {
}

// The core sync coordination function.
async function doSync(db: IDBPDatabase<Database>, Authorization: string) {
async function doSync(db: IDBPDatabase<Database>, accessToken: string) {
console.log("Syncing...");

const fetch = async (url: string, init?: RequestInit) => {
// TODO: Join `init` to extra options
const headers = new Headers(init?.headers);
headers.append("Authorization", accessToken);
const resp = await globalThis.fetch(url, {
headers: {
Authorization,
},
...init,
headers,
});
if (resp.status === 401) {
// TODO: Automatic relogin using refresh token if possible
Expand All @@ -169,9 +181,67 @@ async function doSync(db: IDBPDatabase<Database>, Authorization: string) {
// TODO: Detecting if we just finished syncing.
// TODO: Detect if any syncs are currently in progress Eg. nextPage not delta

// TODO: Including avatar
const user = await fetch("https://graph.microsoft.com/v1.0/me");
await db.put("_kv", mapUser(user), "user"); // TODO: Fix types

Check failure on line 186 in apps/configure/src/lib/sync.ts

View workflow job for this annotation

GitHub Actions / Typecheck

Argument of type '{ id: any; name: any; upn: any; }' is not assignable to parameter of type 'string'.

const isFirstSync = (await db.count("users")) === 0;

const resp = await fetch("https://graph.microsoft.com/v1.0/$batch", {
method: "POST",
headers: new Headers({
"Content-Type": "application/json",
}),
body: JSON.stringify({
requests: [
...(isFirstSync
? [
{
id: "1",
method: "GET",
url: "/users/$count",
headers: {
ConsistencyLevel: "eventual",
},
},
]
: []),
{
id: "2",
method: "GET",
url: "/users/delta",
},
],
}),
});

const usersCountResp = resp.responses.find((r: any) => r.id === "1");
const usersDeltaResp = resp.responses.find((r: any) => r.id === "2");
// TODO: Error handling on these responses

if (usersCountResp) {
const usersCount = usersCountResp.body;
let loadedCount = usersDeltaResp.body.value.length;
const updateProgress = () => {
const progress = ((loadedCount / usersCount) * 100).toFixed(0);
localStorage.setItem("syncProgress", progress);
invalidateStore("syncProgress");
};

updateProgress();
let url = usersDeltaResp.body["@odata.nextLink"];
while (url !== null) {
const delta = await fetch(url);
loadedCount += delta.value.length;
updateProgress();
if (delta["@odata.nextLink"]) {
url = delta["@odata.nextLink"];
} else {
url = null;
}
}
}

// TODO: Sync everything else
// TODO: Progress tracking!!!

Expand All @@ -181,5 +251,5 @@ async function doSync(db: IDBPDatabase<Database>, Authorization: string) {
// await db.put("_meta", users, {});
// console.log(users);

// await new Promise((resolve) => setTimeout(resolve, 10000)); // TODO: Remove this
// await new Promise((resolve) => setTimeout(resolve, 1000)); // TODO: Remove this
}
26 changes: 17 additions & 9 deletions apps/configure/src/routes/(dash).tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
Kbd,
ProgressCircle,
Tooltip,
TooltipContent,
TooltipTrigger,
Expand Down Expand Up @@ -212,16 +213,23 @@ function SyncPanel() {
sync.isSyncing() ? "" : "hidden",
)}
>
{/* // TODO: Progress bar */}
<p>{sync.progress()}</p>

<div class="relative inline-flex">
<div class="w-5 h-5 bg-black rounded-full" />
<div class="w-5 h-5 bg-black rounded-full absolute top-0 left-0 animate-ping" />
<div class="w-5 h-5 bg-black rounded-full absolute top-0 left-0 animate-pulse" />
</div>
<ProgressCircle
size="xs"
value={sync.progress()}
strokeWidth={
sync.isSyncing() && sync.progress() === 0 ? 0 : undefined
}
>
<div class="relative inline-flex">
<div class="w-5 h-5 rounded-full" />
<div class="w-5 h-5 bg-black rounded-full absolute top-0 left-0 animate-ping" />
<Show when={sync.isSyncing() && sync.progress() === 0}>
<div class="w-5 h-5 bg-black rounded-full absolute top-0 left-0 animate-pulse" />
</Show>
</div>
</ProgressCircle>
</TooltipTrigger>
<TooltipContent>Actively syncing with Microsoft...</TooltipContent>
<TooltipContent>Progress syncing with Microsoft...</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,4 @@ export * from "./description-list";
// export * from "./show";
export * from "./kbd";
export * from "./chart";
export * from "./progress-circle";
103 changes: 103 additions & 0 deletions packages/ui/src/progress-circle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import clsx from "clsx";
import type { Component, ComponentProps } from "solid-js";
import { mergeProps, splitProps } from "solid-js";

type Size = "xs" | "sm" | "md" | "lg" | "xl";

const sizes: Record<Size, { radius: number; strokeWidth: number }> = {
xs: { radius: 15, strokeWidth: 3 },
sm: { radius: 19, strokeWidth: 4 },
md: { radius: 32, strokeWidth: 6 },
lg: { radius: 52, strokeWidth: 8 },
xl: { radius: 80, strokeWidth: 10 },
};

type ProgressCircleProps = ComponentProps<"div"> & {
value?: number;
size?: Size;
radius?: number;
strokeWidth?: number;
showAnimation?: boolean;
};

const ProgressCircle: Component<ProgressCircleProps> = (rawProps) => {
const props = mergeProps(
{ size: "md" as Size, showAnimation: true },
rawProps,
);
const [local, others] = splitProps(props, [
"class",
"children",
"value",
"size",
"radius",
"strokeWidth",
"showAnimation",
]);

const value = () => getLimitedValue(local.value);
const radius = () => local.radius ?? sizes[local.size].radius;
const strokeWidth = () => local.strokeWidth ?? sizes[local.size].strokeWidth;
const normalizedRadius = () => radius() - strokeWidth() / 2;
const circumference = () => normalizedRadius() * 2 * Math.PI;
const strokeDashoffset = () => (value() / 100) * circumference();
const offset = () => circumference() - strokeDashoffset();

return (
<div
class={clsx("flex flex-col items-center justify-center", local.class)}
{...others}
>
<svg
width={radius() * 2}
height={radius() * 2}
viewBox={`0 0 ${radius() * 2} ${radius() * 2}`}
class="-rotate-90"
>
<title>Progress Circle</title>
<circle
r={normalizedRadius()}
cx={radius()}
cy={radius()}
stroke-width={strokeWidth()}
fill="transparent"
stroke=""
stroke-linecap="round"
class="stroke-secondary transition-colors ease-linear"
/>
{value() >= 0 ? (
<circle
r={normalizedRadius()}
cx={radius()}
cy={radius()}
stroke-width={strokeWidth()}
stroke-dasharray={`${circumference()} ${circumference()}`}
stroke-dashoffset={offset()}
fill="transparent"
stroke=""
stroke-linecap="round"
class={clsx(
"stroke-primary transition-colors ease-linear",
local.showAnimation
? "transition-all duration-300 ease-in-out"
: "",
)}
/>
) : null}
</svg>
<div class="absolute flex">{local.children}</div>
</div>
);
};

function getLimitedValue(input: number | undefined) {
if (input === undefined) {
return 0;
// biome-ignore lint/style/noUselessElse:
} else if (input > 100) {
return 100;
}
return input;
}

export { ProgressCircle };

0 comments on commit eb2e8f5

Please sign in to comment.