diff --git a/package-lock.json b/package-lock.json index 2d1815b..0ee8772 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1", "dependencies": { "@floating-ui/dom": "^1.6.12", + "dexie": "^4.0.10", "svelte-dnd-action": "^0.9.53", "svelte-persisted-store": "^0.12.0", "zod": "^3.24.1" @@ -1416,6 +1417,12 @@ "dev": true, "license": "MIT" }, + "node_modules/dexie": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/dexie/-/dexie-4.0.10.tgz", + "integrity": "sha512-eM2RzuR3i+M046r2Q0Optl3pS31qTWf8aFuA7H9wnsHTwl8EPvroVLwvQene/6paAs39Tbk6fWZcn2aZaHkc/w==", + "license": "Apache-2.0" + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", diff --git a/package.json b/package.json index 684820d..1f7352d 100644 --- a/package.json +++ b/package.json @@ -31,8 +31,8 @@ "type": "module", "dependencies": { "@floating-ui/dom": "^1.6.12", + "dexie": "^4.0.10", "svelte-dnd-action": "^0.9.53", - "svelte-persisted-store": "^0.12.0", "zod": "^3.24.1" }, "overrides": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f9a472b..e5a17c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,12 +14,12 @@ importers: '@floating-ui/dom': specifier: ^1.6.12 version: 1.6.12 + dexie: + specifier: ^4.0.10 + version: 4.0.10 svelte-dnd-action: specifier: ^0.9.53 version: 0.9.53(svelte@5.15.0) - svelte-persisted-store: - specifier: ^0.12.0 - version: 0.12.0(svelte@5.15.0) zod: specifier: ^3.24.1 version: 3.24.1 @@ -564,6 +564,9 @@ packages: devalue@5.1.1: resolution: {integrity: sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==} + dexie@4.0.10: + resolution: {integrity: sha512-eM2RzuR3i+M046r2Q0Optl3pS31qTWf8aFuA7H9wnsHTwl8EPvroVLwvQene/6paAs39Tbk6fWZcn2aZaHkc/w==} + didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} @@ -940,12 +943,6 @@ packages: peerDependencies: svelte: '>=3.23.0 || ^5.0.0-next.0' - svelte-persisted-store@0.12.0: - resolution: {integrity: sha512-BdBQr2SGSJ+rDWH8/aEV5GthBJDapVP0GP3fuUCA7TjYG5ctcB+O9Mj9ZC0+Jo1oJMfZUd1y9H68NFRR5MyIJA==} - engines: {node: '>=0.14'} - peerDependencies: - svelte: ^3.48.0 || ^4 || ^5 - svelte@5.15.0: resolution: {integrity: sha512-YWl8rAd4hSjERLtLvP6h2pflGtmrJwv+L12BgrOtHYJCpvLS9WKp/YNAdyolw3FymXtcYZqhSWvWlu5O1X7tgQ==} engines: {node: '>=18'} @@ -1439,6 +1436,8 @@ snapshots: devalue@5.1.1: {} + dexie@4.0.10: {} + didyoumean@1.2.2: {} dlv@1.1.3: {} @@ -1805,10 +1804,6 @@ snapshots: dependencies: svelte: 5.15.0 - svelte-persisted-store@0.12.0(svelte@5.15.0): - dependencies: - svelte: 5.15.0 - svelte@5.15.0: dependencies: '@ampproject/remapping': 2.3.0 diff --git a/src/lib/components/MotionForm.svelte b/src/lib/components/MotionForm.svelte index b83f444..908d715 100644 --- a/src/lib/components/MotionForm.svelte +++ b/src/lib/components/MotionForm.svelte @@ -1,10 +1,10 @@ @@ -208,7 +211,7 @@
    { @@ -219,14 +222,16 @@ } } }} - onconsider={(e) => order = processDrag(e)} - onfinalize={(e) => order = processDrag(e)} + onconsider={(e) => dndItems = processDrag(e)} + onfinalize={(e) => order = dndItems = processDrag(e)} aria-labelledby="speaker-list-header" > - {#each order as speaker, i (speaker.id)} - {@const speakerLabel = getLabel(speaker.key)} + {#each dndItems as speaker, i (speaker.id)} {@const selected = speaker.id === selectedSpeakerId} {@const shadow = isDndShadow(speaker)} + {@const delAttrs = findDelegate(delegates, speaker.key)} + {@const speakerLabel = delAttrs?.name ?? "unknown"} +
  1. - +
    diff --git a/src/lib/components/modals/EditMotionCard.svelte b/src/lib/components/modals/EditMotionCard.svelte index 6109385..08a02a5 100644 --- a/src/lib/components/modals/EditMotionCard.svelte +++ b/src/lib/components/modals/EditMotionCard.svelte @@ -2,17 +2,16 @@ import type { Motion } from "$lib/types"; import MotionForm from "$lib/components/MotionForm.svelte"; import EditModal from "$lib/components/modals/EditModal.svelte"; + import { db } from "$lib/db/index.svelte"; import { inputifyMotion } from "$lib/motions/definitions"; - import { getSessionDataContext } from "$lib/stores/session"; - - const { settings: { delegateAttributes } } = getSessionDataContext(); interface Props { motion: Motion; } - let { motion }: Props = $props(); - let inputMotion = $state(inputifyMotion(motion, $delegateAttributes)); + + let inputMotion = $state(inputifyMotion(motion)); + inputifyMotion(motion, db.delegates).then(im => inputMotion = im); diff --git a/src/lib/components/modals/EnableDelegatesCard.svelte b/src/lib/components/modals/EnableDelegatesCard.svelte index 125a75c..21b4b39 100644 --- a/src/lib/components/modals/EnableDelegatesCard.svelte +++ b/src/lib/components/modals/EnableDelegatesCard.svelte @@ -1,26 +1,20 @@ diff --git a/src/lib/components/motions/ModCaucus.svelte b/src/lib/components/motions/ModCaucus.svelte index 3006b3c..3f7ed38 100644 --- a/src/lib/components/motions/ModCaucus.svelte +++ b/src/lib/components/motions/ModCaucus.svelte @@ -2,25 +2,26 @@ import DelLabel from "$lib/components/del-label/DelLabel.svelte"; import SpeakerList from "$lib/components/SpeakerList.svelte"; import Timer from "$lib/components/Timer.svelte"; + import { getSessionContext } from "$lib/context/index.svelte"; + import { db } from "$lib/db/index.svelte"; + import { findDelegate } from "$lib/db/delegates"; import { presentDelegateSchema } from "$lib/motions/form_validation"; - import { getSessionDataContext } from "$lib/stores/session"; - import { getStatsContext, updateStats } from "$lib/stores/stats"; - import type { AppBarData, Motion, Speaker } from "$lib/types"; + import type { Motion, Speaker } from "$lib/types"; import { lazyslide } from "$lib/util"; import Icon from "@iconify/svelte"; - import { getContext, untrack } from "svelte"; + import { untrack } from "svelte"; interface Props { motion: Motion & { kind: "mod" }; + order: Speaker[]; } - let { motion }: Props = $props(); + let { motion, order = $bindable() }: Props = $props(); - const { settings: { delegateAttributes }, presentDelegates } = getSessionDataContext(); - const { stats } = getStatsContext(); - const appBarData = getContext("app-bar"); + const sessionData = getSessionContext(); + const { delegates } = sessionData; $effect(() => { - appBarData.topic = `Topic: ${motion.topic}`; - }) + sessionData.barTopic = `Topic: ${motion.topic}`; + }); // Timer let running: boolean = $state(false); @@ -29,7 +30,6 @@ // Speakers List let speakersList: SpeakerList | undefined = $state(); - let order: Speaker[] = $state([]); let selectedSpeaker = $derived(speakersList?.selectedSpeaker()); $effect(() => { if (running) untrack(() => { @@ -67,7 +67,7 @@ {#key selectedSpeaker?.key}
    {#if typeof selectedSpeaker !== "undefined"} - + {/if}
    {/key} @@ -78,7 +78,7 @@ bind:this={delTimer} bind:running disableKeyHandlers={typeof selectedSpeaker === "undefined"} - onPause={(t) => updateStats(stats, selectedSpeaker?.key, dat => dat.durationSpoken += t)} + onPause={(t) => db.updateDelegate(selectedSpeaker?.key, d => { d.stats.durationSpoken += t; })} /> { if (!isRepeat) updateStats(stats, key, dat => dat.timesSpoken++) }} + onMarkComplete={(key, isRepeat) => { if (!isRepeat) db.updateDelegate(key, d => { d.stats.timesSpoken++; }) }} />
    \ No newline at end of file diff --git a/src/lib/components/motions/RoundRobin.svelte b/src/lib/components/motions/RoundRobin.svelte index ceffe30..f8d4269 100644 --- a/src/lib/components/motions/RoundRobin.svelte +++ b/src/lib/components/motions/RoundRobin.svelte @@ -2,23 +2,24 @@ import DelLabel from "$lib/components/del-label/DelLabel.svelte"; import SpeakerList, { createSpeaker } from "$lib/components/SpeakerList.svelte"; import Timer from "$lib/components/Timer.svelte"; - import { getSessionDataContext } from "$lib/stores/session"; - import { getStatsContext, updateStats } from "$lib/stores/stats"; - import type { AppBarData, Motion, Speaker } from "$lib/types"; + import { getSessionContext } from "$lib/context/index.svelte"; + import { db } from "$lib/db/index.svelte"; + import { findDelegate } from "$lib/db/delegates"; + import type { Motion, Speaker } from "$lib/types"; import { lazyslide } from "$lib/util"; import Icon from "@iconify/svelte"; - import { getContext, untrack } from "svelte"; + import { untrack } from "svelte"; interface Props { motion: Motion & { kind: "rr" }; + order: Speaker[] } - let { motion }: Props = $props(); + let { motion, order = $bindable() }: Props = $props(); - const { settings: { delegateAttributes }, presentDelegates } = getSessionDataContext(); - const { stats } = getStatsContext(); - const appBarData = getContext("app-bar"); + const sessionData = getSessionContext(); + const { delegates } = sessionData; $effect(() => { - appBarData.topic = `Topic: ${motion.topic}`; + sessionData.barTopic = `Topic: ${motion.topic}`; }); // Timer @@ -27,7 +28,11 @@ // Speakers List let speakersList: SpeakerList | undefined = $state(); - let order: Speaker[] = $state($presentDelegates.map(key => createSpeaker(key))); + $effect(() => { + if (untrack(() => order.length == 0)) { + order = $delegates.filter(d => d.isPresent()).map(d => createSpeaker(d.id)); + } + }); let selectedSpeaker = $derived(speakersList?.selectedSpeaker()); $effect(() => { if (running) untrack(() => { @@ -61,7 +66,7 @@ {#key selectedSpeaker?.key}
    {#if typeof selectedSpeaker !== "undefined"} - + {/if}
    {/key} @@ -72,7 +77,7 @@ bind:this={timer} bind:running disableKeyHandlers={typeof selectedSpeaker === "undefined"} - onPause={(t) => updateStats(stats, selectedSpeaker?.key, dat => dat.durationSpoken += t)} + onPause={(t) => db.updateDelegate(selectedSpeaker?.key, d => { d.stats.durationSpoken += t; })} />
    {#if !running} @@ -95,10 +100,10 @@ { if (!isRepeat) updateStats(stats, key, dat => dat.timesSpoken++) }} + onMarkComplete={(key, isRepeat) => { if (!isRepeat) db.updateDelegate(key, d => { d.stats.timesSpoken++; }) }} />
    \ No newline at end of file diff --git a/src/lib/components/nav/SettingsNavigation.svelte b/src/lib/components/nav/SettingsNavigation.svelte index 689cf2f..215ccad 100644 --- a/src/lib/components/nav/SettingsNavigation.svelte +++ b/src/lib/components/nav/SettingsNavigation.svelte @@ -1,29 +1,25 @@ @@ -58,11 +54,32 @@
    +{#snippet sessionRow(key?: number)} + {@const selected = $selectedSession === key} + +
    + +
    +{/snippet}
    +
    + {#each $prevSessions as sessionKey} + {@render sessionRow(+sessionKey)} + {/each} + {#if typeof $selectedSession === "undefined"} + {@render sessionRow(undefined)} + {/if} +
    \ No newline at end of file diff --git a/src/lib/context/index.svelte.ts b/src/lib/context/index.svelte.ts new file mode 100644 index 0000000..3e1b88c --- /dev/null +++ b/src/lib/context/index.svelte.ts @@ -0,0 +1,49 @@ +import { db, DEFAULT_SESSION_DATA } from "$lib/db/index.svelte"; +import type { SessionContext } from "$lib/types"; +import { getContext, hasContext, setContext } from "svelte"; + +const CONTEXT_KEY = "session"; + +// A wrapper class so Svelte is willing to make barTopic $state real. +class SessionImpl implements SessionContext { + delegates = db.enabledDelegatesStore(); + motions = db.sessionDataStore("motions", DEFAULT_SESSION_DATA.motions); + selectedMotion = db.sessionDataStore("selectedMotion", DEFAULT_SESSION_DATA.selectedMotion); + selectedMotionState = db.sessionDataStore("selectedMotionState", DEFAULT_SESSION_DATA.selectedMotionState); + speakersList = db.sessionDataStore("speakersList", DEFAULT_SESSION_DATA.speakersList); + barTitle = db.settingStore("title", ""); + barTopic = $state(); +} + +/** + * Gets the session context object. + * + * This is an object which holds all properties of the session, + * including those accessed via the session database. + * + * This context is useful in that it allows for data to persist between page changes. + * + * @returns the context object + */ +export function getSessionContext() { + return getContext(CONTEXT_KEY); +} + +/** + * Creates the session context object (if it hasn't been created). + * + * @returns the context object + */ +export function createSessionContext() { + if (hasContext(CONTEXT_KEY)) return getSessionContext(); + return setContext(CONTEXT_KEY, new SessionImpl()); +} +/** + * Resets properties of the session. + */ +export async function resetSessionContext(ctx: SessionContext) { + await db.resetSessionData(); + + // Additional attributes to clear: + ctx.barTopic = undefined; +} \ No newline at end of file diff --git a/src/lib/db/delegates.ts b/src/lib/db/delegates.ts new file mode 100644 index 0000000..ce9033f --- /dev/null +++ b/src/lib/db/delegates.ts @@ -0,0 +1,70 @@ +/** + * Delegate table definition for the session database. + */ + +import type { DelegateAttrs, DelegateID, DelegatePresence, DelSessionData, StatsData } from "$lib/types"; +import { Entity } from "dexie"; +import type { SessionDatabase } from "./index.svelte"; + +export class Delegate extends Entity { + // Indexes: + id!: DelegateID; + name!: string; + aliases!: string[]; + order!: number; + + // Non-indexes: + enabled!: boolean; + flagURL!: string; + presence!: DelegatePresence; + stats!: StatsData; + + static readonly indexes = "++id, name, *aliases, order"; + + /** + * Checks a delegate presence status is present. + * @param p the presence status + * @returns whether it indicates presence + */ + isPresent(): boolean { + return this.presence != "NP"; + } + /** + * Checks if name is associated with a given delegate. + * @param name name we're looking at + * @returns whether this delegate could correctly be referred to by the given name + */ + nameEquals(name: string): boolean { + // case-insensitive equals + const eq = (a: string, b: string) => a.localeCompare(b, undefined, { sensitivity: "base" }) == 0; + return eq(this.name, name) || this.aliases.some(n => eq(n, name)); + } + + /** + * @returns the attributes of this delegate + */ + getAttributes(): DelegateAttrs { + return { + name: this.name, + aliases: structuredClone(this.aliases), + flagURL: this.flagURL, + }; + } + getSessionData(): DelSessionData { + return { + presence: this.presence, + stats: structuredClone(this.stats), + } + } +} + +/** + * Find the delegate with the matching ID (note this is linear search). + * @param d Delegate list + * @param searchId Search ID + * @returns The matching delegate (if they exist) + */ +export function findDelegate(d: Delegate[], searchId: DelegateID): Delegate | undefined { + // linear but bleh it's synchronous so whatever + return d.find(({id}) => id == searchId); +} diff --git a/src/lib/db/index.svelte.ts b/src/lib/db/index.svelte.ts new file mode 100644 index 0000000..0f1f4c6 --- /dev/null +++ b/src/lib/db/index.svelte.ts @@ -0,0 +1,302 @@ +import { Delegate } from "./delegates"; +import { KeyValuePair, toKeyValueArray, toObject } from "./keyval"; +import { DEFAULT_DELEGATES } from "$lib/delegate_presets"; +import { getFlagUrl } from "$lib/flags/flagcdn"; +import { DEFAULT_SORT_PRIORITY } from "$lib/motions/definitions"; +import type { DelegateAttrs, DelegateID, DelSessionData, PrevSessionData, SessionData, Settings } from "$lib/types"; +import { Dexie, liveQuery, type EntityTable, type IndexableType, type InsertType } from "dexie"; +import { readable, type Readable, type Updater, type Writable } from "svelte/store"; + +export class SessionDatabase extends Dexie { + delegates!: EntityTable; + settings!: EntityTable; + sessionData!: EntityTable; + prevSessions!: EntityTable, "key">; + + constructor() { + super("sessionDatabase", { cache: "immutable" }); + this.version(1).stores({ + delegates: Delegate.indexes, + settings: KeyValuePair.indexes, + sessionData: KeyValuePair.indexes, + prevSessions: KeyValuePair.indexes, + }); + this.delegates.mapToClass(Delegate); + this.settings.mapToClass(KeyValuePair); + this.sessionData.mapToClass(KeyValuePair); + this.prevSessions.mapToClass(KeyValuePair); + } + + /** + * Updates one entry from the delegate table. + * + * This should only be used for single-time, short updates. + * Large changes (such as changing multiple delegates at once) + * should go through Dexie's bulk update methods + * and multiple operations should go through transactions. + * + * @param id the ID of the entry to update + * @param param parameters to `Dexie.Table.update` + * (either a callback that updates an item or an object indicating what parameters to update) + * @returns a promise on completion + */ + async updateDelegate(id: DelegateID | undefined, param: Parameters[1]): Promise; + async updateDelegate(id: DelegateID | undefined, param: ((obj: Delegate, ctx: { value: any; primKey: IndexableType; }) => void | boolean)): Promise; + async updateDelegate(id: DelegateID | undefined, param: any): Promise { + if (typeof id !== "number") return; + await this.delegates.update(id, param); + } + + /** + * Adds a delegate with the given attributes to the delegate database. + * @param attrs The attributes of the new delegate + */ + async addDelegate(attrs: DelegateAttrs) { + await this.transaction("rw", this.delegates, async () => { + const order = await this.delegates.count(); + const newDel = populateDelegate(attrs, order); + await this.delegates.add(newDel); + }) + } + /** + * Adds a set of delegates with the given attributes to the delegate database. + * @param delegates the attributes of the new delegates + */ + async addDelegates(delegates: DelegateAttrs[]) { + await this.transaction("rw", this.delegates, async () => { + const order = await this.delegates.count(); + const newDels = delegates.map((attrs, i) => populateDelegate(attrs, order + i)); + await this.delegates.bulkAdd(newDels); + }) + } + + /** + * Gets a readable store of all enabled delegates. + * This can be used during a session to get a list of delegates. + * + * Note that this does not give a list of *only* present delegates. + * To do that, you need to additionally filter the result of this store: + * + * ```ts + * const delegates = db.enabledDelegatesStore(); + * const presentDelegates = $delegates.filter(d => d.isPresent()); + * ``` + * + * @returns the store + */ + enabledDelegatesStore(): Readable { + return queryStore(() => { + return db.delegates.orderBy("order") + .filter(e => e.enabled) + .toArray(); + }, []); + } + /** + * Gets the setting from the settings database. + * @param key the key to get the setting for + * @returns the value of the setting + */ + async getSetting(key: K): Promise { + return this.settings.get(key).then(e => e!.val); + } + /** + * Creates a writable store for a given setting. + * @param key the key to make a store for + * @returns the store + */ + settingStore(key: K): Writable; + settingStore(key: K, fallback: Settings[K]): Writable; + settingStore(key: K, fallback?: Settings[K]): Writable { + return getKVStore(this.settings, key, fallback); + } + async resetSettings() { + await this.transaction("rw", this.settings, async () => { + await this.settings.clear(); + await this.settings.bulkAdd(toKeyValueArray(DEFAULT_SETTINGS)); + }); + } + + /** + * Creates a writable store for a given session data key. + * @param key the key to make a store for + * @returns the store + */ + sessionDataStore(key: K): Writable; + sessionDataStore(key: K, fallback: SessionData[K]): Writable; + sessionDataStore(key: K, fallback?: SessionData[K]): Writable { + return getKVStore(this.sessionData, key, fallback); + } + + async getSessionValue(key: K): Promise { + return this.sessionData.get(key).then(e => e?.val); + } + async resetSessionData() { + await this.transaction("rw", [this.sessionData, this.delegates], async () => { + await this.delegates.toCollection().modify(DEFAULT_DEL_SESSION_DATA); + + await this.sessionData.clear(); + await this.sessionData.bulkAdd(toKeyValueArray(DEFAULT_SESSION_DATA)); + }) + } + async saveSessionData() { + return this.transaction("rw", [this.delegates, this.sessionData, this.prevSessions], async () => { + let sessionKey = await this.getSessionValue("sessionKey") ?? await this.prevSessions.count(); + + let sessionData = await this.sessionData.toArray(); + let delegates = await this.delegates.toArray(); + + await this.prevSessions.put({ key: sessionKey, val: { + common: Object.assign(toObject(sessionData) as SessionData, { sessionKey }), + delegates: SessionDatabase.delegatesAsSessionData(delegates) + } }); + }); + } + async loadSessionData(key: number) { + return this.transaction("rw", [this.delegates, this.sessionData, this.prevSessions], async () => { + let entry = await this.prevSessions.get(key); + if (entry) { + let { common, delegates } = entry.val; + await this.saveSessionData(); + + await this.delegates.bulkUpdate( + delegates.map(({ id, session }) => ({ key: id, changes: session })) + ); + + await this.sessionData.clear(); + await this.sessionData.bulkAdd(toKeyValueArray(common)); + } + }); + } + + /** + * Comverts an array of delegates into the delegate session data array + * @param delegates delegate + * @returns the new array + */ + static delegatesAsSessionData(delegates: Delegate[]): PrevSessionData["delegates"] { + return delegates.map(d => ({ id: d.id, session: d.getSessionData() })); + } +} + +export const db = new SessionDatabase(); + +db.on("ready", async (tx) => { + let txdb = tx as typeof db; + if (await txdb.delegates.count() == 0) { + const dels = await _legacyFixDelFlag(DEFAULT_DELEGATES); + await txdb.addDelegates(dels); + } + if (await txdb.settings.count() == 0) { + await txdb.resetSettings(); + } + if (await txdb.sessionData.count() == 0) { + await txdb.sessionData.bulkAdd(toKeyValueArray(DEFAULT_SESSION_DATA)); + } +}) + +/** + * Default session data per delegate. + */ +export const DEFAULT_DEL_SESSION_DATA = { + presence: "NP", + stats: { + motionsProposed: 0, + motionsAccepted: 0, + timesSpoken: 0, + durationSpoken: 0 + } +} as const satisfies DelSessionData; + +export const DEFAULT_SESSION_DATA = { + motions: [], + selectedMotion: null, + selectedMotionState: { + speakersList: [] + }, + speakersList: [] +} as const satisfies SessionData; + +/** + * Default settings. + */ +export const DEFAULT_SETTINGS = { + sortOrder: DEFAULT_SORT_PRIORITY, + title: "General Assembly", + preferences: { + enableMotionRoundRobin: true, + enableMotionExt: true, + pauseMainTimer: true, + } +} satisfies Settings; + +/** + * Adds attributes to a given delegate, allowing it to be inserted into the database. + * @param attrs the attributes the delegate already has + * @param order its position in the database + * @returns the new object which contains all database attributes for the delegate + */ +function populateDelegate(attrs: DelegateAttrs, order: number): InsertType { + let { name, aliases, flagURL: mFlagURL } = attrs; + return Object.assign({ + name, aliases, order, enabled: true, flagURL: mFlagURL ?? "" + }, DEFAULT_DEL_SESSION_DATA); +} +/** + * Method to handle legacy `Record` format. + * @param flagKey the flag key + * @param attrs the delegate attributes + */ +export async function _legacyFixDelFlag(flagKey: string, attrs: DelegateAttrs): Promise; +/** + * Method to handle legacy `Record` format. + * @param delegates the list of delegates, as a flag key-delegate attribute mapping. + */ +export async function _legacyFixDelFlag(delegates: Record): Promise; +export async function _legacyFixDelFlag(flagKeyOrDelegates: string | Record, attrs?: DelegateAttrs): Promise { + if (typeof flagKeyOrDelegates === "string" && typeof attrs === "object") { + let flagKey = flagKeyOrDelegates; + let { name, aliases, flagURL: mFlagURL } = attrs; + let flagURL = mFlagURL ?? (await getFlagUrl(flagKey))?.href ?? (await getFlagUrl("un"))!.href; + return { name, aliases, flagURL }; + } else if (typeof flagKeyOrDelegates === "object") { + let delegates = flagKeyOrDelegates; + return Promise.all( + Object.entries(delegates) + .map(([k, attrs]) => _legacyFixDelFlag(k, attrs)) + ); + } else { + throw new TypeError("Invalid arguments"); + } +} + +/** + * A store on a database query that updates when the database updates. + * + * This is similar to Dexie's `liveQuery`, except with some adjustments to better support Svelte. + * Notably, this adds the option to give a default value and doesn't reload on page switch + * if put in a context. + * + * @param cb the querier function + */ +export function queryStore(cb: () => T | Promise): Readable; +export function queryStore(cb: () => T | Promise, fallback: T): Readable; +export function queryStore(cb: () => T | Promise, fallback?: T) { + const query = liveQuery(cb); + return readable(fallback, (set) => query.subscribe(set).unsubscribe); +} +/** + * Creates a writable store out of a single key-value pair in a database table. + * + * This store assumes the key exists in the table. It will not write if it does not exist. + * + * @param table the table + * @param key the key + * @param fallback a fallback/default value to use before the value is first read successfully + */ +function getKVStore(table: EntityTable, key: string, fallback?: any): Writable { + const store = queryStore(() => table.get(key).then(entry => entry?.val), fallback); + const set = (val: any) => table.update(key, { val: $state.snapshot(val) }); + const update = (updater: Updater) => table.update(key, entry => entry.val = updater(entry.val)); + + return Object.assign(store, { set, update }); +} \ No newline at end of file diff --git a/src/lib/db/keyval.ts b/src/lib/db/keyval.ts new file mode 100644 index 0000000..c4ecd1a --- /dev/null +++ b/src/lib/db/keyval.ts @@ -0,0 +1,24 @@ +/** + * Table definition of key-val stores for the session database. + */ + +import { Entity } from "dexie"; +import type { SessionDatabase } from "./index.svelte"; + +export class KeyValuePair extends Entity implements IKeyValue { + key!: K; + val!: V; + + static readonly indexes = "key"; +} + +interface IKeyValue { + key: K; + val: V; +} +export function toObject(kvs: IKeyValue[]): Record { + return Object.fromEntries(kvs.map(({key, val}) => [key, val])); +} +export function toKeyValueArray(o: Record): IKeyValue[] { + return Object.entries(o).map(([key, val]) => ({ key, val: structuredClone(val) })); +} \ No newline at end of file diff --git a/src/lib/delegate_presets/index.ts b/src/lib/delegate_presets/index.ts index 87d0afd..f93124d 100644 --- a/src/lib/delegate_presets/index.ts +++ b/src/lib/delegate_presets/index.ts @@ -1,4 +1,15 @@ import type { DelegateAttrs } from "$lib/types"; +import DEFAULT_DELEGATES_JSON from "$lib/delegate_presets/preset-un.json"; + +type DelProperties = Record; +/** + * The object of default delegates. + */ +export const DEFAULT_DELEGATES: DelProperties = DEFAULT_DELEGATES_JSON; +/** + * The key of the default preset. + */ +export const DEFAULT_PRESET_KEY = "un"; /** * All defined presets. @@ -16,22 +27,15 @@ export const PRESETS = { */ const NO_PRESET: readonly (keyof typeof PRESETS)[] = ["custom"]; -/** - * @returns the default preset key (the first one defined on the object). - */ -export function defaultPresetKey(): keyof typeof PRESETS { - return (Object.keys(PRESETS) as (keyof typeof PRESETS)[])[0]; -} - /** * Gets the preset data at a given file key. * @param file the preset key (must be a key in `PRESETS`) * @returns the preset data (if it exists) * @throws if key is not in `PRESETS` or JSON is invalid or doesn't exist */ -export async function getPreset(key: keyof typeof PRESETS) { +export async function getPreset(key: keyof typeof PRESETS): Promise { if (!NO_PRESET.includes(key)) { const { default: json } = await import(`$lib/delegate_presets/preset-${key}.json`); - return structuredClone>(json); + return structuredClone(json); } } diff --git a/src/lib/motions/definitions.ts b/src/lib/motions/definitions.ts index c6a4468..97f39d7 100644 --- a/src/lib/motions/definitions.ts +++ b/src/lib/motions/definitions.ts @@ -1,8 +1,10 @@ -import type { DelegateAttrs, Motion, MotionKind, SortEntry } from "$lib/types"; +import type { Motion, MotionKind, SortEntry } from "$lib/types"; import { nonEmptyString, presentDelegateSchema, refineSpeakingTime, timeSchema, topicSchema } from "$lib/motions/form_validation"; import type { MotionInput } from "$lib/motions/types"; import { z } from "zod"; import { stringifyTime } from "$lib/util/time"; +import { type Delegate, findDelegate } from "$lib/db/delegates"; +import type { SessionDatabase } from "$lib/db/index.svelte"; /** * The label/name given to each motion kind. @@ -55,10 +57,10 @@ export const DEFAULT_SORT_PRIORITY: SortEntry[] = [ * Schema verification for a given form. * This takes the form inputs and verifies & creates the motion object associated with the form. */ -export function createMotionSchema(delegates: Record, presentDelegates: string[]) { +export function createMotionSchema(delegates: Delegate[]) { const base = (k: K) => z.object({ id: nonEmptyString({ description: "ID" }), - delegate: presentDelegateSchema(delegates, presentDelegates), + delegate: presentDelegateSchema(delegates), kind: z.literal(k) }); @@ -85,17 +87,11 @@ export function createMotionSchema(delegates: Record, pre ]) satisfies z.ZodType; } -/** - * Defines how to convert a motion back into an input motion. - * @param m - * @param delAttrs - * @returns - */ -export function inputifyMotion(m: Motion, delAttrs: Record): MotionInput { +function partialInputifyMotion(m: Motion): MotionInput { if (m.kind === "mod") { return { id: m.id, - delegate: delAttrs[m.delegate].name, + delegate: "", kind: m.kind, totalTime: stringifyTime(m.totalTime), speakingTime: stringifyTime(m.speakingTime), @@ -105,7 +101,7 @@ export function inputifyMotion(m: Motion, delAttrs: Record; +export function inputifyMotion(m: Motion, delegates?: any): any { + const im = partialInputifyMotion(m); + + // If session database: + if (typeof delegates === "object") { + return (delegates as SessionDatabase["delegates"]).get(m.delegate).then(d => { + if (d) im.delegate = d.name; + return im; + }); + } + + // If array: + if (delegates instanceof Array) { + im.delegate = findDelegate(delegates, m.delegate)?.name ?? ""; + } + // If undefined: + return im; } \ No newline at end of file diff --git a/src/lib/motions/form_validation.ts b/src/lib/motions/form_validation.ts index da913cb..50905b4 100644 --- a/src/lib/motions/form_validation.ts +++ b/src/lib/motions/form_validation.ts @@ -1,4 +1,4 @@ -import type { DelegateAttrs } from "$lib/types"; +import type { Delegate } from "$lib/db/delegates"; import { parseTime } from "$lib/util/time"; import { z } from "zod"; @@ -25,30 +25,29 @@ export function formatValidationError(error: z.ZodError) { * Creates a schema that requires the input is the name of a present delegate. * This also transforms the input to the key of the delegate. * - * @param delegates record of delegates - * @param presentDelegates the list of present delegates + * @param delegates record of delegates and their presence * @returns the schema */ -export function presentDelegateSchema(delegates: Record, presentDelegates: string[]) { +export function presentDelegateSchema(delegates: Delegate[]) { return nonEmptyString({ description: "Delegate name", required_error: "Delegate name is a required field" }) .transform((name, ctx) => { - const key = Object.keys(delegates).find(k => delegates[k].name.localeCompare(name, undefined, { sensitivity: "base" }) == 0); - if (!key) { + const del = delegates.find(d => d.nameEquals(name)); + if (!del) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: `${name} is not a delegate` }) return z.NEVER; - } else if (!presentDelegates.includes(key)) { + } else if (!del.isPresent()) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: `${delegates[key].name} is not a present delegate` + message: `${del.name} is not a present delegate` }) return z.NEVER; } else { - return key; + return del.id; } }) } diff --git a/src/lib/stores/README.md b/src/lib/stores/README.md deleted file mode 100644 index d5d8c92..0000000 --- a/src/lib/stores/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# Stores - -This folder defines stores, which handle cross-page and cross-refresh information. - -- `index.ts`: Boilerplate that allows stores to be created. -- `session.ts`: Session data store -- `settings.ts`: Settings store - -A given store can be created by calling the `createXXContext` function of a given store module in the top level of a `+layout.svelte` file. (For example, `createSessionDataContext` from `session.ts` is called in the top level of `dashboard/+layout.svelte`). - -If you wish to see the internals of how this is implemented, check out `index.ts`. diff --git a/src/lib/stores/index.ts b/src/lib/stores/index.ts deleted file mode 100644 index a4a6e5b..0000000 --- a/src/lib/stores/index.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { getContext, setContext } from "svelte"; -import { persisted } from "svelte-persisted-store"; -import type { Readable, Writable } from "svelte/store"; -import { SHADOW_ITEM_MARKER_PROPERTY_NAME as isDndShadowItem } from "svelte-dnd-action"; -import { getDndItemId } from "$lib/util/dnd"; - -// For the purposes of this project, a store consists of: -// 1. Writable properties -// 2. Derived properties (properties that are calculated from other properties) -// 3. Const properties (static properties that are generated once) -// These three different kinds of properties are handled specifically by the type management below. - -/* TYPE STUFF */ - -// This type stuff doesn't *really* need to be understood, -// but it's used to facilitate easy typing for the other files in this folder. - -// These _KeysXX types are used to filter the keys of a given object type.. -/** - * Gets all keys of Ctx whose values are of type Writable<_> - */ -type _KeysWritable = { [K in keyof Ctx]: Ctx[K] extends Writable ? K : never }[keyof Ctx]; -/** - * Gets all keys of Ctx whose values are of type Readable<_> (or Writable<_>) - */ -type _KeysReadable = { [K in keyof Ctx]: Ctx[K] extends Readable ? K : never }[keyof Ctx]; -/** - * Gets all keys of Ctx which represent a "default" property (1 in above definition) - */ -type _KeysDefault = _KeysWritable; -/** - * Gets all keys of Ctx which represent a "derived" property (2 in above definition) - */ -type _KeysDerived = Exclude<_KeysReadable, _KeysWritable>; -/** - * Gets all keys of Ctx which represent a "const" property (3 in above definition) - */ -type _KeysConst = Exclude>; - -// These types split a given object into the 3 different items defined above. -/** - * Filters an object Ctx to only its "default" properties (def. 1) - */ -type WDefaults = { [K in _KeysDefault]: Ctx[K] }; -/** - * Filters an object Ctx to only its "default" properties (def. 1), - * and strips the `Writable<_>` wrapper around each value type. - */ -type Defaults = { [K in _KeysDefault]: Ctx[K] extends Writable ? V : never }; -/** - * Filters an object Ctx to only its "derived" properties (def. 2) - */ -type Derived = { [K in _KeysDerived]: Ctx[K] }; -/** - * Filters an object Ctx to only its "const" properties (def. 3) - */ -type Consts = { [K in _KeysConst]: Ctx[K] }; - -/* END TYPE STUFF */ - -/** - * Creates all the useful functions and properties needed for a persistent store. - * - * Example: - * - * Suppose we wanted to create a `Settings` store - * that we could access across pages and refreshes - * with schema: - * ```ts - * // Suppose we wanted to create a store for: - * type Settings = { - * // writable properties - * enableFoo: Writable, - * enableBar: Writable, - * // derived property (from the above) - * fooAndBarEnabled: Readable, - * // const property - * baz: boolean - * } - * ``` - * - * We could construct it like so: - * ``` - * const { getDefaults, createContext, resetContext, getStoreContext } = createStore( - * // defaults for all writable properties: - * { enableFoo: false, enableBar: false }, - * // create all derived properties: - * (data) => ({ fooAndBarEnabled: derived([enableFoo, enableBar], ([$foo, $bar]) => $foo && $bar) }), - * // create all const properties: - * () => ({ baz: true }) - * ) - * ``` - * - * This function will automatically construct the persistent stores for each default property. - * Each store can be created (in a `+layout.svelte`) with `createContext` and accessed with `getStoreContext`. - * - * Additionally, by specifying a type parameter, this function can give type-autocomplete support while - * defining each of the three kinds of properties. - * - * @param key a unique key to use for Svelte `getContext`/`setContext` and when storing for persistence - * @param defaults an object defining defaults for all writable fields (this can also be a function that defines all the defaults) - * @param derived a function that takes all the defaults and const values and produces an object defining all derived properties - * @param consts a function that defines all const properties - * @returns an object holding a few fields: - * - `getDefaults()`: a function that returns all of the defaults of the function - * - `createContext()`: a function that initializes and creates the store (should be called in the top-level of a `+layout.svelte` file) - * - `resetContext(ctx)`: a function that resets all writable properties of a store to their defaults - * - `getStoreContext()`: a function returning the store (can also be accessed normally via `getContext(key)`) - */ -export function createStore( - key: string, - defaults: Defaults | (() => Defaults), - derived: (df: WDefaults & Consts) => Derived, - consts: () => Consts -) { - const getDefaults = () => typeof defaults === "function" ? (defaults as () => Defaults)() : structuredClone(defaults); - const createContext = () => { - // Create defaults with writable fields: - const dfCtx = Object.fromEntries( - Object.entries(getDefaults()) - .map(([k, v]) => [k, persisted(`${key}.${k}`, v, { - beforeRead(o) { - // Process changes made due to svelte-dnd-action - if (o instanceof Array) { - for (let item of o) { - if ("id" in item) { - item.id = getDndItemId(item); - } - delete item[isDndShadowItem]; - delete item.originalId; - } - } - - return o; - } - })]) - ) as unknown as WDefaults; - - // Add consts to it: - const dfcCtx = Object.assign(dfCtx, consts()); - // Add derived properties to it: - const ctx = Object.assign(dfcCtx, derived(dfcCtx)); - return setContext(key, ctx); - }; - const resetContext = (ctx: Ctx) => { - for (let entry of Object.entries(getDefaults())) { - let [key, dflt] = entry as unknown as [_KeysDefault, unknown]; - (ctx[key] as Writable).set(dflt); - } - } - const getStoreContext = () => getContext(key); - - return { getDefaults, createContext, resetContext, getStoreContext }; -} \ No newline at end of file diff --git a/src/lib/stores/session.ts b/src/lib/stores/session.ts deleted file mode 100644 index 2c25af5..0000000 --- a/src/lib/stores/session.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { derived } from "svelte/store"; -import { createAccessibleSettings } from "$lib/stores/settings"; -import type { SessionData } from "$lib/types"; -import { createStore } from "."; - -const { createContext, resetContext, getStoreContext } = createStore("sessionData", - { - delegateAttendance: {}, - motions: [], - selectedMotion: null, - speakersList: [] - }, - ({ delegateAttendance }) => ({ - presentDelegates: derived(delegateAttendance, $att => Object.keys($att).filter(k => $att[k] !== "NP")) - }), - () => ({ - settings: createAccessibleSettings() - }) -) - -export { - createContext as createSessionDataContext, - resetContext as resetSessionDataContext, - getStoreContext as getSessionDataContext -}; \ No newline at end of file diff --git a/src/lib/stores/settings.ts b/src/lib/stores/settings.ts deleted file mode 100644 index f5853e3..0000000 --- a/src/lib/stores/settings.ts +++ /dev/null @@ -1,49 +0,0 @@ -// We're assuming preset-un.json exists and is the default. -import DEFAULT_DELEGATES from "$lib/delegate_presets/preset-un.json"; -import { DEFAULT_SORT_PRIORITY } from "$lib/motions/definitions"; -import type { AccessibleSettings, Settings } from "$lib/types"; -import { derived, readonly } from "svelte/store"; -import { createStore } from "."; - -const { getDefaults, createContext, resetContext, getStoreContext } = createStore("settings", - { - delegateAttributes: DEFAULT_DELEGATES, - sortOrder: DEFAULT_SORT_PRIORITY, - delegatesEnabled: Object.fromEntries( - Object.keys(DEFAULT_DELEGATES).map(k => [k, true]) - ), - title: "General Assembly", - preferences: { - enableMotionRoundRobin: true, - enableMotionExt: true, - pauseMainTimer: true, - } - }, - () => ({}), - () => ({}) -); - -export { - getDefaults, - createContext as createSettingsContext, - resetContext as resetSettingsContext, - getStoreContext as getSettingsContext -}; - -/** - * Creates an object which holds a view of the settings. - * - * This is created so that settings can be accessed using the session data store - * whilst preventing accidental modifications to the settings - * and simplifying unimportant details for the session. - */ -export function createAccessibleSettings(): AccessibleSettings { - const settings = getStoreContext(); - return { - delegateAttributes: derived([settings.delegateAttributes, settings.delegatesEnabled], - ([$attrs, $enables]) => Object.fromEntries(Object.entries($attrs).filter(([k]) => $enables[k] ?? false)) - ), - sortOrder: readonly(settings.sortOrder), - title: settings.title - }; -} \ No newline at end of file diff --git a/src/lib/stores/stats.ts b/src/lib/stores/stats.ts deleted file mode 100644 index 7a5b276..0000000 --- a/src/lib/stores/stats.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { StatsData, StatsDataStore } from "$lib/types"; -import { createStore } from "."; - -const { createContext, resetContext, getStoreContext } = createStore("statistics", - { - stats: {} - }, - () => ({}), - () => ({}) -) - -export function defaultStats(): StatsData { - return { - motionsProposed: 0, - motionsAccepted: 0, - timesSpoken: 0, - durationSpoken: 0 - }; -} - -export function updateStats(stats: StatsDataStore["stats"], key: string | undefined, cb: (val: StatsData) => void) { - if (typeof key === "string") stats.update($s => { - $s[key] ??= defaultStats(); - cb($s[key]); - return $s; - }); -} -export { - createContext as createStatsContext, - resetContext as resetStatsContext, - getStoreContext as getStatsContext -}; \ No newline at end of file diff --git a/src/lib/types.d.ts b/src/lib/types.d.ts index 6703f81..390499c 100644 --- a/src/lib/types.d.ts +++ b/src/lib/types.d.ts @@ -1,5 +1,18 @@ import type { Readable, Writable } from "svelte/store"; +/** + * ID of a delegate + */ +export type DelegateID = number; +/** + * ID of a given motion in the motion table. + */ +export type MotionID = string; +/** + * ID of a given speaker slot in the speaker list. + */ +export type SpeakerEntryID = string; + /** * Attributes each delegate in a given preset should have. * @@ -100,91 +113,84 @@ export type Preferences = { } /** - * All configurable settings (in store format). + * All configurable settings. */ export type Settings = { - /** - * Delegate keys to characteristic data about the delegate (e.g., name and aliases) - */ - delegateAttributes: Writable>, - /** * The established sort order. * Values first in the list are prioritized, with the order parameter handling ties. * * Any kinds not specified in this list are thrown at the end. */ - sortOrder: Writable, + sortOrder: SortEntry[], - /** - * Keys of delegates enabled for this assembly. - */ - delegatesEnabled: Writable>, - /** * The title of the assembly. */ - title: Writable, + title: string, /** * Toggleable preferences. */ - preferences: Writable + preferences: Preferences, }; -/** - * A wrapper type around settings to designate all accessible settings - * in `SessionData`. - */ -export type AccessibleSettings = { - delegateAttributes: Readable>, - sortOrder: Readable, - title: Writable -} - // Attendance export type DelegatePresence = "NP" | "P" | "PV"; // Motions +/** + * Data relating to a motion's properties. + */ export type Motion = { - id: string, - delegate: string, + id: MotionID, + delegate: DelegateID, kind: "mod", totalTime: number, speakingTime: number, topic: string, isExtension: boolean } | { - id: string, - delegate: string, + id: MotionID, + delegate: DelegateID, kind: "unmod", totalTime: number, isExtension: boolean } | { - id: string, - delegate: string, + id: MotionID, + delegate: DelegateID, kind: "rr", speakingTime: number, topic: string totalSpeakers: number } | { - id: string, - delegate: string, + id: MotionID, + delegate: DelegateID, kind: "other", totalTime: number, topic: string }; export type MotionKind = Motion["kind"]; +/** + * Data relating to the current motion's operation. + */ +export type CurrentMotionState = { + /** + * Motion's speaker list (if it exists) + */ + speakersList: Speaker[] +}; + export type Speaker = { /** * Identifier for this speaker entry. */ - id: string, + id: SpeakerEntryID, /** - * The key of the delegate. + * The key/delegate ID of the delegate. */ - key: string, + key: DelegateID, /** * Whether they have completed speaking. */ @@ -192,38 +198,77 @@ export type Speaker = { }; // Session Data +/** + * All data stored as "session data" in the database. + * + * This excludes delegate session data which is stored separately. + */ export type SessionData = { - settings: AccessibleSettings, + sessionKey?: number, /** - * Attendance status of each delegate in the current session. + * All specified motions (from the points & motions page). */ - delegateAttendance: Writable>, + motions: Motion[], /** - * Derived attribute (based on delegateAttendance) that produces the list of present delegates. + * The motion that was selected (and is currently on display in the current motion page). */ - presentDelegates: Readable, + selectedMotion: Motion | null, + /** + * The state properties of the motion. + */ + selectedMotionState: CurrentMotionState, + /** + * The speakers list and speaker attributes (such as whether the given speaker has spoken already) + */ + speakersList: Speaker[], +}; +export type DelSessionData = { + presence: DelegatePresence, + stats: StatsData +}; +export type PrevSessionData = { + common: SessionData, + delegates: { + id: DelegateID, + session: DelSessionData + }[] +}; +/** + * All data stored in the session context. + */ +export type SessionContext = { + /** + * Array of enabled delegates. + */ + delegates: Readable, + /** * All specified motions (from the points & motions page). */ - motions: Writable, + motions: Writable, /** * The motion that was selected (and is currently on display in the current motion page). */ - selectedMotion: Writable, + selectedMotion: Writable, + /** + * The state properties of the motion. + */ + selectedMotionState: Writable, /** * The speakers list and speaker attributes (such as whether the given speaker has spoken already) */ - speakersList: Writable -}; + speakersList: Writable, -// App bar data -export type AppBarData = { /** - * Topic to display on the app bar + * Committee title, visible on the app bar. */ - topic: string | undefined -} + barTitle: Writable, + /** + * Current topic of discussion, visible on the app bar. + */ + barTopic: string | undefined, +}; export type ClockMessage = | { kind: "startTick", ts: number } @@ -247,7 +292,4 @@ export type StatsData = { * Total duration this delegate has gone up to speak (in speakers list and moderated caucuses), in milliseconds. */ durationSpoken: number -} -export type StatsDataStore = { - stats: Writable> -} +} \ No newline at end of file diff --git a/src/lib/util/index.ts b/src/lib/util/index.ts index b4b7a86..6310b23 100644 --- a/src/lib/util/index.ts +++ b/src/lib/util/index.ts @@ -32,23 +32,6 @@ export type Comparator = (a: K, b: K) => number; */ export const compare = ((a: any, b: any, reverse: boolean = false) => (reverse ? -1 : 1) * (a < b ? -1 : a > b ? 1 : 0)) satisfies Comparator; -/** - * Creates a new object where Maps each entry in the object to a new entry. - * @param o the object - * @param cb the callback - * @returns the new object - */ -export function mapObj< - K extends keyof unknown, V, - K1 extends keyof unknown, V1 ->(o: Record, cb: (key: K, val: V, i: number) => [K1, V1]): Record { - return Object.fromEntries( - Object.entries(o) - .map(([k, v], i) => cb(k as K, v, i) - ) - ); -} - /** * Allows user to download a file. * @param filename The name of the file to download @@ -110,4 +93,4 @@ export function lazyslide(node: HTMLElement, { delay = 0, duration = 400, easing `${padProperty[1]}: ${t * (padBRValue ?? 0)}px;`; } } satisfies TransitionConfig; -} \ No newline at end of file +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 39ddc54..8dc1a50 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -2,18 +2,14 @@ import "../app.css"; import { computePosition, autoUpdate, offset, shift, flip, arrow, size } from '@floating-ui/dom'; import { initializeStores, Modal, storePopup } from "@skeletonlabs/skeleton"; - import { createSettingsContext } from "$lib/stores/settings"; - import { createSessionDataContext } from "$lib/stores/session"; - import { createStatsContext } from "$lib/stores/stats"; + import { createSessionContext } from "$lib/context/index.svelte"; let { children } = $props(); initializeStores(); storePopup.set({ computePosition, autoUpdate, offset, shift, flip, arrow, size }); - createSettingsContext(); - createSessionDataContext(); - const { stats } = createStatsContext(); + createSessionContext(); function keydown(e: KeyboardEvent) { // Allows ESC to be used to unfocus an element. @@ -21,29 +17,13 @@ (document.activeElement as HTMLElement)?.blur?.(); } } - - function storage(e: StorageEvent) { - // HACK: Synchronize stats across different screens - if (e.storageArea === localStorage && e.key === "statistics.stats") { - if (e.newValue != null && e.oldValue !== e.newValue) { - let json = undefined; - try { - json = JSON.parse(e.newValue); - } catch (e) { - - } - - if (json) stats.set(json); - } - } - } {@render children()} - +