Skip to content

Commit

Permalink
fix: projects not found when used in older worklogs
Browse files Browse the repository at this point in the history
  • Loading branch information
AdrianFahrbach committed Dec 27, 2024
1 parent 1bd700c commit dcc76ff
Show file tree
Hide file tree
Showing 11 changed files with 134 additions and 55 deletions.
24 changes: 17 additions & 7 deletions src/atoms/actions.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import ms from 'ms';
import { deleteFromProjectAtom } from '../services/project.service';
import { AccountId, JiraAccountTokensAtom, LoginsAtom, UUID } from '../types/accounts.types';
import { Worklog } from '../types/global.types';
import { Project, ProjectsAtom, Worklog } from '../types/global.types';
import { jiraAccountTokensAtom, jiraClientsAtom, loginsAtom } from './auth';
import { projectsProtectedAtom } from './project';
import { store } from './store';
import { worklogsLocalAtom, worklogsLocalBackupsAtom, worklogsRemoteAtom } from './worklog';

Expand Down Expand Up @@ -58,7 +58,8 @@ export function storageCleanup(
logins: LoginsAtom,
jiraAccountTokens: JiraAccountTokensAtom,
worklogsLocal: Worklog[],
worklogsLocalBackups: Worklog[]
worklogsLocalBackups: Worklog[],
projects: ProjectsAtom
) {
const now = Date.now();

Expand Down Expand Up @@ -98,15 +99,24 @@ export function storageCleanup(
}
}

// Limit the number of projects
const projects = store.get(projectsProtectedAtom);
if (projects.length > 250) {
store.set(projectsProtectedAtom, projects.slice(-250));
// Limit the number of projects and remove the oldest ones first
if (!!projects) {
const MAX_PROJECTS = 250;
const allProjects: Project[] = [];
Object.keys(projects).forEach(key => {
allProjects.push(...Object.values(projects[key as UUID] ?? {}));
});
if (allProjects.length > MAX_PROJECTS) {
allProjects.sort((a, b) => a.updatedAt - b.updatedAt);
const projectsToDelete = allProjects.slice(0, allProjects.length - MAX_PROJECTS);
deleteFromProjectAtom(projectsToDelete);
}
}

return {
jiraAccountTokens,
worklogsLocal,
worklogsLocalBackups,
projects,
};
}
7 changes: 6 additions & 1 deletion src/atoms/listener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { sendNativeEvent } from '../services/native-event-emitter.service';
import { NativeEvent, StatusBarState } from '../services/native-event-emitter.service.types';
import { StorageKey, setInStorage } from '../services/storage.service';
import { UUID } from '../types/accounts.types';
import { loginsAtom, jiraAccountTokensAtom, primaryUUIDAtom } from './auth';
import { jiraAccountTokensAtom, loginsAtom, primaryUUIDAtom } from './auth';
import { projectsAtom } from './project';
import { settingsAtom } from './setting';
import { store } from './store';
import { activeWorklogAtom, worklogsLocalAtom, worklogsLocalBackupsAtom } from './worklog';
Expand Down Expand Up @@ -32,6 +33,10 @@ store.sub(worklogsLocalBackupsAtom, () => {
const backupWorklogs = store.get(worklogsLocalBackupsAtom);
void setInStorage(StorageKey.WORKLOGS_LOCAL_BACKUPS, backupWorklogs);
});
store.sub(projectsAtom, () => {
const projects = store.get(projectsAtom);
void setInStorage(StorageKey.PROJECTS, projects);
});

