@robinthues/rt-claude-coach 0.2.0 → 0.3.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/dist/backup/dump.d.ts +17 -0
- package/dist/backup/dump.js +118 -0
- package/dist/backup/index.d.ts +11 -0
- package/dist/backup/index.js +202 -0
- package/dist/backup/lock.d.ts +25 -0
- package/dist/backup/lock.js +187 -0
- package/dist/backup/repo.d.ts +20 -0
- package/dist/backup/repo.js +70 -0
- package/dist/backup/restore.d.ts +33 -0
- package/dist/backup/restore.js +102 -0
- package/dist/cli.d.ts +9 -0
- package/dist/cli.js +84 -5
- package/dist/db/client.js +4 -0
- package/dist/lib/config.d.ts +1 -0
- package/dist/lib/config.js +23 -17
- package/dist/viewer/lib/completion.d.ts +5 -0
- package/dist/viewer/lib/completion.js +15 -0
- package/dist/viewer/stores/plan.d.ts +0 -1
- package/dist/viewer/stores/plan.js +6 -7
- package/package.json +1 -1
- package/templates/plan-viewer.html +20 -20
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rebuild a database from `schemaSql` plus a dump produced by `dumpDatabase`.
|
|
3
|
+
*
|
|
4
|
+
* Refuses to touch an existing file: restoring over live data would be the
|
|
5
|
+
* exact accident this whole feature exists to protect against.
|
|
6
|
+
*
|
|
7
|
+
* A restore happens once every few years, at the worst possible moment: the
|
|
8
|
+
* original machine is gone and the user is rebuilding from nothing. If schema
|
|
9
|
+
* creation or dump replay fails partway through, we must not leave a partial
|
|
10
|
+
* file behind — a half-built database at `dbPath` would make the *next*
|
|
11
|
+
* attempt fail with "already exists" and send the user hunting for data they
|
|
12
|
+
* never had, instead of just letting them retry. So on any failure after the
|
|
13
|
+
* file is created, we close it, delete it (this call is the only thing that
|
|
14
|
+
* could have created it, since the existsSync guard above already refused to
|
|
15
|
+
* run otherwise), and rethrow the original error — never a cleanup error.
|
|
16
|
+
*/
|
|
17
|
+
export declare function restoreDatabase(dbPath: string, dumpSql: string, schemaSql: string): void;
|
|
18
|
+
/**
|
|
19
|
+
* Move `dbPath` to `<dbPath>.bak`, replacing an older .bak, and return the
|
|
20
|
+
* new path. Its journal and WAL files move with it under the matching .bak
|
|
21
|
+
* names: left behind, SQLite could apply a stale journal to the restored
|
|
22
|
+
* database, while next to the .bak it still belongs to the data it came
|
|
23
|
+
* from. Sidecars of the older .bak are removed so they cannot be applied to
|
|
24
|
+
* the wrong file.
|
|
25
|
+
*/
|
|
26
|
+
export declare function moveAsideDatabase(dbPath: string): string;
|
|
27
|
+
/** The `restore-db` command. Returns the process exit code. */
|
|
28
|
+
export declare function runRestore(options: {
|
|
29
|
+
dbPath: string;
|
|
30
|
+
dumpPath: string;
|
|
31
|
+
schemaPath: string;
|
|
32
|
+
force: boolean;
|
|
33
|
+
}): number;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2
|
+
import { existsSync, readFileSync, renameSync, rmSync, unlinkSync } from "fs";
|
|
3
|
+
import { log } from "../lib/logging.js";
|
|
4
|
+
/**
|
|
5
|
+
* Rebuild a database from `schemaSql` plus a dump produced by `dumpDatabase`.
|
|
6
|
+
*
|
|
7
|
+
* Refuses to touch an existing file: restoring over live data would be the
|
|
8
|
+
* exact accident this whole feature exists to protect against.
|
|
9
|
+
*
|
|
10
|
+
* A restore happens once every few years, at the worst possible moment: the
|
|
11
|
+
* original machine is gone and the user is rebuilding from nothing. If schema
|
|
12
|
+
* creation or dump replay fails partway through, we must not leave a partial
|
|
13
|
+
* file behind — a half-built database at `dbPath` would make the *next*
|
|
14
|
+
* attempt fail with "already exists" and send the user hunting for data they
|
|
15
|
+
* never had, instead of just letting them retry. So on any failure after the
|
|
16
|
+
* file is created, we close it, delete it (this call is the only thing that
|
|
17
|
+
* could have created it, since the existsSync guard above already refused to
|
|
18
|
+
* run otherwise), and rethrow the original error — never a cleanup error.
|
|
19
|
+
*/
|
|
20
|
+
export function restoreDatabase(dbPath, dumpSql, schemaSql) {
|
|
21
|
+
if (existsSync(dbPath)) {
|
|
22
|
+
throw new Error(`${dbPath} already exists. Move it aside or pass --force.`);
|
|
23
|
+
}
|
|
24
|
+
const db = new DatabaseSync(dbPath);
|
|
25
|
+
try {
|
|
26
|
+
db.exec(schemaSql);
|
|
27
|
+
db.exec("BEGIN");
|
|
28
|
+
try {
|
|
29
|
+
if (dumpSql.trim().length > 0)
|
|
30
|
+
db.exec(dumpSql);
|
|
31
|
+
db.exec("COMMIT");
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
db.exec("ROLLBACK");
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
try {
|
|
40
|
+
db.close();
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// Original error is what the caller needs to see; ignore close failures here.
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
unlinkSync(dbPath);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// Original error still takes priority over any cleanup failure.
|
|
50
|
+
}
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
db.close();
|
|
54
|
+
}
|
|
55
|
+
/** Files SQLite keeps next to a database, named `<db>-journal` and so on. */
|
|
56
|
+
const SIDECARS = ["-journal", "-wal", "-shm"];
|
|
57
|
+
/**
|
|
58
|
+
* Move `dbPath` to `<dbPath>.bak`, replacing an older .bak, and return the
|
|
59
|
+
* new path. Its journal and WAL files move with it under the matching .bak
|
|
60
|
+
* names: left behind, SQLite could apply a stale journal to the restored
|
|
61
|
+
* database, while next to the .bak it still belongs to the data it came
|
|
62
|
+
* from. Sidecars of the older .bak are removed so they cannot be applied to
|
|
63
|
+
* the wrong file.
|
|
64
|
+
*/
|
|
65
|
+
export function moveAsideDatabase(dbPath) {
|
|
66
|
+
const bakPath = `${dbPath}.bak`;
|
|
67
|
+
for (const suffix of SIDECARS)
|
|
68
|
+
rmSync(`${bakPath}${suffix}`, { force: true });
|
|
69
|
+
renameSync(dbPath, bakPath);
|
|
70
|
+
for (const suffix of SIDECARS) {
|
|
71
|
+
if (existsSync(`${dbPath}${suffix}`))
|
|
72
|
+
renameSync(`${dbPath}${suffix}`, `${bakPath}${suffix}`);
|
|
73
|
+
}
|
|
74
|
+
return bakPath;
|
|
75
|
+
}
|
|
76
|
+
/** The `restore-db` command. Returns the process exit code. */
|
|
77
|
+
export function runRestore(options) {
|
|
78
|
+
const { dbPath, dumpPath, schemaPath } = options;
|
|
79
|
+
if (!existsSync(dumpPath)) {
|
|
80
|
+
log.error(`No dump at ${dumpPath}`);
|
|
81
|
+
return 1;
|
|
82
|
+
}
|
|
83
|
+
if (existsSync(dbPath)) {
|
|
84
|
+
if (!options.force) {
|
|
85
|
+
log.error(`${dbPath} already exists. Move it aside or pass --force.`);
|
|
86
|
+
return 1;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const backupPath = existsSync(dbPath) ? moveAsideDatabase(dbPath) : undefined;
|
|
90
|
+
if (backupPath)
|
|
91
|
+
log.info(`Moved the existing database to ${backupPath}`);
|
|
92
|
+
try {
|
|
93
|
+
restoreDatabase(dbPath, readFileSync(dumpPath, "utf-8"), readFileSync(schemaPath, "utf-8"));
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
const kept = backupPath ? ` Your previous database is at ${backupPath}.` : "";
|
|
97
|
+
log.error(`Restore failed: ${error.message}.${kept}`);
|
|
98
|
+
return 1;
|
|
99
|
+
}
|
|
100
|
+
log.success(`Restored ${dbPath} from ${dumpPath}`);
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
package/dist/cli.d.ts
CHANGED
|
@@ -1 +1,10 @@
|
|
|
1
|
+
interface BackupArgs {
|
|
2
|
+
command: "backup";
|
|
3
|
+
init: boolean;
|
|
4
|
+
push: boolean;
|
|
5
|
+
quiet: boolean;
|
|
6
|
+
repo: string;
|
|
7
|
+
}
|
|
8
|
+
export declare const DEFAULT_BACKUP_REPO = "RobinThues/claude-coach-data";
|
|
9
|
+
export declare function parseBackupArgs(args: string[]): BackupArgs;
|
|
1
10
|
export {};
|
package/dist/cli.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { configExists, loadConfig, promptForConfig, saveConfig, saveTokens, getDbPath, createConfig, } from "./lib/config.js";
|
|
1
|
+
import { configExists, loadConfig, promptForConfig, saveConfig, saveTokens, getDbPath, getDataDir, createConfig, } from "./lib/config.js";
|
|
2
2
|
import { log } from "./lib/logging.js";
|
|
3
3
|
import { migrate } from "./db/migrate.js";
|
|
4
4
|
import { execute, initDatabase, query, queryJson } from "./db/client.js";
|
|
@@ -9,6 +9,8 @@ import { syncActivityDetails } from "./strava/details.js";
|
|
|
9
9
|
import { convertPlan } from "./garmin/index.js";
|
|
10
10
|
import { buildFetchList, getDateStatuses, lastNDates, parseRecoveryDay, upsertRecoveryDay, } from "./recovery/index.js";
|
|
11
11
|
import { buildAvailability, isIsoDate } from "./calendar/index.js";
|
|
12
|
+
import { runBackup, runInit } from "./backup/index.js";
|
|
13
|
+
import { runRestore } from "./backup/restore.js";
|
|
12
14
|
import { readFileSync, writeFileSync } from "fs";
|
|
13
15
|
import { dirname, join } from "path";
|
|
14
16
|
import { fileURLToPath } from "url";
|
|
@@ -25,6 +27,27 @@ const proxyUrl = process.env.HTTPS_PROXY ||
|
|
|
25
27
|
if (proxyUrl) {
|
|
26
28
|
setGlobalDispatcher(new ProxyAgent(proxyUrl));
|
|
27
29
|
}
|
|
30
|
+
export const DEFAULT_BACKUP_REPO = "RobinThues/claude-coach-data";
|
|
31
|
+
export function parseBackupArgs(args) {
|
|
32
|
+
const parsed = {
|
|
33
|
+
command: "backup",
|
|
34
|
+
init: false,
|
|
35
|
+
push: true,
|
|
36
|
+
quiet: false,
|
|
37
|
+
repo: DEFAULT_BACKUP_REPO,
|
|
38
|
+
};
|
|
39
|
+
for (const arg of args.slice(1)) {
|
|
40
|
+
if (arg === "--init")
|
|
41
|
+
parsed.init = true;
|
|
42
|
+
else if (arg === "--no-push")
|
|
43
|
+
parsed.push = false;
|
|
44
|
+
else if (arg === "--quiet")
|
|
45
|
+
parsed.quiet = true;
|
|
46
|
+
else if (arg.startsWith("--repo="))
|
|
47
|
+
parsed.repo = arg.slice("--repo=".length);
|
|
48
|
+
}
|
|
49
|
+
return parsed;
|
|
50
|
+
}
|
|
28
51
|
function parseArgs() {
|
|
29
52
|
const args = process.argv.slice(2);
|
|
30
53
|
if (args.length === 0 || args[0] === "sync") {
|
|
@@ -161,6 +184,19 @@ function parseArgs() {
|
|
|
161
184
|
}
|
|
162
185
|
return authArgs;
|
|
163
186
|
}
|
|
187
|
+
if (args[0] === "backup") {
|
|
188
|
+
return parseBackupArgs(args);
|
|
189
|
+
}
|
|
190
|
+
if (args[0] === "restore-db") {
|
|
191
|
+
const parsed = { command: "restore-db", force: false };
|
|
192
|
+
for (const arg of args.slice(1)) {
|
|
193
|
+
if (arg === "--force")
|
|
194
|
+
parsed.force = true;
|
|
195
|
+
else if (arg.startsWith("--dump="))
|
|
196
|
+
parsed.dumpFile = arg.slice("--dump=".length);
|
|
197
|
+
}
|
|
198
|
+
return parsed;
|
|
199
|
+
}
|
|
164
200
|
if (args[0] === "--help" || args[0] === "-h" || args[0] === "help") {
|
|
165
201
|
return { command: "help" };
|
|
166
202
|
}
|
|
@@ -182,6 +218,8 @@ Commands:
|
|
|
182
218
|
import-garmin <file> Import Garmin recovery MCP responses into the database
|
|
183
219
|
calendar-availability <file> Normalize calendar MCP events into per-day busy blocks
|
|
184
220
|
query <sql> Run a SQL query against the database
|
|
221
|
+
backup Commit the coach data directory and push it to the backup repo
|
|
222
|
+
restore-db Rebuild coach.db from backup/coach-db.sql
|
|
185
223
|
help Show this help message
|
|
186
224
|
|
|
187
225
|
Auth Options (for headless/Claude environments):
|
|
@@ -218,6 +256,16 @@ Calendar Availability Options:
|
|
|
218
256
|
Query Options:
|
|
219
257
|
--json Output as JSON (default: plain text)
|
|
220
258
|
|
|
259
|
+
Backup Options:
|
|
260
|
+
--init One-time setup: git init, .gitignore, RESTORE.md, remote
|
|
261
|
+
--no-push Commit locally without pushing
|
|
262
|
+
--quiet Suppress non-error output (used by the Claude Code hook)
|
|
263
|
+
--repo=OWNER/NAME Override the backup repo (default: RobinThues/claude-coach-data)
|
|
264
|
+
|
|
265
|
+
Restore Options:
|
|
266
|
+
--dump=PATH Read the dump from PATH instead of backup/coach-db.sql
|
|
267
|
+
--force Move an existing coach.db to coach.db.bak, then restore
|
|
268
|
+
|
|
221
269
|
Examples:
|
|
222
270
|
# Headless auth flow (for Claude/automated environments)
|
|
223
271
|
npx @robinthues/rt-claude-coach auth --client-id=12345 --client-secret=abc123
|
|
@@ -678,6 +726,24 @@ async function runQuery(args) {
|
|
|
678
726
|
}
|
|
679
727
|
}
|
|
680
728
|
// ============================================================================
|
|
729
|
+
// Backup / Restore Commands
|
|
730
|
+
// ============================================================================
|
|
731
|
+
async function runBackupCommand(args) {
|
|
732
|
+
const code = args.init
|
|
733
|
+
? await runInit({ repo: args.repo, quiet: args.quiet })
|
|
734
|
+
: await runBackup({ push: args.push, quiet: args.quiet });
|
|
735
|
+
process.exit(code);
|
|
736
|
+
}
|
|
737
|
+
function runRestoreDb(args) {
|
|
738
|
+
const code = runRestore({
|
|
739
|
+
dbPath: getDbPath(),
|
|
740
|
+
dumpPath: args.dumpFile ?? join(getDataDir(), "backup", "coach-db.sql"),
|
|
741
|
+
schemaPath: join(__dirname, "db", "schema.sql"),
|
|
742
|
+
force: args.force,
|
|
743
|
+
});
|
|
744
|
+
process.exit(code);
|
|
745
|
+
}
|
|
746
|
+
// ============================================================================
|
|
681
747
|
// Main
|
|
682
748
|
// ============================================================================
|
|
683
749
|
async function main() {
|
|
@@ -710,9 +776,22 @@ async function main() {
|
|
|
710
776
|
case "query":
|
|
711
777
|
await runQuery(args);
|
|
712
778
|
break;
|
|
779
|
+
case "backup":
|
|
780
|
+
await runBackupCommand(args);
|
|
781
|
+
break;
|
|
782
|
+
case "restore-db":
|
|
783
|
+
runRestoreDb(args);
|
|
784
|
+
break;
|
|
713
785
|
}
|
|
714
786
|
}
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
787
|
+
// Vitest sets VITEST=true in every test process. Importing this module for
|
|
788
|
+
// its exported parsers (parseBackupArgs, etc.) must not also run the CLI
|
|
789
|
+
// against the real environment, so skip main() there. Real invocations
|
|
790
|
+
// (tsx src/cli.ts, or bin/claude-coach.js dynamically importing dist/cli.js)
|
|
791
|
+
// never set this variable.
|
|
792
|
+
if (process.env.VITEST !== "true") {
|
|
793
|
+
main().catch((err) => {
|
|
794
|
+
log.error(err.message);
|
|
795
|
+
process.exit(1);
|
|
796
|
+
});
|
|
797
|
+
}
|
package/dist/db/client.js
CHANGED
|
@@ -12,6 +12,10 @@ async function detectBackend() {
|
|
|
12
12
|
const sqlite = await import("node:sqlite");
|
|
13
13
|
const dbPath = getDbPath();
|
|
14
14
|
const db = new sqlite.DatabaseSync(dbPath);
|
|
15
|
+
// A backup dump holds a read-only connection open on this same file. A
|
|
16
|
+
// coach write during that window would otherwise fail at once with
|
|
17
|
+
// "database is locked" instead of just waiting the dump out.
|
|
18
|
+
db.exec("PRAGMA busy_timeout = 5000");
|
|
15
19
|
return {
|
|
16
20
|
query(sql) {
|
|
17
21
|
const stmt = db.prepare(sql);
|
package/dist/lib/config.d.ts
CHANGED
package/dist/lib/config.js
CHANGED
|
@@ -2,51 +2,57 @@ import { homedir } from "os";
|
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
3
3
|
import { join } from "path";
|
|
4
4
|
import * as readline from "readline";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
export function getDataDir() {
|
|
6
|
+
return process.env.CLAUDE_COACH_DIR ?? join(homedir(), ".claude-coach");
|
|
7
|
+
}
|
|
8
|
+
function configFile() {
|
|
9
|
+
return join(getDataDir(), "config.json");
|
|
10
|
+
}
|
|
11
|
+
function tokensFile() {
|
|
12
|
+
return join(getDataDir(), "tokens.json");
|
|
13
|
+
}
|
|
9
14
|
export function ensureConfigDir() {
|
|
10
|
-
|
|
11
|
-
|
|
15
|
+
const dataDir = getDataDir();
|
|
16
|
+
if (!existsSync(dataDir)) {
|
|
17
|
+
mkdirSync(dataDir, { recursive: true });
|
|
12
18
|
}
|
|
13
19
|
}
|
|
14
20
|
export function getConfigPath() {
|
|
15
|
-
return
|
|
21
|
+
return configFile();
|
|
16
22
|
}
|
|
17
23
|
export function getTokensPath() {
|
|
18
|
-
return
|
|
24
|
+
return tokensFile();
|
|
19
25
|
}
|
|
20
26
|
export function getDbPath() {
|
|
21
|
-
return process.env.CLAUDE_COACH_DB ??
|
|
27
|
+
return process.env.CLAUDE_COACH_DB ?? join(getDataDir(), "coach.db");
|
|
22
28
|
}
|
|
23
29
|
export function configExists() {
|
|
24
|
-
return existsSync(
|
|
30
|
+
return existsSync(configFile());
|
|
25
31
|
}
|
|
26
32
|
export function tokensExist() {
|
|
27
|
-
return existsSync(
|
|
33
|
+
return existsSync(tokensFile());
|
|
28
34
|
}
|
|
29
35
|
export function loadConfig() {
|
|
30
36
|
if (!configExists()) {
|
|
31
|
-
throw new Error(`Config not found at ${
|
|
37
|
+
throw new Error(`Config not found at ${configFile()}. Run setup first.`);
|
|
32
38
|
}
|
|
33
|
-
const data = readFileSync(
|
|
39
|
+
const data = readFileSync(configFile(), "utf-8");
|
|
34
40
|
return JSON.parse(data);
|
|
35
41
|
}
|
|
36
42
|
export function saveConfig(config) {
|
|
37
43
|
ensureConfigDir();
|
|
38
|
-
writeFileSync(
|
|
44
|
+
writeFileSync(configFile(), JSON.stringify(config, null, 2));
|
|
39
45
|
}
|
|
40
46
|
export function loadTokens() {
|
|
41
47
|
if (!tokensExist()) {
|
|
42
|
-
throw new Error(`Tokens not found at ${
|
|
48
|
+
throw new Error(`Tokens not found at ${tokensFile()}. Run auth first.`);
|
|
43
49
|
}
|
|
44
|
-
const data = readFileSync(
|
|
50
|
+
const data = readFileSync(tokensFile(), "utf-8");
|
|
45
51
|
return JSON.parse(data);
|
|
46
52
|
}
|
|
47
53
|
export function saveTokens(tokens) {
|
|
48
54
|
ensureConfigDir();
|
|
49
|
-
writeFileSync(
|
|
55
|
+
writeFileSync(tokensFile(), JSON.stringify(tokens, null, 2));
|
|
50
56
|
}
|
|
51
57
|
export function tokensExpired(tokens) {
|
|
52
58
|
// Add 60 second buffer
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the viewer's completion map from the plan's own `completed` flags.
|
|
3
|
+
*/
|
|
4
|
+
export function completedFromPlan(plan) {
|
|
5
|
+
const completed = {};
|
|
6
|
+
plan.weeks?.forEach((week) => {
|
|
7
|
+
week.days?.forEach((day) => {
|
|
8
|
+
day.workouts?.forEach((workout) => {
|
|
9
|
+
if (workout.completed)
|
|
10
|
+
completed[workout.id] = true;
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
return completed;
|
|
15
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { completedFromPlan } from "../lib/completion.js";
|
|
1
2
|
// Load plan from embedded JSON
|
|
2
3
|
function loadPlanData() {
|
|
3
4
|
const el = document.getElementById("plan-data");
|
|
@@ -8,12 +9,10 @@ function loadPlanData() {
|
|
|
8
9
|
// Reactive state using Svelte 5's $state rune is only available in .svelte files
|
|
9
10
|
// So we export the raw data and let components create reactive state
|
|
10
11
|
export const planData = loadPlanData();
|
|
11
|
-
//
|
|
12
|
-
|
|
12
|
+
// Completion is owned by plan.json — the coach records it there from synced
|
|
13
|
+
// activity data. The viewer reads it and never writes it back: a tick stored
|
|
14
|
+
// only in this browser would be a second source of truth that the next render
|
|
15
|
+
// silently overwrites.
|
|
13
16
|
export function loadCompleted() {
|
|
14
|
-
|
|
15
|
-
return saved ? JSON.parse(saved) : {};
|
|
16
|
-
}
|
|
17
|
-
export function saveCompleted(completed) {
|
|
18
|
-
localStorage.setItem(storageKey, JSON.stringify(completed));
|
|
17
|
+
return completedFromPlan(planData);
|
|
19
18
|
}
|