@khalido/home 0.0.0-stage → 0.1.1
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/.env.example +25 -0
- package/CHANGELOG.md +47 -0
- package/LICENSE +21 -0
- package/README.md +77 -2
- package/dist/clock.d.ts +17 -0
- package/dist/clock.js +43 -0
- package/dist/config.d.ts +273 -0
- package/dist/config.js +392 -0
- package/dist/connector.d.ts +34 -0
- package/dist/connector.js +47 -0
- package/dist/connectors/amber/client.d.ts +61 -0
- package/dist/connectors/amber/client.js +106 -0
- package/dist/connectors/amber/index.d.ts +30 -0
- package/dist/connectors/amber/index.js +94 -0
- package/dist/connectors/cast/codec.d.ts +38 -0
- package/dist/connectors/cast/codec.js +102 -0
- package/dist/connectors/cast/index.d.ts +60 -0
- package/dist/connectors/cast/index.js +428 -0
- package/dist/connectors/cast/schemas.d.ts +167 -0
- package/dist/connectors/cast/schemas.js +87 -0
- package/dist/connectors/cast/serve.d.ts +18 -0
- package/dist/connectors/cast/serve.js +57 -0
- package/dist/connectors/cast/state.d.ts +62 -0
- package/dist/connectors/cast/state.js +133 -0
- package/dist/connectors/cast/tls.d.ts +3 -0
- package/dist/connectors/cast/tls.js +67 -0
- package/dist/connectors/cast/writes.d.ts +80 -0
- package/dist/connectors/cast/writes.js +390 -0
- package/dist/connectors/daikin/client.d.ts +65 -0
- package/dist/connectors/daikin/client.js +221 -0
- package/dist/connectors/daikin/index.d.ts +41 -0
- package/dist/connectors/daikin/index.js +108 -0
- package/dist/connectors/enphase/client.d.ts +65 -0
- package/dist/connectors/enphase/client.js +173 -0
- package/dist/connectors/enphase/index.d.ts +43 -0
- package/dist/connectors/enphase/index.js +174 -0
- package/dist/connectors/enphase/schemas.d.ts +51 -0
- package/dist/connectors/enphase/schemas.js +34 -0
- package/dist/connectors/hue/client.d.ts +48 -0
- package/dist/connectors/hue/client.js +95 -0
- package/dist/connectors/hue/events.d.ts +41 -0
- package/dist/connectors/hue/events.js +156 -0
- package/dist/connectors/hue/index.d.ts +42 -0
- package/dist/connectors/hue/index.js +205 -0
- package/dist/connectors/hue/schemas.d.ts +65 -0
- package/dist/connectors/hue/schemas.js +48 -0
- package/dist/connectors/hue/state.d.ts +43 -0
- package/dist/connectors/hue/state.js +84 -0
- package/dist/connectors/printers/client.d.ts +28 -0
- package/dist/connectors/printers/client.js +74 -0
- package/dist/connectors/printers/codec.d.ts +76 -0
- package/dist/connectors/printers/codec.js +246 -0
- package/dist/connectors/printers/found.d.ts +15 -0
- package/dist/connectors/printers/found.js +35 -0
- package/dist/connectors/printers/index.d.ts +52 -0
- package/dist/connectors/printers/index.js +282 -0
- package/dist/connectors/printers/state.d.ts +60 -0
- package/dist/connectors/printers/state.js +99 -0
- package/dist/connectors/spotify/changes.d.ts +32 -0
- package/dist/connectors/spotify/changes.js +84 -0
- package/dist/connectors/spotify/client.d.ts +199 -0
- package/dist/connectors/spotify/client.js +270 -0
- package/dist/connectors/spotify/index.d.ts +92 -0
- package/dist/connectors/spotify/index.js +754 -0
- package/dist/connectors/up/client.d.ts +183 -0
- package/dist/connectors/up/client.js +308 -0
- package/dist/connectors/up/index.d.ts +91 -0
- package/dist/connectors/up/index.js +213 -0
- package/dist/connectors/up/spend.d.ts +78 -0
- package/dist/connectors/up/spend.js +213 -0
- package/dist/connectors/up/webhook.d.ts +13 -0
- package/dist/connectors/up/webhook.js +32 -0
- package/dist/connectors/weather/blocks.d.ts +51 -0
- package/dist/connectors/weather/blocks.js +106 -0
- package/dist/connectors/weather/bom.d.ts +37 -0
- package/dist/connectors/weather/bom.js +237 -0
- package/dist/connectors/weather/ftp.d.ts +5 -0
- package/dist/connectors/weather/ftp.js +46 -0
- package/dist/connectors/weather/google.d.ts +31 -0
- package/dist/connectors/weather/google.js +364 -0
- package/dist/connectors/weather/index.d.ts +53 -0
- package/dist/connectors/weather/index.js +280 -0
- package/dist/connectors/weather/open-meteo.d.ts +15 -0
- package/dist/connectors/weather/open-meteo.js +285 -0
- package/dist/connectors/weather/rain.d.ts +19 -0
- package/dist/connectors/weather/rain.js +51 -0
- package/dist/connectors/weather/source.d.ts +149 -0
- package/dist/connectors/weather/source.js +77 -0
- package/dist/connectors/weather/sun.d.ts +48 -0
- package/dist/connectors/weather/sun.js +65 -0
- package/dist/discover.d.ts +197 -0
- package/dist/discover.js +452 -0
- package/dist/energy.d.ts +61 -0
- package/dist/energy.js +162 -0
- package/dist/finders-entry.d.ts +1 -0
- package/dist/finders-entry.js +2 -0
- package/dist/finders.d.ts +48 -0
- package/dist/finders.js +176 -0
- package/dist/health.d.ts +28 -0
- package/dist/health.js +35 -0
- package/dist/home.d.ts +81 -0
- package/dist/home.js +114 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +11 -0
- package/dist/lane.d.ts +17 -0
- package/dist/lane.js +74 -0
- package/dist/locate.d.ts +27 -0
- package/dist/locate.js +59 -0
- package/dist/manifest.d.ts +31 -0
- package/dist/manifest.js +4 -0
- package/dist/manifests.d.ts +5 -0
- package/dist/manifests.js +23 -0
- package/dist/node-fetch.d.ts +9 -0
- package/dist/node-fetch.js +25 -0
- package/dist/poll.d.ts +10 -0
- package/dist/poll.js +33 -0
- package/docs/cast.md +133 -0
- package/docs/cli.md +41 -0
- package/docs/config.md +36 -0
- package/docs/decisions.md +150 -0
- package/docs/devices.md +128 -0
- package/docs/enphase-notes.md +24 -0
- package/docs/google.md +92 -0
- package/docs/ideas.md +69 -0
- package/docs/matter.md +55 -0
- package/docs/performance.md +12 -0
- package/docs/philosophy.md +23 -0
- package/docs/up.md +84 -0
- package/docs/using.md +65 -0
- package/docs/weather-probe.md +63 -0
- package/examples/home.jsonc +74 -0
- package/examples/home.svg +76 -0
- package/home.schema.json +474 -0
- package/llms.txt +32 -0
- package/package.json +143 -4
- package/skills/draw-home/README.md +25 -0
- package/skills/draw-home/SKILL.md +80 -0
- package/skills/use-home/SKILL.md +86 -0
- package/src/cli/cache.ts +29 -0
- package/src/cli/cast.ts +172 -0
- package/src/cli/config.ts +52 -0
- package/src/cli/context.ts +118 -0
- package/src/cli/discover.ts +182 -0
- package/src/cli/enphase.ts +62 -0
- package/src/cli/hue.ts +124 -0
- package/src/cli/locate.ts +47 -0
- package/src/cli/main.ts +182 -0
- package/src/cli/printers.ts +21 -0
- package/src/cli/probe.ts +274 -0
- package/src/cli/spotify.ts +76 -0
- package/src/cli/status.ts +228 -0
- package/src/cli/up.ts +350 -0
- package/src/clock.ts +62 -0
- package/src/config.ts +578 -0
- package/src/connector.ts +77 -0
- package/src/connectors/amber/README.md +40 -0
- package/src/connectors/amber/client.ts +157 -0
- package/src/connectors/amber/index.ts +118 -0
- package/src/connectors/cast/README.md +44 -0
- package/src/connectors/cast/codec.ts +126 -0
- package/src/connectors/cast/index.ts +552 -0
- package/src/connectors/cast/schemas.ts +101 -0
- package/src/connectors/cast/serve.ts +82 -0
- package/src/connectors/cast/state.ts +203 -0
- package/src/connectors/cast/tls.ts +100 -0
- package/src/connectors/cast/writes.ts +572 -0
- package/src/connectors/daikin/README.md +78 -0
- package/src/connectors/daikin/client.ts +295 -0
- package/src/connectors/daikin/index.ts +145 -0
- package/src/connectors/enphase/README.md +64 -0
- package/src/connectors/enphase/client.ts +244 -0
- package/src/connectors/enphase/index.ts +241 -0
- package/src/connectors/enphase/schemas.ts +45 -0
- package/src/connectors/hue/README.md +50 -0
- package/src/connectors/hue/client.ts +157 -0
- package/src/connectors/hue/events.ts +182 -0
- package/src/connectors/hue/index.ts +261 -0
- package/src/connectors/hue/schemas.ts +60 -0
- package/src/connectors/hue/state.ts +124 -0
- package/src/connectors/printers/README.md +46 -0
- package/src/connectors/printers/client.ts +114 -0
- package/src/connectors/printers/codec.ts +292 -0
- package/src/connectors/printers/found.ts +46 -0
- package/src/connectors/printers/index.ts +385 -0
- package/src/connectors/printers/state.ts +182 -0
- package/src/connectors/spotify/README.md +83 -0
- package/src/connectors/spotify/changes.ts +127 -0
- package/src/connectors/spotify/client.ts +387 -0
- package/src/connectors/spotify/index.ts +937 -0
- package/src/connectors/up/README.md +35 -0
- package/src/connectors/up/client.ts +518 -0
- package/src/connectors/up/index.ts +370 -0
- package/src/connectors/up/spend.ts +280 -0
- package/src/connectors/up/webhook.ts +62 -0
- package/src/connectors/weather/README.md +84 -0
- package/src/connectors/weather/blocks.ts +173 -0
- package/src/connectors/weather/bom.ts +344 -0
- package/src/connectors/weather/ftp.ts +48 -0
- package/src/connectors/weather/google.ts +425 -0
- package/src/connectors/weather/index.ts +396 -0
- package/src/connectors/weather/open-meteo.ts +309 -0
- package/src/connectors/weather/rain.ts +73 -0
- package/src/connectors/weather/source.ts +236 -0
- package/src/connectors/weather/sun.ts +114 -0
- package/src/discover.ts +638 -0
- package/src/energy.ts +221 -0
- package/src/finders-entry.ts +2 -0
- package/src/finders.ts +250 -0
- package/src/health.ts +74 -0
- package/src/home.ts +205 -0
- package/src/index.ts +62 -0
- package/src/lane.ts +110 -0
- package/src/locate.ts +85 -0
- package/src/manifest.ts +43 -0
- package/src/manifests.ts +27 -0
- package/src/node-fetch.ts +41 -0
- package/src/poll.ts +33 -0
package/src/cli/up.ts
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
// `home up …`: Up Bank's reads, and the two writes Up allows (a category, a tag), one transaction at a time.
|
|
2
|
+
// `home up` alone is the status line; nothing here runs from `home status`.
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { writePrivate } from "../finders.ts";
|
|
6
|
+
import {
|
|
7
|
+
type AccountFilter,
|
|
8
|
+
type Category,
|
|
9
|
+
dollars,
|
|
10
|
+
type Household,
|
|
11
|
+
inHousehold,
|
|
12
|
+
readTransactions,
|
|
13
|
+
recurring,
|
|
14
|
+
spendByCategory,
|
|
15
|
+
spendByMerchant,
|
|
16
|
+
type Transaction,
|
|
17
|
+
type TransactionQuery,
|
|
18
|
+
uncategorised,
|
|
19
|
+
up,
|
|
20
|
+
upClient,
|
|
21
|
+
} from "../connectors/up/index.ts";
|
|
22
|
+
import {
|
|
23
|
+
env,
|
|
24
|
+
fail,
|
|
25
|
+
homeTimeZone,
|
|
26
|
+
type Options,
|
|
27
|
+
printJson,
|
|
28
|
+
text,
|
|
29
|
+
usage,
|
|
30
|
+
type Verbs,
|
|
31
|
+
} from "./context.ts";
|
|
32
|
+
|
|
33
|
+
export const UP_USAGE =
|
|
34
|
+
"usage: home up transactions [--since <date>] [--until <date>] [--category <id>] [--tag <label>] [--status HELD|SETTLED] [--limit <n>] [--json|--csv] | categories | uncategorised | spend [--by category|merchant|recurring] | categorise <transaction id> <category id|none> | tag <transaction id> <label> [--remove] | webhooks [get|logs|ping|create|delete] [id|url]";
|
|
35
|
+
|
|
36
|
+
export const UP_OPTIONS: [string, string, { type: unknown[] }?][] = [
|
|
37
|
+
[
|
|
38
|
+
"--since <date>",
|
|
39
|
+
"from this date; uncategorised and spend default to 30 days ago, spend --by recurring to 180",
|
|
40
|
+
{ type: [String] },
|
|
41
|
+
],
|
|
42
|
+
[
|
|
43
|
+
"--until <date>",
|
|
44
|
+
"transactions, spend: before this date",
|
|
45
|
+
{ type: [String] },
|
|
46
|
+
],
|
|
47
|
+
[
|
|
48
|
+
"--category <id>",
|
|
49
|
+
"transactions: one category (a parent includes its children)",
|
|
50
|
+
{ type: [String] },
|
|
51
|
+
],
|
|
52
|
+
["--tag <label>", "transactions: one tag", { type: [String] }],
|
|
53
|
+
["--status <status>", "transactions: HELD or SETTLED", { type: [String] }],
|
|
54
|
+
["--limit <n>", "transactions: at most this many (default 100)"],
|
|
55
|
+
["--csv", "transactions: CSV"],
|
|
56
|
+
[
|
|
57
|
+
"--by <what>",
|
|
58
|
+
"spend: category (default), merchant or recurring",
|
|
59
|
+
{ type: [String] },
|
|
60
|
+
],
|
|
61
|
+
["--remove", "tag: take the tag off"],
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
type UpOptions = Options & {
|
|
65
|
+
since?: string;
|
|
66
|
+
until?: string;
|
|
67
|
+
category?: string;
|
|
68
|
+
tag?: string;
|
|
69
|
+
status?: string;
|
|
70
|
+
limit?: number | string;
|
|
71
|
+
csv?: boolean;
|
|
72
|
+
by?: string;
|
|
73
|
+
remove?: boolean;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** `UP_ACCOUNT` ("JOINT TRANSACTIONAL") as a filter; unset reads every account. */
|
|
77
|
+
function accountFromEnv(): AccountFilter | undefined {
|
|
78
|
+
const words = (env.UP_ACCOUNT ?? "")
|
|
79
|
+
.toUpperCase()
|
|
80
|
+
.split(/[\s,]+/)
|
|
81
|
+
.filter(Boolean);
|
|
82
|
+
if (!words.length) return undefined;
|
|
83
|
+
const filter: AccountFilter = {};
|
|
84
|
+
for (const w of words) {
|
|
85
|
+
if (w === "INDIVIDUAL" || w === "JOINT") filter.ownershipType = w;
|
|
86
|
+
else if (w === "TRANSACTIONAL" || w === "SAVER" || w === "HOME_LOAN")
|
|
87
|
+
filter.accountType = w;
|
|
88
|
+
else
|
|
89
|
+
fail(
|
|
90
|
+
`UP_ACCOUNT: "${w}" is not INDIVIDUAL, JOINT, TRANSACTIONAL, SAVER or HOME_LOAN`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
return filter;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const token = () => env.UP_TOKEN ?? fail("set UP_TOKEN", "auth");
|
|
97
|
+
const client = () => upClient({ token: token(), fetch });
|
|
98
|
+
|
|
99
|
+
/** Up as an integration from UP_TOKEN and UP_ACCOUNT, for `home status`. */
|
|
100
|
+
/** UP_HOUSEHOLD, e.g. "home groceries tag:home": category ids, and tags as tag:label. */
|
|
101
|
+
export function householdFromEnv(): Household | undefined {
|
|
102
|
+
const words = env.UP_HOUSEHOLD?.split(/[\s,]+/).filter(Boolean);
|
|
103
|
+
if (!words?.length) return undefined;
|
|
104
|
+
return {
|
|
105
|
+
categories: words.filter((w) => !w.startsWith("tag:")),
|
|
106
|
+
tags: words.filter((w) => w.startsWith("tag:")).map((w) => w.slice(4)),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const upFromEnv = () =>
|
|
111
|
+
up({
|
|
112
|
+
token: token(),
|
|
113
|
+
account: accountFromEnv(),
|
|
114
|
+
household: householdFromEnv(),
|
|
115
|
+
fetch,
|
|
116
|
+
timeZone: homeTimeZone(),
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
/** A date argument as ISO; "2026-10-01" is midnight in this machine's zone. */
|
|
120
|
+
const iso = (v: string | undefined, name: string) => {
|
|
121
|
+
if (v === undefined) return undefined;
|
|
122
|
+
const ms = Date.parse(/^\d{4}-\d{2}-\d{2}$/.test(v) ? `${v}T00:00` : v);
|
|
123
|
+
return Number.isNaN(ms)
|
|
124
|
+
? fail(`--${name}: not a date: ${v}`)
|
|
125
|
+
: new Date(ms).toISOString();
|
|
126
|
+
};
|
|
127
|
+
const daysAgo = (n: number) =>
|
|
128
|
+
new Date(Date.now() - n * 86_400_000).toISOString();
|
|
129
|
+
|
|
130
|
+
/** Every transaction in the accounts UP_ACCOUNT names, newest first; says so when a read stopped short. */
|
|
131
|
+
async function read(query: TransactionQuery): Promise<Transaction[]> {
|
|
132
|
+
const r = await readTransactions(client(), accountFromEnv(), query);
|
|
133
|
+
if (r.truncated)
|
|
134
|
+
console.error(
|
|
135
|
+
"home: more transactions than the limit; this list is cut short",
|
|
136
|
+
);
|
|
137
|
+
return r.transactions;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** One row as printed: the fields a person reads, nothing about who spent or from which account. */
|
|
141
|
+
const row = (t: Transaction) => ({
|
|
142
|
+
id: t.id,
|
|
143
|
+
at: t.createdAt,
|
|
144
|
+
description: t.description,
|
|
145
|
+
amount: t.amountCents === undefined ? undefined : t.amountCents / 100,
|
|
146
|
+
category: t.categoryId,
|
|
147
|
+
status: t.status,
|
|
148
|
+
tags: t.tags.join(" "),
|
|
149
|
+
});
|
|
150
|
+
const csvCell = (v: unknown) =>
|
|
151
|
+
v === undefined
|
|
152
|
+
? ""
|
|
153
|
+
: /[",\n]/.test(String(v))
|
|
154
|
+
? `"${String(v).replace(/"/g, '""')}"`
|
|
155
|
+
: String(v);
|
|
156
|
+
const line = (t: Transaction) =>
|
|
157
|
+
`${(t.createdAt ?? "").slice(0, 10)} ${(t.amountCents === undefined ? "?" : dollars(t.amountCents, false)).padStart(11)} ${(t.description ?? "").padEnd(32).slice(0, 32)} ${(t.categoryId ?? "-").padEnd(24)} ${t.status === "HELD" ? "held " : ""}${t.id}`;
|
|
158
|
+
|
|
159
|
+
/** The category tree: each parent, then its children, with ids. */
|
|
160
|
+
function tree(categories: Category[]): string[] {
|
|
161
|
+
return categories
|
|
162
|
+
.filter((c) => !c.parentId)
|
|
163
|
+
.flatMap((p) => [
|
|
164
|
+
`${p.id} ${p.name ?? ""}`,
|
|
165
|
+
...categories
|
|
166
|
+
.filter((c) => c.parentId === p.id)
|
|
167
|
+
.map((c) => ` ${c.id.padEnd(36)} ${c.name ?? ""}`),
|
|
168
|
+
]);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** A transaction before and after a write, on two lines. */
|
|
172
|
+
const beforeAfter = (before: Transaction, after: Transaction) => {
|
|
173
|
+
console.log(
|
|
174
|
+
`before ${line(before)} tags: ${before.tags.join(", ") || "-"}`,
|
|
175
|
+
);
|
|
176
|
+
console.log(`after ${line(after)} tags: ${after.tags.join(", ") || "-"}`);
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
export const upVerbs: Verbs = {
|
|
180
|
+
async transactions(_args, o: UpOptions) {
|
|
181
|
+
const status = text(o.status)?.toUpperCase();
|
|
182
|
+
if (status !== undefined && status !== "HELD" && status !== "SETTLED")
|
|
183
|
+
usage(UP_USAGE);
|
|
184
|
+
const limit = Number(text(o.limit) ?? 100);
|
|
185
|
+
if (!Number.isInteger(limit) || limit < 1)
|
|
186
|
+
fail(`--limit: a whole number above 0, not ${text(o.limit)}`);
|
|
187
|
+
const txs = await read({
|
|
188
|
+
since: iso(text(o.since), "since"),
|
|
189
|
+
until: iso(text(o.until), "until"),
|
|
190
|
+
category: text(o.category),
|
|
191
|
+
tag: text(o.tag),
|
|
192
|
+
status: status as "HELD" | "SETTLED" | undefined,
|
|
193
|
+
limit,
|
|
194
|
+
});
|
|
195
|
+
if (o.json) return printJson(txs.map(row));
|
|
196
|
+
if (o.csv) {
|
|
197
|
+
const cols = [
|
|
198
|
+
"id",
|
|
199
|
+
"at",
|
|
200
|
+
"description",
|
|
201
|
+
"amount",
|
|
202
|
+
"category",
|
|
203
|
+
"status",
|
|
204
|
+
"tags",
|
|
205
|
+
] as const;
|
|
206
|
+
return console.log(
|
|
207
|
+
[
|
|
208
|
+
cols.join(","),
|
|
209
|
+
...txs.map((t) => cols.map((k) => csvCell(row(t)[k])).join(",")),
|
|
210
|
+
].join("\n"),
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
for (const t of txs) console.log(line(t));
|
|
214
|
+
},
|
|
215
|
+
async categories(_args, o) {
|
|
216
|
+
const categories = await client().categories();
|
|
217
|
+
if (o.json) return printJson(categories);
|
|
218
|
+
for (const l of tree(categories)) console.log(l);
|
|
219
|
+
},
|
|
220
|
+
async uncategorised(_args, o: UpOptions) {
|
|
221
|
+
const txs = uncategorised(
|
|
222
|
+
await read({
|
|
223
|
+
since: iso(text(o.since), "since") ?? daysAgo(30),
|
|
224
|
+
status: "SETTLED",
|
|
225
|
+
}),
|
|
226
|
+
);
|
|
227
|
+
if (o.json) return printJson(txs.map(row));
|
|
228
|
+
if (!txs.length) return console.error("home: nothing to categorise");
|
|
229
|
+
for (const t of txs) console.log(line(t));
|
|
230
|
+
},
|
|
231
|
+
async spend(_args, o: UpOptions) {
|
|
232
|
+
const by = text(o.by) ?? "category";
|
|
233
|
+
if (by !== "category" && by !== "merchant" && by !== "recurring")
|
|
234
|
+
usage(UP_USAGE);
|
|
235
|
+
// Recurring needs three distinct months, so its default window is six.
|
|
236
|
+
const window = {
|
|
237
|
+
since:
|
|
238
|
+
iso(text(o.since), "since") ?? daysAgo(by === "recurring" ? 180 : 30),
|
|
239
|
+
until: iso(text(o.until), "until"),
|
|
240
|
+
};
|
|
241
|
+
const household = householdFromEnv();
|
|
242
|
+
const txs = (
|
|
243
|
+
await read({ ...window, status: "SETTLED", limit: 5000, pageCap: 60 })
|
|
244
|
+
).filter((t) => inHousehold(t, household));
|
|
245
|
+
if (by === "merchant") {
|
|
246
|
+
const list = spendByMerchant(txs);
|
|
247
|
+
if (o.json) return printJson(list);
|
|
248
|
+
for (const m of list.slice(0, 30))
|
|
249
|
+
console.log(
|
|
250
|
+
`${dollars(m.cents).padStart(9)} ${m.count.toString().padStart(3)}× ${m.merchant}`,
|
|
251
|
+
);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (by === "recurring") {
|
|
255
|
+
const list = recurring(txs);
|
|
256
|
+
if (o.json) return printJson(list);
|
|
257
|
+
console.error(
|
|
258
|
+
"home: a heuristic: the same merchant in three or more months at a similar amount",
|
|
259
|
+
);
|
|
260
|
+
for (const r of list)
|
|
261
|
+
console.log(
|
|
262
|
+
`${dollars(r.medianCents, false).padStart(10)} ${r.months} months ${r.merchant}`,
|
|
263
|
+
);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (by !== "category") usage(UP_USAGE);
|
|
267
|
+
const s = spendByCategory(txs, await client().categories());
|
|
268
|
+
if (o.json) return printJson(s);
|
|
269
|
+
console.log(
|
|
270
|
+
`${dollars(s.cents)} spent in ${s.count} purchases since ${window.since.slice(0, 10)}; ${dollars(s.excluded.cents)} in ${s.excluded.count} transfers and round-ups left out`,
|
|
271
|
+
);
|
|
272
|
+
for (const p of s.byCategory) {
|
|
273
|
+
console.log(`${dollars(p.cents).padStart(9)} ${p.name ?? p.id}`);
|
|
274
|
+
for (const c of p.children)
|
|
275
|
+
console.log(`${dollars(c.cents).padStart(9)} ${c.name ?? c.id}`);
|
|
276
|
+
}
|
|
277
|
+
if (s.uncategorisedCents)
|
|
278
|
+
console.log(
|
|
279
|
+
`${dollars(s.uncategorisedCents).padStart(9)} uncategorised`,
|
|
280
|
+
);
|
|
281
|
+
},
|
|
282
|
+
|
|
283
|
+
/** A write: one transaction's category, or `none` to clear it. Only a child category can be set. */
|
|
284
|
+
async categorise([txId, category]) {
|
|
285
|
+
if (!txId || !category) usage(UP_USAGE);
|
|
286
|
+
const c = client();
|
|
287
|
+
const before = await c.transaction(txId!);
|
|
288
|
+
beforeAfter(
|
|
289
|
+
before,
|
|
290
|
+
await c.setCategory(txId!, category === "none" ? null : category!),
|
|
291
|
+
);
|
|
292
|
+
},
|
|
293
|
+
/** A write: add one tag to a transaction, or take it off with --remove. */
|
|
294
|
+
async tag([txId, label], o: UpOptions) {
|
|
295
|
+
if (!txId || !label) usage(UP_USAGE);
|
|
296
|
+
const c = client();
|
|
297
|
+
const before = await c.transaction(txId!);
|
|
298
|
+
beforeAfter(
|
|
299
|
+
before,
|
|
300
|
+
o.remove
|
|
301
|
+
? await c.removeTags(txId!, [label!])
|
|
302
|
+
: await c.addTags(txId!, [label!]),
|
|
303
|
+
);
|
|
304
|
+
},
|
|
305
|
+
|
|
306
|
+
/** Webhooks: list, get, logs and ping read or test; create and delete are writes. */
|
|
307
|
+
async webhooks([verb, arg], o) {
|
|
308
|
+
const c = client();
|
|
309
|
+
if (verb === undefined) {
|
|
310
|
+
const hooks = await c.webhooks();
|
|
311
|
+
if (o.json) return printJson(hooks);
|
|
312
|
+
if (!hooks.length) return console.error("home: no webhooks");
|
|
313
|
+
for (const h of hooks)
|
|
314
|
+
console.log(`${h.id} ${h.url ?? "?"} ${h.description ?? ""}`);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (!arg) usage(UP_USAGE);
|
|
318
|
+
if (verb === "get") {
|
|
319
|
+
const { secretKey: _, ...hook } = await c.webhook(arg!); // Up returns the secret only on creation; never print one
|
|
320
|
+
return printJson(hook);
|
|
321
|
+
}
|
|
322
|
+
if (verb === "logs") {
|
|
323
|
+
for (const l of await c.webhookLogs(arg!))
|
|
324
|
+
console.log(
|
|
325
|
+
`${l.createdAt ?? "?"} ${l.deliveryStatus ?? "?"} ${l.statusCode ?? ""}`,
|
|
326
|
+
);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (verb === "ping")
|
|
330
|
+
return (await c.pingWebhook(arg!), console.log("ping sent"));
|
|
331
|
+
if (verb === "delete")
|
|
332
|
+
return (await c.deleteWebhook(arg!), console.log(`deleted ${arg}`));
|
|
333
|
+
if (verb === "create") {
|
|
334
|
+
const h = await c.createWebhook(arg!, "home");
|
|
335
|
+
// Up returns the secret this once. It goes to an owner-only file, never the terminal.
|
|
336
|
+
if (!h.secretKey)
|
|
337
|
+
return console.log(`created ${h.id}; Up returned no secret`);
|
|
338
|
+
const path = join(
|
|
339
|
+
homedir(),
|
|
340
|
+
".config",
|
|
341
|
+
"home",
|
|
342
|
+
`up-webhook-${h.id}.secret`,
|
|
343
|
+
);
|
|
344
|
+
writePrivate(path, `${h.secretKey}\n`);
|
|
345
|
+
console.log(`created ${h.id}; its secret is in ${path} (0600)`);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
usage(UP_USAGE);
|
|
349
|
+
},
|
|
350
|
+
};
|
package/src/clock.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** Time as a parameter, so queues and backoff run in tests without waiting. */
|
|
2
|
+
export type Clock = {
|
|
3
|
+
now(): number;
|
|
4
|
+
sleep(ms: number, signal?: AbortSignal): Promise<void>;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
/** The wall clock. `sleep` resolves early when `signal` aborts. */
|
|
8
|
+
export const realClock: Clock = {
|
|
9
|
+
now: () => Date.now(),
|
|
10
|
+
sleep: (ms, signal) =>
|
|
11
|
+
new Promise((resolve) => {
|
|
12
|
+
const done = () => (
|
|
13
|
+
clearTimeout(timer),
|
|
14
|
+
signal?.removeEventListener("abort", done),
|
|
15
|
+
resolve()
|
|
16
|
+
);
|
|
17
|
+
const timer = setTimeout(done, ms);
|
|
18
|
+
signal?.addEventListener("abort", done, { once: true });
|
|
19
|
+
}),
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** The calendar day at `ms` in an IANA time zone (the runtime's when left out), as YYYY-MM-DD: where "today" starts. */
|
|
23
|
+
export const localDay = (ms: number, timeZone?: string) =>
|
|
24
|
+
new Intl.DateTimeFormat("en-CA", {
|
|
25
|
+
timeZone,
|
|
26
|
+
year: "numeric",
|
|
27
|
+
month: "2-digit",
|
|
28
|
+
day: "2-digit",
|
|
29
|
+
}).format(ms);
|
|
30
|
+
|
|
31
|
+
/** The hour of the day (0–23) at `ms` in an IANA time zone, the runtime's when left out. */
|
|
32
|
+
export const localHour = (ms: number, timeZone?: string) =>
|
|
33
|
+
Number(
|
|
34
|
+
new Intl.DateTimeFormat("en-GB", {
|
|
35
|
+
timeZone,
|
|
36
|
+
hour: "2-digit",
|
|
37
|
+
hourCycle: "h23",
|
|
38
|
+
}).format(ms),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
/** "20:15" at `ms` in an IANA time zone, the runtime's when left out. */
|
|
42
|
+
export const localTime = (ms: number, timeZone?: string) =>
|
|
43
|
+
new Intl.DateTimeFormat("en-GB", {
|
|
44
|
+
timeZone,
|
|
45
|
+
hour: "2-digit",
|
|
46
|
+
minute: "2-digit",
|
|
47
|
+
hourCycle: "h23",
|
|
48
|
+
}).format(ms);
|
|
49
|
+
|
|
50
|
+
/** Whether a name is an IANA time zone this runtime knows. */
|
|
51
|
+
export function isTimeZone(name: string): boolean {
|
|
52
|
+
try {
|
|
53
|
+
new Intl.DateTimeFormat("en", { timeZone: name });
|
|
54
|
+
return true;
|
|
55
|
+
} catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The clock's now as an ISO string: the `asOf` every snapshot carries. */
|
|
61
|
+
export const asOf = (clock: Pick<Clock, "now">) =>
|
|
62
|
+
new Date(clock.now()).toISOString();
|