refactor: rework API loader and caching

This commit removes dependency on rxjs.

It also implements better composables to handle data fetching from
remote APIs and caching these values more transparently.

This commit also switches from yarn to npm

It also switches to the official Umami plugin
This commit is contained in:
2024-06-20 09:27:59 +02:00
parent 24d558e0f5
commit d54aabd621
19 changed files with 5508 additions and 4883 deletions

View File

@@ -0,0 +1,62 @@
import { Ref, computed, ref, watchEffect } from 'vue';
interface CacheOptions {
lifetime?: number;
timestampSuffix?: string;
forceUpdate?: boolean;
}
/**
* Cache data in local storage.
*
* The cache is updated if:
* - cache data does not exist
* - cached data is outdated and `data` is not null
* - or `options.forceUpdate` is true, regardless of the value of `data`
*
* Otherwise, data is retrieved from cache.
*
* @param {string} name Name of the cached value in local storage
* @param {Ref<T>} data Data to cache
* @param {CacheOptions} options Tweaks to the behaviour of the function
*/
export const useCache = <T>(
name: string,
data: Ref<T>,
options: CacheOptions,
) => {
const error = ref<string>(null);
const timestampName = name + (options?.timestampSuffix || '-timestamp');
const lifetime = options?.lifetime || 1000 * 60 * 60; // one hour in milliseconds
const lastUpdated: number = +localStorage.getItem(timestampName);
const cacheAge: number = Date.now() - lastUpdated;
const isDataOutdated = computed(() => {
return cacheAge > lifetime;
});
const shouldUpdate = computed(
() => options?.forceUpdate || (isDataOutdated.value && data.value != null),
);
const setData = () => {
console.log('Setting data in cache with name', name);
localStorage.setItem(name, JSON.stringify(data.value));
localStorage.setItem(timestampName, `${Date.now()}`);
};
const getData = () => {
console.log('Getting data from cache with name', name);
const cached = localStorage.getItem(name);
console.log('Value from storage:', cached);
try {
data.value = JSON.parse(cached);
} catch (err) {
console.error('Failed to parse cached data:', err);
data.value = null;
error.value = err;
}
};
getData();
watchEffect(() => (shouldUpdate.value ? setData() : getData()));
return { error, isDataOutdated };
};

View File

@@ -0,0 +1,72 @@
import { ref, Ref } from 'vue';
import { useCache } from './cache';
type FetchAndCacheEmitter = (
event: 'loaded' | 'error' | 'loading',
...args: any[]
) => void;
interface UseFetchAndCacheOptions {
cacheLifetime?: number;
cacheName?: string;
emits?: FetchAndCacheEmitter;
}
const dummyEmits = (
_event: 'loaded' | 'error' | 'loading',
..._args: any[]
) => {};
export const useFetchAndCache = <T, E>(
url: string,
options?: UseFetchAndCacheOptions,
) => {
const data = ref<T | null>(null) as Ref<T>;
const error = ref<E | null>(null) as Ref<E>;
const loading = ref<boolean>(true);
const cacheLifetime: number = options?.cacheLifetime || 1000 * 60 * 60; // one hour
const cacheName: string = options?.cacheName || url;
const { isDataOutdated: isCacheOutdated, error: cacheError } = useCache(
cacheName,
data,
{
lifetime: cacheLifetime,
},
);
const emits: FetchAndCacheEmitter = options?.emits || dummyEmits;
const loaded = () => {
loading.value = false;
emits('loaded', data.value);
};
const fetchData = () => {
loading.value = true;
emits('loading');
console.log('Fetching from URL', url);
fetch(url)
.then((response) => {
if (!response.ok) {
throw new Error(response.statusText);
}
return response.json() as Promise<T>;
})
.then((responseData) => {
data.value = responseData;
loaded();
})
.catch((e) => {
console.warn('Caught error!', e);
error.value = e;
emits('error', e);
})
.finally(() => (loading.value = false));
};
if (isCacheOutdated.value || cacheError.value != null) {
fetchData();
} else {
loaded();
}
return { data, loading, error };
};

View File

@@ -1,106 +0,0 @@
export interface GithubRepo {
id: number;
node_id: string;
name: string;
full_name: string;
private: boolean;
owner: Owner;
html_url: string;
description: string;
fork: boolean;
url: string;
forks_url: string;
keys_url: string;
collaborators_url: string;
teams_url: string;
hooks_url: string;
issue_events_url: string;
events_url: string;
assignees_url: string;
branches_url: string;
tags_url: string;
blobs_url: string;
git_tags_url: string;
git_refs_url: string;
trees_url: string;
statuses_url: string;
languages_url: string;
stargazers_url: string;
contributors_url: string;
subscribers_url: string;
subscription_url: string;
commits_url: string;
git_commits_url: string;
comments_url: string;
issue_comment_url: string;
contents_url: string;
compare_url: string;
merges_url: string;
archive_url: string;
downloads_url: string;
issues_url: string;
pulls_url: string;
milestones_url: string;
notifications_url: string;
labels_url: string;
releases_url: string;
deployments_url: string;
created_at: string;
updated_at: string;
pushed_at: string;
git_url: string;
ssh_url: string;
clone_url: string;
svn_url: string;
homepage: string;
size: number;
stargazers_count: number;
watchers_count: number;
language: string;
has_issues: boolean;
has_projects: boolean;
has_downloads: boolean;
has_wiki: boolean;
has_pages: boolean;
forks_count: number;
mirror_url: null;
archived: boolean;
disabled: boolean;
open_issues_count: number;
license: null;
allow_forking: boolean;
is_template: boolean;
web_commit_signoff_required: boolean;
topics: any[];
visibility: string;
forks: number;
open_issues: number;
watchers: number;
default_branch: string;
}
export interface Owner {
login: string;
id: number;
node_id: string;
avatar_url: string;
gravatar_id: string;
url: string;
html_url: string;
followers_url: string;
following_url: string;
gists_url: string;
starred_url: string;
subscriptions_url: string;
organizations_url: string;
repos_url: string;
events_url: string;
received_events_url: string;
type: string;
site_admin: boolean;
}
export interface GithubError {
message: string;
documentation_url: string;
}