(feat) complete app
This commit is contained in:
parent
7d626ee276
commit
dd8a5bfa8d
11
.eslintrc
Normal file
11
.eslintrc
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"root": true,
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": ["@typescript-eslint"],
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/eslint-recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"prettier"
|
||||
]
|
||||
}
|
||||
5
.prettierignore
Normal file
5
.prettierignore
Normal file
@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
.idea
|
||||
dist
|
||||
data
|
||||
|
||||
0
.prettierrc.json
Normal file
0
.prettierrc.json
Normal file
4408
package-lock.json
generated
Normal file
4408
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
package.json
Normal file
40
package.json
Normal file
@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "jira-graph",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"dev": "run-p copy-resources run-dev",
|
||||
"run-dev": "nodemon src/server.ts",
|
||||
"copy-resources": "cpx src/public/**/* dist/public -w",
|
||||
"prettyprint": "prettier --write .",
|
||||
"build": "tsc --project tsconfig.json"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"express": "^4.18.1",
|
||||
"axios": "^0.27.2",
|
||||
"typescript": "^4.7.4",
|
||||
"lodash.snakecase": "^4.1.1",
|
||||
"typedi": "^0.10.0",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"chart.js": "^3.8.2",
|
||||
"red-agate-svg-canvas": "^0.5.0",
|
||||
"lowdb": "^3.0.0",
|
||||
"serve-favicon": "^2.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node14": "^1.0.3",
|
||||
"ts-node": "^10.9.1",
|
||||
"nodemon": "^2.0.19",
|
||||
"eslint": "^8.20.0",
|
||||
"@typescript-eslint/parser": "^5.31.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.31.0",
|
||||
"@types/lodash.snakecase": "^4.1.7",
|
||||
"@types/node": "14.14.31",
|
||||
"@types/express": "^4.17.13",
|
||||
"prettier": "^2.7.1",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"cpx": "^1.5.0",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"@types/serve-favicon": "^2.5.3"
|
||||
}
|
||||
}
|
||||
28
src/handlers/api.ts
Normal file
28
src/handlers/api.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import express from "express";
|
||||
import { Container } from "typedi";
|
||||
import { HistoryService } from "../service/HistoryService.js";
|
||||
import { ChartService } from "../service/ChartService.js";
|
||||
import { ChartConfiguration } from "chart.js";
|
||||
|
||||
const historyService = Container.get(HistoryService);
|
||||
const chartService = Container.get(ChartService);
|
||||
|
||||
export const apiRouter = express.Router();
|
||||
|
||||
async function getDataChartConfig(): Promise<ChartConfiguration | null> {
|
||||
const historicalData = await historyService.getHistoricalData();
|
||||
return chartService.toChartConfig(historicalData);
|
||||
}
|
||||
|
||||
apiRouter.get("/ping", (req, res) => {
|
||||
return res.send("API alive");
|
||||
});
|
||||
|
||||
apiRouter.get("/history", async (req, res) => {
|
||||
const chartConfig = await getDataChartConfig();
|
||||
if (chartConfig) {
|
||||
return res.json({ chartConfig });
|
||||
} else {
|
||||
return res.status(204).end();
|
||||
}
|
||||
});
|
||||
25
src/handlers/appStatus.ts
Normal file
25
src/handlers/appStatus.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { Request, Response } from "express";
|
||||
import { AppConfig, Configuration } from "../util/Configuration.js";
|
||||
import { Container } from "typedi";
|
||||
|
||||
const version = process.env["npm_package_version"] || "snapshot";
|
||||
const secretProps: Array<keyof Configuration> = [
|
||||
"jiraUsername",
|
||||
"jiraPassword",
|
||||
"jiraAccessToken",
|
||||
];
|
||||
|
||||
export function answerPing(req: Request, res: Response) {
|
||||
res.json({ version });
|
||||
}
|
||||
|
||||
export function createConfigurationHandler() {
|
||||
const config = Container.get(AppConfig);
|
||||
return (req: Request, res: Response) => {
|
||||
const purifiedConfig = Object.assign({}, config.getConfiguration());
|
||||
secretProps.forEach((secretProp) => {
|
||||
purifiedConfig[secretProp] = "***";
|
||||
});
|
||||
res.json(purifiedConfig);
|
||||
};
|
||||
}
|
||||
11
src/handlers/current.ts
Normal file
11
src/handlers/current.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Request, Response } from "express";
|
||||
import { Container } from "typedi";
|
||||
import { StatusService } from "../service/StatusService.js";
|
||||
|
||||
export function createCurrentHandler() {
|
||||
const statusService = Container.get(StatusService);
|
||||
return async (req: Request, res: Response) => {
|
||||
const issues = await statusService.getCurrentStats();
|
||||
res.json(issues);
|
||||
};
|
||||
}
|
||||
87
src/handlers/graph.ts
Normal file
87
src/handlers/graph.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import {
|
||||
SvgCanvas,
|
||||
Rect2D,
|
||||
SvgCanvas2DGradient,
|
||||
} from "red-agate-svg-canvas/modules";
|
||||
import * as ChartJs from "chart.js";
|
||||
import { Request, Response } from "express";
|
||||
|
||||
// Get the global scope.
|
||||
// If running on a node, "g" points to a "global" object.
|
||||
// When running on the browser, "g" points to the "window" object.
|
||||
const g = Function("return this")();
|
||||
|
||||
// Chart options
|
||||
// https://www.chartjs.org/docs/latest/getting-started/usage.html
|
||||
const labels = ["January", "February", "March", "April", "May", "June"];
|
||||
|
||||
const data = {
|
||||
labels: labels,
|
||||
datasets: [
|
||||
{
|
||||
label: "My First dataset",
|
||||
backgroundColor: "rgb(255, 99, 132)",
|
||||
borderColor: "rgb(255, 99, 132)",
|
||||
data: [0, 10, 5, 2, 20, 30, 45],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const config: ChartJs.ChartConfiguration = {
|
||||
type: "line",
|
||||
data: data,
|
||||
options: {},
|
||||
};
|
||||
|
||||
function draw(): string {
|
||||
// SvgCanvas has a "CanvasRenderingContext2D"-compatible interface.
|
||||
const ctx = new SvgCanvas();
|
||||
|
||||
// SvgCanvas lacks the canvas property.
|
||||
(ctx as any).canvas = {
|
||||
width: 800,
|
||||
height: 400,
|
||||
style: {
|
||||
width: "800px",
|
||||
height: "400px",
|
||||
},
|
||||
};
|
||||
|
||||
// SvgCanvas does not have font glyph information,
|
||||
// so manually set the ratio of (font height / font width).
|
||||
ctx.fontHeightRatio = 2;
|
||||
|
||||
// Chart.js needs a "HTMLCanvasElement"-like interface that has "getContext()" method.
|
||||
// "getContext()" should returns a "CanvasRenderingContext2D"-compatible interface.
|
||||
const el = { getContext: () => ctx };
|
||||
|
||||
// If "devicePixelRatio" is not set, Chart.js get the devicePixelRatio from "window" object.
|
||||
// node.js environment has no window object.
|
||||
config.options!.devicePixelRatio = 1;
|
||||
|
||||
// Disable animations.
|
||||
config.options!.animation = false;
|
||||
config.options!.events = [];
|
||||
config.options!.responsive = false;
|
||||
|
||||
// Chart.js needs the "CanvasGradient" in the global scope.
|
||||
const savedGradient = g.CanvasGradient;
|
||||
g.CanvasGradient = SvgCanvas2DGradient;
|
||||
try {
|
||||
const chart = new ChartJs.Chart(el as any, config);
|
||||
} finally {
|
||||
if (savedGradient) {
|
||||
g.CanvasGradient = savedGradient;
|
||||
}
|
||||
}
|
||||
// Render as SVG.
|
||||
const svgString = ctx.render(new Rect2D(0, 0, 800, 400), "px");
|
||||
console.log(svgString);
|
||||
return svgString;
|
||||
}
|
||||
|
||||
export function createDrawSvgHandler() {
|
||||
return (req: Request, res: Response) => {
|
||||
res.send(draw());
|
||||
};
|
||||
}
|
||||
4
src/model/Datapoint.ts
Normal file
4
src/model/Datapoint.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export interface Datapoint {
|
||||
time: Date;
|
||||
remainingEffort: number;
|
||||
}
|
||||
5
src/model/HistoricalData.ts
Normal file
5
src/model/HistoricalData.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { Datapoint } from "./Datapoint.js";
|
||||
|
||||
export interface HistoricalData {
|
||||
dataPoints: Array<Datapoint>;
|
||||
}
|
||||
8
src/model/Issue.ts
Normal file
8
src/model/Issue.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export interface Issue {
|
||||
id: number;
|
||||
key: string;
|
||||
fields: {
|
||||
updated: Date;
|
||||
timeestimate: number;
|
||||
};
|
||||
}
|
||||
6
src/model/IssueOverview.ts
Normal file
6
src/model/IssueOverview.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { Issue } from "./Issue.js";
|
||||
|
||||
export interface IssueOverview {
|
||||
total: number;
|
||||
issues: Array<Issue>;
|
||||
}
|
||||
6
src/model/Stats.ts
Normal file
6
src/model/Stats.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export interface Stats {
|
||||
numIssues: number;
|
||||
totalEstimateInS: number;
|
||||
totalEstimateInH: number;
|
||||
totalEstimateInD: number;
|
||||
}
|
||||
35
src/public/chart.html
Normal file
35
src/public/chart.html
Normal file
@ -0,0 +1,35 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>JIRA Stats V.2</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div>
|
||||
<canvas id="myChart"></canvas>
|
||||
</div>
|
||||
<div id="myErrorArea"></div>
|
||||
<script>
|
||||
fetch("/api/history")
|
||||
.then((response) => {
|
||||
if (response.status === 200) {
|
||||
response.json().then((data) => {
|
||||
if (data) {
|
||||
const chartConfig = data.chartConfig;
|
||||
if (chartConfig) {
|
||||
new Chart(document.getElementById("myChart"), chartConfig);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
document.getElementById(
|
||||
"myErrorArea"
|
||||
).innerHTML = `<strong>Error fetching data: ${error.message} - ${error.stack}`;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
src/public/favicon.png
Normal file
BIN
src/public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
37
src/repository/HistoryRepository.ts
Normal file
37
src/repository/HistoryRepository.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { Service } from "typedi";
|
||||
import { Low, JSONFile } from "lowdb";
|
||||
import { Datapoint } from "../model/Datapoint.js";
|
||||
import { join } from "path";
|
||||
import { __dirname } from "../util/Configuration.js";
|
||||
|
||||
type Data = {
|
||||
points: Array<Datapoint>;
|
||||
};
|
||||
|
||||
const filename = join(__dirname, "../data/db.json");
|
||||
|
||||
@Service()
|
||||
export class HistoryRepository {
|
||||
private readonly db: Low<Data>;
|
||||
|
||||
constructor() {
|
||||
const adapter = new JSONFile<Data>(filename);
|
||||
this.db = new Low(adapter);
|
||||
this.db.read().then(() => {
|
||||
this.db.data = this.db.data || { points: [] };
|
||||
});
|
||||
}
|
||||
|
||||
async getHistory(): Promise<Array<Datapoint>> {
|
||||
return this.db.data!.points;
|
||||
}
|
||||
|
||||
async save(time: Date, remainingEffort: number): Promise<Array<Datapoint>> {
|
||||
this.db.data!.points.push({
|
||||
time,
|
||||
remainingEffort,
|
||||
});
|
||||
await this.db.write();
|
||||
return this.db.data!.points;
|
||||
}
|
||||
}
|
||||
28
src/server.ts
Normal file
28
src/server.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import "reflect-metadata";
|
||||
import express from "express";
|
||||
import {
|
||||
answerPing,
|
||||
createConfigurationHandler,
|
||||
} from "./handlers/appStatus.js";
|
||||
import { Container } from "typedi";
|
||||
import { createCurrentHandler } from "./handlers/current.js";
|
||||
import path from "path";
|
||||
import { apiRouter } from "./handlers/api.js";
|
||||
import { AppConfig, __dirname } from "./util/Configuration.js";
|
||||
import favicon from "serve-favicon";
|
||||
|
||||
const app = express();
|
||||
const config = Container.get(AppConfig);
|
||||
const port = config.getConfiguration().listenPort;
|
||||
|
||||
app.use(favicon(path.join(__dirname, "public", "favicon.png")));
|
||||
app.get("/ping", answerPing);
|
||||
app.get("/config", createConfigurationHandler());
|
||||
app.get("/current", createCurrentHandler());
|
||||
app.use("/api", apiRouter);
|
||||
|
||||
app.use(express.static(path.join(__dirname, "public")));
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`App listening on port ${port}`);
|
||||
});
|
||||
48
src/service/ChartService.ts
Normal file
48
src/service/ChartService.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { Service } from "typedi";
|
||||
import { AppConfig, Configuration } from "../util/Configuration.js";
|
||||
import { ChartConfiguration, ChartTypeRegistry } from "chart.js";
|
||||
import { HistoricalData } from "../model/HistoricalData.js";
|
||||
|
||||
@Service()
|
||||
export class ChartService {
|
||||
private readonly config: Configuration;
|
||||
|
||||
constructor(configService: AppConfig) {
|
||||
this.config = configService.getConfiguration();
|
||||
}
|
||||
|
||||
toChartConfig(history: HistoricalData): ChartConfiguration<"line"> | null {
|
||||
if (!history.dataPoints.length) {
|
||||
return null;
|
||||
}
|
||||
const labels: Array<Date> = [];
|
||||
const values: Array<number> = [];
|
||||
history.dataPoints.forEach((mapping) => {
|
||||
labels.push(mapping.time);
|
||||
values.push(mapping.remainingEffort);
|
||||
});
|
||||
const data = {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: this.config.datasetLabel,
|
||||
backgroundColor: "rgb(255, 99, 132)",
|
||||
borderColor: "rgb(255, 99, 132)",
|
||||
data: values,
|
||||
},
|
||||
],
|
||||
};
|
||||
const config = {
|
||||
type: "line" as "line",
|
||||
data: data,
|
||||
options: {
|
||||
scales: {
|
||||
x: {
|
||||
type: "time" as "time",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
return config;
|
||||
}
|
||||
}
|
||||
25
src/service/HistoryService.ts
Normal file
25
src/service/HistoryService.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { Service } from "typedi";
|
||||
import { HistoricalData } from "../model/HistoricalData.js";
|
||||
import { HistoryRepository } from "../repository/HistoryRepository.js";
|
||||
import { Datapoint } from "../model/Datapoint.js";
|
||||
|
||||
@Service()
|
||||
export class HistoryService {
|
||||
constructor(private readonly repository: HistoryRepository) {}
|
||||
|
||||
private toHistoricalData(datapoints: Array<Datapoint>): HistoricalData {
|
||||
return {
|
||||
dataPoints: datapoints,
|
||||
};
|
||||
}
|
||||
|
||||
async getHistoricalData(): Promise<HistoricalData> {
|
||||
const history = await this.repository.getHistory();
|
||||
return this.toHistoricalData(history);
|
||||
}
|
||||
|
||||
async saveCurrent(time: Date, remainingEffort: number): Promise<number> {
|
||||
const ret = await this.repository.save(time, remainingEffort);
|
||||
return ret.length;
|
||||
}
|
||||
}
|
||||
75
src/service/StatusService.ts
Normal file
75
src/service/StatusService.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import { Service } from "typedi";
|
||||
import { AppConfig, Configuration } from "../util/Configuration.js";
|
||||
import axios from "axios";
|
||||
import { IssueOverview } from "../model/IssueOverview.js";
|
||||
import { Issue } from "../model/Issue.js";
|
||||
import { Stats } from "../model/Stats.js";
|
||||
import { HistoryService } from "./HistoryService.js";
|
||||
|
||||
@Service()
|
||||
export class StatusService {
|
||||
private readonly config: Configuration;
|
||||
|
||||
constructor(
|
||||
configService: AppConfig,
|
||||
private readonly historyService: HistoryService
|
||||
) {
|
||||
this.config = configService.getConfiguration();
|
||||
}
|
||||
|
||||
private getDefaultHeaders() {
|
||||
return {
|
||||
headers: { Authorization: `Bearer ${this.config.jiraAccessToken}` },
|
||||
};
|
||||
}
|
||||
|
||||
private async getSearchUrl(): Promise<string> {
|
||||
const filterUrl = `${this.config.jiraUrl}filter/${this.config.jiraFilter}`;
|
||||
const response = await axios.get(filterUrl, this.getDefaultHeaders());
|
||||
const ret = response.data["searchUrl"];
|
||||
if (!ret || typeof ret !== "string") {
|
||||
throw new Error(`Could not read searchUrl, received: ${ret}`);
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
private async getIssues(): Promise<IssueOverview> {
|
||||
const searchUrl = await this.getSearchUrl();
|
||||
const ret: Array<Issue> = [];
|
||||
let currentStartItem = 0;
|
||||
let total;
|
||||
let maxResults;
|
||||
do {
|
||||
let searchUrlWithPage = `${searchUrl}&startAt=${currentStartItem}`;
|
||||
const response = await axios.get(
|
||||
searchUrlWithPage,
|
||||
this.getDefaultHeaders()
|
||||
);
|
||||
const data = response.data;
|
||||
total = data.total;
|
||||
maxResults = data.maxResults;
|
||||
currentStartItem += maxResults;
|
||||
ret.push(...data.issues);
|
||||
} while (currentStartItem <= total);
|
||||
return {
|
||||
total,
|
||||
issues: ret,
|
||||
};
|
||||
}
|
||||
|
||||
async getCurrentStats(): Promise<Stats> {
|
||||
const issues = await this.getIssues();
|
||||
const totalEstimateInS = issues.issues.reduce((sum, issue) => {
|
||||
return sum + issue.fields.timeestimate;
|
||||
}, 0);
|
||||
const totalEstimateInH = totalEstimateInS / 3600;
|
||||
const totalEstimateInD = totalEstimateInS / 3600 / 8;
|
||||
await this.historyService.saveCurrent(new Date(), totalEstimateInD);
|
||||
return {
|
||||
numIssues: issues.total,
|
||||
totalEstimateInS,
|
||||
totalEstimateInH,
|
||||
totalEstimateInD,
|
||||
};
|
||||
}
|
||||
}
|
||||
80
src/util/Configuration.ts
Normal file
80
src/util/Configuration.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import * as process from "process";
|
||||
import snakecase from "lodash.snakecase";
|
||||
import { Service } from "typedi";
|
||||
import { dirname, join } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
export interface Configuration {
|
||||
jiraUrl: string;
|
||||
jiraFilter: string;
|
||||
dbType: string;
|
||||
dbUrl?: string;
|
||||
listenPort: string;
|
||||
jiraUsername: string;
|
||||
jiraPassword: string;
|
||||
jiraAccessToken: string;
|
||||
datasetLabel: string;
|
||||
}
|
||||
|
||||
const CONFIG_DEFAULTS: Configuration = {
|
||||
jiraUrl: "https://www.exxcellent.de/jira/rest/api/latest/",
|
||||
jiraFilter: "55931",
|
||||
dbType: "local",
|
||||
listenPort: "3000",
|
||||
dbUrl: "",
|
||||
jiraUsername: "pblaesi",
|
||||
jiraPassword: "mySecretJiraPassword",
|
||||
jiraAccessToken: "MzA4MzE5MDc3NjUxOqEVKdb9imjQsdxSoyBxegQQlTfN",
|
||||
datasetLabel: "Remaining Effort",
|
||||
};
|
||||
|
||||
interface ConfigOption {
|
||||
key: keyof Configuration;
|
||||
defaultValue?: string;
|
||||
envVarName?: string;
|
||||
}
|
||||
|
||||
function toEnvVar(key: string): string {
|
||||
return snakecase(key).toUpperCase();
|
||||
}
|
||||
|
||||
function toConfiguration(opts: ConfigOption[]): Configuration {
|
||||
const configBuilder: Configuration = Object.assign({}, CONFIG_DEFAULTS);
|
||||
opts.forEach((option) => {
|
||||
const effectiveEnvVarName = option.envVarName || toEnvVar(option.key);
|
||||
const valueFromEnv = process.env[effectiveEnvVarName];
|
||||
const value = valueFromEnv || option.defaultValue;
|
||||
if (value !== null && value !== undefined) {
|
||||
configBuilder[option.key] = value;
|
||||
}
|
||||
if (
|
||||
configBuilder[option.key] === null ||
|
||||
configBuilder[option.key] === undefined
|
||||
) {
|
||||
throw new Error(
|
||||
`Could not determine value of option ${option.key} using environment variable name ${effectiveEnvVarName}; no default given`
|
||||
);
|
||||
}
|
||||
});
|
||||
return configBuilder;
|
||||
}
|
||||
|
||||
function getConfiguration() {
|
||||
const options: Array<ConfigOption> = [
|
||||
{ key: "jiraUrl" },
|
||||
{ key: "jiraFilter" },
|
||||
{ key: "dbType", defaultValue: "local" },
|
||||
{ key: "dbUrl" },
|
||||
];
|
||||
return toConfiguration(options);
|
||||
}
|
||||
|
||||
@Service()
|
||||
export class AppConfig {
|
||||
getConfiguration(): Configuration {
|
||||
return getConfiguration();
|
||||
}
|
||||
}
|
||||
|
||||
// Since we're in "util" subfolder, basedir is one level up
|
||||
export const __dirname = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
20
tsconfig.json
Normal file
20
tsconfig.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "@tsconfig/node14/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"preserveConstEnums": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"outDir": "dist",
|
||||
"esModuleInterop": true,
|
||||
"target": "ES2020",
|
||||
"module": "ES2020",
|
||||
"moduleResolution": "Node",
|
||||
"lib": ["ES2020"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "**/*.test.ts"],
|
||||
"ts-node": {
|
||||
"esm": true,
|
||||
"experimentalSpecifierResolution": true
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user