From 26d0bda8b29f881c6be25159d01a1bfad2a6fedc Mon Sep 17 00:00:00 2001
From: Tom French <15848336+TomAFrench@users.noreply.github.com>
Date: Sun, 30 Jul 2023 04:07:49 +0800
Subject: [PATCH] chore: add more concrete types to `loot-core` (#1186)

---
 .../src/components/settings/Experimental.tsx  |   2 +-
 .../src/hooks/useFeatureFlag.ts               |   2 +-
 packages/desktop-client/src/style/theme.tsx   |   2 +-
 .../src/client/state-types/prefs.d.ts         |  60 +-------
 packages/loot-core/src/mocks/budget.ts        |  11 +-
 .../src/server/accounts/parse-file.ts         |   2 +-
 packages/loot-core/src/server/api.ts          |  20 +--
 .../loot-core/src/server/budget/actions.ts    | 143 +++++++++++++++---
 packages/loot-core/src/server/main.test.ts    |   2 +-
 packages/loot-core/src/server/main.ts         |   2 +-
 packages/loot-core/src/server/prefs.ts        |  37 +++--
 .../loot-core/src/server/server-config.ts     |  16 +-
 packages/loot-core/src/server/sheet.ts        |  41 +++--
 .../src/server/spreadsheet/globals.ts         |   2 +-
 .../src/server/spreadsheet/spreadsheet.ts     |  68 ++++++---
 .../loot-core/src/server/spreadsheet/util.ts  |   2 +-
 packages/loot-core/src/server/sync/encoder.ts |   2 +-
 packages/loot-core/src/server/sync/index.ts   |   3 +-
 packages/loot-core/src/shared/months.ts       |  72 ++++-----
 packages/loot-core/src/shared/util.ts         |   2 +-
 packages/loot-core/src/types/prefs.d.ts       |  53 +++++++
 upcoming-release-notes/1186.md                |   6 +
 22 files changed, 352 insertions(+), 198 deletions(-)
 create mode 100644 packages/loot-core/src/types/prefs.d.ts
 create mode 100644 upcoming-release-notes/1186.md

diff --git a/packages/desktop-client/src/components/settings/Experimental.tsx b/packages/desktop-client/src/components/settings/Experimental.tsx
index e2478e95d..d0fe85562 100644
--- a/packages/desktop-client/src/components/settings/Experimental.tsx
+++ b/packages/desktop-client/src/components/settings/Experimental.tsx
@@ -1,7 +1,7 @@
 import { type ReactNode, useState } from 'react';
 import { useSelector } from 'react-redux';
 
-import type { FeatureFlag } from 'loot-core/src/client/state-types/prefs';
+import type { FeatureFlag } from 'loot-core/src/types/prefs';
 
 import { useActions } from '../../hooks/useActions';
 import useFeatureFlag from '../../hooks/useFeatureFlag';
diff --git a/packages/desktop-client/src/hooks/useFeatureFlag.ts b/packages/desktop-client/src/hooks/useFeatureFlag.ts
index 6d47a634e..fcb97731b 100644
--- a/packages/desktop-client/src/hooks/useFeatureFlag.ts
+++ b/packages/desktop-client/src/hooks/useFeatureFlag.ts
@@ -1,6 +1,6 @@
 import { useSelector } from 'react-redux';
 
-import { type FeatureFlag } from 'loot-core/src/client/state-types/prefs';
+import type { FeatureFlag } from 'loot-core/src/types/prefs';
 
 const DEFAULT_FEATURE_FLAG_STATE: Record<FeatureFlag, boolean> = {
   reportBudget: false,
diff --git a/packages/desktop-client/src/style/theme.tsx b/packages/desktop-client/src/style/theme.tsx
index 6f349bee1..b87fc90fd 100644
--- a/packages/desktop-client/src/style/theme.tsx
+++ b/packages/desktop-client/src/style/theme.tsx
@@ -1,6 +1,6 @@
 import { useSelector } from 'react-redux';
 
-import type { Theme } from 'loot-core/src/client/state-types/prefs';
+import type { Theme } from 'loot-core/src/types/prefs';
 
 import * as darkTheme from './themes/dark';
 import * as lightTheme from './themes/light';
diff --git a/packages/loot-core/src/client/state-types/prefs.d.ts b/packages/loot-core/src/client/state-types/prefs.d.ts
index 7611242dc..21ad02f3d 100644
--- a/packages/loot-core/src/client/state-types/prefs.d.ts
+++ b/packages/loot-core/src/client/state-types/prefs.d.ts
@@ -1,60 +1,6 @@
-import { type numberFormats } from '../../shared/util';
+import type { LocalPrefs, GlobalPrefs } from '../../types/prefs';
 import type * as constants from '../constants';
 
-export type FeatureFlag =
-  | 'reportBudget'
-  | 'goalTemplatesEnabled'
-  | 'privacyMode'
-  | 'themes';
-
-type NullableValues<T> = { [K in keyof T]: T[K] | null };
-
-export type LocalPrefs = NullableValues<
-  {
-    firstDayOfWeekIdx: `${0 | 1 | 2 | 3 | 4 | 5 | 6}`;
-    dateFormat: string;
-    numberFormat: (typeof numberFormats)[number]['value'];
-    hideFraction: boolean;
-    hideClosedAccounts: boolean;
-    hideMobileMessage: boolean;
-    isPrivacyEnabled: boolean;
-    budgetName: string;
-    'ui.showClosedAccounts': boolean;
-    'expand-splits': boolean;
-    [key: `show-extra-balances-${string}`]: boolean;
-    [key: `hide-cleared-${string}`]: boolean;
-    'budget.collapsed': boolean;
-    'budget.summaryCollapsed': boolean;
-    'budget.showHiddenCategories': boolean;
-    // TODO: pull from src/components/modals/ImportTransactions.js
-    [key: `parse-date-${string}-${'csv' | 'qif'}`]: string;
-    [key: `csv-mappings-${string}`]: string;
-    [key: `csv-delimiter-${string}`]: ',' | ';' | '\t';
-    [key: `csv-has-header-${string}`]: boolean;
-    [key: `flip-amount-${string}-${'csv' | 'qif'}`]: boolean;
-    'flags.updateNotificationShownForVersion': string;
-    id: string;
-    isCached: boolean;
-    lastUploaded: string;
-    cloudFileId: string;
-    groupId: string;
-    budgetType: 'report' | 'rollover';
-    encryptKeyId: string;
-    lastSyncedTimestamp: string;
-    userId: string;
-    resetClock: boolean;
-    lastScheduleRun: string;
-  } & Record<`flags.${FeatureFlag}`, boolean>
->;
-
-export type Theme = 'light' | 'dark';
-export type GlobalPrefs = NullableValues<{
-  floatingSidebar: boolean;
-  maxMonths: number;
-  theme: Theme;
-  documentDir: string; // Electron only
-}>;
-
 export type PrefsState = {
   local: LocalPrefs | null;
   global: GlobalPrefs | null;
@@ -68,12 +14,12 @@ export type SetPrefsAction = {
 
 export type MergeLocalPrefsAction = {
   type: typeof constants.MERGE_LOCAL_PREFS;
-  prefs: Partial<LocalPrefs>;
+  prefs: LocalPrefs;
 };
 
 export type MergeGlobalPrefsAction = {
   type: typeof constants.MERGE_GLOBAL_PREFS;
-  globalPrefs: Partial<GlobalPrefs>;
+  globalPrefs: GlobalPrefs;
 };
 
 export type PrefsActions =
diff --git a/packages/loot-core/src/mocks/budget.ts b/packages/loot-core/src/mocks/budget.ts
index ccfb3e608..2d592cf7b 100644
--- a/packages/loot-core/src/mocks/budget.ts
+++ b/packages/loot-core/src/mocks/budget.ts
@@ -453,10 +453,10 @@ async function createBudget(accounts, payees, groups) {
   }
 
   function setBudgetIfSpent(month, cat) {
-    let spent = sheet.getCellValue(
+    let spent: number = sheet.getCellValue(
       monthUtils.sheetForMonth(month),
       `sum-amount-${cat.id}`,
-    );
+    ) as number;
 
     if (spent < 0) {
       setBudget(month, cat, -spent);
@@ -514,7 +514,10 @@ async function createBudget(accounts, payees, groups) {
           month <= monthUtils.currentMonth()
         ) {
           let sheetName = monthUtils.sheetForMonth(month);
-          let toBudget = sheet.getCellValue(sheetName, 'to-budget');
+          let toBudget: number = sheet.getCellValue(
+            sheetName,
+            'to-budget',
+          ) as number;
           let available = toBudget - prevSaved;
 
           if (available - 403000 > 0) {
@@ -533,7 +536,7 @@ async function createBudget(accounts, payees, groups) {
   await sheet.waitOnSpreadsheet();
 
   let sheetName = monthUtils.sheetForMonth(monthUtils.currentMonth());
-  let toBudget = sheet.getCellValue(sheetName, 'to-budget');
+  let toBudget: number = sheet.getCellValue(sheetName, 'to-budget') as number;
   if (toBudget < 0) {
     await addTransactions(primaryAccount.id, [
       {
diff --git a/packages/loot-core/src/server/accounts/parse-file.ts b/packages/loot-core/src/server/accounts/parse-file.ts
index ea6384083..16434a8a2 100644
--- a/packages/loot-core/src/server/accounts/parse-file.ts
+++ b/packages/loot-core/src/server/accounts/parse-file.ts
@@ -130,7 +130,7 @@ async function parseOFX(filepath): Promise<ParseFileResult> {
     transactions: data.map(trans => ({
       amount: trans.amount,
       imported_id: trans.fi_id,
-      date: trans.date ? dayFromDate(trans.date * 1000) : null,
+      date: trans.date ? dayFromDate(new Date(trans.date * 1000)) : null,
       payee_name: useName ? trans.name : trans.memo,
       imported_payee: useName ? trans.name : trans.memo,
       notes: useName ? trans.memo || null : null, //memo used for payee
diff --git a/packages/loot-core/src/server/api.ts b/packages/loot-core/src/server/api.ts
index 885d21fc0..01190365d 100644
--- a/packages/loot-core/src/server/api.ts
+++ b/packages/loot-core/src/server/api.ts
@@ -299,16 +299,16 @@ handlers['api/budget-month'] = async function ({ month }) {
   // different (for now)
   return {
     month,
-    incomeAvailable: value('available-funds'),
-    lastMonthOverspent: value('last-month-overspent'),
-    forNextMonth: value('buffered'),
-    totalBudgeted: value('total-budgeted'),
-    toBudget: value('to-budget'),
-
-    fromLastMonth: value('from-last-month'),
-    totalIncome: value('total-income'),
-    totalSpent: value('total-spent'),
-    totalBalance: value('total-leftover'),
+    incomeAvailable: value('available-funds') as number,
+    lastMonthOverspent: value('last-month-overspent') as number,
+    forNextMonth: value('buffered') as number,
+    totalBudgeted: value('total-budgeted') as number,
+    toBudget: value('to-budget') as number,
+
+    fromLastMonth: value('from-last-month') as number,
+    totalIncome: value('total-income') as number,
+    totalSpent: value('total-spent') as number,
+    totalBalance: value('total-leftover') as number,
 
     categoryGroups: groups.map(group => {
       if (group.is_income) {
diff --git a/packages/loot-core/src/server/budget/actions.ts b/packages/loot-core/src/server/budget/actions.ts
index 0dd5a735b..62caada23 100644
--- a/packages/loot-core/src/server/budget/actions.ts
+++ b/packages/loot-core/src/server/budget/actions.ts
@@ -5,7 +5,10 @@ import * as prefs from '../prefs';
 import * as sheet from '../sheet';
 import { batchMessages } from '../sync';
 
-export async function getSheetValue(sheetName, cell) {
+export async function getSheetValue(
+  sheetName: string,
+  cell: string,
+): Promise<number> {
   const node = await sheet.getCell(sheetName, cell);
   return safeNumber(typeof node.value === 'number' ? node.value : 0);
 }
@@ -14,26 +17,37 @@ export async function getSheetValue(sheetName, cell) {
 // forth. buffered should never be allowed to go into the negative,
 // and you shouldn't be allowed to pull non-existant money from
 // leftover.
-function calcBufferedAmount(toBudget, buffered, amount) {
+function calcBufferedAmount(
+  toBudget: number,
+  buffered: number,
+  amount: number,
+): number {
   amount = Math.min(Math.max(amount, -buffered), Math.max(toBudget, 0));
   return buffered + amount;
 }
 
-function getBudgetTable() {
+function getBudgetTable(): string {
   let { budgetType } = prefs.getPrefs() || {};
   return budgetType === 'report' ? 'reflect_budgets' : 'zero_budgets';
 }
 
-export function isReflectBudget() {
+export function isReflectBudget(): boolean {
   let { budgetType } = prefs.getPrefs();
   return budgetType === 'report';
 }
 
-function dbMonth(month) {
+function dbMonth(month: string): number {
   return parseInt(month.replace('-', ''));
 }
 
-function getBudgetData(table, month) {
+// TODO: complete list of fields.
+type BudgetData = {
+  is_income: 1 | 0;
+  category: string;
+  amount: number;
+};
+
+function getBudgetData(table: string, month: string): Promise<BudgetData[]> {
   return db.all(
     `
     SELECT b.*, c.is_income FROM v_categories c
@@ -44,7 +58,7 @@ function getBudgetData(table, month) {
   );
 }
 
-function getAllMonths(startMonth) {
+function getAllMonths(startMonth: string): string[] {
   let { createdMonths } = sheet.get().meta();
   let latest = null;
   for (let month of createdMonths) {
@@ -57,7 +71,13 @@ function getAllMonths(startMonth) {
 
 // TODO: Valid month format in all the functions below
 
-export function getBudget({ category, month }) {
+export function getBudget({
+  category,
+  month,
+}: {
+  category: string;
+  month: string;
+}): number {
   let table = getBudgetTable();
   let existing = db.firstSync(
     `SELECT * FROM ${table} WHERE month = ? AND category = ?`,
@@ -66,7 +86,15 @@ export function getBudget({ category, month }) {
   return existing ? existing.amount || 0 : 0;
 }
 
-export function setBudget({ category, month, amount }) {
+export function setBudget({
+  category,
+  month,
+  amount,
+}: {
+  category: string;
+  month: string;
+  amount: unknown;
+}): Promise<void> {
   amount = safeNumber(typeof amount === 'number' ? amount : 0);
   const table = getBudgetTable();
 
@@ -85,7 +113,7 @@ export function setBudget({ category, month, amount }) {
   });
 }
 
-export function setBuffer(month, amount) {
+export function setBuffer(month: string, amount: unknown): Promise<void> {
   let existing = db.firstSync(
     `SELECT id FROM zero_budget_months WHERE id = ?`,
     [month],
@@ -99,7 +127,12 @@ export function setBuffer(month, amount) {
   return db.insert('zero_budget_months', { id: month, buffered: amount });
 }
 
-function setCarryover(table, category, month, flag) {
+function setCarryover(
+  table: string,
+  category: string,
+  month: string,
+  flag: boolean,
+): Promise<void> {
   let existing = db.firstSync(
     `SELECT id FROM ${table} WHERE month = ? AND category = ?`,
     [month, category],
@@ -117,10 +150,14 @@ function setCarryover(table, category, month, flag) {
 
 // Actions
 
-export async function copyPreviousMonth({ month }) {
+export async function copyPreviousMonth({
+  month,
+}: {
+  month: string;
+}): Promise<void> {
   let prevMonth = dbMonth(monthUtils.prevMonth(month));
   let table = getBudgetTable();
-  let budgetData = await getBudgetData(table, prevMonth);
+  let budgetData = await getBudgetData(table, prevMonth.toString());
 
   await batchMessages(async () => {
     budgetData.forEach(prevBudget => {
@@ -136,7 +173,13 @@ export async function copyPreviousMonth({ month }) {
   });
 }
 
-export async function copySinglePreviousMonth({ month, category }) {
+export async function copySinglePreviousMonth({
+  month,
+  category,
+}: {
+  month: string;
+  category: string;
+}): Promise<void> {
   let prevMonth = monthUtils.prevMonth(month);
   let newAmount = await getSheetValue(
     monthUtils.sheetForMonth(prevMonth),
@@ -147,7 +190,7 @@ export async function copySinglePreviousMonth({ month, category }) {
   });
 }
 
-export async function setZero({ month }) {
+export async function setZero({ month }: { month: string }): Promise<void> {
   let categories = await db.all(
     'SELECT * FROM v_categories WHERE tombstone = 0',
   );
@@ -162,7 +205,11 @@ export async function setZero({ month }) {
   });
 }
 
-export async function set3MonthAvg({ month }) {
+export async function set3MonthAvg({
+  month,
+}: {
+  month: string;
+}): Promise<void> {
   let categories = await db.all(
     'SELECT * FROM v_categories WHERE tombstone = 0',
   );
@@ -196,7 +243,15 @@ export async function set3MonthAvg({ month }) {
   });
 }
 
-export async function setNMonthAvg({ month, N, category }) {
+export async function setNMonthAvg({
+  month,
+  N,
+  category,
+}: {
+  month: string;
+  N: number;
+  category: string;
+}): Promise<void> {
   let prevMonth = monthUtils.prevMonth(month);
   let sumAmount = 0;
   for (let l = 0; l < N; l++) {
@@ -212,7 +267,13 @@ export async function setNMonthAvg({ month, N, category }) {
   });
 }
 
-export async function holdForNextMonth({ month, amount }) {
+export async function holdForNextMonth({
+  month,
+  amount,
+}: {
+  month: string;
+  amount: number;
+}): Promise<boolean> {
   let row = await db.first(
     'SELECT buffered FROM zero_budget_months WHERE id = ?',
     [month],
@@ -234,11 +295,19 @@ export async function holdForNextMonth({ month, amount }) {
   return false;
 }
 
-export async function resetHold({ month }) {
+export async function resetHold({ month }: { month: string }): Promise<void> {
   await setBuffer(month, 0);
 }
 
-export async function coverOverspending({ month, to, from }) {
+export async function coverOverspending({
+  month,
+  to,
+  from,
+}: {
+  month: string;
+  to: string;
+  from: string;
+}): Promise<void> {
   let sheetName = monthUtils.sheetForMonth(month);
   let toBudgeted = await getSheetValue(sheetName, 'budget-' + to);
   let leftover = await getSheetValue(sheetName, 'leftover-' + to);
@@ -266,7 +335,15 @@ export async function coverOverspending({ month, to, from }) {
   await setBudget({ category: to, month, amount: toBudgeted + amountCovered });
 }
 
-export async function transferAvailable({ month, amount, category }) {
+export async function transferAvailable({
+  month,
+  amount,
+  category,
+}: {
+  month: string;
+  amount: number;
+  category: string;
+}): Promise<void> {
   let sheetName = monthUtils.sheetForMonth(month);
   let leftover = await getSheetValue(sheetName, 'to-budget');
   amount = Math.max(Math.min(amount, leftover), 0);
@@ -275,7 +352,17 @@ export async function transferAvailable({ month, amount, category }) {
   await setBudget({ category, month, amount: budgeted + amount });
 }
 
-export async function transferCategory({ month, amount, from, to }) {
+export async function transferCategory({
+  month,
+  amount,
+  from,
+  to,
+}: {
+  month: string;
+  amount: number;
+  to: string;
+  from: string;
+}): Promise<void> {
   const sheetName = monthUtils.sheetForMonth(month);
   const fromBudgeted = await getSheetValue(sheetName, 'budget-' + from);
 
@@ -289,13 +376,21 @@ export async function transferCategory({ month, amount, from, to }) {
   }
 }
 
-export async function setCategoryCarryover({ startMonth, category, flag }) {
+export async function setCategoryCarryover({
+  startMonth,
+  category,
+  flag,
+}: {
+  startMonth: string;
+  category: string;
+  flag: boolean;
+}): Promise<void> {
   let table = getBudgetTable();
   let months = getAllMonths(startMonth);
 
   await batchMessages(async () => {
     for (let month of months) {
-      setCarryover(table, category, dbMonth(month), flag);
+      setCarryover(table, category, dbMonth(month).toString(), flag);
     }
   });
 }
diff --git a/packages/loot-core/src/server/main.test.ts b/packages/loot-core/src/server/main.test.ts
index bcb709f09..8854ae574 100644
--- a/packages/loot-core/src/server/main.test.ts
+++ b/packages/loot-core/src/server/main.test.ts
@@ -101,7 +101,7 @@ describe('Budgets', () => {
 describe('Accounts', () => {
   test('create accounts with correct starting balance', async () => {
     prefs.loadPrefs();
-    prefs.savePrefs({ clientId: 'client', groupId: 'group' });
+    prefs.savePrefs({ groupId: 'group' });
 
     await runMutator(async () => {
       // An income category is required because the starting balance is
diff --git a/packages/loot-core/src/server/main.ts b/packages/loot-core/src/server/main.ts
index a46ad4e9a..adcffb081 100644
--- a/packages/loot-core/src/server/main.ts
+++ b/packages/loot-core/src/server/main.ts
@@ -263,7 +263,7 @@ handlers['report-budget-month'] = async function ({ month }) {
 };
 
 handlers['budget-set-type'] = async function ({ type }) {
-  if (type !== 'rollover' && type !== 'report') {
+  if (!prefs.BUDGET_TYPES.includes(type)) {
     throw new Error('Invalid budget type: ' + type);
   }
 
diff --git a/packages/loot-core/src/server/prefs.ts b/packages/loot-core/src/server/prefs.ts
index 46ced65dd..0cb45bcdc 100644
--- a/packages/loot-core/src/server/prefs.ts
+++ b/packages/loot-core/src/server/prefs.ts
@@ -1,14 +1,18 @@
 import { Timestamp } from '@actual-app/crdt';
 
 import * as fs from '../platform/server/fs';
+import type { LocalPrefs } from '../types/prefs';
 
-import { sendMessages } from './sync';
+import { Message, sendMessages } from './sync';
 
-let prefs = null;
+export const BUDGET_TYPES = ['report', 'rollover'] as const;
+export type BudgetType = (typeof BUDGET_TYPES)[number];
 
-export async function loadPrefs(id?) {
+let prefs: LocalPrefs = null;
+
+export async function loadPrefs(id?: string): Promise<LocalPrefs> {
   if (process.env.NODE_ENV === 'test' && !id) {
-    prefs = { dummyTestPrefs: true };
+    prefs = getDefaultPrefs('test', 'test_LocalPrefs');
     return prefs;
   }
 
@@ -42,12 +46,15 @@ export async function loadPrefs(id?) {
   return prefs;
 }
 
-export async function savePrefs(prefsToSet, { avoidSync = false } = {}) {
+export async function savePrefs(
+  prefsToSet: LocalPrefs,
+  { avoidSync = false } = {},
+): Promise<void> {
   Object.assign(prefs, prefsToSet);
 
   if (!avoidSync) {
     // Sync whitelisted prefs
-    let messages = Object.keys(prefsToSet)
+    const messages: Message[] = Object.keys(prefsToSet)
       .map(key => {
         if (key === 'budgetType' || key === 'budgetName') {
           return {
@@ -67,30 +74,20 @@ export async function savePrefs(prefsToSet, { avoidSync = false } = {}) {
     }
   }
 
-  if (!prefs.dummyTestPrefs) {
+  if (process.env.NODE_ENV !== 'test') {
     let prefsPath = fs.join(fs.getBudgetDir(prefs.id), 'metadata.json');
     await fs.writeFile(prefsPath, JSON.stringify(prefs));
   }
 }
 
-export function unloadPrefs() {
+export function unloadPrefs(): void {
   prefs = null;
 }
 
-export function getPrefs() {
+export function getPrefs(): LocalPrefs {
   return prefs;
 }
 
-export function getDefaultPrefs(id, budgetName) {
+export function getDefaultPrefs(id: string, budgetName: string): LocalPrefs {
   return { id, budgetName };
 }
-
-export async function readPrefs(id) {
-  const fullpath = fs.join(fs.getBudgetDir(id), 'metadata.json');
-
-  try {
-    return JSON.parse(await fs.readFile(fullpath));
-  } catch (e) {
-    return null;
-  }
-}
diff --git a/packages/loot-core/src/server/server-config.ts b/packages/loot-core/src/server/server-config.ts
index 2bf09ec5e..623cced37 100644
--- a/packages/loot-core/src/server/server-config.ts
+++ b/packages/loot-core/src/server/server-config.ts
@@ -1,14 +1,22 @@
 import * as fs from '../platform/server/fs';
 
-let config = null;
+type ServerConfig = {
+  BASE_SERVER: string;
+  SYNC_SERVER: string;
+  SIGNUP_SERVER: string;
+  PLAID_SERVER: string;
+  GOCARDLESS_SERVER: string;
+};
 
-function joinURL(base, ...paths) {
+let config: ServerConfig | null = null;
+
+function joinURL(base: string | URL, ...paths: string[]): string {
   let url = new URL(base);
   url.pathname = fs.join(...paths);
   return url.toString();
 }
 
-export function setServer(url) {
+export function setServer(url: string): void {
   if (url == null) {
     config = null;
   } else {
@@ -17,7 +25,7 @@ export function setServer(url) {
 }
 
 // `url` is optional; if not given it will provide the global config
-export function getServer(url?) {
+export function getServer(url?: string): ServerConfig | null {
   if (url) {
     return {
       BASE_SERVER: url,
diff --git a/packages/loot-core/src/server/sheet.ts b/packages/loot-core/src/server/sheet.ts
index 8eb5341c0..4c67f935e 100644
--- a/packages/loot-core/src/server/sheet.ts
+++ b/packages/loot-core/src/server/sheet.ts
@@ -1,3 +1,5 @@
+import { type Database } from 'better-sqlite3';
+
 import { captureBreadcrumb } from '../platform/exceptions';
 import * as sqlite from '../platform/server/sqlite';
 import { sheetForMonth } from '../shared/months';
@@ -7,14 +9,15 @@ import * as prefs from './prefs';
 import Spreadsheet from './spreadsheet/spreadsheet';
 import { resolveName } from './spreadsheet/util';
 
-let globalSheet, globalOnChange;
+let globalSheet: Spreadsheet;
+let globalOnChange;
 let globalCacheDb;
 
-export function get() {
+export function get(): Spreadsheet {
   return globalSheet;
 }
 
-async function updateSpreadsheetCache(rawDb, names) {
+async function updateSpreadsheetCache(rawDb, names: string[]) {
   await sqlite.transaction(rawDb, () => {
     names.forEach(name => {
       const node = globalSheet._getNode(name);
@@ -31,7 +34,11 @@ async function updateSpreadsheetCache(rawDb, names) {
   });
 }
 
-function setCacheStatus(mainDb, cacheDb, { clean }) {
+function setCacheStatus(
+  mainDb: unknown,
+  cacheDb: unknown,
+  { clean }: { clean: boolean },
+) {
   if (clean) {
     // Generate random number and stick in both places
     let num = Math.random() * 10000000;
@@ -53,7 +60,7 @@ function setCacheStatus(mainDb, cacheDb, { clean }) {
   }
 }
 
-function isCacheDirty(mainDb, cacheDb) {
+function isCacheDirty(mainDb: Database, cacheDb: Database): boolean {
   let rows = sqlite.runQuery<{ key?: number }>(
     cacheDb,
     'SELECT key FROM kvcache_key WHERE id = 1',
@@ -84,7 +91,10 @@ function isCacheDirty(mainDb, cacheDb) {
   return rows.length === 0;
 }
 
-export async function loadSpreadsheet(db, onSheetChange?) {
+export async function loadSpreadsheet(
+  db,
+  onSheetChange?,
+): Promise<Spreadsheet> {
   let cacheEnabled = process.env.NODE_ENV !== 'test';
   let mainDb = db.getDatabase();
   let cacheDb;
@@ -157,7 +167,7 @@ export async function loadSpreadsheet(db, onSheetChange?) {
   return sheet;
 }
 
-export function unloadSpreadsheet() {
+export function unloadSpreadsheet(): void {
   if (globalSheet) {
     // TODO: Should wait for the sheet to finish
     globalSheet.unload();
@@ -170,14 +180,14 @@ export function unloadSpreadsheet() {
   }
 }
 
-export async function reloadSpreadsheet(db) {
+export async function reloadSpreadsheet(db): Promise<Spreadsheet> {
   if (globalSheet) {
     unloadSpreadsheet();
     return loadSpreadsheet(db, globalOnChange);
   }
 }
 
-export async function loadUserBudgets(db) {
+export async function loadUserBudgets(db): Promise<void> {
   let sheet = globalSheet;
 
   // TODO: Clear out the cache here so make sure future loads of the app
@@ -218,27 +228,30 @@ export async function loadUserBudgets(db) {
   sheet.endTransaction();
 }
 
-export function getCell(sheet, name) {
+export function getCell(sheet: string, name: string) {
   return globalSheet._getNode(resolveName(sheet, name));
 }
 
-export function getCellValue(sheet, name) {
+export function getCellValue(
+  sheet: string,
+  name: string,
+): string | number | boolean {
   return globalSheet.getValue(resolveName(sheet, name));
 }
 
-export function startTransaction() {
+export function startTransaction(): void {
   if (globalSheet) {
     globalSheet.startTransaction();
   }
 }
 
-export function endTransaction() {
+export function endTransaction(): void {
   if (globalSheet) {
     globalSheet.endTransaction();
   }
 }
 
-export function waitOnSpreadsheet() {
+export function waitOnSpreadsheet(): Promise<void> {
   return new Promise(resolve => {
     if (globalSheet) {
       globalSheet.onFinish(resolve);
diff --git a/packages/loot-core/src/server/spreadsheet/globals.ts b/packages/loot-core/src/server/spreadsheet/globals.ts
index 45b656409..8bc525674 100644
--- a/packages/loot-core/src/server/spreadsheet/globals.ts
+++ b/packages/loot-core/src/server/spreadsheet/globals.ts
@@ -1,4 +1,4 @@
-export function number(v) {
+export function number(v: unknown): number {
   if (typeof v === 'number') {
     return v;
   } else if (typeof v === 'string') {
diff --git a/packages/loot-core/src/server/spreadsheet/spreadsheet.ts b/packages/loot-core/src/server/spreadsheet/spreadsheet.ts
index cab861106..53f62431d 100644
--- a/packages/loot-core/src/server/spreadsheet/spreadsheet.ts
+++ b/packages/loot-core/src/server/spreadsheet/spreadsheet.ts
@@ -5,6 +5,18 @@ import { compileQuery, runCompiledQuery, schema, schemaConfig } from '../aql';
 import Graph from './graph-data-structure';
 import { unresolveName, resolveName } from './util';
 
+type Node = {
+  name: string;
+  expr: string | number | boolean;
+  value: string | number | boolean;
+  sheet: unknown;
+  query?: string;
+  sql?: { sqlPieces: unknown; state: { dependencies: unknown[] } };
+  dynamic?: boolean;
+  _run?: unknown;
+  _dependencies?: string[];
+};
+
 export default class Spreadsheet {
   _meta;
   cacheBarrier;
@@ -12,7 +24,7 @@ export default class Spreadsheet {
   dirtyCells;
   events;
   graph;
-  nodes;
+  nodes: Map<string, Node>;
   running;
   saveCache;
   setCacheStatus;
@@ -21,7 +33,7 @@ export default class Spreadsheet {
   constructor(saveCache?: unknown, setCacheStatus?: unknown) {
     // @ts-expect-error Graph should be converted to class
     this.graph = new Graph();
-    this.nodes = new Map();
+    this.nodes = new Map<string, Node>();
     this.transactionDepth = 0;
     this.saveCache = saveCache;
     this.setCacheStatus = setCacheStatus;
@@ -43,7 +55,7 @@ export default class Spreadsheet {
 
   // Spreadsheet interface
 
-  _getNode(name) {
+  _getNode(name: string): Node {
     const { sheet } = unresolveName(name);
 
     if (!this.nodes.has(name)) {
@@ -293,13 +305,13 @@ export default class Spreadsheet {
     });
   }
 
-  load(name, value) {
+  load(name: string, value: string | number | boolean): void {
     const node = this._getNode(name);
     node.expr = value;
     node.value = value;
   }
 
-  create(name, value) {
+  create(name: string, value: string | number | boolean) {
     return this.transaction(() => {
       const node = this._getNode(name);
       node.expr = value;
@@ -308,24 +320,24 @@ export default class Spreadsheet {
     });
   }
 
-  set(name, value) {
+  set(name: string, value: string | number | boolean): void {
     this.create(name, value);
   }
 
-  recompute(name) {
+  recompute(name: string): void {
     this.transaction(() => {
       this.dirtyCells.push(name);
     });
   }
 
-  recomputeAll() {
+  recomputeAll(): void {
     // Recompute everything!
     this.transaction(() => {
       this.dirtyCells = [...this.nodes.keys()];
     });
   }
 
-  createQuery(sheetName, cellName, query) {
+  createQuery(sheetName: string, cellName: string, query: string): void {
     let name = resolveName(sheetName, cellName);
     let node = this._getNode(name);
 
@@ -340,7 +352,11 @@ export default class Spreadsheet {
     }
   }
 
-  createStatic(sheetName, cellName, initialValue) {
+  createStatic(
+    sheetName: string,
+    cellName: string,
+    initialValue: number | boolean,
+  ): void {
     let name = resolveName(sheetName, cellName);
     let exists = this.nodes.has(name);
     if (!exists) {
@@ -349,10 +365,20 @@ export default class Spreadsheet {
   }
 
   createDynamic(
-    sheetName,
-    cellName,
-    { dependencies = [], run, initialValue, refresh = false },
-  ) {
+    sheetName: string,
+    cellName: string,
+    {
+      dependencies = [],
+      run,
+      initialValue,
+      refresh = false,
+    }: {
+      dependencies?: string[];
+      run?: unknown;
+      initialValue: number | boolean;
+      refresh?: boolean;
+    },
+  ): void {
     let name = resolveName(sheetName, cellName);
     let node = this._getNode(name);
 
@@ -391,7 +417,7 @@ export default class Spreadsheet {
     }
   }
 
-  clearSheet(sheetName) {
+  clearSheet(sheetName: string): void {
     for (let [name, node] of this.nodes.entries()) {
       if (node.sheet === sheetName) {
         this.nodes.delete(name);
@@ -399,19 +425,19 @@ export default class Spreadsheet {
     }
   }
 
-  voidCell(sheetName, name, voidValue = null) {
+  voidCell(sheetName: string, name: string, voidValue = null): void {
     let node = this.getNode(resolveName(sheetName, name));
     node._run = null;
     node.dynamic = false;
     node.value = voidValue;
   }
 
-  deleteCell(sheetName, name) {
+  deleteCell(sheetName: string, name: string): void {
     this.voidCell(sheetName, name);
     this.nodes.delete(resolveName(sheetName, name));
   }
 
-  addDependencies(sheetName, cellName, deps) {
+  addDependencies(sheetName: string, cellName: string, deps: string[]): void {
     let name = resolveName(sheetName, cellName);
 
     deps = deps.map(dep => {
@@ -435,7 +461,11 @@ export default class Spreadsheet {
     }
   }
 
-  removeDependencies(sheetName, cellName, deps) {
+  removeDependencies(
+    sheetName: string,
+    cellName: string,
+    deps: string[],
+  ): void {
     let name = resolveName(sheetName, cellName);
 
     deps = deps.map(dep => {
diff --git a/packages/loot-core/src/server/spreadsheet/util.ts b/packages/loot-core/src/server/spreadsheet/util.ts
index 92709cffa..001772ed3 100644
--- a/packages/loot-core/src/server/spreadsheet/util.ts
+++ b/packages/loot-core/src/server/spreadsheet/util.ts
@@ -9,6 +9,6 @@ export function unresolveName(name) {
   return { sheet: null, name };
 }
 
-export function resolveName(sheet, name) {
+export function resolveName(sheet: string, name: string): string {
   return sheet + '!' + name;
 }
diff --git a/packages/loot-core/src/server/sync/encoder.ts b/packages/loot-core/src/server/sync/encoder.ts
index fc6df6c32..36aca1d5e 100644
--- a/packages/loot-core/src/server/sync/encoder.ts
+++ b/packages/loot-core/src/server/sync/encoder.ts
@@ -20,7 +20,7 @@ function coerceBuffer(value) {
 export async function encode(
   groupId: string,
   fileId: string,
-  since: Timestamp,
+  since: Timestamp | string,
   messages: Message[],
 ): Promise<Uint8Array> {
   let { encryptKeyId } = prefs.getPrefs();
diff --git a/packages/loot-core/src/server/sync/index.ts b/packages/loot-core/src/server/sync/index.ts
index c3ec89329..34dceb7dd 100644
--- a/packages/loot-core/src/server/sync/index.ts
+++ b/packages/loot-core/src/server/sync/index.ts
@@ -12,6 +12,7 @@ import * as connection from '../../platform/server/connection';
 import logger from '../../platform/server/log';
 import { sequential, once } from '../../shared/async';
 import { setIn, getIn } from '../../shared/util';
+import { LocalPrefs } from '../../types/prefs';
 import { triggerBudgetChanges, setType as setBudgetType } from '../budget/base';
 import * as db from '../db';
 import { PostError, SyncError } from '../errors';
@@ -303,7 +304,7 @@ export const applyMessages = sequential(async (messages: Message[]) => {
     return data;
   }
 
-  let prefsToSet: Record<string, unknown> = {};
+  let prefsToSet: LocalPrefs = {};
   let oldData = await fetchData();
 
   undo.appendMessages(messages, oldData);
diff --git a/packages/loot-core/src/shared/months.ts b/packages/loot-core/src/shared/months.ts
index 275f74965..df8248fe6 100644
--- a/packages/loot-core/src/shared/months.ts
+++ b/packages/loot-core/src/shared/months.ts
@@ -1,7 +1,9 @@
 import * as d from 'date-fns';
 import memoizeOne from 'memoize-one';
 
-export function _parse(value: string | number | Date) {
+type DateLike = string | Date;
+
+export function _parse(value: DateLike): Date {
   if (typeof value === 'string') {
     // Dates are hard. We just want to deal with months in the format
     // 2020-01 and days in the format 2020-01-01, but life is never
@@ -74,15 +76,15 @@ export function _parse(value: string | number | Date) {
 
 export const parseDate = _parse;
 
-export function yearFromDate(date: string | number | Date) {
+export function yearFromDate(date: DateLike): string {
   return d.format(_parse(date), 'yyyy');
 }
 
-export function monthFromDate(date: string | number | Date) {
+export function monthFromDate(date: DateLike): string {
   return d.format(_parse(date), 'yyyy-MM');
 }
 
-export function dayFromDate(date: string | number | Date) {
+export function dayFromDate(date: DateLike): string {
   return d.format(_parse(date), 'yyyy-MM-dd');
 }
 
@@ -94,7 +96,7 @@ export function currentMonth(): string {
   }
 }
 
-export function currentDay() {
+export function currentDay(): string {
   if (global.IS_TESTING) {
     return '2017-01-01';
   } else {
@@ -102,26 +104,26 @@ export function currentDay() {
   }
 }
 
-export function nextMonth(month: string | Date) {
+export function nextMonth(month: DateLike): string {
   return d.format(d.addMonths(_parse(month), 1), 'yyyy-MM');
 }
 
-export function prevMonth(month: string | Date) {
+export function prevMonth(month: DateLike): string {
   return d.format(d.subMonths(_parse(month), 1), 'yyyy-MM');
 }
 
-export function addMonths(month: string | Date, n: number) {
+export function addMonths(month: DateLike, n: number): string {
   return d.format(d.addMonths(_parse(month), n), 'yyyy-MM');
 }
 
-export function addWeeks(date: string | Date, n: number) {
+export function addWeeks(date: DateLike, n: number): string {
   return d.format(d.addWeeks(_parse(date), n), 'yyyy-MM-dd');
 }
 
 export function differenceInCalendarMonths(
-  month1: string | Date,
-  month2: string | Date,
-) {
+  month1: DateLike,
+  month2: DateLike,
+): number {
   return d.differenceInCalendarMonths(_parse(month1), _parse(month2));
 }
 
@@ -129,25 +131,25 @@ export function subMonths(month: string | Date, n: number) {
   return d.format(d.subMonths(_parse(month), n), 'yyyy-MM');
 }
 
-export function addDays(day: string | Date, n: number) {
+export function addDays(day: DateLike, n: number): string {
   return d.format(d.addDays(_parse(day), n), 'yyyy-MM-dd');
 }
 
-export function subDays(day: string | Date, n: number) {
+export function subDays(day: DateLike, n: number): string {
   return d.format(d.subDays(_parse(day), n), 'yyyy-MM-dd');
 }
 
-export function isBefore(month1: string | Date, month2: string | Date) {
+export function isBefore(month1: DateLike, month2: DateLike): boolean {
   return d.isBefore(_parse(month1), _parse(month2));
 }
 
-export function isAfter(month1: string | Date, month2: string | Date) {
+export function isAfter(month1: DateLike, month2: DateLike): boolean {
   return d.isAfter(_parse(month1), _parse(month2));
 }
 
 // TODO: This doesn't really fit in this module anymore, should
 // probably live elsewhere
-export function bounds(month: string | Date) {
+export function bounds(month: DateLike): { start: number; end: number } {
   return {
     start: parseInt(d.format(d.startOfMonth(_parse(month)), 'yyyyMMdd')),
     end: parseInt(d.format(d.endOfMonth(_parse(month)), 'yyyyMMdd')),
@@ -155,11 +157,11 @@ export function bounds(month: string | Date) {
 }
 
 export function _range(
-  start: string | Date,
-  end: string | Date,
+  start: DateLike,
+  end: DateLike,
   inclusive = false,
 ): string[] {
-  const months: string[] = [];
+  const months = [];
   let month = monthFromDate(start);
   while (d.isBefore(_parse(month), _parse(end))) {
     months.push(month);
@@ -173,20 +175,20 @@ export function _range(
   return months;
 }
 
-export function range(start: string, end: string) {
+export function range(start: DateLike, end: DateLike): string[] {
   return _range(start, end);
 }
 
-export function rangeInclusive(start: string, end: string) {
+export function rangeInclusive(start: DateLike, end: DateLike): string[] {
   return _range(start, end, true);
 }
 
 export function _dayRange(
-  start: string,
-  end: string | Date,
+  start: DateLike,
+  end: DateLike,
   inclusive = false,
 ): string[] {
-  const days: string[] = [];
+  const days = [];
   let day = start;
   while (d.isBefore(_parse(day), _parse(end))) {
     days.push(day);
@@ -200,44 +202,44 @@ export function _dayRange(
   return days;
 }
 
-export function dayRange(start: string, end: string) {
+export function dayRange(start: DateLike, end: DateLike) {
   return _dayRange(start, end);
 }
 
-export function dayRangeInclusive(start: string, end: string) {
+export function dayRangeInclusive(start: DateLike, end: DateLike) {
   return _dayRange(start, end, true);
 }
 
-export function getMonthIndex(month: string) {
+export function getMonthIndex(month: string): number {
   return parseInt(month.slice(5, 7)) - 1;
 }
 
-export function getYear(month: string) {
+export function getYear(month: string): string {
   return month.slice(0, 4);
 }
 
-export function getMonth(day: string) {
+export function getMonth(day: string): string {
   return day.slice(0, 7);
 }
 
-export function getYearStart(month: string) {
+export function getYearStart(month: string): string {
   return getYear(month) + '-01';
 }
 
-export function getYearEnd(month: string) {
+export function getYearEnd(month: string): string {
   return getYear(month) + '-12';
 }
 
-export function sheetForMonth(month: string) {
+export function sheetForMonth(month: string): string {
   return 'budget' + month.replace('-', '');
 }
 
-export function nameForMonth(month: string | Date) {
+export function nameForMonth(month: DateLike): string {
   // eslint-disable-next-line rulesdir/typography
   return d.format(_parse(month), "MMMM 'yy");
 }
 
-export function format(month: string | Date, str: string) {
+export function format(month: DateLike, str: string): string {
   return d.format(_parse(month), str);
 }
 
diff --git a/packages/loot-core/src/shared/util.ts b/packages/loot-core/src/shared/util.ts
index eedbceb87..22d5d60e2 100644
--- a/packages/loot-core/src/shared/util.ts
+++ b/packages/loot-core/src/shared/util.ts
@@ -262,7 +262,7 @@ setNumberFormat({ format: 'comma-dot', hideFraction: false });
 const MAX_SAFE_NUMBER = 2 ** 51 - 1;
 const MIN_SAFE_NUMBER = -MAX_SAFE_NUMBER;
 
-export function safeNumber(value) {
+export function safeNumber(value: number) {
   if (!Number.isInteger(value)) {
     throw new Error(
       'safeNumber: number is not an integer: ' + JSON.stringify(value),
diff --git a/packages/loot-core/src/types/prefs.d.ts b/packages/loot-core/src/types/prefs.d.ts
new file mode 100644
index 000000000..4dcd19128
--- /dev/null
+++ b/packages/loot-core/src/types/prefs.d.ts
@@ -0,0 +1,53 @@
+import { type numberFormats } from '../shared/util';
+
+export type FeatureFlag =
+  | 'reportBudget'
+  | 'goalTemplatesEnabled'
+  | 'privacyMode'
+  | 'themes';
+
+export type LocalPrefs = Partial<
+  {
+    firstDayOfWeekIdx: `${0 | 1 | 2 | 3 | 4 | 5 | 6}`;
+    dateFormat: string;
+    numberFormat: (typeof numberFormats)[number]['value'];
+    hideFraction: boolean;
+    hideClosedAccounts: boolean;
+    hideMobileMessage: boolean;
+    isPrivacyEnabled: boolean;
+    budgetName: string;
+    'ui.showClosedAccounts': boolean;
+    'expand-splits': boolean;
+    [key: `show-extra-balances-${string}`]: boolean;
+    [key: `hide-cleared-${string}`]: boolean;
+    'budget.collapsed': boolean;
+    'budget.summaryCollapsed': boolean;
+    'budget.showHiddenCategories': boolean;
+    // TODO: pull from src/components/modals/ImportTransactions.js
+    [key: `parse-date-${string}-${'csv' | 'qif'}`]: string;
+    [key: `csv-mappings-${string}`]: string;
+    [key: `csv-delimiter-${string}`]: ',' | ';' | '\t';
+    [key: `csv-has-header-${string}`]: boolean;
+    [key: `flip-amount-${string}-${'csv' | 'qif'}`]: boolean;
+    'flags.updateNotificationShownForVersion': string;
+    id: string;
+    isCached: boolean;
+    lastUploaded: string;
+    cloudFileId: string;
+    groupId: string;
+    budgetType: 'report' | 'rollover';
+    encryptKeyId: string;
+    lastSyncedTimestamp: string;
+    userId: string;
+    resetClock: boolean;
+    lastScheduleRun: string;
+  } & Record<`flags.${FeatureFlag}`, boolean>
+>;
+
+export type Theme = 'light' | 'dark';
+export type GlobalPrefs = Partial<{
+  floatingSidebar: boolean;
+  maxMonths: number;
+  theme: Theme;
+  documentDir: string; // Electron only
+}>;
diff --git a/upcoming-release-notes/1186.md b/upcoming-release-notes/1186.md
new file mode 100644
index 000000000..738569ce6
--- /dev/null
+++ b/upcoming-release-notes/1186.md
@@ -0,0 +1,6 @@
+---
+category: Maintenance
+authors: [TomAFrench]
+---
+
+Improve TypeScript types in `loot-core`
\ No newline at end of file
-- 
GitLab