@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 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
 
@@ -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.0",
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
  ],
@@ -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 storeUnavailable = false
29
+ let storeLocation = 'keyring'
28
30
 
29
- // Containers and cloud sandboxes often have no usable credential store. Failing to persist is not
30
- // fatal while a token can still be supplied per run, but the session then dies with the machine.
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
- return IGNORE_KEYRING ? null : tokenStore.get()
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
- storeUnavailable = true
38
- return
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
- tokenStore.set(token)
53
+ configStore.set(token)
54
+ storeLocation = 'config'
42
55
  } catch {
43
- storeUnavailable = true
56
+ storeLocation = 'memory'
44
57
  }
45
58
  },
46
59
  clear() {
47
- return tokenStore.clear()
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 (!storeUnavailable) return
53
- process.stderr.write('Note: this machine has no usable credential store, so the login only lasts for this run. Set DAYFLOW_REFRESH_TOKEN (or add "refreshToken" to the config file) to sign in without a browser.\n')
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 ${IGNORE_KEYRING || storeUnavailable ? 'memory for this run only' : "this machine's credential store"}.\n`)
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 ${IGNORE_KEYRING || storeUnavailable ? 'memory for this run only' : "this machine's credential store"}.\n`)
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: readConfigFile().refreshToken,
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.')
@@ -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 progress = (current - baseline) / (target - baseline)
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 ?? objective.cycleType
137
- const period = input.period ?? objective.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) throw new Error('Target must differ from baseline.')
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)
@@ -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
- return known.has(listId) ? task : { ...task, listId: fallback }
34
+ const canonicalListId = known.has(listId) ? listId : fallback
35
+ return task?.listId === canonicalListId ? task : { ...task, listId: canonicalListId }
35
36
  })
36
37
  }
37
38