@gouthamjm1997/dayflow 0.2.0 → 0.2.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/README.md +1 -1
- package/docs/okr-plan.md +48 -0
- package/package.json +2 -1
- package/scripts/dayflow.mjs +40 -15
- package/src/dayflow-cli-auth.mjs +47 -0
- package/src/okr.mjs +23 -5
- package/src/task-lists.mjs +2 -1
package/README.md
CHANGED
|
@@ -58,7 +58,7 @@ Use `goals update`, `kr-update`, `archive`, `restore`, or `kr-delete` to maintai
|
|
|
58
58
|
|
|
59
59
|
## Dayflow CLI for Hermes
|
|
60
60
|
|
|
61
|
-
The local CLI can read and update the signed-in Supabase user's Dayflow record. Run `npm run dayflow -- auth login` once; finish Google sign-in in the browser. The refresh token is kept in this machine's credential store (Windows Credential Manager on Windows), not in the repository. CLI requests use the publishable key plus the user's refreshed session and remain subject to the table's RLS policies; no service-role key is used.
|
|
61
|
+
The local CLI can read and update the signed-in Supabase user's Dayflow record. Run `npm run dayflow -- auth login` once; finish Google sign-in in the browser. The refresh token is kept in this machine's credential store (Windows Credential Manager on Windows), not in the repository. On a machine without a usable credential store (a container, server, or CI runner), the token is saved to the Dayflow config file automatically so the login still survives across runs; keep that file private. CLI requests use the publishable key plus the user's refreshed session and remain subject to the table's RLS policies; no service-role key is used.
|
|
62
62
|
|
|
63
63
|
Examples: `npm run dayflow -- tasks list`, `npm run dayflow -- tasks list --list Work`, `npm run dayflow -- tasks add "Prepare project update" --list "Deep work"`, `npm run dayflow -- tasks complete <task-id>`, `npm run dayflow -- lists add "Errands"`, `npm run dayflow -- lists rename <list-id> --name "Home"`, `npm run dayflow -- workouts log --kind Walk --minutes 30 --date 2026-10-02`, and `npm run dayflow -- focus log --minutes 25 --tag "Deep Work"`. Focus logs accept `--tag`, `--seconds`, and `--timer-type countdown|stopwatch`. Task commands accept `--list` with either a list id or its exact name. Sign out with `npm run dayflow -- auth logout`. Sign-in needs `http://localhost:3000` to be free and allowlisted in Supabase.
|
|
64
64
|
|
package/docs/okr-plan.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Dayflow Goals: implementation plan
|
|
2
|
+
|
|
3
|
+
## Framework
|
|
4
|
+
|
|
5
|
+
An objective describes what to achieve. Key results define measurable, verifiable outcomes within a time period; a typical cycle is a quarter, and 3–5 key results support one objective.[1] Dayflow also supports monthly cycles as a shorter personal planning option.
|
|
6
|
+
|
|
7
|
+
## Product choices
|
|
8
|
+
|
|
9
|
+
- Add a Goals destination beside Plan and Focus. Keep the existing task lists; lists organize work, while goal links explain why the work matters.
|
|
10
|
+
- Use one hierarchy: monthly/quarterly objective → measurable key result → daily task. Start with a few objectives rather than making every task its own goal.
|
|
11
|
+
- An objective has a title, optional context, cycle type and period. It can be edited, archived and restored without deleting task history.
|
|
12
|
+
- A key result has an outcome description, unit, baseline, target and current measurement. Support both increasing and decreasing targets, including zero and decimals.
|
|
13
|
+
- Check-ins record an absolute measurement, timestamp and optional evidence/note. Suggest reviewing weekly. A task being completed is not evidence that the measured outcome improved.
|
|
14
|
+
- Show each key result's progress from baseline toward target, capped between 0% and 100%. Objective progress is an equal-weight average of its key results; this is a Dayflow display convention, not a universal OKR grading rule.
|
|
15
|
+
- Show linked tasks below each key result and let the user add another task with that result preselected.
|
|
16
|
+
|
|
17
|
+
## Task alignment
|
|
18
|
+
|
|
19
|
+
- Add an alignment selector to task creation and existing task rows: a key result, Chore / routine, or Needs alignment.
|
|
20
|
+
- Treat chores as an explicit user choice, never a guess based on task title, tag or list name.
|
|
21
|
+
- Keep task capture flexible. An unlinked non-chore task is visibly pending alignment, not silently treated as strategic work.
|
|
22
|
+
- Goals includes an alignment queue of all open non-chore tasks without a valid active key result. It spans every task list, including tasks linked to archived objectives.
|
|
23
|
+
- Existing tasks remain unchanged until classified. No personal goals, metrics or task links are seeded or inferred.
|
|
24
|
+
- Marking a task as a chore clears its goal link; linking a task to a key result removes its chore exemption.
|
|
25
|
+
- Archiving an objective preserves links and measurements. Removing a key result preserves tasks but returns them to Needs alignment.
|
|
26
|
+
|
|
27
|
+
## Persistence and compatibility
|
|
28
|
+
|
|
29
|
+
Use the existing local-first per-user Supabase JSON record; no new public table or expanded permission is needed. UI and CLI share the OKR mutation module. Preserve unrelated data when either client writes. Legacy records without the optional objectives field remain readable with unchanged legacy serialization; the field is initialized when the user begins using Goals.
|
|
30
|
+
|
|
31
|
+
## Acceptance checks
|
|
32
|
+
|
|
33
|
+
1. Create, edit, archive and restore monthly/quarterly objectives.
|
|
34
|
+
2. Create and edit numeric key results; check in and review measurement history.
|
|
35
|
+
3. Correct quarter/month date boundaries and progress for ascending/descending metrics.
|
|
36
|
+
4. Link, relink and exempt tasks; show the correct alignment queue across lists.
|
|
37
|
+
5. Task completion does not modify key-result measurements.
|
|
38
|
+
6. Reload and signed-in JSON serialization preserve objectives, links and unrelated history.
|
|
39
|
+
7. Desktop and mobile forms/navigation fit and remain usable.
|
|
40
|
+
8. Existing tests stay intact; add new feature coverage and run the complete suite/build.
|
|
41
|
+
|
|
42
|
+
## Suggested use, not preloaded data
|
|
43
|
+
|
|
44
|
+
A quarterly objective might be “Communicate technical decisions clearly.” A key result could be “Raise feedback clarity score from 3 to 4.5 out of 5”; a task such as “Draft and rehearse the architecture walkthrough” contributes to it. “Buy groceries” can be marked Chore / routine. These are illustrations, not automatically created goals or asserted personal measurements.
|
|
45
|
+
|
|
46
|
+
## Sources
|
|
47
|
+
|
|
48
|
+
[1] https://www.whatmatters.com/faqs/okr-meaning-definition-example — What Matters: OKR definition and examples
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gouthamjm1997/dayflow",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Dayflow CLI — plan tasks, log focus sessions, and record gym sets against your own Supabase Dayflow project.",
|
|
6
6
|
"type": "module",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"src/task-lists.mjs",
|
|
18
18
|
"src/okr.mjs",
|
|
19
19
|
"src/supabase-data.mjs",
|
|
20
|
+
"docs/okr-plan.md",
|
|
20
21
|
".env.example",
|
|
21
22
|
"README.md"
|
|
22
23
|
],
|
package/scripts/dayflow.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { dirname, join, resolve } from 'node:path'
|
|
|
7
7
|
import { fileURLToPath } from 'node:url'
|
|
8
8
|
import { createClient } from '@supabase/supabase-js'
|
|
9
9
|
import {
|
|
10
|
+
createConfigFileStore,
|
|
10
11
|
createRefreshTokenStore,
|
|
11
12
|
exchangeDayflowCode,
|
|
12
13
|
extractAuthCode,
|
|
@@ -22,35 +23,58 @@ const CONFIG_PATH = process.env.DAYFLOW_CONFIG || join(homedir(), '.dayflow', 'c
|
|
|
22
23
|
const IGNORE_KEYRING = process.env.DAYFLOW_IGNORE_KEYRING === '1'
|
|
23
24
|
const MANUAL_TIMEOUT_MS = 15 * 60 * 1000
|
|
24
25
|
const tokenStore = createRefreshTokenStore()
|
|
26
|
+
const configStore = createConfigFileStore(CONFIG_PATH)
|
|
25
27
|
const repoRoot = dirname(dirname(fileURLToPath(import.meta.url)))
|
|
26
28
|
|
|
27
|
-
let
|
|
29
|
+
let storeLocation = 'keyring'
|
|
28
30
|
|
|
29
|
-
// Containers and cloud sandboxes often have no usable credential store.
|
|
30
|
-
//
|
|
31
|
+
// Containers and cloud sandboxes often have no usable credential store. When the keyring cannot
|
|
32
|
+
// hold the token, fall back to the config file so the sign-in still survives across runs; only if
|
|
33
|
+
// both fail does the session die with the machine.
|
|
31
34
|
const sessionStore = {
|
|
32
35
|
get() {
|
|
33
|
-
|
|
36
|
+
if (!IGNORE_KEYRING) {
|
|
37
|
+
const stored = tokenStore.get()
|
|
38
|
+
if (stored) return stored
|
|
39
|
+
}
|
|
40
|
+
return configStore.get()
|
|
34
41
|
},
|
|
35
42
|
set(token) {
|
|
36
|
-
if (IGNORE_KEYRING) {
|
|
37
|
-
|
|
38
|
-
|
|
43
|
+
if (!IGNORE_KEYRING) {
|
|
44
|
+
try {
|
|
45
|
+
tokenStore.set(token)
|
|
46
|
+
storeLocation = 'keyring'
|
|
47
|
+
return
|
|
48
|
+
} catch {
|
|
49
|
+
// No usable credential store: persist to the config file instead.
|
|
50
|
+
}
|
|
39
51
|
}
|
|
40
52
|
try {
|
|
41
|
-
|
|
53
|
+
configStore.set(token)
|
|
54
|
+
storeLocation = 'config'
|
|
42
55
|
} catch {
|
|
43
|
-
|
|
56
|
+
storeLocation = 'memory'
|
|
44
57
|
}
|
|
45
58
|
},
|
|
46
59
|
clear() {
|
|
47
|
-
|
|
60
|
+
const cleared = tokenStore.clear()
|
|
61
|
+
configStore.clear()
|
|
62
|
+
return cleared
|
|
48
63
|
},
|
|
49
64
|
}
|
|
50
65
|
|
|
66
|
+
function storeDescription() {
|
|
67
|
+
if (storeLocation === 'config') return 'the Dayflow config file (this machine has no credential store)'
|
|
68
|
+
if (storeLocation === 'memory') return 'memory for this run only'
|
|
69
|
+
return "this machine's credential store"
|
|
70
|
+
}
|
|
71
|
+
|
|
51
72
|
function warnIfStoreUnavailable() {
|
|
52
|
-
if (
|
|
53
|
-
|
|
73
|
+
if (storeLocation === 'config') {
|
|
74
|
+
process.stderr.write('Note: no usable credential store on this machine, so the refresh token was saved to the Dayflow config file in plain text. Keep that file private.\n')
|
|
75
|
+
} else if (storeLocation === 'memory') {
|
|
76
|
+
process.stderr.write('Note: this machine has no usable credential store and the config file could not be written, so the login only lasts for this run. Set DAYFLOW_REFRESH_TOKEN to sign in without a browser.\n')
|
|
77
|
+
}
|
|
54
78
|
}
|
|
55
79
|
|
|
56
80
|
function readConfigFile() {
|
|
@@ -171,7 +195,7 @@ async function manualLogin(config) {
|
|
|
171
195
|
const code = await readManualCode()
|
|
172
196
|
await exchangeDayflowCode(attempt.authClient, code, sessionStore)
|
|
173
197
|
warnIfStoreUnavailable()
|
|
174
|
-
process.stdout.write(`Dayflow CLI is signed in. The refresh token is kept in ${
|
|
198
|
+
process.stdout.write(`Dayflow CLI is signed in. The refresh token is kept in ${storeDescription()}.\n`)
|
|
175
199
|
}
|
|
176
200
|
|
|
177
201
|
// Reads one line from stdin. Deliberately not readline: readline attaches its own listener in the
|
|
@@ -276,7 +300,7 @@ async function login(config) {
|
|
|
276
300
|
})
|
|
277
301
|
})
|
|
278
302
|
warnIfStoreUnavailable()
|
|
279
|
-
process.stdout.write(`Dayflow CLI is signed in. The refresh token is kept in ${
|
|
303
|
+
process.stdout.write(`Dayflow CLI is signed in. The refresh token is kept in ${storeDescription()}.\n`)
|
|
280
304
|
}
|
|
281
305
|
|
|
282
306
|
function printHelp() {
|
|
@@ -315,6 +339,7 @@ function printHelp() {
|
|
|
315
339
|
' --manual print the sign-in URL, then paste the callback URL back (servers, containers, no browser)',
|
|
316
340
|
' DAYFLOW_REFRESH_TOKEN (env) or "refreshToken" in the config file signs in with no browser at all.',
|
|
317
341
|
' DAYFLOW_IGNORE_KEYRING=1 ignores the machine credential store and uses only those tokens.',
|
|
342
|
+
' Without a credential store (containers, servers), the login is saved to the config file automatically.',
|
|
318
343
|
'',
|
|
319
344
|
'Config: NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY,',
|
|
320
345
|
`or ${CONFIG_PATH} holding {"url": "...", "publishableKey": "..."} (override with DAYFLOW_CONFIG).`,
|
|
@@ -327,7 +352,7 @@ async function createSession(config) {
|
|
|
327
352
|
const candidates = orderRefreshTokenCandidates({
|
|
328
353
|
storedToken: sessionStore.get(),
|
|
329
354
|
envToken: process.env.DAYFLOW_REFRESH_TOKEN,
|
|
330
|
-
configToken:
|
|
355
|
+
configToken: configStore.get(),
|
|
331
356
|
})
|
|
332
357
|
if (!candidates.length) {
|
|
333
358
|
throw new Error('No Dayflow login is stored. Run dayflow auth login (add --manual when this machine has no browser), or set DAYFLOW_REFRESH_TOKEN.')
|
package/src/dayflow-cli-auth.mjs
CHANGED
|
@@ -1,4 +1,51 @@
|
|
|
1
1
|
import { Entry } from '@napi-rs/keyring'
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { dirname } from 'node:path'
|
|
4
|
+
|
|
5
|
+
// A refresh-token store that persists into the JSON config file. The config file is already a
|
|
6
|
+
// fallback read source for browserless machines; this makes it a write target too, so a sign-in on
|
|
7
|
+
// a machine without a credential store survives across runs instead of dying with the process.
|
|
8
|
+
export function createConfigFileStore(configPath) {
|
|
9
|
+
const readRaw = () => {
|
|
10
|
+
try { return readFileSync(configPath, 'utf8') } catch { return '' }
|
|
11
|
+
}
|
|
12
|
+
const parse = () => {
|
|
13
|
+
const raw = readRaw()
|
|
14
|
+
if (!raw.trim()) return {}
|
|
15
|
+
let parsed
|
|
16
|
+
try {
|
|
17
|
+
parsed = JSON.parse(raw)
|
|
18
|
+
} catch {
|
|
19
|
+
throw new Error('Dayflow config file is not valid JSON.')
|
|
20
|
+
}
|
|
21
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
22
|
+
throw new Error('Dayflow config file is not a JSON object.')
|
|
23
|
+
}
|
|
24
|
+
return parsed
|
|
25
|
+
}
|
|
26
|
+
const write = (update) => {
|
|
27
|
+
const next = update(parse())
|
|
28
|
+
mkdirSync(dirname(configPath), { recursive: true })
|
|
29
|
+
writeFileSync(configPath, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 })
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
get() {
|
|
33
|
+
try { return parse().refreshToken ?? null } catch { return null }
|
|
34
|
+
},
|
|
35
|
+
set(token) {
|
|
36
|
+
if (!token) throw new Error('Cannot store an empty Dayflow refresh token.')
|
|
37
|
+
write((config) => { config.refreshToken = token; return config })
|
|
38
|
+
},
|
|
39
|
+
clear() {
|
|
40
|
+
try {
|
|
41
|
+
write((config) => { delete config.refreshToken; return config })
|
|
42
|
+
} catch {
|
|
43
|
+
return false
|
|
44
|
+
}
|
|
45
|
+
return true
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
}
|
|
2
49
|
|
|
3
50
|
export function createRefreshTokenStore(entry = new Entry('Dayflow CLI', 'supabase-refresh-token')) {
|
|
4
51
|
return {
|
package/src/okr.mjs
CHANGED
|
@@ -33,7 +33,13 @@ export function currentPeriod(cycleType, now = new Date()) {
|
|
|
33
33
|
export function keyResultProgress(keyResult) {
|
|
34
34
|
const { baseline, current, target } = keyResult ?? {}
|
|
35
35
|
if (![baseline, current, target].every(Number.isFinite) || baseline === target) return 0
|
|
36
|
-
const
|
|
36
|
+
const change = current - baseline
|
|
37
|
+
const distance = target - baseline
|
|
38
|
+
// Scaling avoids Infinity / Infinity for opposite finite endpoints near MAX_VALUE.
|
|
39
|
+
const scale = Math.max(Math.abs(baseline), Math.abs(current), Math.abs(target))
|
|
40
|
+
const progress = Number.isFinite(change) && Number.isFinite(distance)
|
|
41
|
+
? change / distance
|
|
42
|
+
: (current / scale - baseline / scale) / (target / scale - baseline / scale)
|
|
37
43
|
return Math.round(Math.max(0, Math.min(1, progress)) * 100)
|
|
38
44
|
}
|
|
39
45
|
|
|
@@ -46,6 +52,11 @@ export function objectiveProgress(objective) {
|
|
|
46
52
|
|
|
47
53
|
const isValidId = (value) => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9_-]{0,199}$/.test(value)
|
|
48
54
|
|
|
55
|
+
function validId(value) {
|
|
56
|
+
if (!isValidId(value)) throw new Error('ID must be a nonempty identifier of at most 200 letters, digits, hyphens or underscores.')
|
|
57
|
+
return value
|
|
58
|
+
}
|
|
59
|
+
|
|
49
60
|
/** Read-only lookup keeps the original record references. */
|
|
50
61
|
export function findKeyResult(objectives, id) {
|
|
51
62
|
if (!isValidId(id)) return null
|
|
@@ -104,11 +115,15 @@ function text(value, label, limit, required = false) {
|
|
|
104
115
|
/** Pure mutations return a detached payload; callers own persistence. */
|
|
105
116
|
export function mutateGoals(payload, operation, input, options = {}) {
|
|
106
117
|
if (!isRecord(input)) throw new Error('Goal input must be an object.')
|
|
118
|
+
if (['update_objective', 'archive_objective', 'update_key_result', 'check_in_key_result', 'delete_key_result', 'align_task'].includes(operation)) {
|
|
119
|
+
validId(input.id)
|
|
120
|
+
}
|
|
121
|
+
if (operation === 'add_key_result') validId(input.objectiveId)
|
|
107
122
|
const source = isRecord(payload) ? payload : {}
|
|
108
123
|
const state = structuredClone(source)
|
|
109
124
|
state.objectives = normalizeObjectives(source.objectives)
|
|
110
125
|
state.tasks = Array.isArray(state.tasks) ? state.tasks : []
|
|
111
|
-
const makeId = options.makeId ?? (() => globalThis.crypto.randomUUID())
|
|
126
|
+
const makeId = () => validId((options.makeId ?? (() => globalThis.crypto.randomUUID()))())
|
|
112
127
|
const timestamp = () => {
|
|
113
128
|
const now = typeof options.now === 'function' ? options.now() : (options.now ?? new Date())
|
|
114
129
|
if (!(now instanceof Date) || !Number.isFinite(now.getTime())) throw new Error('Current date must be valid.')
|
|
@@ -133,8 +148,8 @@ export function mutateGoals(payload, operation, input, options = {}) {
|
|
|
133
148
|
if (input.title !== undefined) objective.title = text(input.title, 'Objective title', 200, true)
|
|
134
149
|
if (input.description !== undefined) objective.description = text(input.description, 'Objective description', 1000)
|
|
135
150
|
if (input.cycleType !== undefined || input.period !== undefined) {
|
|
136
|
-
const cycleType = input.cycleType
|
|
137
|
-
const period = input.period
|
|
151
|
+
const cycleType = input.cycleType !== undefined ? input.cycleType : objective.cycleType
|
|
152
|
+
const period = input.period !== undefined ? input.period : objective.period
|
|
138
153
|
Object.assign(objective, { cycleType, period }, cycleDates(cycleType, period))
|
|
139
154
|
}
|
|
140
155
|
if (input.status !== undefined) {
|
|
@@ -171,7 +186,9 @@ export function mutateGoals(payload, operation, input, options = {}) {
|
|
|
171
186
|
if (input.title !== undefined) keyResult.title = text(input.title, 'Key result title', 200, true)
|
|
172
187
|
if (input.baseline !== undefined) keyResult.baseline = finiteNumber(input.baseline, 'Baseline')
|
|
173
188
|
if (input.target !== undefined) keyResult.target = finiteNumber(input.target, 'Target')
|
|
174
|
-
if (keyResult.baseline === keyResult.target
|
|
189
|
+
if (finiteNumber(keyResult.baseline, 'Baseline') === finiteNumber(keyResult.target, 'Target')) {
|
|
190
|
+
throw new Error('Target must differ from baseline.')
|
|
191
|
+
}
|
|
175
192
|
if (input.unit !== undefined) keyResult.unit = text(input.unit, 'Unit', 32)
|
|
176
193
|
return { payload: state, result: keyResult }
|
|
177
194
|
}
|
|
@@ -215,6 +232,7 @@ export function mutateGoals(payload, operation, input, options = {}) {
|
|
|
215
232
|
}
|
|
216
233
|
if (hasChore) task.isChore = input.isChore
|
|
217
234
|
if (hasKeyResult && !unlink) {
|
|
235
|
+
validId(input.keyResultId)
|
|
218
236
|
const found = findKeyResult(state.objectives, input.keyResultId)
|
|
219
237
|
if (!found) throw new Error('Key result was not found.')
|
|
220
238
|
activeObjective(found.objective)
|
package/src/task-lists.mjs
CHANGED
|
@@ -31,7 +31,8 @@ export function attachTaskListIds(tasks, lists) {
|
|
|
31
31
|
const fallback = normalizedLists[0].id
|
|
32
32
|
return (Array.isArray(tasks) ? tasks : []).map((task) => {
|
|
33
33
|
const listId = String(task?.listId ?? '').trim()
|
|
34
|
-
|
|
34
|
+
const canonicalListId = known.has(listId) ? listId : fallback
|
|
35
|
+
return task?.listId === canonicalListId ? task : { ...task, listId: canonicalListId }
|
|
35
36
|
})
|
|
36
37
|
}
|
|
37
38
|
|