/**
* Communicate with status bar widget
Expand Down
11 changes: 2 additions & 9 deletions src/atoms/project.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,4 @@
import { atom } from 'jotai';
import { Project } from '../types/global.types';
import { ProjectsAtom } from '../types/global.types';

// This protected atom is used to store the projects in a way that they can't be modified directly.
export const projectsProtectedAtom = atom<Project[]>([]);

export const projectsAtom = atom(get => get(projectsProtectedAtom));

export const upsertProjectsAtom = atom(null, (_get, set, project: Project) => {
set(projectsProtectedAtom, projects => [...projects.filter(p => p.id !== project.id), project]);
});
export const projectsAtom = atom<ProjectsAtom>({});
7 changes: 7 additions & 0 deletions src/components/DebugTools.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@ export const DebugTools: FC = () => {
style={{ marginBottom: 12 }}>
<Text style={styles.tabTitle}>Log accountTokens storage</Text>
</Pressable>
<Pressable
onPress={async () => {
await removeFromStorage(StorageKey.PROJECTS);
}}
style={{ marginBottom: 6 }}>
<Text style={styles.tabTitle}>Clear projects storage</Text>
</Pressable>
<Pressable
onPress={() => {
Object.values(StorageKey).forEach(key => removeFromStorage(key));
Expand Down
2 changes: 1 addition & 1 deletion src/components/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export function Select<T extends OptionValue>({ options, value, onChange }: Sele
options.map(option => ({
name: (option.value === value ? '✓ ' : ' ') + option.label,
onClick: () => {
onChange(option.value as T);
onChange(option.value);
},
})),
ref.current
Expand Down
8 changes: 6 additions & 2 deletions src/services/global.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
addWorklogsToBackups,
jiraAccountTokensAtom,
loginsAtom,
projectsAtom,
settingsAtom,
storageCleanup,
store,
Expand All @@ -20,13 +21,15 @@ export async function initialize() {
const storageJiraAccountTokens = await getFromStorage(StorageKey.JIRA_ACCOUNT_TOKENS);
const storageWorklogsLocal = await getFromStorage(StorageKey.WORKLOGS_LOCAL);
const storageWorklogsLocalBackups = await getFromStorage(StorageKey.WORKLOGS_LOCAL_BACKUPS);
const storageProjects = await getFromStorage(StorageKey.PROJECTS);

// Clear storage from old data. This function doesn't save anything, it just returns the cleaned data
const { jiraAccountTokens, worklogsLocal, worklogsLocalBackups } = storageCleanup(
const { jiraAccountTokens, worklogsLocal, worklogsLocalBackups, projects } = storageCleanup(
logins,
storageJiraAccountTokens,
storageWorklogsLocal,
storageWorklogsLocalBackups
storageWorklogsLocalBackups,
storageProjects
);
const settings = await getFromStorage(StorageKey.SETTINGS);

Expand All @@ -35,6 +38,7 @@ export async function initialize() {
// There could be new worklogs in the local backups later, so we can't just set it here
let newWorklogsLocal = worklogsLocal;
const newWorklogsLocalBackups = worklogsLocalBackups;
store.set(projectsAtom, projects);

if (logins === null) {
store.set(loginsAtom, []);
Expand Down
17 changes: 7 additions & 10 deletions src/services/jira-issues.service.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { Issue, Project as JiraProject } from 'jira.js/out/version3/models';
import { getJiraClientByUUID, store } from '../atoms';
import { upsertProjectsAtom } from '../atoms/project';
import { getJiraClientByUUID } from '../atoms';
import { UUID } from '../types/accounts.types';
import { createNewLocalProject, loadAvatarForProject } from './project.service';
import { upsertProjectByJiraProject } from './project.service';

/**
* Gets all issues that match a given search query.
Expand Down Expand Up @@ -70,7 +69,7 @@ export async function getIssuesBySearchQuery(query: string, uuid: UUID) {
});

// Add all projects to local projects atom
upsertIssuesProject(issues, uuid);
await upsertIssuesProject(issues, uuid);

return issues;
}
Expand All @@ -92,10 +91,8 @@ export async function getIssueByKey(issueKey: string, uuid: UUID) {
/**
* Adds all projects to the local atom and loads the avatar for each project.
*/
export function upsertIssuesProject(issues: Issue[], uuid: UUID) {
issues.forEach(issue => {
const project = createNewLocalProject(issue.fields.project as JiraProject, uuid);
store.set(upsertProjectsAtom, project);
void loadAvatarForProject(project);
});
export async function upsertIssuesProject(issues: Issue[], uuid: UUID) {
for (const issue of issues) {
await upsertProjectByJiraProject(issue.fields.project as JiraProject, uuid);
}
}
15 changes: 4 additions & 11 deletions src/services/jira-worklogs.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,11 @@ import { Issue, Project as JiraProject, Worklog as JiraWorklog } from 'jira.js/o
import ms from 'ms';
import { Alert } from 'react-native';
import { getJiraClientByUUID, loginsAtom, settingsAtom, store, worklogsRemoteAtom } from '../atoms';
import { upsertProjectsAtom } from '../atoms/project';
import { AccountId, UUID } from '../types/accounts.types';
import { Worklog, WorklogState } from '../types/global.types';
import { IssueKey, Worklog, WorklogState } from '../types/global.types';
import { convertAdfToMd, convertMdToAdf } from './atlassian-document-format.service';
import { formatDateToJiraFormat, formatDateToYYYYMMDD, parseDateFromYYYYMMDD } from './date.service';
import { upsertIssuesProject } from './jira-issues.service';
import { createNewLocalProject, loadAvatarForProject } from './project.service';
import { upsertProjectByJiraProject } from './project.service';
import { parseDurationStringToSeconds } from './time.service';

/**
Expand All @@ -21,7 +19,7 @@ function convertWorklogs(worklogs: JiraWorklog[], uuid: UUID, accountId: Account
id: worklog.id ?? '',
issue: {
id: issue.id,
key: issue.key,
key: issue.key as IssueKey,
summary: issue.fields.summary,
},
started: formatDateToYYYYMMDD(new Date(worklog.started ?? 0)),
Expand Down Expand Up @@ -56,9 +54,6 @@ export async function getRemoteWorklogs(uuid: UUID, accountId: AccountId): Promi
startAt: currentIssue,
});

// Update local projects with the projects of the fetched issues
upsertIssuesProject(issuesCall.issues ?? [], uuid);

for (const issue of issuesCall.issues ?? []) {
// Get worklogs for each issue
if (issue.fields.worklog?.total && issue.fields.worklog?.total < (issue.fields.worklog?.maxResults ?? 0)) {
Expand Down Expand Up @@ -91,9 +86,7 @@ export async function getRemoteWorklogs(uuid: UUID, accountId: AccountId): Promi
}

if (issue.fields.project) {
const project = createNewLocalProject(issue.fields.project as JiraProject, uuid);
store.set(upsertProjectsAtom, project);
void loadAvatarForProject(project);
await upsertProjectByJiraProject(issue.fields.project as JiraProject, uuid);
}
}

Expand Down
71 changes: 61 additions & 10 deletions src/services/project.service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import axios from 'axios';
import { Buffer } from 'buffer';
import { Project as JiraProject } from 'jira.js/out/version3/models';
import { jiraAccountTokensAtom, projectsAtom, store, upsertProjectsAtom } from '../atoms';
import ms from 'ms';
import { jiraAccountTokensAtom, projectsAtom, store } from '../atoms';
import { UUID } from '../types/accounts.types';
import { Project } from '../types/global.types';
import { getAccountIdFromUUID } from './account.service';
Expand All @@ -14,31 +15,81 @@ export function createNewLocalProject(project: JiraProject, uuid: UUID): Project
avatar: null,
name: project.name,
uuid,
updatedAt: Date.now(),
};
}

export function getProjectByIssueKey(issueKey: string, uuid: UUID) {
export function getProjectByIssueKey(issueKey: string, uuid: UUID): Project | undefined {
const projectId = issueKey.split('-')[0];
const projects = store.get(projectsAtom);
return projects.find(p => p.key === projectId && p.uuid === uuid) ?? null;
return store.get(projectsAtom)?.[uuid]?.[projectId];
}

export async function loadAvatarForProject(project: Project) {
if (project.avatar) {
export function deleteFromProjectAtom(projectsToDelete: Project[]) {
const loadedProjects = { ...store.get(projectsAtom) };
projectsToDelete.forEach(project => {
if (loadedProjects[project.uuid]) {
delete loadedProjects[project.uuid]![project.key];
}
});
store.set(projectsAtom, loadedProjects);
}

/**
* Adds a specific project to the projects atom.
* This should not be used directly, but rather through other functions that handle things like
* loading the avatar of the project based on the timestamp.
*/
export function upsertToProjectsAtom(project: Project) {
const loadedProjects = { ...store.get(projectsAtom) };
if (!loadedProjects[project.uuid]) {
loadedProjects[project.uuid] = {};
}
loadedProjects[project.uuid]![project.key] = project;
store.set(projectsAtom, loadedProjects);
}

export async function upsertProjectByJiraProject(project: JiraProject, uuid: UUID) {
// We can update the projects often, we just don't want to have too many updates in a short time
const DETAILS_UPDATE_THRESHOLD = ms('1min');
const AVATAR_UPDATE_THRESHOLD = ms('5min');
const currentProject = store.get(projectsAtom)?.[uuid]?.[project.key];
if (currentProject && currentProject.updatedAt > Date.now() - DETAILS_UPDATE_THRESHOLD) {
return;
}

const tokens = store.get(jiraAccountTokensAtom)?.[getAccountIdFromUUID(project.uuid)];
const newProject = {
id: project.id,
key: project.key,
_avatarUrl: project.avatarUrls?.['48x48'] ?? null,
avatar: currentProject?.avatar ?? null,
name: project.name,
uuid,
updatedAt: Date.now(),
};

// Update avatar if it's older than the threshold
if (newProject.avatar === null || newProject.updatedAt > Date.now() - AVATAR_UPDATE_THRESHOLD) {
newProject.avatar = await loadAvatarForProject(newProject._avatarUrl, uuid);
}

upsertToProjectsAtom(newProject);
}

export async function loadAvatarForProject(_avatarUrl: string | null, uuid: UUID): Promise<string | null> {
if (!_avatarUrl) {
return null;
}
const tokens = store.get(jiraAccountTokensAtom)?.[getAccountIdFromUUID(uuid)];
if (!tokens) {
// No tokens for this account found. Maybe it isn't initialized yet
return;
return null;
}
const avatarUrl = project._avatarUrl + '?format=png';
const avatarUrl = _avatarUrl + '?format=png';
const imageRes = await axios.get(avatarUrl, {
responseType: 'arraybuffer',
headers: { Authorization: `Bearer ${tokens.accessToken}` },
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
const base64 = Buffer.from(imageRes.data, 'binary').toString('base64');
store.set(upsertProjectsAtom, { ...project, avatar: `data:image/png;charset=utf-8;base64,${base64}` });
return `data:image/png;charset=utf-8;base64,${base64}`;
}
5 changes: 4 additions & 1 deletion src/services/storage.service.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { appVisibility, SidebarLayout } from '../const';
import { JiraAccountTokensAtom, LoginsAtom } from '../types/accounts.types';
import { DayId, Worklog } from '../types/global.types';
import { DayId, ProjectsAtom, Worklog } from '../types/global.types';

export enum StorageKey {
LOGINS = 'logins',
JIRA_ACCOUNT_TOKENS = 'jiraAccountTokens',
SETTINGS = 'settings',
WORKLOGS_LOCAL = 'worklogsLocal',
WORKLOGS_LOCAL_BACKUPS = 'worklogsLocalBackups',
PROJECTS = 'projects',
LAST_VERSION = 'lastVersion',
}

Expand Down Expand Up @@ -39,6 +40,7 @@ interface StorageTypes {
[StorageKey.SETTINGS]: SettingsModel;
[StorageKey.WORKLOGS_LOCAL]: Worklog[];
[StorageKey.WORKLOGS_LOCAL_BACKUPS]: Worklog[];
[StorageKey.PROJECTS]: ProjectsAtom;
[StorageKey.LAST_VERSION]: string | null;
}

Expand All @@ -61,6 +63,7 @@ export const defaultStorageValues: { [key in StorageKey]: StorageTypes[key] } =
},
[StorageKey.WORKLOGS_LOCAL]: [],
[StorageKey.WORKLOGS_LOCAL_BACKUPS]: [],
[StorageKey.PROJECTS]: {},
[StorageKey.LAST_VERSION]: null,
};

Expand Down
Loading

0 comments on commit dcc76ff

Please sign in to comment.