@nightkatana/kronosys-app 1.0.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +81 -0
- package/app/api/action/route.ts +16 -0
- package/app/api/backup/route.ts +84 -0
- package/app/api/health/route.ts +22 -0
- package/app/api/state/route.ts +27 -0
- package/app/apple-icon.png +0 -0
- package/app/changelog/page.tsx +122 -0
- package/app/globals.css +210 -0
- package/app/guide/layout.tsx +11 -0
- package/app/guide/page.tsx +278 -0
- package/app/icon.png +0 -0
- package/app/layout.tsx +77 -0
- package/app/licenses/layout.tsx +11 -0
- package/app/licenses/page.tsx +246 -0
- package/app/manifest.ts +32 -0
- package/app/page.tsx +1610 -0
- package/app/reporting/page.tsx +2943 -0
- package/app/settings/layout.tsx +10 -0
- package/app/settings/page.tsx +3518 -0
- package/bin/kronosys.mjs +46 -0
- package/components/KronosysPackageVersionProvider.tsx +19 -0
- package/components/KronosysPayloadProvider.tsx +109 -0
- package/components/PwaRegister.tsx +25 -0
- package/components/SiteLegalFooter.tsx +21 -0
- package/components/ThemeProvider.tsx +78 -0
- package/components/dashboard/AppShellLiveSessionDrawer.tsx +394 -0
- package/components/dashboard/AppShellRouteNav.tsx +131 -0
- package/components/dashboard/AppVersionStamp.tsx +16 -0
- package/components/dashboard/DashboardCollapsibleSection.tsx +57 -0
- package/components/dashboard/DashboardColumnHintsBanner.tsx +159 -0
- package/components/dashboard/DashboardCommandCenter.tsx +470 -0
- package/components/dashboard/DashboardLangGateModal.tsx +118 -0
- package/components/dashboard/DashboardLoadingOverlay.tsx +42 -0
- package/components/dashboard/DashboardSimpleModal.tsx +337 -0
- package/components/dashboard/DashboardSuspenseFallback.tsx +52 -0
- package/components/dashboard/DashboardToastProvider.tsx +64 -0
- package/components/dashboard/DashboardTour.tsx +435 -0
- package/components/dashboard/DeferredDescriptionPopoverWrap.tsx +39 -0
- package/components/dashboard/DeleteSessionModal.tsx +130 -0
- package/components/dashboard/DescriptionTooltipPortaled.tsx +31 -0
- package/components/dashboard/GitIdentityQuickSetupModal.tsx +211 -0
- package/components/dashboard/HeaderIntegrationBadges.tsx +69 -0
- package/components/dashboard/InlineMetricHelpTrigger.tsx +102 -0
- package/components/dashboard/IssuePickerModal.tsx +168 -0
- package/components/dashboard/KronoFocusPanel.tsx +834 -0
- package/components/dashboard/KronosysDatetimePopoverField.tsx +357 -0
- package/components/dashboard/KronosysTimePopoverField.tsx +233 -0
- package/components/dashboard/LanguageMenu.tsx +123 -0
- package/components/dashboard/MongoMirrorSyncLine.tsx +57 -0
- package/components/dashboard/NewSessionScopeModal.tsx +410 -0
- package/components/dashboard/PageRefreshButton.tsx +130 -0
- package/components/dashboard/PlainHelpPopover.tsx +97 -0
- package/components/dashboard/ReportingPageToc.tsx +68 -0
- package/components/dashboard/ReportingTour.tsx +342 -0
- package/components/dashboard/SavedProjectPicker.tsx +92 -0
- package/components/dashboard/SavedTagPicker.tsx +115 -0
- package/components/dashboard/ScrollToTopFab.tsx +41 -0
- package/components/dashboard/SelectedSessionSidebarBlock.tsx +630 -0
- package/components/dashboard/SessionEndReasonEditor.tsx +114 -0
- package/components/dashboard/SessionListPanel.tsx +320 -0
- package/components/dashboard/SessionLocMetricsSection.tsx +128 -0
- package/components/dashboard/SettingsTagsProjectsSection.tsx +993 -0
- package/components/dashboard/SettingsTour.tsx +332 -0
- package/components/dashboard/TagPills.tsx +149 -0
- package/components/dashboard/TagsHelpTrigger.tsx +84 -0
- package/components/dashboard/TaskFocusPanel.tsx +1261 -0
- package/components/dashboard/TaskSessionLiveCard.tsx +832 -0
- package/components/dashboard/TaskSubtasksBlock.tsx +748 -0
- package/components/dashboard/ThemeToggle.test.tsx +26 -0
- package/components/dashboard/ThemeToggle.tsx +36 -0
- package/components/dashboard/UserGuideBodyText.tsx +62 -0
- package/components/dashboard/WorkspaceGitRepoCard.tsx +191 -0
- package/components/dashboard/taskFieldStyles.ts +139 -0
- package/components/dashboard/useAnchoredFloatingPortalStyle.ts +71 -0
- package/components/dashboard/useDescriptionPopoverAfterMs.ts +220 -0
- package/components/dashboard/useKronoFocusLiveSeconds.ts +36 -0
- package/components/dashboard/useSmoothStopwatchMs.ts +25 -0
- package/lib/appShellHeaderClasses.ts +12 -0
- package/lib/backupCsvExport.test.ts +149 -0
- package/lib/backupCsvExport.ts +392 -0
- package/lib/changelogCopy.ts +34 -0
- package/lib/concurrentTaskStartPreference.ts +29 -0
- package/lib/dashboardClockFormat.ts +13 -0
- package/lib/dashboardColumnChrome.ts +3 -0
- package/lib/dashboardColumnHintsStorage.ts +57 -0
- package/lib/dashboardCopy.ts +1831 -0
- package/lib/dashboardDetachedUrlHintStorage.ts +24 -0
- package/lib/dashboardGitIdentityBannerStorage.ts +36 -0
- package/lib/dashboardLangStorage.ts +72 -0
- package/lib/dashboardQuickSearch.ts +476 -0
- package/lib/dashboardQuickSearchQuery.test.ts +63 -0
- package/lib/dashboardQuickSearchQuery.ts +179 -0
- package/lib/dashboardSessionNav.ts +33 -0
- package/lib/dashboardShortcuts.ts +268 -0
- package/lib/dashboardTimeZone.ts +91 -0
- package/lib/dashboardTourStorage.ts +68 -0
- package/lib/dataDir.test.ts +87 -0
- package/lib/dataDir.ts +83 -0
- package/lib/devDataPreferenceFile.ts +55 -0
- package/lib/devDataRuntimeInfo.ts +34 -0
- package/lib/formatIsoShort.test.ts +46 -0
- package/lib/formatIsoShort.ts +29 -0
- package/lib/generatedUserChangelog.ts +34 -0
- package/lib/gitlabIssueSearch.ts +8 -0
- package/lib/kronoFocusDurationHistory.ts +71 -0
- package/lib/kronoFocusRhythm.test.ts +130 -0
- package/lib/kronoFocusRhythm.ts +46 -0
- package/lib/kronoFocusTimerUrgency.test.ts +74 -0
- package/lib/kronoFocusTimerUrgency.ts +24 -0
- package/lib/kronosysApi.ts +143 -0
- package/lib/legacyEditorPayloadKeys.ts +52 -0
- package/lib/legacyKronoFocusStorageKeys.test.ts +29 -0
- package/lib/legacyKronoFocusStorageKeys.ts +32 -0
- package/lib/licensesCopy.ts +128 -0
- package/lib/openPlainTextInNewTab.ts +49 -0
- package/lib/readKronosysPackageVersion.ts +10 -0
- package/lib/reportingAggregate.test.ts +325 -0
- package/lib/reportingAggregate.ts +819 -0
- package/lib/reportingDatePresets.ts +41 -0
- package/lib/reportingMetricHelp.ts +430 -0
- package/lib/reportingNonFinalIndicators.test.ts +157 -0
- package/lib/reportingNonFinalIndicators.ts +102 -0
- package/lib/reportingStrings.ts +491 -0
- package/lib/reportingTagWeekBreakdown.test.ts +141 -0
- package/lib/reportingTagWeekBreakdown.ts +181 -0
- package/lib/reportingWeekLayout.test.ts +239 -0
- package/lib/reportingWeekLayout.ts +313 -0
- package/lib/sessionAssiduity.test.ts +25 -0
- package/lib/sessionAssiduity.ts +33 -0
- package/lib/sessionEndReason.ts +55 -0
- package/lib/sessionEndWarnings.test.ts +200 -0
- package/lib/sessionEndWarnings.ts +125 -0
- package/lib/sessionListMerge.test.ts +101 -0
- package/lib/sessionListMerge.ts +70 -0
- package/lib/sessionTaskSidebarStats.test.ts +24 -0
- package/lib/sessionTaskSidebarStats.ts +54 -0
- package/lib/settingsCopy.ts +1276 -0
- package/lib/taskParsing.test.ts +153 -0
- package/lib/taskParsing.ts +737 -0
- package/lib/theme.ts +15 -0
- package/lib/translucentButtonClasses.ts +34 -0
- package/lib/usageProfile.test.ts +84 -0
- package/lib/usageProfile.ts +52 -0
- package/lib/userGuideCopy.ts +464 -0
- package/lib/workspaceLocDefaults.ts +21 -0
- package/next-env.d.ts +6 -0
- package/next.config.ts +15 -0
- package/package.json +87 -0
- package/postcss.config.mjs +12 -0
- package/public/apple-icon.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/icon.png +0 -0
- package/public/next.svg +1 -0
- package/public/sw.js +13 -0
- package/public/traceback.png +0 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/server/actionDispatch.test.ts +723 -0
- package/server/actionDispatch.ts +1476 -0
- package/server/actionTaskSession.test.ts +713 -0
- package/server/actionTaskSession.ts +717 -0
- package/server/db.ts +42 -0
- package/server/defaultCfg.ts +87 -0
- package/server/gitlabTokenStore.ts +34 -0
- package/server/kronoFocusHydrate.test.ts +142 -0
- package/server/kronoFocusHydrate.ts +69 -0
- package/server/kronoFocusMigrate.test.ts +53 -0
- package/server/kronoFocusMigrate.ts +78 -0
- package/server/mainTimerHydrate.test.ts +65 -0
- package/server/mainTimerHydrate.ts +53 -0
- package/server/payloadStore.test.ts +78 -0
- package/server/payloadStore.ts +83 -0
- package/server/sessionWallHydrate.test.ts +46 -0
- package/server/sessionWallHydrate.ts +88 -0
- package/tsconfig.json +41 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const readUseProd = vi.hoisted(() => vi.fn(() => false));
|
|
4
|
+
|
|
5
|
+
vi.mock("@/lib/devDataPreferenceFile", async (importOriginal) => {
|
|
6
|
+
const mod = await importOriginal<typeof import("@/lib/devDataPreferenceFile")>();
|
|
7
|
+
return {
|
|
8
|
+
...mod,
|
|
9
|
+
readUseProductionDataInDevelopmentFromFile: () => readUseProd(),
|
|
10
|
+
};
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
computeDataDirectoryForCurrentProcessEnv,
|
|
15
|
+
resetDataDirectoryCache,
|
|
16
|
+
resolveProductionDefaultDataDirectory,
|
|
17
|
+
} from "./dataDir";
|
|
18
|
+
import { resetSqliteConnection } from "@/server/db";
|
|
19
|
+
|
|
20
|
+
describe("dataDir (dev / prod isolation)", () => {
|
|
21
|
+
const origNodeEnv = process.env.NODE_ENV;
|
|
22
|
+
const origTrace = process.env.TRACE_DATA_DIR;
|
|
23
|
+
const origKrono = process.env.KRONOSYS_DEV_USE_PROD_DATA;
|
|
24
|
+
const env = process.env as Record<string, string | undefined>;
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
readUseProd.mockReturnValue(false);
|
|
28
|
+
delete process.env.TRACE_DATA_DIR;
|
|
29
|
+
delete process.env.KRONOSYS_DEV_USE_PROD_DATA;
|
|
30
|
+
resetDataDirectoryCache();
|
|
31
|
+
resetSqliteConnection();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
if (origNodeEnv === undefined) {
|
|
36
|
+
delete env.NODE_ENV;
|
|
37
|
+
} else {
|
|
38
|
+
env.NODE_ENV = origNodeEnv;
|
|
39
|
+
}
|
|
40
|
+
if (origTrace === undefined) {
|
|
41
|
+
delete process.env.TRACE_DATA_DIR;
|
|
42
|
+
} else {
|
|
43
|
+
process.env.TRACE_DATA_DIR = origTrace;
|
|
44
|
+
}
|
|
45
|
+
if (origKrono === undefined) {
|
|
46
|
+
delete process.env.KRONOSYS_DEV_USE_PROD_DATA;
|
|
47
|
+
} else {
|
|
48
|
+
process.env.KRONOSYS_DEV_USE_PROD_DATA = origKrono;
|
|
49
|
+
}
|
|
50
|
+
readUseProd.mockReset();
|
|
51
|
+
readUseProd.mockReturnValue(false);
|
|
52
|
+
resetDataDirectoryCache();
|
|
53
|
+
resetSqliteConnection();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("uses TRACE_DATA_DIR when set", () => {
|
|
57
|
+
env.NODE_ENV = "development";
|
|
58
|
+
process.env.TRACE_DATA_DIR = "/tmp/kronosys-trace-xyz";
|
|
59
|
+
expect(computeDataDirectoryForCurrentProcessEnv()).toBe("/tmp/kronosys-trace-xyz");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("uses v4 for non-development NODE_ENV", () => {
|
|
63
|
+
env.NODE_ENV = "production";
|
|
64
|
+
const prod = resolveProductionDefaultDataDirectory();
|
|
65
|
+
expect(computeDataDirectoryForCurrentProcessEnv()).toBe(prod);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("uses v4-dev in development by default (isolated file preference off)", () => {
|
|
69
|
+
env.NODE_ENV = "development";
|
|
70
|
+
const prod = resolveProductionDefaultDataDirectory();
|
|
71
|
+
const got = computeDataDirectoryForCurrentProcessEnv();
|
|
72
|
+
expect(got).not.toBe(prod);
|
|
73
|
+
expect(got).toMatch(/[\\/]v4-dev$/);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("uses production v4 in development when KRONOSYS_DEV_USE_PROD_DATA is set", () => {
|
|
77
|
+
env.NODE_ENV = "development";
|
|
78
|
+
process.env.KRONOSYS_DEV_USE_PROD_DATA = "1";
|
|
79
|
+
expect(computeDataDirectoryForCurrentProcessEnv()).toBe(resolveProductionDefaultDataDirectory());
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("uses production v4 in development when file preference is on", () => {
|
|
83
|
+
env.NODE_ENV = "development";
|
|
84
|
+
readUseProd.mockReturnValue(true);
|
|
85
|
+
expect(computeDataDirectoryForCurrentProcessEnv()).toBe(resolveProductionDefaultDataDirectory());
|
|
86
|
+
});
|
|
87
|
+
});
|
package/lib/dataDir.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
isTruthyDevUseProdEnv,
|
|
7
|
+
readUseProductionDataInDevelopmentFromFile,
|
|
8
|
+
} from "@/lib/devDataPreferenceFile";
|
|
9
|
+
|
|
10
|
+
type VersionSegment = "v4" | "v4-dev";
|
|
11
|
+
|
|
12
|
+
let resolvedDataDirCache: string | null = null;
|
|
13
|
+
|
|
14
|
+
function userDataRootWithSegment(segment: VersionSegment): string {
|
|
15
|
+
if (process.platform === "darwin") {
|
|
16
|
+
return path.join(os.homedir(), "Library", "Application Support", "Kronosys", segment);
|
|
17
|
+
}
|
|
18
|
+
if (process.platform === "win32") {
|
|
19
|
+
const base = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
|
|
20
|
+
return path.join(base, "Kronosys", segment);
|
|
21
|
+
}
|
|
22
|
+
return path.join(os.homedir(), ".local", "share", "kronosys", segment);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Répertoire « production » (instance packagée / `next start`), segment `v4` — pour affichage et comparaison.
|
|
27
|
+
*/
|
|
28
|
+
export function resolveProductionDefaultDataDirectory(): string {
|
|
29
|
+
return userDataRootWithSegment("v4");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Résout le chemin de données en fonction de l’environnement actuel, sans tenir compte du cache de processus.
|
|
34
|
+
* Utilisé pour afficher l’endroit qu’impliquerait le redémarrage après changement d’option.
|
|
35
|
+
*/
|
|
36
|
+
export function computeDataDirectoryForCurrentProcessEnv(): string {
|
|
37
|
+
const override = process.env.TRACE_DATA_DIR?.trim();
|
|
38
|
+
if (override) {
|
|
39
|
+
return path.resolve(override);
|
|
40
|
+
}
|
|
41
|
+
if (process.env.NODE_ENV !== "development") {
|
|
42
|
+
return userDataRootWithSegment("v4");
|
|
43
|
+
}
|
|
44
|
+
if (isTruthyDevUseProdEnv(process.env.KRONOSYS_DEV_USE_PROD_DATA)) {
|
|
45
|
+
return userDataRootWithSegment("v4");
|
|
46
|
+
}
|
|
47
|
+
if (readUseProductionDataInDevelopmentFromFile()) {
|
|
48
|
+
return userDataRootWithSegment("v4");
|
|
49
|
+
}
|
|
50
|
+
return userDataRootWithSegment("v4-dev");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Répertoire des données locales (SQLite). Surcharge : `TRACE_DATA_DIR`.
|
|
55
|
+
* Sous `next dev`, défaut : segment `v4-dev` (isolé) sauf préférence fichier ou `KRONOSYS_DEV_USE_PROD_DATA`.
|
|
56
|
+
*/
|
|
57
|
+
export function resolveDataDirectory(): string {
|
|
58
|
+
if (!resolvedDataDirCache) {
|
|
59
|
+
resolvedDataDirCache = computeDataDirectoryForCurrentProcessEnv();
|
|
60
|
+
}
|
|
61
|
+
return resolvedDataDirCache;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Lorsque la préférence ou les variables d’environnement ont changé sur le disque, le chemin « préféré »
|
|
66
|
+
* peut différer de celui utilisé par le processus (cache SQLite) jusqu’au redémarrage du serveur.
|
|
67
|
+
*/
|
|
68
|
+
export function dataDirectoryResolutionIsStale(): boolean {
|
|
69
|
+
return resolveDataDirectory() !== computeDataDirectoryForCurrentProcessEnv();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function resetDataDirectoryCache(): void {
|
|
73
|
+
resolvedDataDirCache = null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function ensureDataDirectory(): string {
|
|
77
|
+
const dir = resolveDataDirectory();
|
|
78
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
79
|
+
return dir;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Expose le chemin du fichier de préférence (hors v4) pour l’UI paramètres. */
|
|
83
|
+
export { getDevDataPreferenceFilePath } from "./devDataPreferenceFile";
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
|
|
5
|
+
const FILE_NAME = "dev-data-preference.json";
|
|
6
|
+
|
|
7
|
+
type DevDataPreferenceFile = {
|
|
8
|
+
/** Lorsque `true`, `next dev` utilise le même répertoire que l’exécutable de production (`v4`). */
|
|
9
|
+
useProductionDataInDevelopment?: boolean;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function preferenceFilePath(): string {
|
|
13
|
+
if (process.platform === "darwin") {
|
|
14
|
+
return path.join(os.homedir(), "Library", "Application Support", "Kronosys", FILE_NAME);
|
|
15
|
+
}
|
|
16
|
+
if (process.platform === "win32") {
|
|
17
|
+
const base = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
|
|
18
|
+
return path.join(base, "Kronosys", FILE_NAME);
|
|
19
|
+
}
|
|
20
|
+
return path.join(os.homedir(), ".config", "kronosys", FILE_NAME);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Fichier hors `v4` / `v4-dev` : préférence pour partager le coffre avec la prod en mode `next dev`.
|
|
25
|
+
* Ne s’applique pas si `TRACE_DATA_DIR` est défini.
|
|
26
|
+
*/
|
|
27
|
+
export function getDevDataPreferenceFilePath(): string {
|
|
28
|
+
return preferenceFilePath();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function readUseProductionDataInDevelopmentFromFile(): boolean {
|
|
32
|
+
const p = preferenceFilePath();
|
|
33
|
+
try {
|
|
34
|
+
const raw = fs.readFileSync(p, "utf8");
|
|
35
|
+
const j = JSON.parse(raw) as DevDataPreferenceFile;
|
|
36
|
+
return j.useProductionDataInDevelopment === true;
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function writeUseProductionDataInDevelopmentToFile(value: boolean): void {
|
|
43
|
+
const p = preferenceFilePath();
|
|
44
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
45
|
+
const body: DevDataPreferenceFile = { useProductionDataInDevelopment: value };
|
|
46
|
+
fs.writeFileSync(p, `${JSON.stringify(body, null, 2)}\n`, "utf8");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function isTruthyDevUseProdEnv(value: string | undefined): boolean {
|
|
50
|
+
if (!value) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
const v = value.trim().toLowerCase();
|
|
54
|
+
return v === "1" || v === "true" || v === "yes" || v === "on";
|
|
55
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getDevDataPreferenceFilePath,
|
|
3
|
+
isTruthyDevUseProdEnv,
|
|
4
|
+
readUseProductionDataInDevelopmentFromFile,
|
|
5
|
+
} from "@/lib/devDataPreferenceFile";
|
|
6
|
+
import {
|
|
7
|
+
computeDataDirectoryForCurrentProcessEnv,
|
|
8
|
+
dataDirectoryResolutionIsStale,
|
|
9
|
+
resolveDataDirectory,
|
|
10
|
+
resolveProductionDefaultDataDirectory,
|
|
11
|
+
} from "@/lib/dataDir";
|
|
12
|
+
import type { DevDataRuntimeInfo } from "@/lib/kronosysApi";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Aperçu côté serveur pour l’isolation des données en `next dev` (répertoire distinct de `next start`).
|
|
16
|
+
*/
|
|
17
|
+
export function getDevDataRuntimeInfo(): DevDataRuntimeInfo {
|
|
18
|
+
const traceDataDirOverride = Boolean(process.env.TRACE_DATA_DIR?.trim());
|
|
19
|
+
const isNextDevelopment = process.env.NODE_ENV === "development";
|
|
20
|
+
const envForcesProductionData =
|
|
21
|
+
isNextDevelopment && isTruthyDevUseProdEnv(process.env.KRONOSYS_DEV_USE_PROD_DATA);
|
|
22
|
+
const useProductionDataInDevelopment = readUseProductionDataInDevelopmentFromFile();
|
|
23
|
+
return {
|
|
24
|
+
isNextDevelopment,
|
|
25
|
+
traceDataDirOverride,
|
|
26
|
+
envForcesProductionData,
|
|
27
|
+
useProductionDataInDevelopment,
|
|
28
|
+
activeDataDirectory: resolveDataDirectory(),
|
|
29
|
+
preferredDataDirectory: computeDataDirectoryForCurrentProcessEnv(),
|
|
30
|
+
dataDirectoryResolutionMismatch: dataDirectoryResolutionIsStale(),
|
|
31
|
+
preferencesFilePath: getDevDataPreferenceFilePath(),
|
|
32
|
+
productionDataDirectory: resolveProductionDefaultDataDirectory(),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { formatIsoInstantShort } from "./formatIsoShort";
|
|
4
|
+
|
|
5
|
+
describe("formatIsoInstantShort", () => {
|
|
6
|
+
it("retourne null pour une chaîne vide", () => {
|
|
7
|
+
expect(formatIsoInstantShort("", "fr")).toBeNull();
|
|
8
|
+
expect(formatIsoInstantShort(" ", "fr")).toBeNull();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("retourne null pour une date invalide", () => {
|
|
12
|
+
expect(formatIsoInstantShort("pas-une-date", "fr")).toBeNull();
|
|
13
|
+
expect(formatIsoInstantShort("9999-99-99", "fr")).toBeNull();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("retourne une chaîne non vide pour une date ISO valide en fr", () => {
|
|
17
|
+
const result = formatIsoInstantShort("2026-04-29T12:00:00Z", "fr");
|
|
18
|
+
expect(result).not.toBeNull();
|
|
19
|
+
expect(typeof result).toBe("string");
|
|
20
|
+
expect((result as string).length).toBeGreaterThan(0);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("retourne une chaîne non vide pour une date ISO valide en en", () => {
|
|
24
|
+
const result = formatIsoInstantShort("2026-04-29T12:00:00Z", "en");
|
|
25
|
+
expect(result).not.toBeNull();
|
|
26
|
+
expect(typeof result).toBe("string");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("fr utilise le locale fr-CA (pas en-CA)", () => {
|
|
30
|
+
// fr-CA formate typiquement aa-mm-jj hh:mm alors qu'en-CA est différent
|
|
31
|
+
const fr = formatIsoInstantShort("2026-01-15T00:00:00Z", "fr");
|
|
32
|
+
const en = formatIsoInstantShort("2026-01-15T00:00:00Z", "en");
|
|
33
|
+
expect(fr).not.toEqual(en);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("supporte les dates sans timezone explicite (heure locale)", () => {
|
|
37
|
+
const result = formatIsoInstantShort("2026-06-01T08:30:00", "fr");
|
|
38
|
+
expect(result).not.toBeNull();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("supporte les timestamps millis (new Date via ISO)", () => {
|
|
42
|
+
const iso = new Date(2026, 3, 1, 9, 0, 0).toISOString();
|
|
43
|
+
const result = formatIsoInstantShort(iso, "en");
|
|
44
|
+
expect(result).not.toBeNull();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Lang } from "@/lib/dashboardCopy";
|
|
2
|
+
import { DEFAULT_DASHBOARD_TIME_ZONE, isValidIanaTimeZone } from "@/lib/dashboardTimeZone";
|
|
3
|
+
|
|
4
|
+
/** Affiche une date-heure ISO en style court cohérent avec le sélecteur de tâche passée. */
|
|
5
|
+
export function formatIsoInstantShort(
|
|
6
|
+
iso: string,
|
|
7
|
+
lang: Lang,
|
|
8
|
+
timeZone: string = DEFAULT_DASHBOARD_TIME_ZONE,
|
|
9
|
+
use24HourClock: boolean = true
|
|
10
|
+
): string | null {
|
|
11
|
+
const raw = iso.trim();
|
|
12
|
+
if (!raw) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
const d = new Date(raw);
|
|
16
|
+
if (Number.isNaN(d.getTime())) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
const tz = timeZone.trim();
|
|
20
|
+
const opts: Intl.DateTimeFormatOptions = {
|
|
21
|
+
dateStyle: "short",
|
|
22
|
+
timeStyle: "short",
|
|
23
|
+
hour12: !use24HourClock,
|
|
24
|
+
};
|
|
25
|
+
if (tz && isValidIanaTimeZone(tz)) {
|
|
26
|
+
opts.timeZone = tz;
|
|
27
|
+
}
|
|
28
|
+
return new Intl.DateTimeFormat(lang === "fr" ? "fr-CA" : "en-CA", opts).format(d);
|
|
29
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/* Auto-generated by scripts/build-user-changelog.mjs. Do not edit manually. */
|
|
2
|
+
export type UserChangelogEntry = {
|
|
3
|
+
version: string;
|
|
4
|
+
items: string[];
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export const USER_CHANGELOG_ENTRIES: UserChangelogEntry[] = [
|
|
8
|
+
{
|
|
9
|
+
"version": "3.0.0",
|
|
10
|
+
"items": [
|
|
11
|
+
"aefaf9e: Première version officielle publiée."
|
|
12
|
+
]
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "2.0.0",
|
|
16
|
+
"items": [
|
|
17
|
+
"aefaf9e: Première version officielle publiée."
|
|
18
|
+
]
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"version": "1.0.0",
|
|
22
|
+
"items": [
|
|
23
|
+
"aefaf9e: Première version officielle publiée."
|
|
24
|
+
]
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"version": "0.1.1",
|
|
28
|
+
"items": [
|
|
29
|
+
"Added an in-app user CHANGELOG page (`/changelog`).",
|
|
30
|
+
"Added an update modal on Dashboard startup that opens the CHANGELOG after a version change.",
|
|
31
|
+
"Added browser-local tracking of the last seen app version to show update notes once per version."
|
|
32
|
+
]
|
|
33
|
+
}
|
|
34
|
+
];
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Délai maximal côté serveur pour un GET GitLab `/api/v4/issues` lors d’une recherche. */
|
|
2
|
+
export const GITLAB_ISSUES_SEARCH_TIMEOUT_MS = 12_000;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Délai maximal côté navigateur pour l’action `fetchRemoteIssues` (navigateur → API locale).
|
|
6
|
+
* Légère marge au-dessus du timeout GitLab pour laisser le serveur renvoyer une erreur structurée.
|
|
7
|
+
*/
|
|
8
|
+
export const TRACE_ISSUE_SEARCH_CLIENT_TIMEOUT_MS = GITLAB_ISSUES_SEARCH_TIMEOUT_MS + 5000;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/** Plage alignée sur le panneau KronoFocus (édition durée de travail). */
|
|
2
|
+
const MAX_WORK_SEC = 8 * 3600;
|
|
3
|
+
|
|
4
|
+
const STORAGE_KEY = "kronosys-kronoFocus-duration-custom-v1";
|
|
5
|
+
|
|
6
|
+
/** Nombre maximal de durées personnalisées mémorisées (hors défaut 25 min). */
|
|
7
|
+
export const MAX_KRONO_FOCUS_CUSTOM_DURATION_SLOTS = 12;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Charge les durées personnalisées (secondes), les plus récentes en premier.
|
|
11
|
+
* Exclut la durée par défaut ; valeurs hors plage filtrées.
|
|
12
|
+
*/
|
|
13
|
+
export function loadKronoFocusDurationHistory(defaultWorkSec: number): number[] {
|
|
14
|
+
if (typeof window === "undefined") return [];
|
|
15
|
+
try {
|
|
16
|
+
const raw = localStorage.getItem(STORAGE_KEY);
|
|
17
|
+
if (!raw) return [];
|
|
18
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
19
|
+
if (!Array.isArray(parsed)) return [];
|
|
20
|
+
const seen = new Set<number>();
|
|
21
|
+
const out: number[] = [];
|
|
22
|
+
for (const item of parsed) {
|
|
23
|
+
const n = typeof item === "number" ? item : Number(item);
|
|
24
|
+
if (!Number.isFinite(n)) continue;
|
|
25
|
+
const s = Math.max(60, Math.min(MAX_WORK_SEC, Math.floor(n)));
|
|
26
|
+
if (s === defaultWorkSec) continue;
|
|
27
|
+
if (seen.has(s)) continue;
|
|
28
|
+
seen.add(s);
|
|
29
|
+
out.push(s);
|
|
30
|
+
if (out.length >= MAX_KRONO_FOCUS_CUSTOM_DURATION_SLOTS) break;
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
} catch {
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function persistKronoFocusDurationHistory(entries: number[]): void {
|
|
39
|
+
if (typeof window === "undefined") return;
|
|
40
|
+
try {
|
|
41
|
+
localStorage.setItem(
|
|
42
|
+
STORAGE_KEY,
|
|
43
|
+
JSON.stringify(entries.slice(0, MAX_KRONO_FOCUS_CUSTOM_DURATION_SLOTS)),
|
|
44
|
+
);
|
|
45
|
+
} catch {
|
|
46
|
+
/* quota, navigation privée, etc. */
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Supprime toutes les durées personnalisées mémorisées (le défaut 25 min n’y figure pas). */
|
|
51
|
+
export function clearKronoFocusDurationHistory(): void {
|
|
52
|
+
if (typeof window === "undefined") return;
|
|
53
|
+
try {
|
|
54
|
+
localStorage.removeItem(STORAGE_KEY);
|
|
55
|
+
} catch {
|
|
56
|
+
/* ignore */
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Après une durée appliquée avec succès : met la durée en tête de l’historique si elle n’est pas le défaut.
|
|
62
|
+
*/
|
|
63
|
+
export function pushKronoFocusDurationHistory(
|
|
64
|
+
previous: number[],
|
|
65
|
+
appliedSeconds: number,
|
|
66
|
+
defaultWorkSec: number,
|
|
67
|
+
): number[] {
|
|
68
|
+
const s = Math.max(60, Math.min(MAX_WORK_SEC, Math.floor(appliedSeconds)));
|
|
69
|
+
if (s === defaultWorkSec) return previous;
|
|
70
|
+
return [s, ...previous.filter((x) => x !== s)].slice(0, MAX_KRONO_FOCUS_CUSTOM_DURATION_SLOTS);
|
|
71
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
KRONO_FOCUS_MAX_BREAK_SEC,
|
|
5
|
+
KRONO_FOCUS_MAX_WORK_SEC,
|
|
6
|
+
KRONO_FOCUS_MIN_SEGMENT_SEC,
|
|
7
|
+
KRONO_FOCUS_RHYTHM_PRESETS,
|
|
8
|
+
clampBreakDurationSeconds,
|
|
9
|
+
clampWorkDurationSeconds,
|
|
10
|
+
readLongBreakDurationSeconds,
|
|
11
|
+
readShortBreakDurationSeconds,
|
|
12
|
+
readWorkDurationSeconds,
|
|
13
|
+
} from "./kronoFocusRhythm";
|
|
14
|
+
|
|
15
|
+
describe("constantes", () => {
|
|
16
|
+
it("KRONO_FOCUS_MIN_SEGMENT_SEC = 60s", () => {
|
|
17
|
+
expect(KRONO_FOCUS_MIN_SEGMENT_SEC).toBe(60);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("KRONO_FOCUS_MAX_WORK_SEC = 8h", () => {
|
|
21
|
+
expect(KRONO_FOCUS_MAX_WORK_SEC).toBe(8 * 3600);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("KRONO_FOCUS_MAX_BREAK_SEC = 8h", () => {
|
|
25
|
+
expect(KRONO_FOCUS_MAX_BREAK_SEC).toBe(8 * 3600);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("clampWorkDurationSeconds", () => {
|
|
30
|
+
it("valeur dans la plage → retournée telle quelle (floor)", () => {
|
|
31
|
+
expect(clampWorkDurationSeconds(25 * 60)).toBe(25 * 60);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("valeur inférieure au minimum → ramenée à 60s", () => {
|
|
35
|
+
expect(clampWorkDurationSeconds(0)).toBe(60);
|
|
36
|
+
expect(clampWorkDurationSeconds(30)).toBe(60);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("valeur supérieure au maximum → ramenée à 8h", () => {
|
|
40
|
+
expect(clampWorkDurationSeconds(9 * 3600)).toBe(8 * 3600);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("applique Math.floor sur les décimales", () => {
|
|
44
|
+
expect(clampWorkDurationSeconds(90.9)).toBe(90);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("clampBreakDurationSeconds", () => {
|
|
49
|
+
it("valeur dans la plage → retournée telle quelle", () => {
|
|
50
|
+
expect(clampBreakDurationSeconds(5 * 60)).toBe(5 * 60);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("valeur inférieure au minimum → 60s", () => {
|
|
54
|
+
expect(clampBreakDurationSeconds(10)).toBe(60);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("valeur supérieure au maximum → 8h", () => {
|
|
58
|
+
expect(clampBreakDurationSeconds(9 * 3600)).toBe(8 * 3600);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("readWorkDurationSeconds", () => {
|
|
63
|
+
it("retourne le défaut 25min si pm est undefined", () => {
|
|
64
|
+
expect(readWorkDurationSeconds(undefined)).toBe(25 * 60);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("retourne la valeur du payload si valide", () => {
|
|
68
|
+
expect(readWorkDurationSeconds({ workDurationSeconds: 50 * 60 })).toBe(50 * 60);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("retourne le défaut si la valeur est inférieure au minimum", () => {
|
|
72
|
+
expect(readWorkDurationSeconds({ workDurationSeconds: 30 })).toBe(25 * 60);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("retourne le défaut si la valeur est non numérique", () => {
|
|
76
|
+
expect(readWorkDurationSeconds({ workDurationSeconds: "abc" })).toBe(25 * 60);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("retourne le défaut si la valeur est Infinity", () => {
|
|
80
|
+
expect(readWorkDurationSeconds({ workDurationSeconds: Infinity })).toBe(25 * 60);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("clamp la valeur si elle dépasse le maximum", () => {
|
|
84
|
+
expect(readWorkDurationSeconds({ workDurationSeconds: 9 * 3600 })).toBe(8 * 3600);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("readShortBreakDurationSeconds", () => {
|
|
89
|
+
it("retourne le défaut 5min si pm est undefined", () => {
|
|
90
|
+
expect(readShortBreakDurationSeconds(undefined)).toBe(5 * 60);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("retourne la valeur du payload si valide", () => {
|
|
94
|
+
expect(readShortBreakDurationSeconds({ shortBreakDurationSeconds: 10 * 60 })).toBe(10 * 60);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("retourne le défaut si la valeur est trop petite", () => {
|
|
98
|
+
expect(readShortBreakDurationSeconds({ shortBreakDurationSeconds: 10 })).toBe(5 * 60);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("readLongBreakDurationSeconds", () => {
|
|
103
|
+
it("retourne le défaut 15min si pm est undefined", () => {
|
|
104
|
+
expect(readLongBreakDurationSeconds(undefined)).toBe(15 * 60);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("retourne la valeur du payload si valide", () => {
|
|
108
|
+
expect(readLongBreakDurationSeconds({ longBreakDurationSeconds: 20 * 60 })).toBe(20 * 60);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("KRONO_FOCUS_RHYTHM_PRESETS", () => {
|
|
113
|
+
it("contient 4 presets", () => {
|
|
114
|
+
expect(KRONO_FOCUS_RHYTHM_PRESETS).toHaveLength(4);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("le premier preset est 25-5-15", () => {
|
|
118
|
+
const p = KRONO_FOCUS_RHYTHM_PRESETS[0];
|
|
119
|
+
expect(p.id).toBe("25-5-15");
|
|
120
|
+
expect(p.workSeconds).toBe(25 * 60);
|
|
121
|
+
expect(p.shortBreakSeconds).toBe(5 * 60);
|
|
122
|
+
expect(p.longBreakSeconds).toBe(15 * 60);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("tous les presets ont workSeconds >= 60", () => {
|
|
126
|
+
for (const p of KRONO_FOCUS_RHYTHM_PRESETS) {
|
|
127
|
+
expect(p.workSeconds).toBeGreaterThanOrEqual(60);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** Durée minimale d’un segment (travail ou pause), en secondes. */
|
|
2
|
+
export const KRONO_FOCUS_MIN_SEGMENT_SEC = 60;
|
|
3
|
+
export const KRONO_FOCUS_MAX_WORK_SEC = 8 * 3600;
|
|
4
|
+
export const KRONO_FOCUS_MAX_BREAK_SEC = 8 * 3600;
|
|
5
|
+
|
|
6
|
+
export function clampWorkDurationSeconds(n: number): number {
|
|
7
|
+
return Math.max(KRONO_FOCUS_MIN_SEGMENT_SEC, Math.min(KRONO_FOCUS_MAX_WORK_SEC, Math.floor(n)));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function clampBreakDurationSeconds(n: number): number {
|
|
11
|
+
return Math.max(KRONO_FOCUS_MIN_SEGMENT_SEC, Math.min(KRONO_FOCUS_MAX_BREAK_SEC, Math.floor(n)));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function readSeconds(
|
|
15
|
+
pm: Record<string, unknown> | undefined,
|
|
16
|
+
key: string,
|
|
17
|
+
fallback: number,
|
|
18
|
+
clamp: (n: number) => number,
|
|
19
|
+
): number {
|
|
20
|
+
if (!pm) return fallback;
|
|
21
|
+
const v = pm[key];
|
|
22
|
+
if (typeof v === "number" && Number.isFinite(v) && v >= KRONO_FOCUS_MIN_SEGMENT_SEC) {
|
|
23
|
+
return clamp(v);
|
|
24
|
+
}
|
|
25
|
+
return fallback;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function readWorkDurationSeconds(pm: Record<string, unknown> | undefined): number {
|
|
29
|
+
return readSeconds(pm, "workDurationSeconds", 25 * 60, clampWorkDurationSeconds);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function readShortBreakDurationSeconds(pm: Record<string, unknown> | undefined): number {
|
|
33
|
+
return readSeconds(pm, "shortBreakDurationSeconds", 5 * 60, clampBreakDurationSeconds);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function readLongBreakDurationSeconds(pm: Record<string, unknown> | undefined): number {
|
|
37
|
+
return readSeconds(pm, "longBreakDurationSeconds", 15 * 60, clampBreakDurationSeconds);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Presets « travail / pause courte / pause longue » (après chaque 4e segment de travail). */
|
|
41
|
+
export const KRONO_FOCUS_RHYTHM_PRESETS = [
|
|
42
|
+
{ id: "25-5-15", workSeconds: 25 * 60, shortBreakSeconds: 5 * 60, longBreakSeconds: 15 * 60 },
|
|
43
|
+
{ id: "50-10-20", workSeconds: 50 * 60, shortBreakSeconds: 10 * 60, longBreakSeconds: 20 * 60 },
|
|
44
|
+
{ id: "45-15-15", workSeconds: 45 * 60, shortBreakSeconds: 15 * 60, longBreakSeconds: 15 * 60 },
|
|
45
|
+
{ id: "52-17-30", workSeconds: 52 * 60, shortBreakSeconds: 17 * 60, longBreakSeconds: 30 * 60 },
|
|
46
|
+
] as const;
|