diff --git a/app/entry.worker.tsx b/app/entry.worker.tsx new file mode 100644 index 0000000..1ca26e6 --- /dev/null +++ b/app/entry.worker.tsx @@ -0,0 +1,210 @@ +/// + +import { json } from "@remix-run/server-runtime"; + +export type {}; +declare let self: ServiceWorkerGlobalScope; + +let STATIC_ASSETS = ["/build/", "/icons/"]; + +let ASSET_CACHE = "asset-cache"; +let DATA_CACHE = "data-cache"; +let DOCUMENT_CACHE = "document-cache"; + +function debug(...messages: any[]) { + if (process.env.NODE_ENV === "development") { + console.debug(...messages); + } +} + +async function handleInstall(event: ExtendableEvent) { + debug("Service worker installed"); +} + +async function handleActivate(event: ExtendableEvent) { + debug("Service worker activated"); +} + +async function handleMessage(event: ExtendableMessageEvent) { + let cachePromises: Map> = new Map(); + + if (event.data.type === "REMIX_NAVIGATION") { + let { isMount, location, matches, manifest } = event.data; + let documentUrl = location.pathname + location.search + location.hash; + + let [dataCache, documentCache, existingDocument] = await Promise.all([ + caches.open(DATA_CACHE), + caches.open(DOCUMENT_CACHE), + caches.match(documentUrl), + ]); + + if (!existingDocument || !isMount) { + debug("Caching document for", documentUrl); + cachePromises.set( + documentUrl, + documentCache.add(documentUrl).catch((error) => { + debug(`Failed to cache document for ${documentUrl}:`, error); + }) + ); + } + + if (isMount) { + for (let match of matches) { + if (manifest.routes[match.id].hasLoader) { + let params = new URLSearchParams(location.search); + params.set("_data", match.id); + let search = params.toString(); + search = search ? `?${search}` : ""; + let url = location.pathname + search + location.hash; + if (!cachePromises.has(url)) { + debug("Caching data for", url); + cachePromises.set( + url, + dataCache.add(url).catch((error) => { + debug(`Failed to cache data for ${url}:`, error); + }) + ); + } + } + } + } + } + + await Promise.all(cachePromises.values()); +} + +async function handleFetch(event: FetchEvent): Promise { + let url = new URL(event.request.url); + + if (isAssetRequest(event.request)) { + let cached = await caches.match(event.request, { + cacheName: ASSET_CACHE, + ignoreVary: true, + ignoreSearch: true, + }); + if (cached) { + debug("Serving asset from cache", url.pathname); + return cached; + } + + debug("Serving asset from network", url.pathname); + let response = await fetch(event.request); + if (response.status === 200) { + let cache = await caches.open(ASSET_CACHE); + await cache.put(event.request, response.clone()); + } + return response; + } + + if (isLoaderRequest(event.request)) { + try { + debug("Serving data from network", url.pathname + url.search); + let response = await fetch(event.request.clone()); + let cache = await caches.open(DATA_CACHE); + await cache.put(event.request, response.clone()); + return response; + } catch (error) { + debug( + "Serving data from network failed, falling back to cache", + url.pathname + url.search + ); + let response = await caches.match(event.request); + if (response) { + response.headers.set("X-Remix-Worker", "yes"); + return response; + } + + return json( + { message: "Network Error" }, + { + status: 500, + headers: { "X-Remix-Catch": "yes", "X-Remix-Worker": "yes" }, + } + ); + } + } + + if (isDocumentGetRequest(event.request)) { + try { + debug("Serving document from network", url.pathname); + let response = await fetch(event.request); + let cache = await caches.open(DOCUMENT_CACHE); + await cache.put(event.request, response.clone()); + return response; + } catch (error) { + debug( + "Serving document from network failed, falling back to cache", + url.pathname + ); + let response = await caches.match(event.request); + if (response) { + return response; + } + throw error; + } + } + + return fetch(event.request.clone()); +} + +function isMethod(request: Request, methods: string[]) { + return methods.includes(request.method.toLowerCase()); +} + +function isAssetRequest(request: Request) { + return ( + isMethod(request, ["get"]) && + STATIC_ASSETS.some((publicPath) => request.url.startsWith(publicPath)) + ); +} + +function isLoaderRequest(request: Request) { + let url = new URL(request.url); + return isMethod(request, ["get"]) && url.searchParams.get("_data"); +} + +function isDocumentGetRequest(request: Request) { + return isMethod(request, ["get"]) && request.mode === "navigate"; +} + +self.addEventListener("install", (event) => { + event.waitUntil(handleInstall(event).then(() => self.skipWaiting())); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil(handleActivate(event).then(() => self.clients.claim())); +}); + +self.addEventListener("message", (event) => { + event.waitUntil(handleMessage(event)); +}); + +self.addEventListener("fetch", (event) => { + event.respondWith( + (async () => { + let result = {} as + | { error: unknown; response: undefined } + | { error: undefined; response: Response }; + try { + result.response = await handleFetch(event); + } catch (error) { + result.error = error; + } + + return appHandleFetch(event, result); + })() + ); +}); + +async function appHandleFetch( + event: FetchEvent, + { + error, + response, + }: + | { error: unknown; response: undefined } + | { error: undefined; response: Response } +): Promise { + return response; +} + diff --git a/app/root.tsx b/app/root.tsx index 516f5d8..60fd9a4 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -1,3 +1,5 @@ +import { useEffect } from "react"; +import type { ReactNode } from "react"; import { LinksFunction, LoaderFunction, @@ -5,7 +7,17 @@ import { useLoaderData, } from "remix"; import type { User, Team } from "@prisma/client"; -import { Links, LiveReload, Outlet, useCatch, Meta, Scripts } from "remix"; +import { + Links, + LiveReload, + Outlet, + useCatch, + Meta, + Scripts, + ScrollRestoration, + useLocation, + useMatches, +} from "remix"; import { getUser } from "./utils/session.server"; import styles from "./tailwind.css"; @@ -44,29 +56,94 @@ export const loader: LoaderFunction = async ({ request }) => { return data; }; +let isMount = true; + function Document({ children, title = `Explit`, }: { - children: React.ReactNode; + children: ReactNode; title?: string; }) { const data = useLoaderData(); + let location = useLocation(); + let matches = useMatches(); + + useEffect(() => { + let mounted = isMount; + isMount = false; + if ("serviceWorker" in navigator) { + if (navigator.serviceWorker.controller) { + navigator.serviceWorker.controller?.postMessage({ + type: "REMIX_NAVIGATION", + isMount: mounted, + location, + matches, + manifest: window.__remixManifest, + }); + } else { + let listener = async () => { + await navigator.serviceWorker.ready; + navigator.serviceWorker.controller?.postMessage({ + type: "REMIX_NAVIGATION", + isMount: mounted, + location, + matches, + manifest: window.__remixManifest, + }); + }; + navigator.serviceWorker.addEventListener("controllerchange", listener); + return () => { + navigator.serviceWorker.removeEventListener( + "controllerchange", + listener + ); + }; + } + } + }, [location]); + return ( - + + {title} + + + + + + + + {children} + {process.env.NODE_ENV === "development" ? : null} diff --git a/app/routes/expenses/index.tsx b/app/routes/expenses/index.tsx index 72b27ae..49b406c 100644 --- a/app/routes/expenses/index.tsx +++ b/app/routes/expenses/index.tsx @@ -66,8 +66,6 @@ export const loader: LoaderFunction = async ({ request }) => { ...userData, dueAmount: avgPerUser - userData.spent, })); - console.log("totalExpenses", totalExpenses); - console.log("expensesByUser", expensesByUser); const data: LoaderData = { lastExpenses, @@ -128,7 +126,7 @@ export default function ExpensesIndexRoute() {

