Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix/caching #7

Merged
merged 4 commits into from
Jan 29, 2024
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
1 change: 1 addition & 0 deletions .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"prefer-arrow-callback": ["warn", { "allowNamedFunctions": true }], // Disallow arrow functions as expressions
"func-style": ["warn", "declaration", { "allowArrowFunctions": false }], // Disallow the use of function expressions
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-non-null-assertion": "error",
"@typescript-eslint/naming-convention": [
"error",
{ "selector": "typeLike", "format": ["PascalCase"] },
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,4 @@ jobs:
run: yarn build
env:
SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
SUPABASE_KEY: ${{ secrets.SUPABASE_KEY }}
SUPABASE_KEY: ${{ secrets.SUPABASE_KEY }}
1 change: 0 additions & 1 deletion src/home/authentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,4 @@ export async function authentication() {
if (gitHubUser) {
displayGitHubUserInformation(gitHubUser);
}
return undefined; // for type error in next function
}
78 changes: 56 additions & 22 deletions src/home/fetch-github/fetch-and-display-previews.ts
Original file line number Diff line number Diff line change
@@ -1,59 +1,93 @@
import { getImageFromDB } from "../getters/get-indexed-db";
import { getImageFromCache } from "../getters/get-indexed-db";
import { getLocalStore } from "../getters/get-local-store";
import { GitHubIssue } from "../github-types";
import { taskManager } from "../home";
import { renderGitHubIssues } from "../rendering/render-github-issues";
import { Sorting } from "../sorting/generate-sorting-buttons";
import { sortIssuesController } from "../sorting/sort-issues-controller";
import { fetchAvatar } from "./fetch-avatar";
import { organizationImageCache } from "./fetch-issues-full";
import { fetchIssuePreviews } from "./fetch-issues-preview";
import { TaskMaybeFull, TaskNoFull, TaskNoState, TaskWithFull } from "./preview-to-full-mapping";

export type Options = {
ordering: "normal" | "reverse";
};

export async function fetchAndDisplayPreviews(sorting?: Sorting, options = { ordering: "normal" }) {
const container = document.getElementById("issues-container") as HTMLDivElement;
if (!container) {
throw new Error("Could not find issues container");
}
let issues: GitHubIssue[] = (getLocalStore("gitHubIssuesPreview") as GitHubIssue[]) || [];
if (!issues.length) {
issues = await fetchIssuePreviews();
localStorage.setItem("gitHubIssuesPreview", JSON.stringify(issues));
export async function fetchAndDisplayPreviewsFromCache(sorting?: Sorting, options = { ordering: "normal" }) {
const _cachedTasks = (getLocalStore("gitHubTasks") || []) as TaskNoState[];
const cachedTasks = _cachedTasks.map((task) => ({ ...task, isNew: false, isModified: false })) as TaskMaybeFull[];
taskManager.addTasks(cachedTasks);
if (!cachedTasks.length) {
// load from network if there are no cached issues
return await fetchAndDisplayPreviewsFromNetwork(sorting, options);
} else {
displayGitHubIssues(sorting, options); // FIXME:
return fetchAvatars();
}
}

export async function fetchAndDisplayPreviewsFromNetwork(sorting?: Sorting, options = { ordering: "normal" }) {
const fetchedPreviews = await fetchIssuePreviews();
const cachedTasks = taskManager.getTasks();
const updatedCachedIssues = verifyGitHubIssueState(cachedTasks, fetchedPreviews);
displayGitHubIssues(sorting, options); // FIXME:
taskManager.addTasks(updatedCachedIssues);
return fetchAvatars();
}

async function fetchAvatars() {
const cachedTasks = taskManager.getTasks();
const urlPattern = /https:\/\/github\.com\/(?<org>[^/]+)\/(?<repo>[^/]+)\/issues\/(?<issue_number>\d+)/;

const avatarPromises = issues.map(async (issue) => {
const match = issue.body.match(urlPattern);
const avatarPromises = cachedTasks.map(async (task) => {
const match = task.preview.body.match(urlPattern);
const orgName = match?.groups?.org;
if (orgName) {
return fetchAvatar(orgName);
}
return Promise.resolve();
});

return Promise.allSettled(avatarPromises).then(() => {
displayIssues(issues, container, sorting, options);
return issues;
await Promise.allSettled(avatarPromises);
return cachedTasks;
}

export function taskWithFullTest(task: TaskNoFull | TaskWithFull): task is TaskWithFull {
return (task as TaskWithFull).full !== null && (task as TaskWithFull).full !== undefined;
}

function verifyGitHubIssueState(cached: TaskMaybeFull[], fetchedPreviews: TaskNoFull[]): (TaskNoFull | TaskWithFull)[] {
return fetchedPreviews.map((fetched) => {
const cachedIssue = cached.find((cached) => cached.full?.id === fetched.preview.id);
if (cachedIssue && cachedIssue.full) {
const cachedFullIssue = cachedIssue.full;
const isModified = new Date(cachedFullIssue.updated_at) < new Date(fetched.preview.updated_at);
const task = { ...fetched, full: cachedFullIssue, isNew: false, isModified };
return taskWithFullTest(task) ? task : ({ preview: fetched.preview, isNew: true, isModified: false } as TaskNoFull);
}
return { preview: fetched.preview, isNew: true, isModified: false } as TaskNoFull;
});
}

function displayIssues(issues: GitHubIssue[], container: HTMLDivElement, sorting?: Sorting, options = { ordering: "normal" }) {
export function displayGitHubIssues(sorting?: Sorting, options = { ordering: "normal" }) {
// Load avatars from cache
const urlPattern = /https:\/\/github\.com\/(?<org>[^/]+)\/(?<repo>[^/]+)\/issues\/(?<issue_number>\d+)/;
issues.forEach(async (issue) => {
const match = issue.body.match(urlPattern);
const cachedTasks = taskManager.getTasks();
cachedTasks.forEach(async ({ preview }) => {
if (!preview.body) {
throw new Error(`Preview body is undefined for task with id: ${preview.id}`);
}
const match = preview.body.match(urlPattern);
const orgName = match?.groups?.org;
if (orgName) {
const avatarUrl = await getImageFromDB({ dbName: "ImageDatabase", storeName: "ImageStore", orgName: `avatarUrl-${orgName}` });
const avatarUrl = await getImageFromCache({ dbName: "GitHubAvatars", storeName: "ImageStore", orgName: `avatarUrl-${orgName}` });
if (avatarUrl) {
organizationImageCache.set(orgName, avatarUrl);
}
}
});

// Render issues
const sortedIssues = sortIssuesController(issues, sorting, options);
renderGitHubIssues(container, sortedIssues);
const sortedIssues = sortIssuesController(cachedTasks, sorting, options);
renderGitHubIssues(sortedIssues);
}
8 changes: 4 additions & 4 deletions src/home/fetch-github/fetch-avatar.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Octokit } from "@octokit/rest";
import { getGitHubAccessToken } from "../getters/get-github-access-token";
import { getImageFromDB, saveImageToDB } from "../getters/get-indexed-db";
import { getImageFromCache, saveImageToCache } from "../getters/get-indexed-db";
import { organizationImageCache } from "./fetch-issues-full";

export async function fetchAvatar(orgName: string) {
Expand All @@ -11,7 +11,7 @@ export async function fetchAvatar(orgName: string) {
}

// If not in local cache, check IndexedDB
const avatarBlob = await getImageFromDB({ dbName: "ImageDatabase", storeName: "ImageStore", orgName: `avatarUrl-${orgName}` });
const avatarBlob = await getImageFromCache({ dbName: "GitHubAvatars", storeName: "ImageStore", orgName: `avatarUrl-${orgName}` });
if (avatarBlob) {
// If the avatar Blob is found in IndexedDB, add it to the cache
organizationImageCache.set(orgName, avatarBlob);
Expand All @@ -28,8 +28,8 @@ export async function fetchAvatar(orgName: string) {
await fetch(avatarUrl)
.then((response) => response.blob())
.then(async (blob) => {
await saveImageToDB({
dbName: "ImageDatabase",
await saveImageToCache({
dbName: "GitHubAvatars",
storeName: "ImageStore",
keyName: "name",
orgName: `avatarUrl-${orgName}`,
Expand Down
71 changes: 30 additions & 41 deletions src/home/fetch-github/fetch-issues-full.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,20 @@
import { Octokit } from "@octokit/rest";
import { getGitHubAccessToken } from "../getters/get-github-access-token";
import { getLocalStore } from "../getters/get-local-store";
import { GitHubIssue } from "../github-types";
import { taskWithFullTest } from "./fetch-and-display-previews";
import { fetchAvatar } from "./fetch-avatar";
import { PreviewToFullMapping } from "./preview-to-full-mapping";

export const previewToFullMapping = new PreviewToFullMapping().getMapping();
import { TaskMaybeFull, TaskWithFull } from "./preview-to-full-mapping";

// export const previewToFullMapping = new PreviewToFullMapping().getMapping();
export const organizationImageCache = new Map<string, Blob | null>();

export function fetchIssuesFull(previews: GitHubIssue[]) {
export async function fetchIssuesFull(taskPreviews: TaskMaybeFull[]) {
const authToken = getGitHubAccessToken();
if (!authToken) throw new Error("No auth token found");
const octokit = new Octokit({ auth: getGitHubAccessToken() });
const urlPattern = /https:\/\/github\.com\/(?<org>[^/]+)\/(?<repo>[^/]+)\/issues\/(?<issue_number>\d+)/;

const cachedPreviews = previews || (getLocalStore("gitHubIssuesPreview") as GitHubIssue[]);

const issueFetchPromises = cachedPreviews.map((preview) => {
const match = preview.body.match(urlPattern);
const issueFetchPromises = taskPreviews.map(async (task) => {
const match = task.preview.body.match(urlPattern);

if (!match || !match.groups) {
console.error("Invalid issue body URL format");
Expand All @@ -27,37 +23,30 @@ export function fetchIssuesFull(previews: GitHubIssue[]) {

const { org, repo, issue_number } = match.groups;

return octokit
.request("GET /repos/{org}/{repo}/issues/{issue_number}", { issue_number, repo, org })
.then(({ data: response }) => {
const full = response as GitHubIssue;

// Update the cache with the fetched issue if it's more recent than the cached issue
const cachedIssues = (getLocalStore("gitHubIssuesFull") || []) as GitHubIssue[];
const cachedIssuesMap = new Map(cachedIssues.map((issue) => [issue.id, issue]));
const cachedIssue = cachedIssuesMap.get(full.id);
if (!cachedIssue || new Date(full.updated_at) > new Date(cachedIssue.updated_at)) {
cachedIssuesMap.set(full.id, full);
const updatedCachedIssues = Array.from(cachedIssuesMap.values());
localStorage.setItem("gitHubIssuesFull", JSON.stringify(updatedCachedIssues));
}

previewToFullMapping.set(preview.id, full);
const issueElement = document.querySelector(`[data-preview-id="${preview.id}"]`);
issueElement?.setAttribute("data-full-id", full.id.toString());

localStorage.setItem("gitHubIssuesFull", JSON.stringify(Array.from(previewToFullMapping.entries())));
return { full, issueElement };
})
.then(async ({ full }) => {
const urlMatch = full.html_url.match(urlPattern);
const orgName = urlMatch?.groups?.org;
if (orgName) {
await fetchAvatar(orgName);
}
return full;
});
const { data: response } = await octokit.request("GET /repos/{org}/{repo}/issues/{issue_number}", { issue_number, repo, org });

// Update the full property in the taskPreview object
task.full = response as GitHubIssue;

const urlMatch = task.full.html_url.match(urlPattern);
const orgName = urlMatch?.groups?.org;
if (orgName) {
await fetchAvatar(orgName);
}
const isTaskWithFull = taskWithFullTest(task);

if (isTaskWithFull) {
return task;
} else {
throw new Error("Task is not a TaskWithFull");
}
});

return issueFetchPromises;
const settled = await Promise.allSettled(issueFetchPromises);
const filtered = settled
.filter((result): result is PromiseFulfilledResult<TaskWithFull> => result.status === "fulfilled")
.map((result) => result.value)
.filter((issue): issue is TaskWithFull => issue !== null);

return filtered;
}
27 changes: 9 additions & 18 deletions src/home/fetch-github/fetch-issues-preview.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { Octokit } from "@octokit/rest";
import { getGitHubAccessToken } from "../getters/get-github-access-token";
import { GitHubIssue } from "../github-types";
import { GitHubIssueWithNewFlag } from "./preview-to-full-mapping";
import { TaskNoFull } from "./preview-to-full-mapping";

export async function fetchIssuePreviews(): Promise<GitHubIssueWithNewFlag[]> {
export async function fetchIssuePreviews(): Promise<TaskNoFull[]> {
const octokit = new Octokit({ auth: getGitHubAccessToken() });

// Fetch fresh issues and mark them as new if they don't exist in local storage
let freshIssues: GitHubIssue[] = [];
try {
freshIssues = (
Expand All @@ -18,20 +17,12 @@ export async function fetchIssuePreviews(): Promise<GitHubIssueWithNewFlag[]> {
console.error(`Failed to fetch issue previews: ${error}`);
}

// Retrieve existing issues from local storage
const storedIssuesJSON = localStorage.getItem("gitHubIssuesPreview");
const storedIssues = storedIssuesJSON ? (JSON.parse(storedIssuesJSON) as GitHubIssue[]) : [];
const tasks = freshIssues.map((preview: GitHubIssue) => ({
preview: preview,
full: null,
isNew: true,
isModified: true,
})) as TaskNoFull[];

// Create a set of existing issue IDs for quick lookup
const existingIssueIds = new Set(storedIssues.map((issue) => issue.id));

// Map fresh issues to GitHubIssueWithNewFlag, setting isNew appropriately
const freshIssuesWithNewFlag = freshIssues.map((issue) => ({
...issue,
isNew: !existingIssueIds.has(issue.id),
})) as GitHubIssueWithNewFlag[];

localStorage.setItem("gitHubIssuesPreview", JSON.stringify(freshIssuesWithNewFlag));

return freshIssuesWithNewFlag;
return tasks;
}
36 changes: 22 additions & 14 deletions src/home/fetch-github/preview-to-full-mapping.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,27 @@
import { GitHubIssue } from "../github-types";

export type GitHubIssueWithNewFlag = GitHubIssue & { isNew?: boolean };
export type TaskNoState = {
preview: GitHubIssue;
full: null | GitHubIssue;
};

export class PreviewToFullMapping {
private _map: Map<number, GitHubIssue>;
export type TaskNoFull = {
preview: GitHubIssue;
full: null;
isNew: boolean;
isModified: boolean;
};

constructor() {
try {
this._map = new Map(JSON.parse(localStorage.getItem("gitHubIssuesFull") || "[]")) as Map<number, GitHubIssue>;
} catch (e) {
this._map = new Map();
}
}
export type TaskMaybeFull = {
preview: GitHubIssue;
full: null | GitHubIssue;
isNew: boolean;
isModified: boolean;
};

getMapping() {
return this._map;
}
}
export type TaskWithFull = {
preview: GitHubIssue;
full: GitHubIssue;
isNew: boolean;
isModified: boolean;
};
4 changes: 2 additions & 2 deletions src/home/getters/get-indexed-db.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export async function saveImageToDB({
export async function saveImageToCache({
dbName,
storeName,
keyName,
Expand Down Expand Up @@ -41,7 +41,7 @@ export async function saveImageToDB({
});
}

export function getImageFromDB({ dbName, storeName, orgName }: { dbName: string; storeName: string; orgName: string }): Promise<Blob | null> {
export function getImageFromCache({ dbName, storeName, orgName }: { dbName: string; storeName: string; orgName: string }): Promise<Blob | null> {
return new Promise((resolve, reject) => {
const open = indexedDB.open(dbName, 2); // Increase version number to ensure onupgradeneeded is called
open.onupgradeneeded = function () {
Expand Down
21 changes: 18 additions & 3 deletions src/home/getters/get-local-store.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,30 @@
import { GitHubIssue } from "../github-types";
import { TaskMaybeFull, TaskNoState } from "../fetch-github/preview-to-full-mapping";
import { OAuthToken } from "./get-github-access-token";

export function getLocalStore(key: string): (GitHubIssue | GitHubIssue)[] | OAuthToken | null {
export function getLocalStore(key: string): TaskNoState[] | OAuthToken | null {
const cachedIssues = localStorage.getItem(key);
if (cachedIssues) {
try {
const value = JSON.parse(cachedIssues);
return value;

return value; // as OAuthToken;
} catch (error) {
console.error(error);
}
}
return null;
}

export function setLocalStore(key: string, value: TaskMaybeFull[] | OAuthToken) {
// remove state from issues before saving to local storage
if (Array.isArray(value) && value.length && "isNew" in value[0] && "isModified" in value[0]) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const tasksWithoutState = value.map(({ isNew, isModified, preview, full }) => ({
preview,
full,
}));
localStorage[key] = JSON.stringify(tasksWithoutState);
} else {
localStorage[key] = JSON.stringify(value);
}
}
Loading
Loading