tuiboard 0.7.3 → 0.8.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/.tuiboard/config.example.yaml +7 -0
- package/CHANGELOG.md +22 -0
- package/README.md +71 -7
- package/package.json +1 -1
- package/src/calendar/setup.ts +33 -15
- package/src/config/loader.ts +5 -0
- package/src/input/handleKey.ts +53 -0
- package/src/store/calendar.ts +193 -2
- package/src/store/index.ts +207 -1
- package/src/store/timeline.ts +19 -1
- package/src/ui/Modal.tsx +191 -0
- package/src/ui/TimelineView.tsx +75 -9
|
@@ -68,10 +68,17 @@ archive_column: Archive
|
|
|
68
68
|
# own credentials — nothing is hosted, nothing leaves your machine. All-day
|
|
69
69
|
# events are skipped; each calendar keeps its own color. Paths support `~`.
|
|
70
70
|
#
|
|
71
|
+
# To CREATE / EDIT / DELETE events (not just read), re-auth with:
|
|
72
|
+
# calendar-setup google --write. Then press `n` / click an empty slot to create;
|
|
73
|
+
# click an existing event to select it, `e` to edit, `d` to delete.
|
|
74
|
+
# `default_calendar` (a calendar id) sets the default target for new events;
|
|
75
|
+
# override per-event in the modal.
|
|
76
|
+
#
|
|
71
77
|
# calendars:
|
|
72
78
|
# google:
|
|
73
79
|
# enabled: true
|
|
74
80
|
# token: ~/.config/tuiboard/google_token.json
|
|
81
|
+
# # default_calendar: you@example.com # default target for new events
|
|
75
82
|
# # color: "#e8a05c" # fallback when a calendar has no color
|
|
76
83
|
# microsoft:
|
|
77
84
|
# enabled: true
|
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,27 @@ All notable changes to **tuiboard** are documented here.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.8.0] - 2026-06-03
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **Create, edit & delete Google Calendar events from the Agenda** (opt-in
|
|
12
|
+
write). Re-authorize with `tuiboard calendar-setup google --write`, then:
|
|
13
|
+
- **Create** — press `n` (or click an empty Agenda slot) to open a new-event
|
|
14
|
+
modal: type a title (append `HH:MM-HH:MM` to set the time), pick the target
|
|
15
|
+
calendar, Enter to create. A `default_calendar` config sets the default
|
|
16
|
+
target (override per-event in the modal).
|
|
17
|
+
- **Edit / delete** — click an existing event on a writable calendar to select
|
|
18
|
+
it, then `e` (or Enter) to edit its title/time, `d` to delete (with confirm),
|
|
19
|
+
`Esc` to deselect. Edits stay on the same calendar. Read-only events can't be
|
|
20
|
+
selected.
|
|
21
|
+
|
|
22
|
+
Every change appears in the Agenda and on Google Calendar immediately.
|
|
23
|
+
Read-only setups are unaffected — the write UI only appears when the token
|
|
24
|
+
carries the write scope, and only Google events on owner/writer calendars are
|
|
25
|
+
selectable. Microsoft event write is not supported yet.
|
|
26
|
+
- Expanded the README with a step-by-step Google Cloud OAuth client setup (the
|
|
27
|
+
bring-your-own-credentials flow), so first-time users have a clear path.
|
|
28
|
+
|
|
8
29
|
## [0.7.3] - 2026-06-02
|
|
9
30
|
|
|
10
31
|
### Fixed
|
|
@@ -113,6 +134,7 @@ First public release on npm. This entry captures the full feature set at launch.
|
|
|
113
134
|
|
|
114
135
|
Built with [OpenTUI](https://opentui.com) + SolidJS on Bun.
|
|
115
136
|
|
|
137
|
+
[0.8.0]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.8.0
|
|
116
138
|
[0.7.3]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.7.3
|
|
117
139
|
[0.7.2]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.7.2
|
|
118
140
|
[0.7.1]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.7.1
|
package/README.md
CHANGED
|
@@ -193,13 +193,37 @@ tuiboard calendar-setup google # opens your browser (read-only scope)
|
|
|
193
193
|
tuiboard calendar-setup microsoft # device-code flow, no redirect
|
|
194
194
|
```
|
|
195
195
|
|
|
196
|
-
**
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
196
|
+
**Microsoft** needs an Azure app registration (Public client, `Calendars.Read`
|
|
197
|
+
delegated) whose client ID goes in `~/.config/tuiboard/azure_config.json` —
|
|
198
|
+
running `calendar-setup microsoft` with no config writes a template that walks
|
|
199
|
+
you through it.
|
|
200
|
+
|
|
201
|
+
#### Google: one-time OAuth client setup
|
|
202
|
+
|
|
203
|
+
There's no hosted tuiboard app — **you create your own free OAuth client** in
|
|
204
|
+
your own Google Cloud project, so nothing is shared and your data never passes
|
|
205
|
+
through anyone else's servers. It takes about five minutes, once:
|
|
206
|
+
|
|
207
|
+
1. Go to the [Google Cloud Console](https://console.cloud.google.com/) and
|
|
208
|
+
create a project (or pick an existing one) from the project dropdown.
|
|
209
|
+
2. **APIs & Services → Library** → search **"Google Calendar API"** → **Enable**.
|
|
210
|
+
3. **APIs & Services → OAuth consent screen**: if prompted, choose **External**,
|
|
211
|
+
give the app a name and your email, and save. You don't need to publish it or
|
|
212
|
+
submit for verification — as the project owner you're automatically a test
|
|
213
|
+
user of your own app, which is all tuiboard needs. (Add your Google address
|
|
214
|
+
under **Test users** if it asks.)
|
|
215
|
+
4. **APIs & Services → Credentials → Create credentials → OAuth client ID**.
|
|
216
|
+
Application type: **Desktop app**. Create.
|
|
217
|
+
5. **Download JSON** on the client you just made, and save it as
|
|
218
|
+
`~/.config/tuiboard/google_credentials.json`.
|
|
219
|
+
6. Run `tuiboard calendar-setup google` (add `--write` to also create/edit/delete
|
|
220
|
+
— see below). Your browser opens; approve the access. You'll briefly see an
|
|
221
|
+
"unverified app" notice — that's expected for your own personal client; click
|
|
222
|
+
**Advanced → go to (your app)** to continue. The token is saved and the
|
|
223
|
+
command prints the YAML block to paste into your config.
|
|
224
|
+
|
|
225
|
+
The `calendar-setup` command prints these exact steps too if it doesn't find the
|
|
226
|
+
credentials file.
|
|
203
227
|
|
|
204
228
|
After connecting, the command prints the exact YAML to paste into your config:
|
|
205
229
|
|
|
@@ -219,6 +243,46 @@ expired, or unconfigured calendar never breaks the board — it just shows no
|
|
|
219
243
|
events. Set either provider's `enabled: false` (or drop the block) to turn it
|
|
220
244
|
off; add a `color:` to override the fallback block color.
|
|
221
245
|
|
|
246
|
+
### Creating, editing & deleting events (Google, opt-in)
|
|
247
|
+
|
|
248
|
+
Reading is the default. To also **create, edit, and delete** Google Calendar
|
|
249
|
+
events from the Agenda, re-authorize with the write scope:
|
|
250
|
+
|
|
251
|
+
```bash
|
|
252
|
+
tuiboard calendar-setup google --write
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
**Create** — in the Agenda zone, press **`n`** (or **click an empty time slot**)
|
|
256
|
+
to open the new-event modal: type a title (append `HH:MM-HH:MM` to change the
|
|
257
|
+
time), press Enter, pick the target calendar with `j`/`k`, Enter to create. Only
|
|
258
|
+
calendars you can write to (owner/writer) show in the picker.
|
|
259
|
+
|
|
260
|
+
**Edit / delete** — **click an existing event** in the Agenda to select it (only
|
|
261
|
+
events on writable calendars can be selected; read-only ones just say so). Then:
|
|
262
|
+
|
|
263
|
+
- **`e`** (or Enter) opens the edit modal, prefilled with the title and time —
|
|
264
|
+
change either (`Title HH:MM-HH:MM`) and Enter to save.
|
|
265
|
+
- **`d`** deletes it (with a confirm).
|
|
266
|
+
- **`Esc`** deselects.
|
|
267
|
+
|
|
268
|
+
Edits stay on the same calendar (moving an event between calendars isn't
|
|
269
|
+
supported). Every change appears in the Agenda right away and on Google Calendar.
|
|
270
|
+
|
|
271
|
+
Set the calendar new events default to with `default_calendar` (a calendar id;
|
|
272
|
+
unset → your primary). You can still override per-event in the modal:
|
|
273
|
+
|
|
274
|
+
```yaml
|
|
275
|
+
calendars:
|
|
276
|
+
google:
|
|
277
|
+
enabled: true
|
|
278
|
+
token: ~/.config/tuiboard/google_token.json
|
|
279
|
+
default_calendar: you@example.com # optional; default target for new events
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
The write scope is opt-in: without `--write`, tuiboard stays read-only and the
|
|
283
|
+
`n` shortcut / slot-click / event-selection do nothing. Microsoft event write
|
|
284
|
+
isn't supported yet (read-only).
|
|
285
|
+
|
|
222
286
|
## Markdown board format
|
|
223
287
|
|
|
224
288
|
`tuiboard` reads and writes **plain CommonMark** with the Obsidian
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tuiboard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Terminal kanban for markdown task boards, with optional Today/Tomorrow planner, 24h agenda + calendar overlay, and a live Claude Code agent view. Use only the panels you want.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/calendar/setup.ts
CHANGED
|
@@ -21,7 +21,9 @@ import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
|
21
21
|
import { loadConfig } from "~/config/loader";
|
|
22
22
|
|
|
23
23
|
const TUIBOARD_DIR = join(homedir(), ".config", "tuiboard");
|
|
24
|
-
const
|
|
24
|
+
const GOOGLE_SCOPE_RO = "https://www.googleapis.com/auth/calendar.readonly";
|
|
25
|
+
/** Added with `--write`: lets tuiboard CREATE events (and still read them). */
|
|
26
|
+
const GOOGLE_SCOPE_EVENTS = "https://www.googleapis.com/auth/calendar.events";
|
|
25
27
|
const MS_SCOPE = "Calendars.Read offline_access openid profile";
|
|
26
28
|
|
|
27
29
|
function expandPath(p: string, root: string): string {
|
|
@@ -32,8 +34,10 @@ function expandPath(p: string, root: string): string {
|
|
|
32
34
|
function openBrowser(url: string): void {
|
|
33
35
|
try {
|
|
34
36
|
if (process.platform === "win32") {
|
|
35
|
-
// `
|
|
36
|
-
|
|
37
|
+
// NOT `cmd /c start`: cmd treats the `&` in an OAuth URL as a command
|
|
38
|
+
// separator and truncates it (→ Google "invalid_request"). rundll32 gets
|
|
39
|
+
// the URL as a single literal argv, no shell parsing.
|
|
40
|
+
spawn("rundll32", ["url.dll,FileProtocolHandler", url], { detached: true, stdio: "ignore" }).unref();
|
|
37
41
|
} else if (process.platform === "darwin") {
|
|
38
42
|
spawn("open", [url], { detached: true, stdio: "ignore" }).unref();
|
|
39
43
|
} else {
|
|
@@ -58,8 +62,10 @@ interface GoogleClientSecrets {
|
|
|
58
62
|
token_uri?: string;
|
|
59
63
|
}
|
|
60
64
|
|
|
61
|
-
async function setupGoogle(credsPath: string, tokenPath: string): Promise<number> {
|
|
65
|
+
async function setupGoogle(credsPath: string, tokenPath: string, write: boolean): Promise<number> {
|
|
66
|
+
const scopes = write ? [GOOGLE_SCOPE_RO, GOOGLE_SCOPE_EVENTS] : [GOOGLE_SCOPE_RO];
|
|
62
67
|
console.log("\n── Google Calendar setup ──────────────────────────────────");
|
|
68
|
+
console.log(write ? "Mode: read + create events" : "Mode: read-only (add --write to create events)");
|
|
63
69
|
if (!existsSync(credsPath)) {
|
|
64
70
|
console.log(`OAuth client file not found:\n ${credsPath}\n
|
|
65
71
|
Create it:
|
|
@@ -88,7 +94,10 @@ Create it:
|
|
|
88
94
|
console.log("client_id / client_secret missing from the OAuth client file.");
|
|
89
95
|
return 1;
|
|
90
96
|
}
|
|
91
|
-
|
|
97
|
+
// Force the current v2 authorization endpoint. Desktop client_secret files
|
|
98
|
+
// still ship the legacy `/o/oauth2/auth` in `auth_uri`, which returns
|
|
99
|
+
// "Error 400: invalid_request (GeneralOAuthFlow)" for loopback + multi-scope.
|
|
100
|
+
const authUri = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
92
101
|
const tokenUri = secrets.token_uri || "https://oauth2.googleapis.com/token";
|
|
93
102
|
|
|
94
103
|
// Loopback server captures the ?code= redirect.
|
|
@@ -96,15 +105,22 @@ Create it:
|
|
|
96
105
|
const result = new Promise<{ code?: string; error?: string }>((r) => (resolveResult = r));
|
|
97
106
|
const server = Bun.serve({
|
|
98
107
|
port: 0,
|
|
108
|
+
// Bind the IPv4 loopback explicitly. On Windows `localhost` resolves to
|
|
109
|
+
// IPv6 `::1` first; if the server is IPv4-only the redirect is refused.
|
|
110
|
+
// We also use 127.0.0.1 in the redirect URI so the two always match.
|
|
111
|
+
hostname: "127.0.0.1",
|
|
99
112
|
fetch(req) {
|
|
100
113
|
const u = new URL(req.url);
|
|
101
114
|
const code = u.searchParams.get("code") ?? undefined;
|
|
102
115
|
const error = u.searchParams.get("error") ?? undefined;
|
|
103
116
|
if (code || error) {
|
|
104
|
-
resolveResult({ code, error });
|
|
105
117
|
const msg = error
|
|
106
118
|
? `Authorization failed: ${error}`
|
|
107
119
|
: "tuiboard is now connected to Google Calendar. You can close this tab.";
|
|
120
|
+
// Resolve AFTER the response is handed back so the success page has a
|
|
121
|
+
// moment to flush; resolving synchronously lets the caller stop the
|
|
122
|
+
// server before the bytes reach the browser (→ ERR_CONNECTION_REFUSED).
|
|
123
|
+
setTimeout(() => resolveResult({ code, error }), 300);
|
|
108
124
|
return new Response(`<!doctype html><meta charset=utf-8><body style="font:16px system-ui;padding:3rem">${msg}</body>`, {
|
|
109
125
|
headers: { "content-type": "text/html" },
|
|
110
126
|
});
|
|
@@ -112,24 +128,24 @@ Create it:
|
|
|
112
128
|
return new Response("waiting for Google…", { headers: { "content-type": "text/plain" } });
|
|
113
129
|
},
|
|
114
130
|
});
|
|
115
|
-
const redirectUri = `http://
|
|
131
|
+
const redirectUri = `http://127.0.0.1:${server.port}`;
|
|
116
132
|
const authUrl =
|
|
117
133
|
`${authUri}?` +
|
|
118
134
|
new URLSearchParams({
|
|
119
135
|
client_id: clientId,
|
|
120
136
|
redirect_uri: redirectUri,
|
|
121
137
|
response_type: "code",
|
|
122
|
-
scope:
|
|
138
|
+
scope: scopes.join(" "),
|
|
123
139
|
access_type: "offline",
|
|
124
140
|
prompt: "consent",
|
|
125
141
|
}).toString();
|
|
126
142
|
|
|
127
|
-
console.log(
|
|
143
|
+
console.log(`Opening your browser to authorize Google Calendar (${write ? "read + write" : "read-only"})…`);
|
|
128
144
|
console.log(`If it doesn't open, visit:\n ${authUrl}\n`);
|
|
129
145
|
openBrowser(authUrl);
|
|
130
146
|
|
|
131
147
|
const { code, error } = await result;
|
|
132
|
-
server.stop(
|
|
148
|
+
server.stop(); // graceful: lets the success page finish sending
|
|
133
149
|
if (error || !code) {
|
|
134
150
|
console.log(`\nSetup cancelled${error ? `: ${error}` : ""}.`);
|
|
135
151
|
return 1;
|
|
@@ -165,7 +181,7 @@ Create it:
|
|
|
165
181
|
token_uri: tokenUri,
|
|
166
182
|
client_id: clientId,
|
|
167
183
|
client_secret: clientSecret,
|
|
168
|
-
scopes
|
|
184
|
+
scopes,
|
|
169
185
|
expiry: data.expires_in ? new Date(Date.now() + data.expires_in * 1000).toISOString() : undefined,
|
|
170
186
|
});
|
|
171
187
|
console.log(`\n✓ Google Calendar connected. Token saved:\n ${tokenPath}\n`);
|
|
@@ -279,8 +295,9 @@ function usage(): void {
|
|
|
279
295
|
console.log(`tuiboard calendar-setup — connect a calendar to the Agenda overlay
|
|
280
296
|
|
|
281
297
|
Usage:
|
|
282
|
-
tuiboard calendar-setup google
|
|
283
|
-
tuiboard calendar-setup
|
|
298
|
+
tuiboard calendar-setup google Browser OAuth, read-only
|
|
299
|
+
tuiboard calendar-setup google --write Browser OAuth, read + create events
|
|
300
|
+
tuiboard calendar-setup microsoft Device-code flow for Microsoft 365
|
|
284
301
|
|
|
285
302
|
Tokens are written to the paths in your tuiboard config (calendars.google.token
|
|
286
303
|
/ calendars.microsoft.tokenCache), or ~/.config/tuiboard/ if no calendars block
|
|
@@ -289,7 +306,8 @@ prints the exact YAML.`);
|
|
|
289
306
|
}
|
|
290
307
|
|
|
291
308
|
export async function runCalendarSetup(argv: string[]): Promise<number> {
|
|
292
|
-
const
|
|
309
|
+
const write = argv.includes("--write");
|
|
310
|
+
const provider = (argv.find((a) => !a.startsWith("--")) ?? "").toLowerCase();
|
|
293
311
|
const cfg = loadConfig();
|
|
294
312
|
const root = cfg.root;
|
|
295
313
|
|
|
@@ -299,7 +317,7 @@ export async function runCalendarSetup(argv: string[]): Promise<number> {
|
|
|
299
317
|
? expandPath(g.credentials, root)
|
|
300
318
|
: join(TUIBOARD_DIR, "google_credentials.json");
|
|
301
319
|
const tokenPath = g?.token ? expandPath(g.token, root) : join(TUIBOARD_DIR, "google_token.json");
|
|
302
|
-
const rc = await setupGoogle(credsPath, tokenPath);
|
|
320
|
+
const rc = await setupGoogle(credsPath, tokenPath, write);
|
|
303
321
|
if (rc === 0 && !g) {
|
|
304
322
|
console.log(`Add this to your tuiboard config (${cfg.loaded ? "config found" : "create ~/.config/tuiboard/config.yaml"}):
|
|
305
323
|
|
package/src/config/loader.ts
CHANGED
|
@@ -83,6 +83,9 @@ export interface GoogleCalendarConfig {
|
|
|
83
83
|
credentials?: string;
|
|
84
84
|
/** Fallback color when a calendar has none of its own. */
|
|
85
85
|
color?: string;
|
|
86
|
+
/** Calendar id new events are created on by default (override in the modal).
|
|
87
|
+
* Unset → the account's primary calendar. Requires `--write` setup. */
|
|
88
|
+
defaultCalendar?: string;
|
|
86
89
|
}
|
|
87
90
|
|
|
88
91
|
export interface MicrosoftCalendarConfig {
|
|
@@ -152,6 +155,7 @@ interface RawConfig {
|
|
|
152
155
|
token?: string;
|
|
153
156
|
credentials?: string;
|
|
154
157
|
color?: string;
|
|
158
|
+
default_calendar?: string;
|
|
155
159
|
};
|
|
156
160
|
microsoft?: {
|
|
157
161
|
enabled?: boolean;
|
|
@@ -191,6 +195,7 @@ function normalizeCalendars(
|
|
|
191
195
|
token: expandPath(g.token, root),
|
|
192
196
|
credentials: g.credentials ? expandPath(g.credentials, root) : undefined,
|
|
193
197
|
color: g.color,
|
|
198
|
+
defaultCalendar: g.default_calendar,
|
|
194
199
|
};
|
|
195
200
|
}
|
|
196
201
|
const m = raw.microsoft;
|
package/src/input/handleKey.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { isHiddenColumn } from "~/config/loader";
|
|
17
|
+
import { googleTokenCanWrite } from "~/store/calendar";
|
|
17
18
|
import { isTask } from "~/parser/markdown";
|
|
18
19
|
import {
|
|
19
20
|
isoToday,
|
|
@@ -59,6 +60,14 @@ export function handleKey(
|
|
|
59
60
|
}
|
|
60
61
|
return;
|
|
61
62
|
}
|
|
63
|
+
if (ui.modal.kind === "confirm-delete-event") {
|
|
64
|
+
if (key.name === "y" || key.name === "enter" || key.name === "return") {
|
|
65
|
+
void store.confirmDeleteEvent();
|
|
66
|
+
} else if (key.name === "n") {
|
|
67
|
+
store.closeModal();
|
|
68
|
+
}
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
62
71
|
if ((ui.modal.kind === "help" ||
|
|
63
72
|
ui.modal.kind === "detail" ||
|
|
64
73
|
ui.modal.kind === "agent-detail") &&
|
|
@@ -66,6 +75,17 @@ export function handleKey(
|
|
|
66
75
|
store.closeModal();
|
|
67
76
|
return;
|
|
68
77
|
}
|
|
78
|
+
// New-event modal: step 1 typing goes to the <input>; step 2 (no input
|
|
79
|
+
// focused) is the calendar picker, driven here.
|
|
80
|
+
if (ui.modal.kind === "event") {
|
|
81
|
+
const p = ui.eventPicker;
|
|
82
|
+
if (p && p.step === 2) {
|
|
83
|
+
if (key.name === "j" || key.name === "down") { store.setEventSel(p.sel + 1); return; }
|
|
84
|
+
if (key.name === "k" || key.name === "up") { store.setEventSel(p.sel - 1); return; }
|
|
85
|
+
if (key.name === "enter" || key.name === "return") { void store.confirmEventPicker(); return; }
|
|
86
|
+
}
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
69
89
|
return;
|
|
70
90
|
}
|
|
71
91
|
|
|
@@ -85,6 +105,11 @@ export function handleKey(
|
|
|
85
105
|
store.flashBanner("info", wasMode ? "Arm mode off" : "Disarmed");
|
|
86
106
|
return;
|
|
87
107
|
}
|
|
108
|
+
if (ui.selectedCalEvent) {
|
|
109
|
+
store.clearCalSelection();
|
|
110
|
+
store.flashBanner("info", "Event deselected");
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
88
113
|
if (ui.grabbing) {
|
|
89
114
|
store.exitGrab();
|
|
90
115
|
store.flashBanner("info", "Grab released");
|
|
@@ -294,6 +319,34 @@ function handleTimelineZone(
|
|
|
294
319
|
);
|
|
295
320
|
const target = entries[ui.row];
|
|
296
321
|
|
|
322
|
+
// A selected (clicked) calendar event takes over e/d/Enter for edit/delete.
|
|
323
|
+
// Any other key drops the selection and is then handled normally below.
|
|
324
|
+
const selCal = ui.selectedCalEvent;
|
|
325
|
+
if (selCal) {
|
|
326
|
+
if (key.name === "e" || key.name === "enter" || key.name === "return") {
|
|
327
|
+
store.openEventEditModal();
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (key.name === "d") {
|
|
331
|
+
openLater({ kind: "confirm-delete-event" });
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
store.clearCalSelection();
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// `n` (Agenda zone) = create a Google Calendar event at the now-rounded slot.
|
|
338
|
+
// Mirrors `n` = new task in the board. Gated on Google write being connected.
|
|
339
|
+
if ((key.name === "n" || key.name === "N") && !key.ctrl) {
|
|
340
|
+
const g = store.config.calendars?.google;
|
|
341
|
+
if (g && googleTokenCanWrite(g.token)) {
|
|
342
|
+
const { startMin, endMin } = nextNowBlock();
|
|
343
|
+
store.openEventModal(store.agendaDate(), startMin, endMin);
|
|
344
|
+
} else {
|
|
345
|
+
store.flashBanner("warn", "Connect Google write first: tuiboard calendar-setup google --write");
|
|
346
|
+
}
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
|
|
297
350
|
// Armed-block adjustments take priority over navigation. While a block
|
|
298
351
|
// is armed, j/k nudge its start time and +/- nudge its end.
|
|
299
352
|
const armedRef = ui.armedTimelineRef;
|
package/src/store/calendar.ts
CHANGED
|
@@ -29,6 +29,14 @@ export interface CalEvent {
|
|
|
29
29
|
endMin: number;
|
|
30
30
|
color: string;
|
|
31
31
|
source: "google" | "microsoft";
|
|
32
|
+
/** Google calendar id this event lives on (set for Google events only). */
|
|
33
|
+
calendarId?: string;
|
|
34
|
+
/** Google event id (set for Google events only). Needed to edit/delete. */
|
|
35
|
+
eventId?: string;
|
|
36
|
+
/** True when this event can be edited/deleted from tuiboard: a Google event
|
|
37
|
+
* on an owner/writer calendar, with a write-scoped token. Microsoft events
|
|
38
|
+
* and read-only-calendar events are never editable. */
|
|
39
|
+
editable?: boolean;
|
|
32
40
|
}
|
|
33
41
|
|
|
34
42
|
const GOOGLE_FALLBACK_COLOR = "#e8a05c";
|
|
@@ -143,6 +151,182 @@ async function googleAccessToken(tokenPath: string): Promise<string | null> {
|
|
|
143
151
|
}
|
|
144
152
|
}
|
|
145
153
|
|
|
154
|
+
// ─── Google write (calendar list + event creation) ──────────────────────────
|
|
155
|
+
|
|
156
|
+
/** A calendar the connected account can write to (accessRole owner/writer). */
|
|
157
|
+
export interface WritableCalendar {
|
|
158
|
+
id: string;
|
|
159
|
+
summary: string;
|
|
160
|
+
accessRole: string;
|
|
161
|
+
color: string;
|
|
162
|
+
primary: boolean;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* List the calendars the connected Google account can WRITE to (owner/writer),
|
|
167
|
+
* with display name + color. Used by the new-event calendar picker. Returns []
|
|
168
|
+
* on any failure. (Read path `fetchGoogle` keeps its own, reader-scoped query.)
|
|
169
|
+
*/
|
|
170
|
+
export async function listGoogleCalendars(
|
|
171
|
+
cfg: GoogleCalendarConfig,
|
|
172
|
+
): Promise<WritableCalendar[]> {
|
|
173
|
+
const access = await googleAccessToken(cfg.token);
|
|
174
|
+
if (!access) return [];
|
|
175
|
+
try {
|
|
176
|
+
const res = await fetch(
|
|
177
|
+
"https://www.googleapis.com/calendar/v3/users/me/calendarList",
|
|
178
|
+
{ headers: { Authorization: `Bearer ${access}` } },
|
|
179
|
+
);
|
|
180
|
+
if (!res.ok) return [];
|
|
181
|
+
const list = (await res.json()) as {
|
|
182
|
+
items?: Array<{
|
|
183
|
+
id: string;
|
|
184
|
+
summary?: string;
|
|
185
|
+
accessRole?: string;
|
|
186
|
+
backgroundColor?: string;
|
|
187
|
+
primary?: boolean;
|
|
188
|
+
}>;
|
|
189
|
+
};
|
|
190
|
+
return (list.items ?? [])
|
|
191
|
+
.filter((c) => c.accessRole === "owner" || c.accessRole === "writer")
|
|
192
|
+
.map((c) => ({
|
|
193
|
+
id: c.id,
|
|
194
|
+
summary: c.summary ?? c.id,
|
|
195
|
+
accessRole: c.accessRole ?? "reader",
|
|
196
|
+
color: c.backgroundColor || cfg.color || GOOGLE_FALLBACK_COLOR,
|
|
197
|
+
primary: c.primary === true,
|
|
198
|
+
}));
|
|
199
|
+
} catch {
|
|
200
|
+
return [];
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** True if the persisted Google token carries an event-write scope. Gates the
|
|
205
|
+
* whole event-creation UI so read-only users never see it. */
|
|
206
|
+
export function googleTokenCanWrite(tokenPath: string): boolean {
|
|
207
|
+
try {
|
|
208
|
+
const tok = JSON.parse(readFileSync(tokenPath, "utf-8")) as { scopes?: string[] };
|
|
209
|
+
return (
|
|
210
|
+
Array.isArray(tok.scopes) &&
|
|
211
|
+
tok.scopes.some((s) => s.includes("calendar.events") || s.endsWith("/auth/calendar"))
|
|
212
|
+
);
|
|
213
|
+
} catch {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** RFC3339 timestamp with the LOCAL UTC offset for `min` minutes past local
|
|
219
|
+
* midnight of `dateIso` — e.g. "2026-06-03T15:00:00+02:00". Per-instant offset,
|
|
220
|
+
* so DST is handled; Google then stores the wall-clock time exactly as typed. */
|
|
221
|
+
function localRfc3339(dateIso: string, min: number): string {
|
|
222
|
+
const d = new Date(dayStartMs(dateIso) + min * 60000);
|
|
223
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
224
|
+
const offMin = -d.getTimezoneOffset(); // minutes east of UTC
|
|
225
|
+
const sign = offMin >= 0 ? "+" : "-";
|
|
226
|
+
const off = Math.abs(offMin);
|
|
227
|
+
return (
|
|
228
|
+
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
|
|
229
|
+
`T${pad(d.getHours())}:${pad(d.getMinutes())}:00` +
|
|
230
|
+
`${sign}${pad(Math.floor(off / 60))}:${pad(off % 60)}`
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Create a Google Calendar event on `calendarId`. Unlike the read path, this
|
|
236
|
+
* surfaces failures (returns {ok:false,error}) so the UI can flash a banner.
|
|
237
|
+
*/
|
|
238
|
+
export async function createGoogleEvent(
|
|
239
|
+
cfg: GoogleCalendarConfig,
|
|
240
|
+
args: { calendarId: string; title: string; dateIso: string; startMin: number; endMin: number },
|
|
241
|
+
): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
242
|
+
const access = await googleAccessToken(cfg.token);
|
|
243
|
+
if (!access) {
|
|
244
|
+
return { ok: false, error: "not authorized — run: tuiboard calendar-setup google --write" };
|
|
245
|
+
}
|
|
246
|
+
try {
|
|
247
|
+
const res = await fetch(
|
|
248
|
+
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(args.calendarId)}/events`,
|
|
249
|
+
{
|
|
250
|
+
method: "POST",
|
|
251
|
+
headers: { Authorization: `Bearer ${access}`, "Content-Type": "application/json" },
|
|
252
|
+
body: JSON.stringify({
|
|
253
|
+
summary: args.title,
|
|
254
|
+
start: { dateTime: localRfc3339(args.dateIso, args.startMin) },
|
|
255
|
+
end: { dateTime: localRfc3339(args.dateIso, args.endMin) },
|
|
256
|
+
}),
|
|
257
|
+
},
|
|
258
|
+
);
|
|
259
|
+
if (!res.ok) {
|
|
260
|
+
const body = await res.text().catch(() => "");
|
|
261
|
+
return { ok: false, error: `${res.status} ${body.slice(0, 140)}` };
|
|
262
|
+
}
|
|
263
|
+
return { ok: true };
|
|
264
|
+
} catch (e) {
|
|
265
|
+
return { ok: false, error: String(e) };
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Edit an existing Google Calendar event's title + time (same calendar — moving
|
|
271
|
+
* an event between calendars is intentionally not supported). PATCH so untouched
|
|
272
|
+
* fields (attendees, description, recurrence, …) are preserved.
|
|
273
|
+
*/
|
|
274
|
+
export async function updateGoogleEvent(
|
|
275
|
+
cfg: GoogleCalendarConfig,
|
|
276
|
+
args: { calendarId: string; eventId: string; title: string; dateIso: string; startMin: number; endMin: number },
|
|
277
|
+
): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
278
|
+
const access = await googleAccessToken(cfg.token);
|
|
279
|
+
if (!access) {
|
|
280
|
+
return { ok: false, error: "not authorized — run: tuiboard calendar-setup google --write" };
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
const res = await fetch(
|
|
284
|
+
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(args.calendarId)}/events/${encodeURIComponent(args.eventId)}`,
|
|
285
|
+
{
|
|
286
|
+
method: "PATCH",
|
|
287
|
+
headers: { Authorization: `Bearer ${access}`, "Content-Type": "application/json" },
|
|
288
|
+
body: JSON.stringify({
|
|
289
|
+
summary: args.title,
|
|
290
|
+
start: { dateTime: localRfc3339(args.dateIso, args.startMin) },
|
|
291
|
+
end: { dateTime: localRfc3339(args.dateIso, args.endMin) },
|
|
292
|
+
}),
|
|
293
|
+
},
|
|
294
|
+
);
|
|
295
|
+
if (!res.ok) {
|
|
296
|
+
const body = await res.text().catch(() => "");
|
|
297
|
+
return { ok: false, error: `${res.status} ${body.slice(0, 140)}` };
|
|
298
|
+
}
|
|
299
|
+
return { ok: true };
|
|
300
|
+
} catch (e) {
|
|
301
|
+
return { ok: false, error: String(e) };
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Delete a Google Calendar event. DELETE returns 204 (no body) on success. */
|
|
306
|
+
export async function deleteGoogleEvent(
|
|
307
|
+
cfg: GoogleCalendarConfig,
|
|
308
|
+
args: { calendarId: string; eventId: string },
|
|
309
|
+
): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
310
|
+
const access = await googleAccessToken(cfg.token);
|
|
311
|
+
if (!access) {
|
|
312
|
+
return { ok: false, error: "not authorized — run: tuiboard calendar-setup google --write" };
|
|
313
|
+
}
|
|
314
|
+
try {
|
|
315
|
+
const res = await fetch(
|
|
316
|
+
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(args.calendarId)}/events/${encodeURIComponent(args.eventId)}`,
|
|
317
|
+
{ method: "DELETE", headers: { Authorization: `Bearer ${access}` } },
|
|
318
|
+
);
|
|
319
|
+
// 410 Gone = already deleted; treat as success (the goal state is reached).
|
|
320
|
+
if (!res.ok && res.status !== 410) {
|
|
321
|
+
const body = await res.text().catch(() => "");
|
|
322
|
+
return { ok: false, error: `${res.status} ${body.slice(0, 140)}` };
|
|
323
|
+
}
|
|
324
|
+
return { ok: true };
|
|
325
|
+
} catch (e) {
|
|
326
|
+
return { ok: false, error: String(e) };
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
146
330
|
async function fetchGoogle(
|
|
147
331
|
cfg: GoogleCalendarConfig,
|
|
148
332
|
dateIso: string,
|
|
@@ -168,13 +352,17 @@ async function fetchGoogle(
|
|
|
168
352
|
);
|
|
169
353
|
if (!listRes.ok) return [];
|
|
170
354
|
const list = (await listRes.json()) as {
|
|
171
|
-
items?: Array<{ id: string; backgroundColor?: string; selected?: boolean }>;
|
|
355
|
+
items?: Array<{ id: string; backgroundColor?: string; selected?: boolean; accessRole?: string }>;
|
|
172
356
|
};
|
|
357
|
+
// Events are editable only with a write-scoped token AND on a calendar the
|
|
358
|
+
// account owns/can-write. Computed once here, stamped on each event below.
|
|
359
|
+
const canWrite = googleTokenCanWrite(cfg.token);
|
|
173
360
|
const cals = (list.items ?? []).map((c) => ({
|
|
174
361
|
id: c.id,
|
|
175
362
|
color: c.backgroundColor || fallback,
|
|
363
|
+
writable: canWrite && (c.accessRole === "owner" || c.accessRole === "writer"),
|
|
176
364
|
}));
|
|
177
|
-
if (cals.length === 0) cals.push({ id: "primary", color: fallback });
|
|
365
|
+
if (cals.length === 0) cals.push({ id: "primary", color: fallback, writable: false });
|
|
178
366
|
|
|
179
367
|
const events: CalEvent[] = [];
|
|
180
368
|
const seen = new Set<string>();
|
|
@@ -209,6 +397,9 @@ async function fetchGoogle(
|
|
|
209
397
|
endMin,
|
|
210
398
|
color: cal.color,
|
|
211
399
|
source: "google",
|
|
400
|
+
calendarId: cal.id,
|
|
401
|
+
eventId: it.id,
|
|
402
|
+
editable: cal.writable && !!it.id,
|
|
212
403
|
},
|
|
213
404
|
});
|
|
214
405
|
}
|
package/src/store/index.ts
CHANGED
|
@@ -28,7 +28,16 @@ import {
|
|
|
28
28
|
type BoardWatcher,
|
|
29
29
|
} from "~/io/watcher";
|
|
30
30
|
import { createAgentsStore, type AgentsStore } from "./agents";
|
|
31
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
createCalendarStore,
|
|
33
|
+
createGoogleEvent,
|
|
34
|
+
deleteGoogleEvent,
|
|
35
|
+
googleTokenCanWrite,
|
|
36
|
+
listGoogleCalendars,
|
|
37
|
+
updateGoogleEvent,
|
|
38
|
+
type CalendarStore,
|
|
39
|
+
type WritableCalendar,
|
|
40
|
+
} from "./calendar";
|
|
32
41
|
import { ConflictError, statMtime, writeBoardFile } from "~/io/writer";
|
|
33
42
|
import { isTask, parseBoard } from "~/parser/markdown";
|
|
34
43
|
import { serializeBoard } from "~/parser/serialize";
|
|
@@ -67,9 +76,44 @@ export type ModalKind =
|
|
|
67
76
|
| { kind: "confirm-delete"; ref: TaskRef }
|
|
68
77
|
| { kind: "detail"; ref: TaskRef }
|
|
69
78
|
| { kind: "agent-detail"; sessionId: string }
|
|
79
|
+
| { kind: "event"; dateIso: string; startMin: number; endMin: number }
|
|
80
|
+
| { kind: "event-edit" }
|
|
81
|
+
| { kind: "confirm-delete-event" }
|
|
70
82
|
| { kind: "search" }
|
|
71
83
|
| { kind: "help" };
|
|
72
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Transient state for the two-step "new calendar event" modal. Step 1 is the
|
|
87
|
+
* title+time `<input>`; step 2 is the calendar picker, navigated via handleKey
|
|
88
|
+
* (no input focused). Lives in UI state so the key handler can drive it.
|
|
89
|
+
*/
|
|
90
|
+
export interface EventPicker {
|
|
91
|
+
step: 1 | 2;
|
|
92
|
+
/** Selection index into `cals` (step 2). */
|
|
93
|
+
sel: number;
|
|
94
|
+
title: string;
|
|
95
|
+
dateIso: string;
|
|
96
|
+
startMin: number;
|
|
97
|
+
endMin: number;
|
|
98
|
+
cals: WritableCalendar[];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The Google Calendar event currently selected in the Agenda (by clicking an
|
|
103
|
+
* editable event band). Parallel to `armedTimelineRef` but for read/write
|
|
104
|
+
* calendar events rather than tasks. While set, `e` edits and `d` deletes it.
|
|
105
|
+
*/
|
|
106
|
+
export interface SelectedCalEvent {
|
|
107
|
+
calendarId: string;
|
|
108
|
+
eventId: string;
|
|
109
|
+
title: string;
|
|
110
|
+
startMin: number;
|
|
111
|
+
endMin: number;
|
|
112
|
+
/** The Agenda day the event was selected on — its date for the API call. */
|
|
113
|
+
dateIso: string;
|
|
114
|
+
color: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
73
117
|
/** Which dashboard zone owns the keyboard cursor. */
|
|
74
118
|
export type ActiveZone = "planner" | "board" | "timeline" | "agents";
|
|
75
119
|
|
|
@@ -118,6 +162,13 @@ export interface UIState {
|
|
|
118
162
|
* cancels.
|
|
119
163
|
*/
|
|
120
164
|
armedTimelineRef?: TaskRef;
|
|
165
|
+
/**
|
|
166
|
+
* The Google Calendar event currently selected in the Agenda (clicked). While
|
|
167
|
+
* set, the Agenda zone's `e` edits it and `d` deletes it; any navigation key
|
|
168
|
+
* or `Esc` clears it. Only ever set for editable events (owner/writer + write
|
|
169
|
+
* token), so the presence of this implies "actionable".
|
|
170
|
+
*/
|
|
171
|
+
selectedCalEvent?: SelectedCalEvent;
|
|
121
172
|
/**
|
|
122
173
|
* Persistent calendar "arm mode" (toggled with `c`). While on, clicking any
|
|
123
174
|
* task in the board / planner panel arms it for the timeline, so you can
|
|
@@ -146,6 +197,8 @@ export interface UIState {
|
|
|
146
197
|
banner?: { kind: "info" | "warn" | "error"; text: string; ts: number };
|
|
147
198
|
/** Open modal, if any. Keyboard handler routes input to the modal when set. */
|
|
148
199
|
modal?: ModalKind;
|
|
200
|
+
/** Two-step new-event modal state (set only while `modal.kind === "event"`). */
|
|
201
|
+
eventPicker?: EventPicker;
|
|
149
202
|
}
|
|
150
203
|
|
|
151
204
|
export interface UndoEntry {
|
|
@@ -1058,6 +1111,150 @@ export function createTuiStore({ config }: CreateStoreOptions) {
|
|
|
1058
1111
|
|
|
1059
1112
|
function closeModal(): void {
|
|
1060
1113
|
setState("ui", "modal", undefined);
|
|
1114
|
+
setState("ui", "eventPicker", undefined);
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
// ─── New calendar event (two-step modal) ─────────────────────────────────
|
|
1118
|
+
|
|
1119
|
+
/** Open the "new event" modal for a time slot. Guarded on Google write being
|
|
1120
|
+
* connected; prefetches the writable calendars while the user types. */
|
|
1121
|
+
function openEventModal(dateIso: string, startMin: number, endMin: number): void {
|
|
1122
|
+
const g = config.calendars?.google;
|
|
1123
|
+
if (!g || !googleTokenCanWrite(g.token)) {
|
|
1124
|
+
flashBanner("warn", "Connect Google write first: tuiboard calendar-setup google --write");
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
setState("ui", "eventPicker", { step: 1, sel: 0, title: "", dateIso, startMin, endMin, cals: [] });
|
|
1128
|
+
// Defer the modal open so the OpenTUI <input> mounts after this key event.
|
|
1129
|
+
setTimeout(() => openModal({ kind: "event", dateIso, startMin, endMin }), 0);
|
|
1130
|
+
void listGoogleCalendars(g).then((cals) => {
|
|
1131
|
+
setState("ui", "eventPicker", produce((p: EventPicker | undefined) => {
|
|
1132
|
+
if (p && p.cals.length === 0) p.cals = cals;
|
|
1133
|
+
}));
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/** Step 1 → step 2: stash the parsed title + time, load calendars if needed,
|
|
1138
|
+
* preselect the default, and either show the picker or (single calendar)
|
|
1139
|
+
* create immediately. */
|
|
1140
|
+
async function advanceEventToStep2(title: string, startMin: number, endMin: number): Promise<void> {
|
|
1141
|
+
const g = config.calendars?.google;
|
|
1142
|
+
if (!g || !state.ui.eventPicker) { closeModal(); return; }
|
|
1143
|
+
let cals = state.ui.eventPicker.cals;
|
|
1144
|
+
if (cals.length === 0) cals = await listGoogleCalendars(g);
|
|
1145
|
+
if (cals.length === 0) {
|
|
1146
|
+
flashBanner("warn", "No writable Google calendars");
|
|
1147
|
+
closeModal();
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
const def = g.defaultCalendar;
|
|
1151
|
+
const idx = cals.findIndex((c) => (def ? c.id === def : c.primary));
|
|
1152
|
+
setState("ui", "eventPicker", produce((p: EventPicker | undefined) => {
|
|
1153
|
+
if (!p) return;
|
|
1154
|
+
p.title = title;
|
|
1155
|
+
p.startMin = startMin;
|
|
1156
|
+
p.endMin = endMin;
|
|
1157
|
+
p.cals = cals;
|
|
1158
|
+
p.sel = idx < 0 ? 0 : idx;
|
|
1159
|
+
p.step = 2;
|
|
1160
|
+
}));
|
|
1161
|
+
if (cals.length === 1) void confirmEventPicker();
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
/** Move the step-2 calendar selection (wraps). */
|
|
1165
|
+
function setEventSel(n: number): void {
|
|
1166
|
+
setState("ui", "eventPicker", produce((p: EventPicker | undefined) => {
|
|
1167
|
+
if (!p || p.cals.length === 0) return;
|
|
1168
|
+
p.sel = ((n % p.cals.length) + p.cals.length) % p.cals.length;
|
|
1169
|
+
}));
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
/** Create the event on the selected calendar, refresh the agenda, close. */
|
|
1173
|
+
async function confirmEventPicker(): Promise<void> {
|
|
1174
|
+
const p = state.ui.eventPicker;
|
|
1175
|
+
const g = config.calendars?.google;
|
|
1176
|
+
if (!p || !g) { closeModal(); return; }
|
|
1177
|
+
const calendarId = p.cals[p.sel]?.id ?? g.defaultCalendar ?? "primary";
|
|
1178
|
+
const title = p.title.trim() || "(busy)";
|
|
1179
|
+
closeModal();
|
|
1180
|
+
const r = await createGoogleEvent(g, {
|
|
1181
|
+
calendarId,
|
|
1182
|
+
title,
|
|
1183
|
+
dateIso: p.dateIso,
|
|
1184
|
+
startMin: p.startMin,
|
|
1185
|
+
endMin: p.endMin,
|
|
1186
|
+
});
|
|
1187
|
+
if (r.ok) {
|
|
1188
|
+
calendarStore.refresh(true);
|
|
1189
|
+
flashBanner("info", `📅 Event created: ${title}`);
|
|
1190
|
+
} else {
|
|
1191
|
+
flashBanner("error", `Create failed: ${r.error}`);
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
// ─── Edit / delete an existing calendar event ────────────────────────────
|
|
1196
|
+
|
|
1197
|
+
/** Select an editable Google event (clicked in the Agenda). Toggles off if
|
|
1198
|
+
* the same event is clicked again. Clears any armed task so the two
|
|
1199
|
+
* selection models don't fight. */
|
|
1200
|
+
function selectCalEvent(sel: SelectedCalEvent): void {
|
|
1201
|
+
const cur = state.ui.selectedCalEvent;
|
|
1202
|
+
if (cur && cur.calendarId === sel.calendarId && cur.eventId === sel.eventId) {
|
|
1203
|
+
setState("ui", "selectedCalEvent", undefined);
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
setState("ui", "armedTimelineRef", undefined);
|
|
1207
|
+
setState("ui", "selectedCalEvent", sel);
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
function clearCalSelection(): void {
|
|
1211
|
+
setState("ui", "selectedCalEvent", undefined);
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
/** Open the edit modal for the selected event (deferred so the <input>
|
|
1215
|
+
* mounts after this key event). No-op if nothing is selected. */
|
|
1216
|
+
function openEventEditModal(): void {
|
|
1217
|
+
if (!state.ui.selectedCalEvent) return;
|
|
1218
|
+
setTimeout(() => openModal({ kind: "event-edit" }), 0);
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
/** Save the edited title/time to the selected event, refresh, close. */
|
|
1222
|
+
async function confirmEventEdit(title: string, startMin: number, endMin: number): Promise<void> {
|
|
1223
|
+
const sel = state.ui.selectedCalEvent;
|
|
1224
|
+
const g = config.calendars?.google;
|
|
1225
|
+
if (!sel || !g) { closeModal(); return; }
|
|
1226
|
+
closeModal();
|
|
1227
|
+
const r = await updateGoogleEvent(g, {
|
|
1228
|
+
calendarId: sel.calendarId,
|
|
1229
|
+
eventId: sel.eventId,
|
|
1230
|
+
title: title.trim() || sel.title,
|
|
1231
|
+
dateIso: sel.dateIso,
|
|
1232
|
+
startMin,
|
|
1233
|
+
endMin,
|
|
1234
|
+
});
|
|
1235
|
+
clearCalSelection();
|
|
1236
|
+
if (r.ok) {
|
|
1237
|
+
calendarStore.refresh(true);
|
|
1238
|
+
flashBanner("info", `✏ Event updated: ${title.trim() || sel.title}`);
|
|
1239
|
+
} else {
|
|
1240
|
+
flashBanner("error", `Update failed: ${r.error}`);
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
/** Delete the selected event, refresh, close. */
|
|
1245
|
+
async function confirmDeleteEvent(): Promise<void> {
|
|
1246
|
+
const sel = state.ui.selectedCalEvent;
|
|
1247
|
+
const g = config.calendars?.google;
|
|
1248
|
+
if (!sel || !g) { closeModal(); return; }
|
|
1249
|
+
closeModal();
|
|
1250
|
+
const r = await deleteGoogleEvent(g, { calendarId: sel.calendarId, eventId: sel.eventId });
|
|
1251
|
+
clearCalSelection();
|
|
1252
|
+
if (r.ok) {
|
|
1253
|
+
calendarStore.refresh(true);
|
|
1254
|
+
flashBanner("info", `🗑 Event deleted: ${sel.title}`);
|
|
1255
|
+
} else {
|
|
1256
|
+
flashBanner("error", `Delete failed: ${r.error}`);
|
|
1257
|
+
}
|
|
1061
1258
|
}
|
|
1062
1259
|
|
|
1063
1260
|
// ─── Private mutation helper ─────────────────────────────────────────────
|
|
@@ -1133,6 +1330,15 @@ export function createTuiStore({ config }: CreateStoreOptions) {
|
|
|
1133
1330
|
resetAllOverdueToToday,
|
|
1134
1331
|
openModal,
|
|
1135
1332
|
closeModal,
|
|
1333
|
+
openEventModal,
|
|
1334
|
+
advanceEventToStep2,
|
|
1335
|
+
setEventSel,
|
|
1336
|
+
confirmEventPicker,
|
|
1337
|
+
selectCalEvent,
|
|
1338
|
+
clearCalSelection,
|
|
1339
|
+
openEventEditModal,
|
|
1340
|
+
confirmEventEdit,
|
|
1341
|
+
confirmDeleteEvent,
|
|
1136
1342
|
flashBanner,
|
|
1137
1343
|
clearBanner,
|
|
1138
1344
|
// undo
|
package/src/store/timeline.ts
CHANGED
|
@@ -50,6 +50,12 @@ export interface CalTimelineEntry extends BaseEntry {
|
|
|
50
50
|
title: string;
|
|
51
51
|
color: string;
|
|
52
52
|
source: "google" | "microsoft";
|
|
53
|
+
/** Google calendar id (Google events only) — needed to edit/delete. */
|
|
54
|
+
calendarId?: string;
|
|
55
|
+
/** Google event id (Google events only) — needed to edit/delete. */
|
|
56
|
+
eventId?: string;
|
|
57
|
+
/** True when this event can be edited/deleted from tuiboard. */
|
|
58
|
+
editable?: boolean;
|
|
53
59
|
}
|
|
54
60
|
|
|
55
61
|
export type TimelineEntry = TaskTimelineEntry | CalTimelineEntry;
|
|
@@ -168,7 +174,16 @@ export function buildTimelineEntries(
|
|
|
168
174
|
* the target day) into grid entries, clipped to the rendered window.
|
|
169
175
|
*/
|
|
170
176
|
export function buildCalendarEntries(
|
|
171
|
-
events: Array<{
|
|
177
|
+
events: Array<{
|
|
178
|
+
title: string;
|
|
179
|
+
startMin: number;
|
|
180
|
+
endMin: number;
|
|
181
|
+
color: string;
|
|
182
|
+
source: "google" | "microsoft";
|
|
183
|
+
calendarId?: string;
|
|
184
|
+
eventId?: string;
|
|
185
|
+
editable?: boolean;
|
|
186
|
+
}>,
|
|
172
187
|
): CalTimelineEntry[] {
|
|
173
188
|
const out: CalTimelineEntry[] = [];
|
|
174
189
|
for (const e of events) {
|
|
@@ -179,6 +194,9 @@ export function buildCalendarEntries(
|
|
|
179
194
|
title: e.title,
|
|
180
195
|
color: e.color,
|
|
181
196
|
source: e.source,
|
|
197
|
+
calendarId: e.calendarId,
|
|
198
|
+
eventId: e.eventId,
|
|
199
|
+
editable: e.editable,
|
|
182
200
|
startMin: e.startMin,
|
|
183
201
|
endMin: e.endMin,
|
|
184
202
|
startRow: rows.startRow,
|
package/src/ui/Modal.tsx
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
} from "~/store/parsers";
|
|
22
22
|
import { ATTR, T } from "~/ui/glyphs";
|
|
23
23
|
import { AGENDA_WIDTH } from "~/ui/layout";
|
|
24
|
+
import { formatHm } from "~/store/timeline";
|
|
24
25
|
import type { TuiStore } from "~/store/index";
|
|
25
26
|
import type { PriorityLevel, TimeBlock } from "~/types";
|
|
26
27
|
|
|
@@ -50,6 +51,9 @@ function ModalRouter(props: { store: TuiStore; modal: NonNullable<TuiStore["stat
|
|
|
50
51
|
case "confirm-delete": return <ConfirmDeleteModal store={props.store} modal={m} />;
|
|
51
52
|
case "detail": return <DetailModal store={props.store} modal={m} />;
|
|
52
53
|
case "agent-detail": return <AgentDetailModal store={props.store} modal={m} />;
|
|
54
|
+
case "event": return <EventModal store={props.store} />;
|
|
55
|
+
case "event-edit": return <EventEditModal store={props.store} />;
|
|
56
|
+
case "confirm-delete-event": return <ConfirmDeleteEventModal store={props.store} />;
|
|
53
57
|
case "search": return <SearchModal store={props.store} />;
|
|
54
58
|
case "help": return <HelpModal store={props.store} />;
|
|
55
59
|
}
|
|
@@ -267,6 +271,191 @@ function TimeBlockModal(props: { store: TuiStore; modal: Extract<NonNullable<Tui
|
|
|
267
271
|
);
|
|
268
272
|
}
|
|
269
273
|
|
|
274
|
+
// ─── New calendar event ──────────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Two-step "new Google Calendar event" modal. Step 1: a title+time `<input>`
|
|
278
|
+
* (time prefilled from the clicked slot; append `HH:MM-HH:MM` to override).
|
|
279
|
+
* Step 2: a non-input calendar list navigated via handleKey (no input focused),
|
|
280
|
+
* preselected to the configured default. See `openEventModal` / `confirmEventPicker`.
|
|
281
|
+
*/
|
|
282
|
+
function EventModal(props: { store: TuiStore }) {
|
|
283
|
+
const picker = () => props.store.state.ui.eventPicker;
|
|
284
|
+
const defaultId = () => props.store.config.calendars?.google?.defaultCalendar;
|
|
285
|
+
const [value, setValue] = createSignal("");
|
|
286
|
+
const [error, setError] = createSignal<string | undefined>();
|
|
287
|
+
|
|
288
|
+
function submit(text: string) {
|
|
289
|
+
const p = picker();
|
|
290
|
+
if (!p) return;
|
|
291
|
+
const trimmed = text.trim();
|
|
292
|
+
let title = trimmed;
|
|
293
|
+
let startMin = p.startMin;
|
|
294
|
+
let endMin = p.endMin;
|
|
295
|
+
// Peel a trailing time token: "Standup 9:00-9:30" / "Lunch 12-13".
|
|
296
|
+
const m = trimmed.match(/\s(\S+)$/);
|
|
297
|
+
const tok = m?.[1];
|
|
298
|
+
if (m && m.index !== undefined && tok) {
|
|
299
|
+
const tb = parseTimeBlockShortcut(tok);
|
|
300
|
+
if (tb && tb.endMin > tb.startMin) {
|
|
301
|
+
title = trimmed.slice(0, m.index).trim();
|
|
302
|
+
startMin = tb.startMin;
|
|
303
|
+
endMin = tb.endMin;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (!title) {
|
|
307
|
+
setError("Title required");
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
void props.store.advanceEventToStep2(title, startMin, endMin);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return (
|
|
314
|
+
<Show when={picker()}>
|
|
315
|
+
<Show
|
|
316
|
+
when={picker()!.step === 2}
|
|
317
|
+
fallback={
|
|
318
|
+
<DialogShell
|
|
319
|
+
title="New event"
|
|
320
|
+
hint={`${formatHm(picker()!.startMin)}-${formatHm(picker()!.endMin)} · append HH:MM-HH:MM to change · Enter add · Esc cancel`}
|
|
321
|
+
>
|
|
322
|
+
<input
|
|
323
|
+
focused
|
|
324
|
+
value={value()}
|
|
325
|
+
onInput={(v: string) => {
|
|
326
|
+
setValue(v);
|
|
327
|
+
setError(undefined);
|
|
328
|
+
}}
|
|
329
|
+
onSubmit={((v: string) => submit(v)) as any}
|
|
330
|
+
/>
|
|
331
|
+
<Show when={error()}>
|
|
332
|
+
<text>
|
|
333
|
+
<span style={{ fg: T.bannerError }}>{error()!}</span>
|
|
334
|
+
</text>
|
|
335
|
+
</Show>
|
|
336
|
+
</DialogShell>
|
|
337
|
+
}
|
|
338
|
+
>
|
|
339
|
+
<DialogShell
|
|
340
|
+
title={`Calendar · ${formatHm(picker()!.startMin)}-${formatHm(picker()!.endMin)}`}
|
|
341
|
+
hint="j/k choose · Enter create · Esc cancel"
|
|
342
|
+
>
|
|
343
|
+
<For each={picker()!.cals}>
|
|
344
|
+
{(c, i) => {
|
|
345
|
+
const isSel = () => i() === picker()!.sel;
|
|
346
|
+
const isDefault = defaultId() ? c.id === defaultId() : c.primary;
|
|
347
|
+
return (
|
|
348
|
+
<box style={{ backgroundColor: isSel() ? T.cardBgCursor : undefined }}>
|
|
349
|
+
<text wrapMode="none" truncate>
|
|
350
|
+
<span style={{ fg: isSel() ? T.accent : T.textDim }}>
|
|
351
|
+
{isSel() ? "▶ " : " "}
|
|
352
|
+
</span>
|
|
353
|
+
<span style={{ fg: c.color }}>{"● "}</span>
|
|
354
|
+
<span style={{ fg: T.text }}>{c.summary}</span>
|
|
355
|
+
<Show when={isDefault}>
|
|
356
|
+
<span style={{ fg: T.textDim }}>{" (default)"}</span>
|
|
357
|
+
</Show>
|
|
358
|
+
</text>
|
|
359
|
+
</box>
|
|
360
|
+
);
|
|
361
|
+
}}
|
|
362
|
+
</For>
|
|
363
|
+
</DialogShell>
|
|
364
|
+
</Show>
|
|
365
|
+
</Show>
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ─── Edit existing calendar event ────────────────────────────────────────────
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Edit the selected Google Calendar event's title + time (same calendar). A
|
|
373
|
+
* single `<input>` prefilled with "Title HH:MM-HH:MM"; Enter saves via PATCH.
|
|
374
|
+
* Reads `ui.selectedCalEvent` (set by clicking an editable event in the Agenda).
|
|
375
|
+
*/
|
|
376
|
+
function EventEditModal(props: { store: TuiStore }) {
|
|
377
|
+
const sel = () => props.store.state.ui.selectedCalEvent;
|
|
378
|
+
const s0 = sel();
|
|
379
|
+
const [value, setValue] = createSignal(
|
|
380
|
+
s0 ? `${s0.title} ${formatHm(s0.startMin)}-${formatHm(s0.endMin)}` : "",
|
|
381
|
+
);
|
|
382
|
+
const [error, setError] = createSignal<string | undefined>();
|
|
383
|
+
|
|
384
|
+
function submit(text: string) {
|
|
385
|
+
const s = sel();
|
|
386
|
+
if (!s) {
|
|
387
|
+
props.store.closeModal();
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
const trimmed = text.trim();
|
|
391
|
+
let title = trimmed;
|
|
392
|
+
let startMin = s.startMin;
|
|
393
|
+
let endMin = s.endMin;
|
|
394
|
+
// Peel a trailing time token: "Standup 9:00-9:30" / "Lunch 12-13".
|
|
395
|
+
const m = trimmed.match(/\s(\S+)$/);
|
|
396
|
+
const tok = m?.[1];
|
|
397
|
+
if (m && m.index !== undefined && tok) {
|
|
398
|
+
const tb = parseTimeBlockShortcut(tok);
|
|
399
|
+
if (tb && tb.endMin > tb.startMin) {
|
|
400
|
+
title = trimmed.slice(0, m.index).trim();
|
|
401
|
+
startMin = tb.startMin;
|
|
402
|
+
endMin = tb.endMin;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
if (!title) {
|
|
406
|
+
setError("Title required");
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
void props.store.confirmEventEdit(title, startMin, endMin);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return (
|
|
413
|
+
<Show when={sel()}>
|
|
414
|
+
<DialogShell
|
|
415
|
+
title="Edit event"
|
|
416
|
+
hint="append HH:MM-HH:MM to change the time · Enter save · Esc cancel"
|
|
417
|
+
>
|
|
418
|
+
<input
|
|
419
|
+
focused
|
|
420
|
+
value={value()}
|
|
421
|
+
onInput={(v: string) => {
|
|
422
|
+
setValue(v);
|
|
423
|
+
setError(undefined);
|
|
424
|
+
}}
|
|
425
|
+
onSubmit={((v: string) => submit(v)) as any}
|
|
426
|
+
/>
|
|
427
|
+
<Show when={error()}>
|
|
428
|
+
<text>
|
|
429
|
+
<span style={{ fg: T.bannerError }}>{error()!}</span>
|
|
430
|
+
</text>
|
|
431
|
+
</Show>
|
|
432
|
+
</DialogShell>
|
|
433
|
+
</Show>
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ─── Confirm delete calendar event ───────────────────────────────────────────
|
|
438
|
+
|
|
439
|
+
function ConfirmDeleteEventModal(props: { store: TuiStore }) {
|
|
440
|
+
const sel = () => props.store.state.ui.selectedCalEvent;
|
|
441
|
+
return (
|
|
442
|
+
<DialogShell title="Delete event?" hint="⏎/y confirm · Esc/n cancel">
|
|
443
|
+
<text wrapMode="none" truncate>
|
|
444
|
+
<span style={{ fg: sel()?.color ?? T.text }}>{"📅 "}</span>
|
|
445
|
+
<span style={{ fg: T.text }}>{sel()?.title ?? "(missing)"}</span>
|
|
446
|
+
<Show when={sel()}>
|
|
447
|
+
<span style={{ fg: T.textDim }}>
|
|
448
|
+
{` ${formatHm(sel()!.startMin)}-${formatHm(sel()!.endMin)}`}
|
|
449
|
+
</span>
|
|
450
|
+
</Show>
|
|
451
|
+
</text>
|
|
452
|
+
<text>
|
|
453
|
+
<span style={{ fg: T.textDim }}>Deletes from Google Calendar — cannot be undone here.</span>
|
|
454
|
+
</text>
|
|
455
|
+
</DialogShell>
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
270
459
|
// ─── Assign ──────────────────────────────────────────────────────────────────
|
|
271
460
|
|
|
272
461
|
function AssignModal(props: { store: TuiStore; modal: Extract<NonNullable<TuiStore["state"]["ui"]["modal"]>, { kind: "assign" }> }) {
|
|
@@ -599,6 +788,8 @@ function HelpModal(props: { store: TuiStore }) {
|
|
|
599
788
|
<span style={{ fg: T.text }}>{" [ / ] Previous / next day (tasks + calendar events)\n"}</span>
|
|
600
789
|
<span style={{ fg: T.text }}>{" \\ Jump back to today\n"}</span>
|
|
601
790
|
<span style={{ fg: T.textDim }}>{"\nAgenda (timeline) scheduling\n"}</span>
|
|
791
|
+
<span style={{ fg: T.text }}>{" n / click slot New Google Calendar event (needs: calendar-setup google --write)\n"}</span>
|
|
792
|
+
<span style={{ fg: T.text }}>{" click an event Select an editable Google event — then e edit · d delete · Esc\n"}</span>
|
|
602
793
|
<span style={{ fg: T.text }}>{" c (any zone) Toggle ARM MODE — then click a task, click a slot, repeat\n"}</span>
|
|
603
794
|
<span style={{ fg: T.text }}>{" click empty row Place the armed task here (30-min block, or move if it has one)\n"}</span>
|
|
604
795
|
<span style={{ fg: T.text }}>{" click band Arm an existing block (or place the armed task at its start)\n"}</span>
|
package/src/ui/TimelineView.tsx
CHANGED
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
onMount,
|
|
37
37
|
} from "solid-js";
|
|
38
38
|
|
|
39
|
+
import { googleTokenCanWrite } from "~/store/calendar";
|
|
39
40
|
import type { TaskRef } from "~/store/index";
|
|
40
41
|
import {
|
|
41
42
|
DAY_START_HOUR,
|
|
@@ -92,6 +93,13 @@ export function TimelineView(props: TimelineViewProps) {
|
|
|
92
93
|
const viewedDate = () => props.store.agendaDate();
|
|
93
94
|
const isToday = () => props.store.state.ui.agendaOffset === 0;
|
|
94
95
|
|
|
96
|
+
// The Google event currently selected (clicked) for edit/delete, if any.
|
|
97
|
+
const selectedCal = () => props.store.state.ui.selectedCalEvent;
|
|
98
|
+
const selectedCalKey = () => {
|
|
99
|
+
const s = selectedCal();
|
|
100
|
+
return s ? `${s.calendarId}:${s.eventId}` : undefined;
|
|
101
|
+
};
|
|
102
|
+
|
|
95
103
|
// Task entries drive the cursor + arm/keyboard interactions.
|
|
96
104
|
const entries = createMemo(() => {
|
|
97
105
|
props.store.state.rev; // recompute on any board mutation
|
|
@@ -189,11 +197,34 @@ export function TimelineView(props: TimelineViewProps) {
|
|
|
189
197
|
const onBlockClick = (entry: TimelineEntry, event: MouseEventLike) => {
|
|
190
198
|
props.store.setActiveZone("timeline");
|
|
191
199
|
|
|
192
|
-
// Calendar events
|
|
193
|
-
//
|
|
194
|
-
//
|
|
200
|
+
// Calendar events can't be armed or time-block-moved. While a task is armed,
|
|
201
|
+
// a click places that task at this slot (unchanged). Otherwise: an editable
|
|
202
|
+
// Google event gets SELECTED for edit/delete (toggles off on re-click); a
|
|
203
|
+
// read-only event just reports that it can't be changed.
|
|
195
204
|
if (entry.kind !== "task") {
|
|
196
|
-
if (armedRef())
|
|
205
|
+
if (armedRef()) {
|
|
206
|
+
onEmptyRowClick(entry.startRow, event);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (entry.kind === "calendar") {
|
|
210
|
+
if (entry.editable && entry.calendarId && entry.eventId) {
|
|
211
|
+
const wasSelected = selectedCalKey() === `${entry.calendarId}:${entry.eventId}`;
|
|
212
|
+
props.store.selectCalEvent({
|
|
213
|
+
calendarId: entry.calendarId,
|
|
214
|
+
eventId: entry.eventId,
|
|
215
|
+
title: entry.title,
|
|
216
|
+
startMin: entry.startMin,
|
|
217
|
+
endMin: entry.endMin,
|
|
218
|
+
dateIso: viewedDate(),
|
|
219
|
+
color: entry.color,
|
|
220
|
+
});
|
|
221
|
+
if (!wasSelected) {
|
|
222
|
+
props.store.flashBanner("info", `Selected "${tailTruncate(entry.title, 28)}" · e edit · d delete · Esc`);
|
|
223
|
+
}
|
|
224
|
+
} else {
|
|
225
|
+
props.store.flashBanner("info", "Read-only event — not on a writable calendar");
|
|
226
|
+
}
|
|
227
|
+
}
|
|
197
228
|
return;
|
|
198
229
|
}
|
|
199
230
|
|
|
@@ -238,7 +269,17 @@ export function TimelineView(props: TimelineViewProps) {
|
|
|
238
269
|
const onEmptyRowClick = (rowIndex: number, event: MouseEventLike) => {
|
|
239
270
|
const armed = armedTask();
|
|
240
271
|
const ref = armedRef();
|
|
241
|
-
if (!armed || !ref)
|
|
272
|
+
if (!armed || !ref) {
|
|
273
|
+
// Nothing armed: an empty-slot click creates a Google Calendar event at
|
|
274
|
+
// that time (only when Google write is connected — otherwise a no-op).
|
|
275
|
+
const g = props.store.config.calendars?.google;
|
|
276
|
+
if (g && googleTokenCanWrite(g.token)) {
|
|
277
|
+
const startMin = Math.max(0, DAY_START_HOUR * 60 + rowIndex * MINS_PER_ROW);
|
|
278
|
+
const endMin = Math.min(24 * 60 - 1, startMin + DEFAULT_BLOCK_MIN);
|
|
279
|
+
props.store.openEventModal(viewedDate(), startMin, endMin);
|
|
280
|
+
}
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
242
283
|
const targetMin = DAY_START_HOUR * 60 + rowIndex * MINS_PER_ROW;
|
|
243
284
|
|
|
244
285
|
// Unscheduled task → create a fresh block at the clicked row.
|
|
@@ -335,10 +376,21 @@ export function TimelineView(props: TimelineViewProps) {
|
|
|
335
376
|
</span>
|
|
336
377
|
</text>
|
|
337
378
|
</Show>
|
|
379
|
+
{/* A selected calendar event shows its own action hint. */}
|
|
380
|
+
<Show when={selectedCal()}>
|
|
381
|
+
<text wrapMode="none">
|
|
382
|
+
<span style={{ fg: T.warm, attributes: ATTR.bold }}>
|
|
383
|
+
{"📅 "}{tailTruncate(selectedCal()!.title, 28)}{" "}
|
|
384
|
+
</span>
|
|
385
|
+
<span style={{ fg: T.textDim }}>
|
|
386
|
+
{" e edit · d delete · Esc deselect"}
|
|
387
|
+
</span>
|
|
388
|
+
</text>
|
|
389
|
+
</Show>
|
|
338
390
|
{/* Day-navigation hint — always visible in the resting state (not while
|
|
339
|
-
arming) so the [ ] day-switch is
|
|
340
|
-
"\ today" reset is highlighted
|
|
341
|
-
<Show when={!armMode() && !armedTask()}>
|
|
391
|
+
arming or with an event selected) so the [ ] day-switch is
|
|
392
|
+
discoverable. Off-today, the "\ today" reset is highlighted. */}
|
|
393
|
+
<Show when={!armMode() && !armedTask() && !selectedCal()}>
|
|
342
394
|
<text wrapMode="none">
|
|
343
395
|
<span style={{ fg: T.warm }}>{"◷ "}</span>
|
|
344
396
|
<span style={{ fg: T.textDim }}>{"[ ] change day · "}</span>
|
|
@@ -376,6 +428,7 @@ export function TimelineView(props: TimelineViewProps) {
|
|
|
376
428
|
rowIndex={i()}
|
|
377
429
|
cursorEntry={isActive() ? cursorEntry() : undefined}
|
|
378
430
|
armedEntry={armedEntry()}
|
|
431
|
+
selectedCalKey={selectedCalKey()}
|
|
379
432
|
innerWidth={props.width ? props.width - 4 : undefined}
|
|
380
433
|
onBlockClick={onBlockClick}
|
|
381
434
|
onEmptyRowClick={onEmptyRowClick}
|
|
@@ -423,6 +476,8 @@ interface TimelineRowProps {
|
|
|
423
476
|
cursorEntry: TimelineEntry | undefined;
|
|
424
477
|
/** When set, the armed entry — used to tint its rows warm. */
|
|
425
478
|
armedEntry: TimelineEntry | undefined;
|
|
479
|
+
/** `${calendarId}:${eventId}` of the selected calendar event, if any. */
|
|
480
|
+
selectedCalKey: string | undefined;
|
|
426
481
|
/** Panel content width (border+padding already removed). Undefined = fullscreen. */
|
|
427
482
|
innerWidth?: number;
|
|
428
483
|
onBlockClick: (entry: TimelineEntry, event: MouseEventLike) => void;
|
|
@@ -455,6 +510,13 @@ function TimelineRow(props: TimelineRowProps) {
|
|
|
455
510
|
const rightIsArmed = () =>
|
|
456
511
|
!!props.armedEntry && right().entry === props.armedEntry;
|
|
457
512
|
|
|
513
|
+
const isSelectedCal = (e: TimelineEntry | undefined) =>
|
|
514
|
+
!!props.selectedCalKey &&
|
|
515
|
+
e?.kind === "calendar" &&
|
|
516
|
+
`${e.calendarId}:${e.eventId}` === props.selectedCalKey;
|
|
517
|
+
const leftIsSelCal = () => isSelectedCal(left().entry);
|
|
518
|
+
const rightIsSelCal = () => isSelectedCal(right().entry);
|
|
519
|
+
|
|
458
520
|
const entryDone = (e: TimelineEntry | undefined) =>
|
|
459
521
|
e?.kind === "task" && e.task.done;
|
|
460
522
|
const leftIsDone = () => entryDone(left().entry);
|
|
@@ -490,6 +552,7 @@ function TimelineRow(props: TimelineRowProps) {
|
|
|
490
552
|
backgroundColor: laneBg(
|
|
491
553
|
leftIsCursor(),
|
|
492
554
|
leftIsArmed(),
|
|
555
|
+
leftIsSelCal(),
|
|
493
556
|
leftIsBlock(),
|
|
494
557
|
leftIsDone(),
|
|
495
558
|
),
|
|
@@ -518,6 +581,7 @@ function TimelineRow(props: TimelineRowProps) {
|
|
|
518
581
|
backgroundColor: laneBg(
|
|
519
582
|
leftIsCursor(),
|
|
520
583
|
leftIsArmed(),
|
|
584
|
+
leftIsSelCal(),
|
|
521
585
|
leftIsBlock(),
|
|
522
586
|
leftIsDone(),
|
|
523
587
|
),
|
|
@@ -540,6 +604,7 @@ function TimelineRow(props: TimelineRowProps) {
|
|
|
540
604
|
backgroundColor: laneBg(
|
|
541
605
|
rightIsCursor(),
|
|
542
606
|
rightIsArmed(),
|
|
607
|
+
rightIsSelCal(),
|
|
543
608
|
rightIsBlock(),
|
|
544
609
|
rightIsDone(),
|
|
545
610
|
),
|
|
@@ -726,10 +791,11 @@ function isBlockKind(k: RowMapEntry["kind"]): boolean {
|
|
|
726
791
|
function laneBg(
|
|
727
792
|
isCursor: boolean,
|
|
728
793
|
isArmed: boolean,
|
|
794
|
+
isSelectedCal: boolean,
|
|
729
795
|
isBlock: boolean,
|
|
730
796
|
isDone: boolean,
|
|
731
797
|
): string | undefined {
|
|
732
|
-
if (isArmed) return T.warmDim;
|
|
798
|
+
if (isArmed || isSelectedCal) return T.warmDim;
|
|
733
799
|
if (isCursor) return T.cardBgCursor;
|
|
734
800
|
if (isBlock) return isDone ? T.cardBlockBgDone : T.cardBlockBg;
|
|
735
801
|
return undefined;
|