Who needs to pay who

{data.teamCounts?.map((user) => ( -
+
{user.icon ?? user.username[0]} diff --git a/app/routes/resources/manifest[.]json.ts b/app/routes/resources/manifest[.]json.ts new file mode 100644 index 0000000..34c1a7e --- /dev/null +++ b/app/routes/resources/manifest[.]json.ts @@ -0,0 +1,57 @@ +import { json } from "remix"; +import type { LoaderFunction } from "remix"; + +export let loader: LoaderFunction = () => { + return json( + { + short_name: "Explit", + name: "Explit | Track and split shared expenses with friends and family.", + start_url: "/", + display: "standalone", + background_color: "#22252d", + theme_color: "#793ef9", + icons: [ + { + src: "/icons/favicon-32x32.png", + sizes: "32x32", + type: "image/png", + density: "0.75", + }, + { + src: "/icons/android-icon-48x48.png", + sizes: "48x48", + type: "image/png", + density: "1.0", + }, + { + src: "/icons/mstile-70x70.png", + sizes: "70x70", + type: "image/png", + density: "1.5", + }, + { + src: "/icons/mstile-144x144.png", + sizes: "144x144", + type: "image/png", + density: "3.0", + }, + { + src: "/icons/android-chrome-192x192.png", + sizes: "192x192", + type: "image/png", + density: "4.0", + }, + { + src: "/icons/android-chrome-512x512.png", + sizes: "512x512", + type: "image/png", + }, + ], + }, + { + headers: { + "Cache-Control": "public, max-age=600", + }, + } + ); +}; diff --git a/package.json b/package.json index ff6b5c6..75e1395 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,9 @@ "scripts": { "build": "npm run build:css && remix build", "build:css": "tailwindcss -o ./app/tailwind.css", - "dev": "concurrently \"npm run dev:css\" \"remix dev\"", + "build:worker": "esbuild ./app/entry.worker.ts --outfile=./public/entry.worker.js --minify --bundle --format=esm --define:process.env.NODE_ENV='\"production\"'", + "dev": "concurrently \"npm run dev:css\" \"npm run dev:worker\" \"remix dev\"", + "dev:worker": "esbuild ./app/entry.worker.ts --outfile=./public/entry.worker.js --bundle --format=esm --define:process.env.NODE_ENV='\"development\"' --watch", "dev:css": "tailwindcss -o ./app/tailwind.css --watch", "postinstall": "remix setup node", "prepare": "husky install", diff --git a/public/browserconfig.xml b/public/browserconfig.xml new file mode 100644 index 0000000..b3930d0 --- /dev/null +++ b/public/browserconfig.xml @@ -0,0 +1,9 @@ + + + + + + #da532c + + + diff --git a/public/favicon.ico b/public/favicon.ico index 8830cf6..beb3f0a 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/public/icons/android-chrome-192x192.png b/public/icons/android-chrome-192x192.png new file mode 100644 index 0000000..df7376d Binary files /dev/null and b/public/icons/android-chrome-192x192.png differ diff --git a/public/icons/android-chrome-512x512.png b/public/icons/android-chrome-512x512.png new file mode 100644 index 0000000..652b60f Binary files /dev/null and b/public/icons/android-chrome-512x512.png differ diff --git a/public/icons/apple-touch-icon.png b/public/icons/apple-touch-icon.png new file mode 100644 index 0000000..6951a8b Binary files /dev/null and b/public/icons/apple-touch-icon.png differ diff --git a/public/icons/favicon-16x16.png b/public/icons/favicon-16x16.png new file mode 100644 index 0000000..022af70 Binary files /dev/null and b/public/icons/favicon-16x16.png differ diff --git a/public/icons/favicon-32x32.png b/public/icons/favicon-32x32.png new file mode 100644 index 0000000..fd09361 Binary files /dev/null and b/public/icons/favicon-32x32.png differ diff --git a/public/icons/mstile-144x144.png b/public/icons/mstile-144x144.png new file mode 100644 index 0000000..d60b519 Binary files /dev/null and b/public/icons/mstile-144x144.png differ diff --git a/public/icons/mstile-150x150.png b/public/icons/mstile-150x150.png new file mode 100644 index 0000000..cbe371f Binary files /dev/null and b/public/icons/mstile-150x150.png differ diff --git a/public/icons/mstile-310x150.png b/public/icons/mstile-310x150.png new file mode 100644 index 0000000..411e18f Binary files /dev/null and b/public/icons/mstile-310x150.png differ diff --git a/public/icons/mstile-310x310.png b/public/icons/mstile-310x310.png new file mode 100644 index 0000000..7cd6e8f Binary files /dev/null and b/public/icons/mstile-310x310.png differ diff --git a/public/icons/mstile-70x70.png b/public/icons/mstile-70x70.png new file mode 100644 index 0000000..bab4a2c Binary files /dev/null and b/public/icons/mstile-70x70.png differ diff --git a/public/icons/safari-pinned-tab.svg b/public/icons/safari-pinned-tab.svg new file mode 100644 index 0000000..fe03a4e --- /dev/null +++ b/public/icons/safari-pinned-tab.svg @@ -0,0 +1,40 @@ + + + + +Created by potrace 1.14, written by Peter Selinger 2001-2017 + + + + + + + diff --git a/public/site.webmanifest b/public/site.webmanifest new file mode 100644 index 0000000..b20abb7 --- /dev/null +++ b/public/site.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "", + "short_name": "", + "icons": [ + { + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +}